On the last CTF, I watched a team of three people spend 48 minutes on a pwn-task for 150 points. The vulnerability was found in seven minutes – the classic buffer overflow with ret2win. The remaining 41 minutes collected payload manually: counted the offset in the IDA, turned the address over the bay, sent through echo -e in nc, missed one byte and received segfault. A script on the pwntools of eight lines would have taken the flag in three minutes. I’ve seen it so many times that I’ve decided to write a step-by-step parsing—from the installation to the work exploit that connects to the CTF server, sends a payload and pulls out the flag. With all the rakes that every newcomer comes to.
[Applicable: CTF, laboratory environments, test stands. In productive pentests - only for checking PoC on services agreed with the customer.]
Installation of pwntools and preparation of the environment
Requirements for the environment
Ubuntu 20.04+ / Debian 11+ / Kali Linux. WSL2 is suitable for most tasks, but kernel-operation and part of the tricks with GDB require a full-fledged VM (VirtualBox/VMware) Read more - in our material about binary analysis of vulnerabilities.
Python 3.8+
RAM: 2 GB minimum, 4 GB recommended – GDB with pwndbg eats resources when debugging heavy binary
Dependencies: python3-dev, git, libssl-dev, libffi-dev, build-essential
Installation in two steps. First system packages: sudo apt-get update && sudo apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential. Then pwntools: python3 -m pip install --upgrade pwntools. According to the documentation on docs.pwntools.com, this is enough for the eyes for all the main modules.
After installation, check the utility pwn - dial pwn version in the terminal. If you see a warning about ~/.local/bin not in $PATH – add a line export PATH="$HOME/.local/bin:$PATH" in ~/.bashrc and restart the session.
Additionally put GDB and pwndbg: sudo apt-get install gdb, then clone pwndbg with GitHub and run setup.sh. Pwntools is able to connect GDB to the running process through gdb.attach(p) – without this, the debugging of exploits turns into a blind divination according to segfaults.
The exploit template is generated by the team pwn template ./binary_name > exploit.py. At the output is a ready-made skeleton with imports, context settings and switching between local/remote through command line arguments. At competitions this template saves minutes that usually go to the boiler.
Binary Exploration: checksec, context and ELF
Before writing payload, intelligence. Launch checksec ./vuln (the utility is built into pwntools and pwndbg). A typical conclusion for training task:
NX enabled — stack unexecutable, shellcode on the stack will not start, you need an ROP or ret2win
PIE disabled – the basic address of the binary is fixed, the addresses of the functions can be used directly
No canary — no Stack Canary, buffer overflow does not require canary leakage
Partial RELRO – section .got.plt available on the record (unlike Full RELRO), you can overwrite the GOT record to redirect calls
These four parameters determine the entire operating strategy. Skip this step is to spend hours on incorrect assumptions. I saw people who spent half an hour trying to put shellcode on the stack when NX was included. Don't be like that.
Global facility context sets the architecture, the OS and the order of bytes for the entire script. The most reliable way is to tie to the binary: context.binary = './vuln'. Pwntools will determine arch, endianness and word size from the ELF title itself. The manual alternative — context.arch = 'amd64' and context.os = 'linux'. Without the right context p32()/p64() will pack with the wrong order of bytes, payload will not work, and the reason you will look for hours. I ask at each CTF parse: “The context was put up?” – and in half of the cases the answer is “a, exactly...”
Class ELF of pwnlib.elf parsite binar and gives software access to symbols, GOT and PLT. Instead of hardcode addresses from IDA, download ELF: elf = ELF('./vuln'). Function address: elf.symbols['win']. GOT-record: elf.got['puts']. PLT-record: elf.plt['puts']. If the binary was recompiled and the addresses shifted, ELF() will pick up the new values automatically, and the hardcode will break. At competitions, the organizers sometimes update the binary in the middle of the round – and the script with ELF() continues to work while the neighboring team convulsively interrupts addresses.
Level of logging: context.log_level = 'debug' outputs the hex-dump of each sent and received byte. Switch to 'info' after debugging, otherwise the withdrawal will choke.
Tubes: connection to the process and remote server
Tube is the central abstraction of pwntools I/O. Three types under three scenarios:
process('./vuln') – starting a local binary as a child process. The main debugging tool: test payload on your car, make sure that everything works, and only then switch to remote. Challenge p = process('./vuln') starts the binary and returns the tube to interact with its stdin/stdout.
remote('challenge.ctf.com', 1337) – TCP connection to the CTF server. Direct replacement nc, only scripted. The organizers give the address and the port - you connect with a script and take the flag.
listen(4444) – server socket for the return shell. Method wait_for_connection() blocked to incoming connection. It is used less often, but for tasks with reverse shell - indispensable.
All three types inherit a single interface of pwnlib.tubes. Methods send(), recv(), interactive() work the same – communicate with the local process or with a remote server, no difference. The exploit switches from local to remote replacement of one line. In the template pwn template switching is implemented through the argument: python3 exploit.py REMOTE host port uses remote(), run without arguments — process(). The beauty.
Exploit automation: send, recv and data work
Methods of sending
send(data) sends bytes as is, without adding anything. Use when payload should be bypaut exact. sendline(data) adds \n – analogue of pressing Enter. In most CTFs, the server is waiting to enter with the translation of the line, so that sendline() – default choice.
sendlineafter(delim, data) – workhorse: waiting for a line delim in the server response, then sends data + \n. Covers 90% of interaction scenarios. Example: server outputs Enter your name:, the script causes p.sendlineafter(b'name:', payload) and definitely falls at the right moment of dialogue. There's a steam room sendafter() - the same, but without \n.
Methods of reception
recv
accepts to n byte, but does not guarantee exactly n - will return how much in the buffer. recvline() – one line to \n. recvuntil(delim) - all before the appearance delim, parameter drop=True removes the divider from the result. recvall() – everything before the connection is closed, useful at the end of the exploit for flag capture.
interactive() switches tube into interactive mode – the “lets go of the steering wheel” script, and you work directly with the process as through nc. A typical finale: sent payload, got shell, called interactive() and recruit cat flag.txt hands.
Bytes, not str - everyone is burning on it
All methods are accepted and returned bytes, not str. Write p.send(b'AAAA'), not p.send('AAAA'). In Python 3, the text string will call TypeError. Every beginner steps on these rakes – especially since the old writeups in Python 2 do not know this difference and take the side.
Search for offset through cyclic and buffer overflow
Offset to return address - distance in bytes from the beginning of the buffer to the saved RIP on the stack. You can count in IDA: buffer size + padding from compiler + saved RBP. But the compiler adds alignment to a multiple of 16, and the visual count regularly lies. More securely – cyclic() from the module pwnlib.util.cyclic.
The module generates the De Bruyne pattern—a sequence where each N-length substring is unique. Challenge cyclic(200) creates 200 bytes of this pattern. Send it to a binary - the program falls, and in the register RIP (or in corefile) is a 4- or 8-byte fragment of the pattern. Transmit this value as integer in cyclic_find() and get an accurate offset. For example, cyclic_find(0x6161616c) return the numerical offset of this value within the pattern. Or so: cyclic_find(core.read(core.rsp, 4)), if you need to pull the value out of corefile directly.
Automation through pwntools is even more convenient: run the binary through p = process('./vuln'), send p.sendline(cyclic(200)), wait for the crash. Create a corefile: core = p.corefile. Read the required register — core.rsp or core.pc (Depends on the type of overflow). Transmit the value with the pattern fragment in cyclic_find() – and get an offset without a single launch of GDB hands. Three lines of code instead of fifteen minutes in the debugger.
Works if: binary really colors when overflowing (no signal processing), core dump is included (ulimit -c unlimited).
Does not work if: binary intercepts SIGSEGV through signal(), or the input is trimmed fgets()/read() to the length that does not reach the RIP.
Address Packing and Flat: Payload Assembly without Errors
In x86/x64, the data is stored in little-endian: address 0xdeadbeef in memory lies like \xef\xbe\xad\xde. Turning manually is the right way to error on the third address in the chain (checked, and more than once). Functions p32() and p64() of pwnlib.util.packing do it automatically: p32(0xdeadbeef) returns b'\xef\xbe\xad\xde'. The reverses — u32() and u64() – unpack the bytes back into the number. In fact p32(addr) - that's struct.pack('<I', addr), but without having to remember format codes.
Behavior depends on context.arch: at amd64 generalized pack() choose 64 bits, when i386 - 32. In exploits, I prefer the obvious p32()/p64(), so as not to depend on the global state.
Function flat() – less well-known, but extremely useful thing. It compatents arguments by automatically packing numbers through pack() according to the current context. Instead of b'A' * 72 + p64(0x401234) Write flat(b'A' * 72, 0x401234) - in short, cleaner, less likely to screw up the order of bytes.
Full example of the exploit buffer overflow in Python
Typical CTF pwn-task: binary vuln with buffer overflow and function win(), which takes the flag. checksec shows NX enabled, PIE disabled, No canary. The task is to redirect execution to win().
from pwn import *
context.binary = elf = ELF('./vuln')
p = process('./vuln')
offset = 72
payload = flat(b'A' * offset, elf.symbols['win'])
p.sendlineafter(b'Input:', payload)
p.interactive()
Parsing on the lines. context.binary = elf = ELF('./vuln') – with one line, load the ELF and configure the context (arch, endianness will be determined from the binary header). flat() collects padding from 72 bytes of garbage and address win(), packed according to the rules of the current architecture. sendlineafter() waiting for an invitation Input: and sends a payload with a line translation. interactive() switches to manual mode - a flag or shell will appear on the screen.
Switching to Server: Replace process('./vuln') on remote('challenge.ctf.com', 1337). All the rest of the code is unchanged. That's why there are tubes.
Pwntools ROP: chains for real pwn-tash
When Target Functions win() no and need to call system("/bin/sh") via libc – a ROP chain will be required. Class ROP of pwnlib.rop scans the binary on the gadgets and automatically builds chains of calls.
from pwn import *
elf = context.binary = ELF('./vuln')
rop = ROP(elf)
# Stage 1: leak
rop.call('puts', [elf.got['puts']])
rop.call('main')
payload = flat(b'A' * offset, rop.chain())
ROP(elf) finding gadgets of kind pop rdi; ret, pop rsi; pop r15; ret in the binary. Method call() arranges arguments through found gadgets and adds the address of the function. rop.chain() returns ready-made bytes. This snippet makes leak addresses puts from GOT - knowing it, you can calculate the libc database and find system() + line /bin/sh for the second round. Return to main through rop.call('main') allow you to send a second payload. At the same time, it may be necessary to align the stack - add the gadget ret before the call (more in the section about stack alignment below).
Method rop.dump() will display the readable description of the chain: which gadget where leads, what lies on the stack. When the chain doesn't work, dump() will show which pwntools gadget has chosen and whether the arguments are correctly arranged. Without it, you stare stupidly at segfault and guess.
Preconditions and restrictions
Works if: PIE disabled (the addresses of the gadgets are fixed), in the binary there are enough gadgets for calling convention target architecture (for x64 - minimum pop rdi; ret).
Does not work if: PIE is included and there is no leakage of the base address of the binary. Then first you need a leak through a duplicate string or partial overwrite, recalculating the base through elf.address = leaked - elf.symbols['known'], and only then the construction of the ROP. Also, it will not work if the stripped binary and symbols are missing - the addresses of the functions will have to be searched manually through the reverse in IDA/Ghidra.
GDB debugging exploit and typical mistakes
gdb.attach for real-time debugging
Challenge gdb.attach(p) opens the GDB (with pwndbg or gef, if installed) connected to the running process. You can put breakpoint, see the stack after sending payload, make sure that the RIP is overwritten with the right address. Parameter gdbscript allows you to transfer commands immediately: gdb.attach(p, gdbscript='b *main+42\nc') – breakpoint and one line. Works with process(); for remote purposes, additional adjustment (SSH, gdbserver) is required – with the usual remote() It won't start out of the box. Debug locally, then switch.
Mistakes that lose time
The wrong offset. They counted in IDA 64 bytes, and actually 72 - the compiler added alignment to the number, multiple of 16. Always check through cyclic() + corefile, do not trust one visual analysis of the stack in the disassembler.
Extra newline. sendline() adds \n. If payload already contains exactly the right number of bytes, the extra symbol will shift everything to byte and break the return address. Use send() for those payloads where each byte counts.
Stack alignment on x86-64. When calling system() the stack must be aligned with 16 bytes. If the alignment is broken, system() Painted on instructions movaps inside the glibc, and segfault looks like a problem in payload, although the address is overwritten correctly. Solution: add the gadget ret (one byte \xc3) before the target function address. Without experience, this reason can be found for hours - I myself once spent the whole evening while I got to me.
Libc mismatch. The exploit works locally, but not on the server - the glibc versions differ, the offsets system() and lines /bin/sh not matching. The utility pwninit (described in The Pwner's Roadmap on izzy.sh) solves the problem: it patches the binary for using the server version of the libc/linker so that the local environment coincides with the remote. Encounter in the first weeks of remote-tasks is not an optional knowledge. For heap tasks where the behavior of the allocator is important, the project glibc-all-in-one allows you to download and test against any version of glibc.
bytes vs str. p.send('AAAA') instead of p.send(b'AAAA') Python 3 will throw away TypeError. Every writeup older than 2020 in Python 2 had this problem, so beginners copy the code and don’t understand why it doesn’t work. Add b - and live quietly.
I lead a pwn-direction at competitions for three years and see the same picture: people find vulnerability, calculate offset, know the address of the target function - and spend 40 minutes on manual assembly payload through struct.pack and sending through subprocess.Popen. Then another 20 minutes on debugging, because forgot about endianness or added an extra byte.
Pwntools is not “another library to study.” This is the difference between "understand the buffer overflow theory" and "taking the flag in three minutes." Template from pwn template, cyclic() for offset, ELF() for symbols, flat() for payload assembly - four tools that cover 80% of the primary and mid-level pwn-tass. The remaining 20% — heap exploitation, format string, kernel pwn — are built on top of the same foundation, the same tubes and the same context.
And one thing that no pwntools tutorial says directly: don't try to learn the entire library. In pwnlib Dozens of modules — shellcraft, dynelf, fmtstr, filepointer, rop.srop. At the start you need tubes, packing, ELF and cyclic. Connect everything else as tasks that require it. Trying to master everything at once before the first solved tack is a trap. The four base modules worked out on ten real binarys yield more than reading the documentation from the crust to the crust. Take any carging with picoCTF or pwnable.kr, write an exploit on this article - and then it will go itself.
[Applicable: CTF, laboratory environments, test stands. In productive pentests - only for checking PoC on services agreed with the customer.]
Installation of pwntools and preparation of the environment
Requirements for the environment
Ubuntu 20.04+ / Debian 11+ / Kali Linux. WSL2 is suitable for most tasks, but kernel-operation and part of the tricks with GDB require a full-fledged VM (VirtualBox/VMware) Read more - in our material about binary analysis of vulnerabilities.
Python 3.8+
RAM: 2 GB minimum, 4 GB recommended – GDB with pwndbg eats resources when debugging heavy binary
Dependencies: python3-dev, git, libssl-dev, libffi-dev, build-essential
Installation in two steps. First system packages: sudo apt-get update && sudo apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential. Then pwntools: python3 -m pip install --upgrade pwntools. According to the documentation on docs.pwntools.com, this is enough for the eyes for all the main modules.
After installation, check the utility pwn - dial pwn version in the terminal. If you see a warning about ~/.local/bin not in $PATH – add a line export PATH="$HOME/.local/bin:$PATH" in ~/.bashrc and restart the session.
Additionally put GDB and pwndbg: sudo apt-get install gdb, then clone pwndbg with GitHub and run setup.sh. Pwntools is able to connect GDB to the running process through gdb.attach(p) – without this, the debugging of exploits turns into a blind divination according to segfaults.
The exploit template is generated by the team pwn template ./binary_name > exploit.py. At the output is a ready-made skeleton with imports, context settings and switching between local/remote through command line arguments. At competitions this template saves minutes that usually go to the boiler.
Binary Exploration: checksec, context and ELF
Before writing payload, intelligence. Launch checksec ./vuln (the utility is built into pwntools and pwndbg). A typical conclusion for training task:
NX enabled — stack unexecutable, shellcode on the stack will not start, you need an ROP or ret2win
PIE disabled – the basic address of the binary is fixed, the addresses of the functions can be used directly
No canary — no Stack Canary, buffer overflow does not require canary leakage
Partial RELRO – section .got.plt available on the record (unlike Full RELRO), you can overwrite the GOT record to redirect calls
These four parameters determine the entire operating strategy. Skip this step is to spend hours on incorrect assumptions. I saw people who spent half an hour trying to put shellcode on the stack when NX was included. Don't be like that.
Global facility context sets the architecture, the OS and the order of bytes for the entire script. The most reliable way is to tie to the binary: context.binary = './vuln'. Pwntools will determine arch, endianness and word size from the ELF title itself. The manual alternative — context.arch = 'amd64' and context.os = 'linux'. Without the right context p32()/p64() will pack with the wrong order of bytes, payload will not work, and the reason you will look for hours. I ask at each CTF parse: “The context was put up?” – and in half of the cases the answer is “a, exactly...”
Class ELF of pwnlib.elf parsite binar and gives software access to symbols, GOT and PLT. Instead of hardcode addresses from IDA, download ELF: elf = ELF('./vuln'). Function address: elf.symbols['win']. GOT-record: elf.got['puts']. PLT-record: elf.plt['puts']. If the binary was recompiled and the addresses shifted, ELF() will pick up the new values automatically, and the hardcode will break. At competitions, the organizers sometimes update the binary in the middle of the round – and the script with ELF() continues to work while the neighboring team convulsively interrupts addresses.
Level of logging: context.log_level = 'debug' outputs the hex-dump of each sent and received byte. Switch to 'info' after debugging, otherwise the withdrawal will choke.
Tubes: connection to the process and remote server
Tube is the central abstraction of pwntools I/O. Three types under three scenarios:
process('./vuln') – starting a local binary as a child process. The main debugging tool: test payload on your car, make sure that everything works, and only then switch to remote. Challenge p = process('./vuln') starts the binary and returns the tube to interact with its stdin/stdout.
remote('challenge.ctf.com', 1337) – TCP connection to the CTF server. Direct replacement nc, only scripted. The organizers give the address and the port - you connect with a script and take the flag.
listen(4444) – server socket for the return shell. Method wait_for_connection() blocked to incoming connection. It is used less often, but for tasks with reverse shell - indispensable.
All three types inherit a single interface of pwnlib.tubes. Methods send(), recv(), interactive() work the same – communicate with the local process or with a remote server, no difference. The exploit switches from local to remote replacement of one line. In the template pwn template switching is implemented through the argument: python3 exploit.py REMOTE host port uses remote(), run without arguments — process(). The beauty.
Exploit automation: send, recv and data work
Methods of sending
send(data) sends bytes as is, without adding anything. Use when payload should be bypaut exact. sendline(data) adds \n – analogue of pressing Enter. In most CTFs, the server is waiting to enter with the translation of the line, so that sendline() – default choice.
sendlineafter(delim, data) – workhorse: waiting for a line delim in the server response, then sends data + \n. Covers 90% of interaction scenarios. Example: server outputs Enter your name:, the script causes p.sendlineafter(b'name:', payload) and definitely falls at the right moment of dialogue. There's a steam room sendafter() - the same, but without \n.
Methods of reception
recv
interactive() switches tube into interactive mode – the “lets go of the steering wheel” script, and you work directly with the process as through nc. A typical finale: sent payload, got shell, called interactive() and recruit cat flag.txt hands.
Bytes, not str - everyone is burning on it
All methods are accepted and returned bytes, not str. Write p.send(b'AAAA'), not p.send('AAAA'). In Python 3, the text string will call TypeError. Every beginner steps on these rakes – especially since the old writeups in Python 2 do not know this difference and take the side.
Search for offset through cyclic and buffer overflow
Offset to return address - distance in bytes from the beginning of the buffer to the saved RIP on the stack. You can count in IDA: buffer size + padding from compiler + saved RBP. But the compiler adds alignment to a multiple of 16, and the visual count regularly lies. More securely – cyclic() from the module pwnlib.util.cyclic.
The module generates the De Bruyne pattern—a sequence where each N-length substring is unique. Challenge cyclic(200) creates 200 bytes of this pattern. Send it to a binary - the program falls, and in the register RIP (or in corefile) is a 4- or 8-byte fragment of the pattern. Transmit this value as integer in cyclic_find() and get an accurate offset. For example, cyclic_find(0x6161616c) return the numerical offset of this value within the pattern. Or so: cyclic_find(core.read(core.rsp, 4)), if you need to pull the value out of corefile directly.
Automation through pwntools is even more convenient: run the binary through p = process('./vuln'), send p.sendline(cyclic(200)), wait for the crash. Create a corefile: core = p.corefile. Read the required register — core.rsp or core.pc (Depends on the type of overflow). Transmit the value with the pattern fragment in cyclic_find() – and get an offset without a single launch of GDB hands. Three lines of code instead of fifteen minutes in the debugger.
Works if: binary really colors when overflowing (no signal processing), core dump is included (ulimit -c unlimited).
Does not work if: binary intercepts SIGSEGV through signal(), or the input is trimmed fgets()/read() to the length that does not reach the RIP.
Address Packing and Flat: Payload Assembly without Errors
In x86/x64, the data is stored in little-endian: address 0xdeadbeef in memory lies like \xef\xbe\xad\xde. Turning manually is the right way to error on the third address in the chain (checked, and more than once). Functions p32() and p64() of pwnlib.util.packing do it automatically: p32(0xdeadbeef) returns b'\xef\xbe\xad\xde'. The reverses — u32() and u64() – unpack the bytes back into the number. In fact p32(addr) - that's struct.pack('<I', addr), but without having to remember format codes.
Behavior depends on context.arch: at amd64 generalized pack() choose 64 bits, when i386 - 32. In exploits, I prefer the obvious p32()/p64(), so as not to depend on the global state.
Function flat() – less well-known, but extremely useful thing. It compatents arguments by automatically packing numbers through pack() according to the current context. Instead of b'A' * 72 + p64(0x401234) Write flat(b'A' * 72, 0x401234) - in short, cleaner, less likely to screw up the order of bytes.
Full example of the exploit buffer overflow in Python
Typical CTF pwn-task: binary vuln with buffer overflow and function win(), which takes the flag. checksec shows NX enabled, PIE disabled, No canary. The task is to redirect execution to win().
from pwn import *
context.binary = elf = ELF('./vuln')
p = process('./vuln')
offset = 72
payload = flat(b'A' * offset, elf.symbols['win'])
p.sendlineafter(b'Input:', payload)
p.interactive()
Parsing on the lines. context.binary = elf = ELF('./vuln') – with one line, load the ELF and configure the context (arch, endianness will be determined from the binary header). flat() collects padding from 72 bytes of garbage and address win(), packed according to the rules of the current architecture. sendlineafter() waiting for an invitation Input: and sends a payload with a line translation. interactive() switches to manual mode - a flag or shell will appear on the screen.
Switching to Server: Replace process('./vuln') on remote('challenge.ctf.com', 1337). All the rest of the code is unchanged. That's why there are tubes.
Pwntools ROP: chains for real pwn-tash
When Target Functions win() no and need to call system("/bin/sh") via libc – a ROP chain will be required. Class ROP of pwnlib.rop scans the binary on the gadgets and automatically builds chains of calls.
from pwn import *
elf = context.binary = ELF('./vuln')
rop = ROP(elf)
# Stage 1: leak
rop.call('puts', [elf.got['puts']])
rop.call('main')
payload = flat(b'A' * offset, rop.chain())
ROP(elf) finding gadgets of kind pop rdi; ret, pop rsi; pop r15; ret in the binary. Method call() arranges arguments through found gadgets and adds the address of the function. rop.chain() returns ready-made bytes. This snippet makes leak addresses puts from GOT - knowing it, you can calculate the libc database and find system() + line /bin/sh for the second round. Return to main through rop.call('main') allow you to send a second payload. At the same time, it may be necessary to align the stack - add the gadget ret before the call (more in the section about stack alignment below).
Method rop.dump() will display the readable description of the chain: which gadget where leads, what lies on the stack. When the chain doesn't work, dump() will show which pwntools gadget has chosen and whether the arguments are correctly arranged. Without it, you stare stupidly at segfault and guess.
Preconditions and restrictions
Works if: PIE disabled (the addresses of the gadgets are fixed), in the binary there are enough gadgets for calling convention target architecture (for x64 - minimum pop rdi; ret).
Does not work if: PIE is included and there is no leakage of the base address of the binary. Then first you need a leak through a duplicate string or partial overwrite, recalculating the base through elf.address = leaked - elf.symbols['known'], and only then the construction of the ROP. Also, it will not work if the stripped binary and symbols are missing - the addresses of the functions will have to be searched manually through the reverse in IDA/Ghidra.
GDB debugging exploit and typical mistakes
gdb.attach for real-time debugging
Challenge gdb.attach(p) opens the GDB (with pwndbg or gef, if installed) connected to the running process. You can put breakpoint, see the stack after sending payload, make sure that the RIP is overwritten with the right address. Parameter gdbscript allows you to transfer commands immediately: gdb.attach(p, gdbscript='b *main+42\nc') – breakpoint and one line. Works with process(); for remote purposes, additional adjustment (SSH, gdbserver) is required – with the usual remote() It won't start out of the box. Debug locally, then switch.
Mistakes that lose time
The wrong offset. They counted in IDA 64 bytes, and actually 72 - the compiler added alignment to the number, multiple of 16. Always check through cyclic() + corefile, do not trust one visual analysis of the stack in the disassembler.
Extra newline. sendline() adds \n. If payload already contains exactly the right number of bytes, the extra symbol will shift everything to byte and break the return address. Use send() for those payloads where each byte counts.
Stack alignment on x86-64. When calling system() the stack must be aligned with 16 bytes. If the alignment is broken, system() Painted on instructions movaps inside the glibc, and segfault looks like a problem in payload, although the address is overwritten correctly. Solution: add the gadget ret (one byte \xc3) before the target function address. Without experience, this reason can be found for hours - I myself once spent the whole evening while I got to me.
Libc mismatch. The exploit works locally, but not on the server - the glibc versions differ, the offsets system() and lines /bin/sh not matching. The utility pwninit (described in The Pwner's Roadmap on izzy.sh) solves the problem: it patches the binary for using the server version of the libc/linker so that the local environment coincides with the remote. Encounter in the first weeks of remote-tasks is not an optional knowledge. For heap tasks where the behavior of the allocator is important, the project glibc-all-in-one allows you to download and test against any version of glibc.
bytes vs str. p.send('AAAA') instead of p.send(b'AAAA') Python 3 will throw away TypeError. Every writeup older than 2020 in Python 2 had this problem, so beginners copy the code and don’t understand why it doesn’t work. Add b - and live quietly.
I lead a pwn-direction at competitions for three years and see the same picture: people find vulnerability, calculate offset, know the address of the target function - and spend 40 minutes on manual assembly payload through struct.pack and sending through subprocess.Popen. Then another 20 minutes on debugging, because forgot about endianness or added an extra byte.
Pwntools is not “another library to study.” This is the difference between "understand the buffer overflow theory" and "taking the flag in three minutes." Template from pwn template, cyclic() for offset, ELF() for symbols, flat() for payload assembly - four tools that cover 80% of the primary and mid-level pwn-tass. The remaining 20% — heap exploitation, format string, kernel pwn — are built on top of the same foundation, the same tubes and the same context.
And one thing that no pwntools tutorial says directly: don't try to learn the entire library. In pwnlib Dozens of modules — shellcraft, dynelf, fmtstr, filepointer, rop.srop. At the start you need tubes, packing, ELF and cyclic. Connect everything else as tasks that require it. Trying to master everything at once before the first solved tack is a trap. The four base modules worked out on ten real binarys yield more than reading the documentation from the crust to the crust. Take any carging with picoCTF or pwnable.kr, write an exploit on this article - and then it will go itself.