Shellcode
Writing and debugging your own shellcode
Last updated
Writing and debugging your own shellcode
Last updated
When you have found a buffer overflow or any other way to jump to your input a common exploitation method is to write shellcode. With shellcode, you write data in the form of assembly instructions so that any code you have written will be executed when the program jumps to it.
Shellcode is called "shellcode" because you often write code that gives you an interactive shell. This can be done using the execve("/bin/sh", NULL, NULL)
syscall to spawn an sh
shell with no arguments or environment variables. There are existing shellcodes people have created for various different architectures and use cases, like the following database:
While these are often enough, in some cases, you will need to write your own custom shellcode that does exactly what you need. There are many different ways of writing shellcode because it simply involves extracting the bytes as machine code from an existing program.
To have the most control and shortest shellcode, it is recommended to write them in assembly, but in theory, you could just as well write it in C and later extract the assembly from it after compiling.
We can write a simple execve(pathname, argv[], envp[])
syscall like explained above in assembly. We first need to know the correct syscall number, to choose execve
. Then set the arguments, which in x64 are in order rdi, rsi, rdx
. The first is the most important, as the file to execute. This will be "/bin/sh"
in our case, but as it is a char*
type it need to be a pointer to that string. Since we control the shellcode, we can simply include that string in it and reference it relatively from the rip
register as that will be in the middle of our shellcode as we are executing it. The whole thing looks something like this:
Now that we have the assembly code, we'll need to compile it, and later extract the raw shellcode bytes from that compiled binary. First compile it to a runnable ELF file:
This will create a shellcode-elf
file that you can run to test out your shellcode, and debug it if something goes wrong. If it seems to work correctly, like giving you a shell in this example, you can extract the .text
section which contains the code we wrote, but this time as raw bytes from the binary:
After that, you will have your shellcode in a shellcode-raw
file that you can include in your payload.
When the shellcode you created doesn't seem to work correctly, you need to debug it. It's probably some simple mistake because assembly is hard, but you just need to understand what is happening. To get a high-level overview of the syscalls you are executing, running strace
is a good option. It runs your program and at the same time will print which syscalls are executed and their return values. For example:
This method is more useful if you have a combination of multiple syscalls where you want to see the intermediate results. For more detailed analysis like stepping through individual instructions, you can use a real debugger like GDB. Simply run the shellcode in GDB and you can break at the first instruction using the starti
command. Here we can examine the next instructions, the state of registers, look at the stack, and much more.
In case your shellcode works alone, but not inside your exploit, you can also add a debugger to the exploited binary to step through everything in a different context, which might reveal differences. An easy way to set a breakpoint at the start of your payload is to include the int3
instruction, which triggers a trace/breakpoint trap in any debugger. You can manually add the \xcc
byte it translates to, or simply include the int3
in your assembly source code before compiling (see this video for a full explanation):
While the shellcode above gives you an interactive sh
shell, you might find yourself requiring something more. If you are exploiting a SUID binary where your privileges are elevated, often the goal is to become that user, instead of only spawning a shell as yourself. By default, spawning an execve
shell as above using a SUID binary will not give you the permissions of that user, but instead, take yours. Look at the following example:
This can be unintuitive because the program should execute as root
because of the s
bit we set in the permissions. However, when we execute the shellcode we are still the same low-privilege user
. This is because the setuid bit is only allowing the program to elevate its permissions. We just have to perform this elevation still using the setreuid
(user) and setregid
(group) syscalls to take over this user. Both of these syscalls take in two arguments as the "real" and "effective" IDs. We can hardcode all these to 0 to try and elevate to root if the SUID binary is executed as root, but in some cases, it will be owned by a different user or group ID.
To make a generic method for this, we can request the current effective IDs and set the real IDs to that value. This will basically be the following two syscalls:
It will set both the user and group to the executing user allowing you to elevate from any SUID binary to any user, not just root. In assembly, these syscalls would look like this:
Compiling this shellcode instead, we can see the permissions are correctly transferred over:
Pretty often, you are limited in your input and thus what shellcode you can provide. Some characters or patterns might be interpreted differently from other characters making the choice of what bytes to use in your shellcode important. Common examples are #0-null-bytes, which are often used to end a string, and \n newlines (and others) that might be the end of input.
Below is a table of problematic bytes in common builtin functions (source):
Byte | Problematic Functions |
---|---|
|
|
|
|
|
|
|
|
|
|
| protocol-specific (telnet, VT100, etc.) |
\n
newlines (and others)When you provide shellcode, often this is done via a command-line input. Many functions that accept user input via STDIN will wait until it is completed with a \n
. This means that if you send a newline inside of your payload prematurely, it will end your input and not copy the full shellcode.
The byte value of a newline is 0x0a
, or 10 in decimal. If you want to set a value of 10 with a mov
instruction into a register, for example, it might encode to 0x0a
breaking the payload.
In this case, the easiest solution is often to choose a slightly smaller or larger value that still serves the same purpose. Sometimes you do need exactly 10 though, but then simply set it to a different value first, and change it with another instruction right after. For example:
We can fix this, by first setting rax
to 9, and then incrementing it by one to get the same result:
In the same way, many more small tricks like this exist. Like using add
, sub
, xor
or and
. It is a matter of being creative in getting values in the right place.
\0
null bytesA very common bad char that you will encounter is the null byte, 0x00
. This 0 value is so commonly needed that there are some specific tricks to set zero values.
Firstly, simply clearing a register:
When setting other registers the instructions also often contain leading zeros to set a 64-bit value to 10 for example. In most cases, you can eliminate these leading zeros by simply using a 32-, 16-, or 8-bit value depending on the size of your value. In a previous example, we were setting rax
to 10, but to make sure higher bits also are set back to 0 we needed to use a full 64-bit value in the assembled instruction (look at all the null bytes).
To solve this, we'll set the 8-bit al
register instead (see Registers for more info):
Lastly, you might need zero-delimited strings that don't perfectly align with the 64, 32, 16, or 8 bits we did previously.
In these cases, either make the string aligned with these boundaries, intentionally ending the payload with the required null byte, or use shifts to get the exact string. Let's say we want the little-endian "/bin/sh" string into the rdi
register: