SI485H: Stack Based Binary Exploits and Defenses (F15)

Home Policy Calendar Resources

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 : 0xb
  • ebx : 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.