Post

404CTF 2025

404CTF 2025

Sécurité Matérielle

Trop d’IQ firstblood

1
2
C'est quand même marrant de regarder le spectre d'un signal, non ? Ça l'est beaucoup moins quand on a écrasé le fichier original ensuite... J'ai appliqué une transformée de Fourier discrète sur l'entièreté de mon signal, que j'ai ensuite enregistré au format IQ.
La fréquence d'échantillonage est de 44100Hz et le fichier est au format IQ Complex128 (donc deux Float64 par sample).

Il sufit d’inverser l’opération avec NumPy.

1
2
3
4
5
6
7
8
9
10
from scipy.io.wavfile import write

data = np.fromfile("chall.iq", dtype=np.complex128)

reconstructed_signal = np.fft.ifft(data)

signal_real = np.real(reconstructed_signal)
signal_normalized = signal_real / np.max(np.abs(signal_real))

write("reconstructed.wav", 44100, (signal_normalized * 32767).astype(np.int16))
  • Flag: 404CTF{4e5da8e7}

Space Radio firstblood

1
2
Il est 7h du matin et vous décidez d'écouter votre station préférée pour vous réveiller. Vous allumez le poste, mais rien, à part une odeur de brûlé et une épaisse fumée qui s'élève du pauvre poste de radio. Tant pis, vous décidez de faire votre propre démodulateur FM pour entendre votre station favorite.
La fréquence d'échantillonage est de 48kHz

Démodulateur FM en python :

1
2
3
4
5
6
7
8
9
10
11
import numpy as np
from scipy.signal import decimate
from scipy.io.wavfile import write

iq_data = np.fromfile("a.iq", dtype=np.complex64)

phase = np.angle(iq_data)
demodulated = np.diff(phase)
demodulated = np.unwrap(demodulated)

write("output_audio.wav", 48000, (demodulated * 32767).astype(np.int16))
  • Flag: 404CTF{3278e8739f83}

R16D4 firstblood

1
2
3
Dans la pièce principale du vaisseau de la flotte intergalactique se trouve un petit robot, de son doux nom R16D4, qui semble s'amuser avec un petit circuit. Vous vous approchez et lui demandez à quoi sert les quatre LEDs du circuit électronique qu'il tient entre les mains. Il vous dit que cela ne vous regarde pas. Piqué au vif, vous insistez pour qu'il vous révèle la séquence lumineuse qu'il émettait avant votre arrivée. Il vous tend alors un papier qui contient un schéma du circuit qu'il utilise, le code du microcontrôleur ainsi que les tensions successives appliquées à l'entrée du circuit.

Retrouvez l'état des quatres LEDs pour chacune des tensions indiquées. Le flag est la concaténation de chacun des états des quatres LEDs.

Le code lit l’état des pins 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, A6, A7 et affiche sur A0, A1, A2, A3 en binaire le nombre de pins à l’état haut, A3 étant le bit de poid faible.

Le schémam révèle une chaine de résistances, 16 en tout, reliant le +5V au GND, il s’aggit d’un pont diviseur de tension, avec 5/16 = 0.3125V entre les résistances, à chaque résistance est connecté un comparateur de tension, lui même connecté aux pins d’entrée du microcontrôleur, le premier va donc switch à 0.3125V, le deuxième à 2*0.3125 = 0.625V, et ainsi de suite. On peut donc récupérer les valeurs de tension et calculer combiens sont actifs.

1
2
3
4
5
6
7
t = [2.34, 3.9, 0.47, 0.78, 4.52, 2.96]

out = ""
for v in t:
  out += bin(int(v/0.3125))[2:].zfill(4)[::-1] # On inverse car les leds sont inversées

print(out)
  • Flag: 404CTF{111000111000010001111001}

Comment est votre température ? firstblood

1
2
3
4
5
Vous êtes en charge de surveiller les serres où poussent les plantes destinées à l'alimentation des passagers du vaisseau dans lequel vous voyagez. Cependant, en arrivant ce matin, vous vous rendez compte que l'écran d'affichage ne fonctionne plus : impossible de savoir quelle est la température et l'hygrométrie de la serre ! Ce sont des données capitales pour s'assurer que les végétaux poussent correctement, vous devez trouver un moyen de récupérer ces valeurs. Vous décidez de vous pencher sur le circuit.

Le circuit est basé sur un microcontrôleur qui dialogue avec un capteur SHT40, vous trouverez les spécifications de ce dernier dans les ressources du challenge. Trouvez le numéro de série du capteur ainsi que les valeurs de température (en °C) et d'hygrométrie (en %RH) réelles arrondies à l'entier inférieur.

Le flag (insensible à la case) est au format 404CTF{<numero de série en hexadécimal>|<température>|<hygrométrie>}

La datasheet nous indique que la puce communique en I2C, en utilisant PulseView on peut décoder le protocole.

En voici une simplifiée.

1
2
3
4
5
6
7
Address write: 44

Data write: 89
Data read: 0FA85DB84AC1

Data write: FD
Data read: 5E5326A89C0D

Au chapitre 4.5 de la datasheet on peut retrouver la liste des commandes I2C supportées.

CommandResponse lengthDescription
0x896read serial number
0xFD6measure T & RH with high precision (high repeatability)

