Lec. 08: Shell Code and System Calls in x86
Table of Contents
1 What is "shell code"?
The term shellcode refers to a small bit of binary code that can launch a shell (e.g., like a bash shell). Once you have a shell on a system, then you can do a lot of good (or bad).
Shell code fits into the stack smashing from the last class in that it is the code we wish to execute once the return address is overwritten. Like before, our exploit string will overrun a buffer and rewrite the return address, but instead of jumping to some function, we'll jump back into the data we overrun the buffer with. That data should be the shell code.
However, before we can do all of that, we first need to better understand shell code, what is good vs. bad shell code, and how to use it. Good shell code is compact and contains no NULL bytes. In later lessons, we will write our own shell code; today, we'll use a very standard example of shell code. This example was based on a post from a blog called Security Bits that seems to have disappeared into the Internet, here is the Internet Archive of the original article.
2 Executing a Shell in C and in x86
Before we dive into the details of shellcode, let's discuss how we actually execute a shell in x86 which is just the execution of another program, like the execve() system call. Something like this:
#include <unistd.h> int main() { char *args[] = {"/bin/sh", NULL}; execve(args[0], args, NULL); return 0; }
Recall back to systems programming, and you'll remember that a system
call is invoked by interrupting the normal flow of a program, context
switching the operating system in, which then performs the actual
tasks of the system call. In this case, that task will be replacing
the current program binary with another program's binary, the
/bin/sh
program.
Let's take a look at how we get there in the dissembler. First, we need to make sure we compile our program using static reference so we can fully see how this works:
user@si485H-base:demo$ gcc --static exec.c -o exec
Then we can use gdb to poke around:
user@si485H-base:demo$ gdb -q exec (gdb) ds main Dump of assembler code for function main: 0x08048e44 <+0>: push ebp 0x08048e45 <+1>: mov ebp,esp 0x08048e47 <+3>: and esp,0xfffffff0 0x08048e4a <+6>: sub esp,0x20 0x08048e4d <+9>: mov DWORD PTR [esp+0x18],0x80be9c8 0x08048e55 <+17>: mov DWORD PTR [esp+0x1c],0x0 0x08048e5d <+25>: mov eax,DWORD PTR [esp+0x18] 0x08048e61 <+29>: mov DWORD PTR [esp+0x8],0x0 0x08048e69 <+37>: lea edx,[esp+0x18] 0x08048e6d <+41>: mov DWORD PTR [esp+0x4],edx 0x08048e71 <+45>: mov DWORD PTR [esp],eax 0x08048e74 <+48>: call 0x806c470 <execve> 0x08048e79 <+53>: mov eax,0x0 0x08048e7e <+58>: leave 0x08048e7f <+59>: ret End of assembler dump. (gdb) ds execve Dump of assembler code for function execve: 0x0806c470 <+0>: push ebx 0x0806c471 <+1>: mov edx,DWORD PTR [esp+0x10] 0x0806c475 <+5>: mov ecx,DWORD PTR [esp+0xc] 0x0806c479 <+9>: mov ebx,DWORD PTR [esp+0x8] 0x0806c47d <+13>: mov eax,0xb 0x0806c482 <+18>: call DWORD PTR ds:0x80ea9f0 0x0806c488 <+24>: cmp eax,0xfffff000 0x0806c48d <+29>: ja 0x806c491 <execve+33> 0x0806c48f <+31>: pop ebx 0x0806c490 <+32>: ret 0x0806c491 <+33>: mov edx,0xffffffe8 0x0806c497 <+39>: neg eax 0x0806c499 <+41>: mov ecx,DWORD PTR gs:0x0 0x0806c4a0 <+48>: mov DWORD PTR [ecx+edx*1],eax 0x0806c4a3 <+51>: or eax,0xffffffff 0x0806c4a6 <+54>: pop ebx 0x0806c4a7 <+55>: ret End of assembler dump. (gdb) ds *0x80ea9f0 Dump of assembler code for function _dl_sysinfo_int80: 0x0806f040 <+0>: int 0x80 0x0806f042 <+2>: ret End of assembler dump.
Now if we put all the instructions together that lead to the intrupt
instruction int
which invokes the operating system:
0x0806c471 <+1>: mov edx,DWORD PTR [esp+0x10] 0x0806c475 <+5>: mov ecx,DWORD PTR [esp+0xc] 0x0806c479 <+9>: mov ebx,DWORD PTR [esp+0x8] 0x0806c47d <+13>: mov eax,0xb (...) 0x0806f040 <+0>: int 0x80
Recall that the arguments to execve
are execve(args[0],args,NULL)
and the memory references offset from esp
at 0x8, 0xc, and 0x10
correspond to those arguments. The tasks is matching those values up
with the setup in main()
, which should correspond to 0x0, 0x4, and
0x8 offset from esp in main.
To see why, consider that two stack operations occur between main()
and exeve()
. First, there is a call to exeve()
, which will push
the return value on the stack, and then in exeve()
, there is a push
ebx
which will save the value of ebx. Both those operations shift the
stack down (subtract from the stack pointer) by 4 bytes, so we can
look into main()
plus 8 bytes from esp offsets in exeve
.
When we finally do look into main()
at the offsets 0x0, 0x4 and 0x8,
we find the following instructions:
0x08048e4d <+9>: mov DWORD PTR [esp+0x18],0x80bea08 0x08048e5d <+25>: mov eax,DWORD PTR [esp+0x18] 0x08048e61 <+29>: mov DWORD PTR [esp+0x8],0x0 0x08048e69 <+37>: lea edx,[esp+0x18] 0x08048e6d <+41>: mov DWORD PTR [esp+0x4],edx 0x08048e71 <+45>: mov DWORD PTR [esp],eax
And with a bit of work, we can see how these match to the arguments of
execve
and the setting of the registers. First, lets look at what at
address 0x80bea08:
(gdb) x/x 0x80bea08 0x80bea08: 0x6e69622f (gdb) x/s 0x80bea08 0x80bea08: "/bin/sh"
That is the address of the string "/bin/sh" wich is written to stack
at address esp+0x18
. The next mov instruction loads that address
into eax
.
At instruction <+29>
, 0x0 is written to address esp+0x8. and at
instruction <+37>
, the address of [esp+0x18] is loaded into edx
using a lea. So the literal value of edx
at this point is the
calculation esp+0x18
, a reference to the memory that stores the
reference 0x80bea08 which references the string "/bin/sh". The value
in edx is then written to esp+0x4
in instruction <+41>
. Finally,
in <+45>
, the value in eax
is written to the top of the stack.
Visually, the stack at this point looks a bit like this (note I use [ ] when indicating a reference calculated from an offset):
___________.--> "/bin/sh" / | esp + 0x18 -> | 0x80bea08 |<-. .-' esp + 0x14 -> | | | / esp + 0x10 -> | | | | eax: 0x80bea08 esp + 0xc -> | | | | ebx: ????? esp + 0x8 -> | 0x00 | | | ecx: ????? esp + 0x4 -> | [esp+0x18] | -' | edx: [esp+0x18] esp + 0x0 -> | 0x80bea08 | ---' '--------------'
Let's now match the arguments up to those in execve()
:
args[0]
:esp+0x0
: memory reference to "/bin/sh"args
:esp+0x4
: memory reference to a memory reference to "/bin/sh" (double pointer)NULL
:esp+0x8
: NULL or 0 value
After the call to execve()
and the push ebx
in exeve, the stack
now looks like this:
___________.--> "/bin/sh" / | esp + 0x20 -> | 0x80bea08 |<-. .-' esp + 0x1c -> | | | / esp + 0x18 -> | | | | eax: 0x80bea08 esp + 0x14 -> | | | | ebx: ????? esp + 0x10 -> | 0x00 | | | ecx: ????? esp + 0xc -> | [esp+0x20] | -' | edx: [esp+0x20] esp + 0x8 -> | 0x80bea08 | ---' esp + 0x4 -> | ret addr | esp + 0x0 -> | saved ebx | '--------------'
Now, if we look again at the instructions prior to the interupt:
0x0806c471 <+1>: mov edx,DWORD PTR [esp+0x10] 0x0806c475 <+5>: mov ecx,DWORD PTR [esp+0xc] 0x0806c479 <+9>: mov ebx,DWORD PTR [esp+0x8] 0x0806c47d <+13>: mov eax,0xb (...) 0x0806f040 <+0>: int 0x80
Visually, the world looks like this:
___________.--> "/bin/sh" / | esp + 0x20 -> | 0x80bea08 |<-. .-' esp + 0x1c -> | | | / esp + 0x18 -> | | | | eax: 0xb esp + 0x14 -> | | | | ebx: 0x80bea08 esp + 0x10 -> | 0x00 | | | ecx: [esp+0x20] esp + 0xc -> | [esp+0x20] | -' | edx: 0x00 esp + 0x8 -> | 0x80bea08 | ---' esp + 0x4 -> | ret addr | esp + 0x0 -> | saved ebx | '--------------'
And, we can write down the ultimate register values to their ultimate values:
eax
: 0xbebx
: 0x80bea08 : a memory reference to the string "/bin/sh"ecx
: [esp+0x20] : a memory reference to the memory reference to "/bin/sh" (double pinter)edx
: 0x00 : a null byte
With all that setup, we can now focus on the interrupt itself. The interrupt number is 0x80 or 128. This is the interrupt number for all system calls. After the interrupt, the operating system is invoked and will look at the state of the registers to determine which system call to perform using which arguments.
Which system call to execute is always set in eax
. This is generally
referred to as the system call number, and in this case, the system
call number is 0xb or 12 for the exec system call. You can look at
this handy table for other system
calls. http://asm.sourceforge.net/syscall.html. You can also find all
the system call numbers in the following include file:
/usr/include/i386-linux-gnu/asm/unistd_32.h
The next registers, ebx, ecx, and edx are used to pass the system
call's arguments. In this case, that is the path to the executable
in ebx (e.g., "/bin/sh"), the argv array for the executable in ecx (e.g., the double
pointer to "/bin/sh"), and environment setup in edx (.e.g., NULL since
we have none). One the system call completes, the return value (e.g.,
success or failure) is set in eax
.
This use of registers for argument passing is the same for all system calls, not just the exec-like variants. The reason for this is actually quite simple and practical: The operating system and the user programs do not share the same memory address. It is incredibly important that they do not for security purposes — imagine a universe where any old user program with a buffer overflow can accidentally change the operating systems -— but it does lead to some particular challenges, like argument passing for systems calls where data must be explicitly passed between os-space and user-space. Using the registers is an obvious solution since registers are shared between all executing programs. The OS knows that right after a system call is made via an interrupt that the registers are all prepared the way they need to identify the right system call, pass arguments, and set a return value. There are also explicit ways for the kernel to reach into user memory and grab data and put data back, which also must happen here because only the pointer to the start of the /bin/sh string is passed, but that process is a discussion for a different class — an operating systems class.