En 4.7 la description du packet pour le Serial Number.

1
The serial number ... is transmitted as two 16-bit words, each followed by an 8-bit CRC.

Le numéro de série est donc 0FA8B84A.

En 1 on nous donne un pseudo-code qui utilise justement la commande 0xFD.

1
2
3
4
5
6
7
8
9
10
11
i2c_write(i2c_addr=0x44, tx_bytes=[0xFD])
wait_seconds(0.01)
rx_bytes = i2c_read(i2c_addr=0x44, number_of_bytes=6)
t_ticks = rx_bytes[0] * 256 + rx_bytes[1]
checksum_t = rx_bytes[2]
rh_ticks = rx_bytes[3] * 256 + rx_bytes[4]
checksum_rh = rx_bytes[5]
t_degC = -45 + 175 * t_ticks/65535
rh_pRH = -6 + 125 * rh_ticks/65535
if (rh_pRH > 100): rh_pRH = 100
if (rh_pRH < 0): rh_pRH = 0

Il nous suffit plus qu’à l’adapter.

1
2
3
4
5
6
7
8
rx_bytes = [0x5E, 0x53, 0x26, 0xA8, 0x9C, 0x0D]
t_ticks = rx_bytes[0] * 256 + rx_bytes[1]
checksum_t = rx_bytes[2]
rh_ticks = rx_bytes[3] * 256 + rx_bytes[4]
checksum_rh = rx_bytes[5]
t_degC = -45 + 175 * t_ticks/65535
rh_pRH = -6 + 125 * rh_ticks/65535
print(t_degC, rh_pRH)

Ce qui nous donne 19.48043030441748 76.33005264362555.

  • Flag: 404CTF{0FA8B84A|19|76}

Code Radiospatial n1

1
2
3
4
5
Pour communiquer entre les différents vaisseaux, plusieurs méthodes sont utilisées. Parfois, ces méthodes sont un peu archaïques et n'assurent pas la confidentialité des données échangées. Pourtant, l'amiral de la flotte maintient que "le POCSAG, ça a beau être un peu vieux, ça reste quand même super !".

Vous avez intercepté une transmission POCSAG confidentielle, retrouvez les informations échangées.

Le format du fichier est au format IQ Complex64 et la fréquence d'échantillonnage est de 4.9152 MHz.

On peut rejouer le fichier en utilisant Gqrx avec les options : file=chall.iq,rate=4915200,repeat=true,throttle=true, on peut démoduler le signal en Narrow FM centré sur 135kHz, activer le stream via UDP en bas à droite puis utiliser la commande nc -l -u localhost 7355 | sox -t raw -esigned-integer -b 16 -r 48000 - -esigned-integer -b 16 -r 22050 -t raw - | multimon-ng -t raw -a POCSAG1200 -f alpha - pour démoduler le packet POCSAG.

  • Flag: 404CTF{fb31e1acc2e6eae8be01182d3029ffcb958e3368ca991ceb53895b8c97f2f275}

Unidentified Serial Bus [1/2]

1
2
3
4
5
6
7
8
9
Alors que vous étiez en train de faire une petite sieste dehors sur votre astéroïde B-612, une sorte de satellite, venu de nulle part, s'écrase dans un fracas assourdissant à quelques pas de votre transat.

Intrigué, vous décidez de démonter l'engin et de regarder ce qui se trouve à l'intérieur. Vous récupérez un étrange circuit constitué de deux parties qui échangent des informations via le protocole USB 1.1. Vous sortez votre plus bel oscilloscope et analysez le signal qui passe sur les deux paires différentielles.

Pour cette première partie, vous devez retrouver les informations suivantes concernant les périphériques USB : bDeviceClass, idVendor et idProduct. Le flag sera au format 404CTF{bDeviceClass|idVendor|idProduct} où les différentes valeurs seront en hexadécimal sans préfixe. Le flag est insensible à la case.

Les fichiers sont au format RAW float32.

Note : le champ de synchronisation des paquets consiste en la transmission sur le bus des bits 0000000000000001 contrairement à la norme USB1.1

Comme la plupart des outils n’arrivent pas à décoder les trames, et pour me lancer un challenge, j’ai décidé de tout faire en python sans aucune librairie USB. Après avoir chargé les données dans python et les avoir converti en données numeriques,

1
2
3
4
5
d_plus = np.fromfile("USB1_D_plus.raw", dtype=np.float32)
d_neg = np.fromfile("USB1_D_neg.raw", dtype=np.float32)

d_plus = (d_plus > 1.5).astype(np.int8)
d_neg = (d_neg > 1.5).astype(np.int8)

j’ai rapidement observé le signal pour déterminer la frequence du signal, le signal se répétant toutes les 40 valeurs, on peut décimer le signal de 20 pour facilier le traitement des données.

1
2
d_plus = d_plus[::20]
d_neg = d_neg[::20]

Le protocole USB communique en utilisant une paire differencielle au lieu d’un signal de clock et de données, ce qui rend le signal moins sensible aux perturbations (si une des paire est impactée, la 2eme devrait etre impactée de la meme manière), Il faut donc reconstruire le signal à partir de la paire en soustrayant la paire D+ à la paire D-, à partir de là il y a 3 cas à traiter :

  • D+ == 1 et D- == 0, donc Diff == 1
  • D+ == 0 et D- == 1, donc Diif == -1
  • D+ == 0 et D- == 0, donc Diff == 0
  • D+ == 1 et D- == 1 n’existe pas.

En fonction de la vitesse du port USB (Low ou Fast), 1 et -1 codent l’état J et l’état K ou inversement, pour des raisons de simplicité, on va faire abstraction de K et J et dire que si Diff == 1, on code un 1 et si Diif == -1 on code un 0. Un packet USB est toujours construit de la même manière: un Sync Word (dans notre cas 0000000000000001), le packet, puis 2 SE0 (Single Ended 0, quand D+ == D- == 0) suivit de l’état J qu’on va ignorer. J’ai décidé de coder le SE0 en mettant 2 dans les données.

1
2
diff = d_plus - d_neg
diff = np.where(diff == 0, 2, np.where(diff > 0, 1, 0))

Mais ce n’est pas encore fini pour l’étape de décodage … La norme USB, probablement pour des questions de rapidité, encode le flux de données en NRZI, ce n’est pas la valeur qui code un bit, mais le changement de valeur, si t-1 == t0 on code un 1, sinon on code un 0. Ce script python décode, en identifiant les 2 SE0 de fin de packet en mettant un 2 dans la liste finale, avant de convertir en une chaine de caractère, principalement pour faciliter la suite.

1
2
3
4
5
6
7
8
9
10
def nrzi_decode(sig):
    out = []
    for i in range(1, len(sig)-1):
        if sig[i-1] == 2 and sig[i] == 2:
            out.append(2)
        else:
            out.append(1 if sig[i-1] == sig[i] else 0)
    return ''.join(map(str, out))

nrzi = nrzi_decode(diff)

Maintenant que nous avons de flux de bits, il ne reste plus qu’à diviser le flux en packet, en identifiant le Sync Word, puis le prochain 2. Un seul petit detail, si on transmet trop de 1 à la suite, les appareils risquent de perdre le fil de la transmission (aucun changement dans le signal pendant trop de temps), USB envoie donc un 0 tout les 6 1 à la suite, il faut donc les retirer pour avoir le packet final.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def remove_bit_stuffing(sig):
    sig = list(sig)
    out = ""
    while sig:
        out += sig[0]
        sig = sig[1:]
        if sig and out[-6:] == "1"*6: sig = sig[1:]
    return out

packets = []
i = 0
while i < len(nrzi)-16:
    if nrzi[i:i+16] == seq:
        start = i+16
        length = nrzi[start:].index("2")
        packets.append(remove_bit_stuffing(nrzi[start:start+length]))
        i = start + length
    else:
        i += 1

Tout les champs USB sont transmis en LSB, il faut donc inverser les bits avant de les décoder.

Un packet USB commence toujours par un PID (Packet ID et non pas Process ID :3) de 4 bits, suivi de ce même PID avec les 0 et 1 inversés. Il y a 3 catégories de packets, chaque PID d’une même categorie utilise le même format.

  • Les Token (IN, OUT, SOF, SETUP): Une addresse sur 7bits, un endpoint sur 4bits et un CRC sur 5bits.
  • Les Data (DATA0, DATA1): Des blocks de 8bits terminés par un CRC de 16bits.
  • Les Handshake (ACK, NAK, STALL): Vide. Voila la partie du script qui s’occupe de décoder les packets.
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
PIDs = {
  0b0001: "OUT",
  0b1001: "IN",
  0b0101: "SOF",
  0b1101: "SETUP",
  0b0011: "DATA1",
  0b1011: "DATA0",
  0b0010: "ACK",
  0b1010: "NAK",
  0b1110: "STALL",
  0b1100: "PRE"
}

for p in packets:
    PID = int(p[:4][::-1], 2)

    if PID in PIDs:
        print(f"[+] Packet {PIDs[PID]}")
        data = p[8:]

        if PIDs[PID] in ["IN", "OUT", "SOF", "SETUP"]:
            print(f"    [+] ADDR = {data[0:7]}")
            print(f"    [+] ENDP = {data[7:11]}")
            print(f"    [+] CRC = {data[11:11+5]}")

        elif PIDs[PID] in ["DATA0", "DATA1"]:
            
            data_bytes = bytes([
                int(data[i*8:i*8+8][::-1], 2) 
                for i in range(len(data)//8)
            ])
            print(f"    [+] DATA = '{data_bytes[:-2].hex()}'")
            print(f"    [+] CRC = {data_bytes[-2:].hex()}")
    else:
        print(f"[-] Invalid packet {p}")
    print()

Parmis tout les packets, un en particulier nous interèsse.

1
2
3
[+] Packet DATA0
    [+] DATA = '1201000281020140e71a3f9c000101020301'
    [+] CRC = 934b

Il s’aggit du Device Descriptor, qui selon la documentation se décompose ainsi:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bLength 0x12
bDescriptorType 0x01 
bcdUSB 0x0200
bDeviceClass 0x81
bDeviceSubClass 0x02
bDeviceProtocol 0x01
bMaxPacketSize0 0x40
idVendor e71a
idProduct 3f9c
bcdDevice 0x0100
iManufacturer 0x01
iProduct 0x02
iSerialNumber 0x03
bNumConfigurations 0x01
  • Flag: 404CTF{81|e71a|3f9c}

Unidentified Serial Bus [2/2]

1
2
3
Pour cette seconde partie, vous devez retrouver les données qui ont été échangées sur le bus pour avoir le flag.

Les fichiers sont au format RAW float32 et différents de ceux relatifs à la première partie.

On peut réutiliser le code du chall precedent pour lister tout les packets. La majorité sont des DATA, en modifiant legèrement le script j’ai concaténé tout les DATA.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
--- USB1/decode.py      2025-05-15 18:23:59.695763214 +0200
+++ USB2/decode.py      2025-05-15 18:23:38.453161583 +0200
@@ -60,6 +60,8 @@
        0b1100: "PRE"
 }

+out = b""
 for p in packets:
     PID = int(p[:4][::-1], 2)

@@ -78,8 +80,10 @@
                 int(data[i*8:i*8+8][::-1], 2)
                 for i in range(len(data)//8)
             ])
+            out += data_bytes[:-2]
             print(f"    [+] DATA = '{data_bytes[:-2].hex()}'")
             print(f"    [+] CRC = {data_bytes[-2:].hex()}")
     else:

Qui une fois print nous donne b'\x00\x05\x1f\x00\x00\x80\x06\x01\x00\x00\x12\x00\x12\x01\x00\x02\x81\x02\x01@\xe7\x1a?\x9c\x00\x01\x01\x02\x03\x01Le flag est : 404CTF{9f993d54e688927dbfad50d6980c4b3dbf61991ba06fbe707409d699c724116b}' (btw, on peut voir un Device Descriptor juste avant le flag).

  • Flag: 404CTF{9f993d54e688927dbfad50d6980c4b3dbf61991ba06fbe707409d699c724116b}

Analyse Forensique

Tape ton MDP

1
Trouvez le mot de passe exfiltré.

On nous fourni un fichier pcapng relativement lourd, dont beaucoup de traffic normal, mais des requètes se démarquent du reste, elles sont faites à 10.0.2.4:8000 avec le chemin /upload toutes ces requètes envoient des fichiers très similaires, plusieurs séquences de 32 caractères, séparé par des virgules. Je les extrait via la commande tshark -r tape_ton_mdp.pcapng -Y "ip.addr == 10.0.2.4 and http.request.method == POST" -T fields -e http.file_data | xxd -r -p, Après les avoir décodé en base64, toutes les sequences sont très similaires, mais rien d’évident qui apparait. En regardant de plus près, le premier packet contient bGxISEk=, qui une fois décodé donne llHHi, c’est l’information exacte qu’il me manquait, la description de “Comment decoder ca ?”, il s’aggit d’un format décrivant :

  • 2 entiers longs signés 2 * 8octet
  • 2 entiers cours non-signé 2 * 2octet
  • 1 entier signé 4octet

Ce qui nous donne une longueur de 24 octets, ce qui correspond à la longueur d’un des éléments décodés (24 * 4/3, en base64 on utilise 4 octets pour représenter 3 octest => ratio de 4/3). On peut décoder ça avec la bibliotheque builtin struct.

1
2
3
4
5
6
7
import base64
import struct

values = ".....".split(",")
values = [struct.unpack("llHHI", base64.b64decode(x)) for x in values]

for v in values: print(v)

Et voici un extrait de la sortie :

1
2
3
4
5
6
7
8
9
10
11
12
(1740646496, 475045, 4, 4, 42)
(1740646496, 475045, 1, 42, 1)
(1740646496, 475045, 0, 0, 0)
(1740646496, 627668, 4, 4, 16)
(1740646496, 627668, 1, 16, 1)
(1740646496, 627668, 0, 0, 0)
(1740646496, 696673, 4, 4, 42)
(1740646496, 696673, 1, 42, 0)
(1740646496, 696673, 0, 0, 0)
(1740646496, 775769, 4, 4, 19)
(1740646496, 775769, 1, 19, 1)
(1740646496, 775769, 0, 0, 0)

On peut directement identifier des blocks de 3 lignes et un timestamp en première colonne, puis surement la partie en microseconde du timestamp. Lorsque la 3eme colonne vaut 1, quasiment tout le temps, la 5eme colonne vaut 1, sauf quand la 4eme colonne vaut 42, dans ce cas, la 5eme colonne alterne entre 1 et 0.

Après un peu de reflexion (et en me rappelant du titre du chall), j’en ai conclu qu’il s’agissait probablement de l’exfiltration d’un keylogger, j’ai donc écrit un script Python qui reconverti les packets en texte.

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
import base64
import struct

keymap = {
    2: '&',3: 'é',4: '"',5: "'",6: '(',7: '-',8: 'è',9: '_',10: 'ç',11: 'à',
    12: ')',13: '=',16: 'a',17: 'z',18: 'e',19: 'r',20: 't',21: 'y',22: 'u',23: 'i',
    24: 'o',25: 'p',26: '^',27: '$',30: 'q',31: 's',32: 'd',33: 'f',34: 'g',35: 'h',
    36: 'j',37: 'k',38: 'l',39: 'm',40: 'ù',41: '²',43: '*',44: 'w',45: 'x',46: 'c',
    47: 'v',48: 'b',49: 'n',50: ',',51: ';',52: ':',53: '!',57: ' ',28: '\n',14: '[BACKSPACE]'
}

keymap_shift = {
    2: '1',3: '2',4: '3',5: '4',6: '5',7: '6',8: '7',9: '8',10: '9',11: '0',
    12: '°',13: '+',16: 'A',17: 'Z',18: 'E',19: 'R',20: 'T',21: 'Y',22: 'U',23: 'I',
    24: 'O',25: 'P',26: '¨',27: '£',30: 'Q',31: 'S',32: 'D',33: 'F',34: 'G',35: 'H',
    36: 'J',37: 'K',38: 'L',39: 'M',40: '%',41: '',43: 'µ',44: 'W',45: 'X',46: 'C',
    47: 'V',48: 'B',49: 'N',50: '?',51: '.',52: '/',53: '§',57: ' ',28: '\n',14: '[BACKSPACE]'
}

keymap_altgr = {
    2: '',3: '~',4: '#',5: '{',6: '[',7: '|',8: '`',9: '\\',10: '^',11: '@',
    12: ']',13: '}',16: '',17: '',18: '',19: '',20: '',21: '',22: '',23: '',
    24: '',25: '',26: '',27: '',30: '',31: '',32: '',33: '',34: '',35: '',
    36: '',37: '',38: '',39: '',40: '',41: '',43: '',44: '',45: '',46: '',
    47: '',48: '',49: '',50: '',51: '',52: '',53: '',57: ' ',28: '\n',14: '[BACKSPACE]'
}


values = "...".split(",")
values = [ struct.unpack("llHHI", base64.b64decode(x)) for x in values]

out = ""
is_shift = False
is_altgr = False

for v in values[1::3]:
    key = v[3]
    pressed = v[4]
    if key == 42 or key == 54: is_shift = pressed
    if key == 100: is_altgr = pressed
    
    if not pressed: continue
    elif key in keymap:
        if is_shift: out += keymap_shift[key]
        elif is_altgr: out += keymap_altgr[key]
        else: out += keymap[key]
print(out)

Ce qui nous donne :

1
2
3
4
5
6
fi
Ariane 6
orbite de transfert géostationnaire
googgle[BACKSPACE][BACKSPACE][BACKSPACE][BACKSPACE][BACKSPACE][BACKSPACE][BACKSPACE]bloc note en ligne
mail : toto@gmail.com
mdp : 404CTF{k3yl0gg3r_3xf1ltr4t10n}ESA
  • Flag: 404CTF{k3yl0gg3r_3xf1ltr4t10n}

USB 51

1
Alors que vous travaillez tranquillement dans votre bureau à l'ESA (Agence Spatiale Européenne), une alarme intrusion retentit. Il semblerait qu'un petit malin ait essayé d'exfiltrer des documents secrets. Mais pas de panique : armé de vos outils et de vos connaissances, vous êtes prêt à analyser la capture réseau de l’attaque ! Vous ne devriez pas avoir trop de mal à retrouver le document exfiltré, ainsi que les informations cachées qu’il contient — celles que l’attaquant cherchait sûrement...

En ouvrant la capture avec wireshark on retrouve des packets USB, mais un se démarque des qutres par sa taille, plus de 40ko. Après l’avoir extrait la commande file nous retourne

1
2
λ file ctf.dat  
ctf.dat: PDF document, version 1.7, 4 page(s)

En l’ouvrant on trouve du binaire, qui une fois décodé nous donne le flag.

  • Flag: 404CTF{W3_c0ME_IN_p3aC3}

Dockerflag

1
En vous baladant sur le système informatique du vaisseau, vous tombez sur un vieux projet réalisé il y a bien longtemps, dans une galaxie lointaine, très lointaine. Le projet avait été arrêté assez rapidement et supprimé de votre Gitlab interne, mais peut-être que l'image Docker du site web que vous avez en votre possession a encore quelques secrets bien gardés...

Après avoir extrait tout les fichiers tar.gz (for i in *.tar.gz; do tar xf $i;done), on peut trouver un dossier /app dans qui continent notemment un dossier .git. Malheureusement git ne le detecte pas, il faut donc explorer le dossier à la main. Dans le dossier .git on peut retrouver un dossier objects contetant les différents commits compréssés en utilisant zlib. En utilisant zlib-flate du paquet qpdf sous Arch Linux (on juste utiliser zlib dans Python, etc) on peut décompresser les données, retrouver le flag en clair dedans. Ma commande finale : find . -type f -exec bash -c 'zlib-flate -uncompress < {}' \; | strings | grep 404

  • Flag: 404CTF{492f3f38d6b5d3ca859514e250e25ba65935bcdd9f4f40c124b773fe536fee7d}

Rétro-Ingénierie

3x3cut3_m3

1
Nous avons besoin de vous ! Aidez-nous à faire décoller la fusée 😃 !

Apres formatage du fichier, on se retourve avec ca :

1
2
3
4
5
6
7
8
$jPVIMo4 = "...";
$Zh4nwQ = $jPVIMo4.ToCharArray();
[array]::Reverse($Zh4nwQ);
-join $Zh4nwQ 2>&1> $null;
$zMS = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("$Zh4nwQ"));
$6yNw = "Invoke-Expression";
New-Alias -Name PWN -Value $6yNw -Force;
PWN $zMS;

Le script inverse $jPVIMo4, puis le decode en base64 avant de l’executer. En reproduisant ca on obtient … un nouveau script qui refait la meme chose … je repete ces operations en boucle jusqua obtenir quelquechose de different. Apres renomage, on obtient (sans toute la partie qui gere l’emetre des bips PARTICULIEREMENT INSUPORTABLES) :

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
$buf1 = @(42, 17, 99, 84, 63, 19, 88, 7, 31, 55, 91, 12, 33, 20, 75, 11)
$username_length = ($env:USERNAME).Length

$passwd = Read-Host -Prompt "Veuillez entrer le mot de passe pour faire décoller la fusée"

$temp = @()
for ($i = 0; $i -lt $passwd.Length; $i++) {
    $passwd_at_i = [int][char]$passwd[$i]
    $v = (($passwd_at_i -bxor $buf1[$i]) - $username_length) % 169
    if ($v -lt 0) {
        $v += 169
    } 
    $temp += $v
}

$buf2 = @(93, 72, 28, 24, 67, 23, 98, 58, 35, 75, 98, 87, 68, 30, 97, 33)
$valid = $true
for ($i = 0; $i -lt $buf2.Length; $i++) {
    if ($buf2[$i] -ne $temp[$i]) {
        $valid = $false
        break
    }
}

if ($valid) {
    Write-Host "Mot de passe correct ! La fusée s'envoleeee !" -ForegroundColor Green
} else {
    Write-Host "Mot de passe incorrect. La fusée vient d'exploser" -ForegroundColor Red
}

Le script effectue donc un XOR entre le mot de passe et un buffer static, puis soustrait la longueur du nom d’utilisateur, puis compare le resultat avec un autre buffer static. On va donc pouvoir XOR les 2 buffer statics, a un modulo 169 pres, pour obtenir le flag a un detail pres, il va falloir bruteforce la longueur du username. Voici un script qui va nous fournir les valeurs le username_length plausibles

1
2
3
4
5
6
7
8
9
10
11
12
buf1 = [42, 17, 99, 84, 63, 19, 88, 7, 31, 55, 91, 12, 33, 20, 75, 11]
buf2 = [93, 72, 28, 24, 67, 23, 98, 58, 35, 75, 98, 87, 68, 30, 97, 33]

for username_length in range(1, 256):
    flag = []
    for i in range(len(buf1)):
        g_mod = (buf2[i] - username_length) % 169
        f = (g_mod ^ buf1[i])
        if 32 <= f <= 126: flag.append(f)
        else: break
    else:
        print(username_length, bytes(flag))

Ce qui nous donne pour 160, la valeur L@Fus33D3c0ll3!!

  • Flag: 404CTF{L@Fus33D3c0ll3!!}

Cbizarre [1/2]

1
Vous êtes prêt à partir en voyage spatial ! Mais la fusée demande le fameux flag qui commence par 404CTF{...}… sauf que vous l’avez oublié 😢. Ni une ni deux, vous vous plongez dans les méandres du programme pour voir s’il est vraiment nécessaire d’avoir un mot de passe pour découvrir ce mystérieux flag…

En utilisant strings sur le binaire on trouve un lien pastebin https://pastebin.com/raw/n8CXuwE0, contenant le flag.

  • Flag: 404CTF{PAst3_mY_FL2g}

Cbizarre [2/2]

1
Vous êtes à bord de la fusée lorsque vous recevez un message en provenance de la Terre. Mais, étourdi comme toujours, vous avez encore oublié le mot de passe… Allez, c’est reparti pour tenter de retrouver ce flag récalcitrant !

En décompilant le binaire avec Binary Ninja on peut remarquer ce bloc de code qui se répète avec différentes valeurs pour a et b:

1
2
3
4
if (argv[1][a] != b){
    fwrite("Error: Incorrect password.\n", 1, 0x1b, stderr);
    exit(1);
}

On peut donc determiner argv[1] simplement en affectant à argv[1][a] la valeur b, ce qui nous donne faVMPZa%3yNKo@nMv%1x. Après tout ces if, le programme fait ça :

1
2
3
int64_t var_28;
memcpy(&var_28, "\x52\x51\x62\x0e\x04\x1c\x1a\x66\x54\x49\x7e\x2f\x49\x33\x02\x20\x06\x69\x02\x05\x00", 0x15);
printf("Bravo ! Vous avez le flag ! %s\n", xor(&var_28, argv[1], 0x14));

On peut répliquer ce code en python pour obtenir le flag.

1
2
3
4
5
a = b'faVMPZa%3yNKo@nMv%1x'
b = b"\x52\x51\x62\x0e\x04\x1c\x1a\x66\x54\x49\x7e\x2f\x49\x33\x02\x20\x06\x69\x02\x05\x00"

xor = lambda x,y: bytes(a^b for a,b in zip(x,y))
print(xor(a, b))
  • Flag: 404CTF{Cg00d&slmpL3}

Reversconstrictor

1
Lors de votre voyage intergalactique, vous croisez un serpent géant qui fonce droit sur vous. Heureusement, ce genre de problème avait été anticipé : il vous suffit d’activer votre super blaster intergalactique pour vous sortir de cette mauvaise passe. Mais au moment d’appuyer sur le bouton, une fenêtre s’affiche sur votre tableau de bord… Elle demande un mot de passe pour mettre à jour vos systèmes avant de pouvoir déclencher le laser ! Dépêchez-vous de retrouver ce mot de passe !

En utilisant strings sur le binaire on peut identifier des chaines de caractères typiques d’un executable créé à l’aide de pyinstaller.

1
2
3
4
5
6
7
x_tk_data/ttk/xpTheme.tcl
x_tk_data/unsupported.tcl
x_tk_data/xmfbox.tcl
xbase_library.zip
xmodules/encrypt_key.cpython-39.pyc
zPYZ-00.pyz
5libpython3.9.so.1.0

En utilisant pyinstxtractor-ng on peut unpack le binaire. Dans les fichiers extraits on peut retrouver chall.pyc qui contient le code python principal, mais compilé. On peut alors utiliser pycdc pour le décompiler. Une fois décompilé on retrouve le bloc de code :

1
2
3
4
5
def validate_password(password):
    if xor(module.encode_password(password.encode('ascii')), module.encrypt_key(0x6D39D56F8A40A6BBE43A82A53B2C762EA780C21A32C6B3EF765D3A54F3432432F3E6D39D56F8A40A6BBE43A82A53B2C762EA780C21A32C6B3EF765D3A54F3432432F3E)) == b'\xe9J\x1aB\xe2\xc5\xf3S\'\xd6>\n$\x94\x1a\x07\'F\xc6\xa1\x07\xb7\xcc\xec\xe1\x84\xec\xac\xe4\xd64\x8f\xc3\x12\x04\x16$n\x15\xec\xe1\xaee5\xc7\xecOX"\x98EO\x1f2\xb4\x15\xc4\xed\xf4\xcd$\xd3\xd3u\xc2\xf8\xc6\xae\x06\x08\xcd\xff\xe0(\xe9\xb0\xe7\xde6\x90\xcc\xfd\x02}%\x1a\x1a\xc9#\x10\xc2\x86\x06\x08\xcd\xfe&\xb8K\x0f)\x9a\xb6\xb9\x02\x17\xa0\xd8\xe4]\x98\xf5*\x154<\x06\x875\xbd\x05@\xe6\x88\xe3&6%\xcc\x18\x06\\%\xa4\x1a7!\xfe\xc3\xae\x06\x08\xcd\xff\xe2\x18\xe2x\xe0\x927x\r\xfa\xa6\xbd\xe67\x97\xf7\xe5)f\x94\xc8\xbdv\r\xef\x12\x1bZ\xe8e\xf3S\'\xd6>\n"8\x1be\x9c\xdf\xe8\x9b\x06\xb7\x0b3V\x1f\xedN\x87\xbbI!C>8z%\xc0\xeaM\xb5\xd1p\xd1\x0f|A\xd7B\x03\xc54\xd5T\xb9\xfd\x88;\xbf\x10\x81L\x90L\x0b\xff\xed\xe1\xe5dQ\xc4\x17\xd5\xafUl\xec':
        label.config('Mot de passe correct !', **('text',))
    else:
        label.config('Mot de passe incorrect !', **('text',))
  • Note: On obtient un pseudocode python, on peut notemment le voir sur les 3 dernières lignes, label.config('Mot de passe correct !', **('text',)) est en réalité label.config(text='Mot de passe correct !'), cette “erreur” est dûe à la manière dont est compilés les paramètres nommés, vous pouvez regarder plus en profondeur en utilisant pycdas, qui fait partie de pycdc pour voir à quoi ressemble le bytecode.

Ce code fait appel à la bibliotèque module qui est chargée depuis le fichier modules/encrypt_key.cpython-39.pyc (écrit au début du code). Malheureusement si on essaie de décompiler le fichier, il arrive à décompiler uniquement las fonction encrypt_key.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def encrypt_key(key):
    for _ in range(100):
        key <<= 1
        key ^= 0x40440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044440440444044404440440440440440444044440440444044
        key >>= 1
        key &= 0xF3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF33271ADF3F3271ADF3271ADF3F3271ADFF3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF33271ADF3F3271ADF3271ADF3F3271ADFF3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF33271ADF3F3271ADF3271ADF3F3271ADFF3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF3F3271ADF3271ADF33271ADF3F3271ADF3271ADF3F3271ADF1ADFF3271ADF1ADFADF3F3271ADF1ADFF3271ADF1ADFADF3F327
        key -= 0x4351EAC5DB5A0D3F31513511EAC5DB5A0D3F3521EAC5DB5A0D3F3151EAC5DB5A0D3F2143EAC5DB5AEAC5DB5A0D3F3151EAC5DB5A0D3F31514351EAC5DB5A0D3F31513511EAC5DB5A0D3F3521EAC5DB5A0D3F3151EAC5DB5A0D3F2143EAC5DB5AEAC5DB5A0D3F3151EAC5DB5A0D3F31514351EAC5DB5A0D3F31513511EAC5DB5A0D3F3521EAC5DB5A0D3F3151EAC5DB5A0D3F2143EAC5DB5AEAC5DB5A0D3F3151EAC5DB5A0D3F31514351EAC5DB5A0D3F31513511EAC5DB5A0D3F3521EAC5DB5A0D3F3151EAC5DB5A0D3F2143EAC5DB5AEAC5DB5A0D3F3151EAC5DB5A0D3F315131510D3F31513151DB5A0D3F315131510D3F31513151DB5A0D3F
        key ^= 0x40440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044044044044044404404404404404440440440440440444044440440444044404440440440440440444044440440444044
        key <<= 1
        key += 4324354
        key >>= 1
    key = abs(key)
    encrypted_bytes = key.to_bytes((key.bit_length() + 7) // 8, 'big', **('byteorder',))
    return encrypted_bytes

Comme des fois en rétro-ingénérie de binaires, il va falloir lire le code “assembleur”, dans notre cas le bytecode avec pycdas afin de comprendre la fonction encode_password, dans mon cas j’ai reconstitué le code python en lisant le bytecode (la structure du bytecode python est assez etrange et donc difficile à comprendre au début, mais une fois que c’est compris, ça devient très simple de convertir le code, question d’habitude).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def encode_password(password):
    a = b''
    x_list = [110, -34, -230]
    for i in range(len(password)):
        b = password[i]//11 + 11
        c = password[i]%11
        d = b+c
        e = b*c
        r = []
        for i in range(3):
            x = x_list[i]
            y = x**2 - d*x + e
            if y > 0:
                if y < 65535:
                    if y not in r:
                        r.append(y)
        for i in range(3):
            a += bytes.fromhex(hex((r[i])//256))
            a += bytes.fromhex(hex((r[i])%256))
    return a

Pour inverser cette fonction j’ai reconstitué chaque valeur de r, puis j’ai bruteforce password[i] pour trouver la valeur donnant en sortie la valeur de r recherchée.

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
def decode_password(encoded):
    x_list = [110, -34, -230]
    password = []

    for i in range(0, len(encoded), 6):
        r = []
        for j in range(3):
            y = (encoded[i + 2*j] << 8) + encoded[i + 2*j + 1]
            r.append(y)

        for p in range(256):
            b = p // 11 + 11
            c = p % 11
            d = b + c
            e = b * c

            r_check = []
            for x in x_list:
                y = x**2 - d*x + e
                if y > 0 and y < 65535:
                    r_check.append(y)

            if sorted(r_check) == sorted(r):
                password.append(p)
                break

    return bytes(password)

En exploitant la propriété du XOR selon laquelle si password ^ key = message alors message ^ key = password on peut retrouver la valeur de password encodé, puis appliquer cette fonction pour le décoder.

1
2
3
4
5
6
7
8
enc_key = encrypt_key(0x6D39D56F8A40A6BBE43A82A53B2C762EA780C21A32C6B3EF765D3A54F3432432F3E6D39D56F8A40A6BBE43A82A53B2C762EA780C21A32C6B3EF765D3A54F3432432F3E)
message = b'\xe9J\x1aB\xe2\xc5\xf3S\'\xd6>\n$\x94\x1a\x07\'F\xc6\xa1\x07\xb7\xcc\xec\xe1\x84\xec\xac\xe4\xd64\x8f\xc3\x12\x04\x16$n\x15\xec\xe1\xaee5\xc7\xecOX"\x98EO\x1f2\xb4\x15\xc4\xed\xf4\xcd$\xd3\xd3u\xc2\xf8\xc6\xae\x06\x08\xcd\xff\xe0(\xe9\xb0\xe7\xde6\x90\xcc\xfd\x02}%\x1a\x1a\xc9#\x10\xc2\x86\x06\x08\xcd\xfe&\xb8K\x0f)\x9a\xb6\xb9\x02\x17\xa0\xd8\xe4]\x98\xf5*\x154<\x06\x875\xbd\x05@\xe6\x88\xe3&6%\xcc\x18\x06\\%\xa4\x1a7!\xfe\xc3\xae\x06\x08\xcd\xff\xe2\x18\xe2x\xe0\x927x\r\xfa\xa6\xbd\xe67\x97\xf7\xe5)f\x94\xc8\xbdv\r\xef\x12\x1bZ\xe8e\xf3S\'\xd6>\n"8\x1be\x9c\xdf\xe8\x9b\x06\xb7\x0b3V\x1f\xedN\x87\xbbI!C>8z%\xc0\xeaM\xb5\xd1p\xd1\x0f|A\xd7B\x03\xc54\xd5T\xb9\xfd\x88;\xbf\x10\x81L\x90L\x0b\xff\xed\xe1\xe5dQ\xc4\x17\xd5\xafUl\xec'

xor = lambda x,y: bytes(a^b for a,b in zip(x,y))

enc_password = xor(message, enc_key)
password = decode_password(enc_password)
print(password)
  • Flag: 404CTF{D0_y0U_L0v3_Pyth02?1_l0v3_pYt60n!}
This post is licensed under CC BY 4.0 by the author.