6 minutes
🇫🇷 FCSC 2025 - speedrun/polygraph
Sommaire
- Introduction
- Description du challenge
- Analyse initiale
- Reverse engineering de la VM
- Modélisation et résolution symbolique
- Génération du flag final
- Conclusion
Introduction
Voici le writeup d’un challenge de reverse engineering / speedrun du FCSC 2025. Ce challenge simule une machine virtuelle personnalisée exécutant un microcode qui vérifie la validité d’une entrée.
Description du challenge
Vous nous aviez dit que vous connaissiez le secret. Si cela est bien le cas alors il n'y aura aucun problème. Sinon...
Analyse initiale
Informations sur le binaire
On nous donne un exécutable Windows 64 bits :
file polygraph.exe;
polygraph.exe: PE32+ executable (console) x86-64, for MS Windows
Le binaire est un PE32+ partiellement strippé.
Points d’entrée clés
- Le programme lit l’entrée utilisateur via
gets_s(input, 0x11)
, limitant ainsi l’entrée à 16 octets maximum.
gets_s(input,0x11);
- Initialisation d’un tableau global de registres à zéro :
_registers_start = ZEXT816(0);
_DAT_140005f70 = 0;
- Interprétation d’un microcode composé de 773 instructions, chacune codée sur 3 octets :
microcode_size = 0x305;
do {
opcode = microcode[-1];
if (opcode == 0xb) {
uVar1 = *(uint *)(®isters_start + (ulonglong)microcode[1] * 4);
LAB_140001286:
*(uint *)(®isters_start + (ulonglong)*microcode * 4) =
*(int *)(®isters_start + (ulonglong)*microcode * 4) + uVar1;
}
else if (opcode == 0x16) {
*(int *)(®isters_start + (ulonglong)*microcode * 4) =
*(int *)(®isters_start + (ulonglong)*microcode * 4) -
*(int *)(®isters_start + (ulonglong)microcode[1] * 4);
}
else if (opcode == 0x2c) {
*(int *)(®isters_start + (ulonglong)*microcode * 4) =
*(int *)(®isters_start + (ulonglong)*microcode * 4) << (microcode[1] & 0x1f);
}
else if (opcode == 0x37) {
*(uint *)(®isters_start + (ulonglong)*microcode * 4) =
*(uint *)(®isters_start + (ulonglong)*microcode * 4) >> (microcode[1] & 0x1f);
}
else if (opcode == 0x42) {
*(int *)(®isters_start + (ulonglong)*microcode * 4) =
*(int *)(®isters_start + (ulonglong)*microcode * 4) *
*(int *)(®isters_start + (ulonglong)microcode[1] * 4);
}
else if (opcode == 0x4d) {
*(uint *)(®isters_start + (ulonglong)*microcode * 4) =
*(uint *)(®isters_start + (ulonglong)*microcode * 4) |
*(uint *)(®isters_start + (ulonglong)microcode[1] * 4);
}
else if (opcode == 0x58) {
*(uint *)(®isters_start + (ulonglong)*microcode * 4) = (uint)(byte)input[microcode[1]];
}
else if (opcode == 99) {
uVar1 = (uint)microcode[1];
goto LAB_140001286;
}
microcode = microcode + 3;
microcode_size = microcode_size + -1;
} while (microcode_size != 0);
- Vérification et affichage du flag :
if (_registers_start == 0) {
iVar2 = sha256_hash(input);
if (iVar2 == 0) {
puts("Something went wrong.");
goto LAB_14000131d;
}
custom_print("You\'re honest.\nFCSC{");
do {
custom_print(&DAT_140003300,flag_buffer[microcode_size]);
microcode_size = microcode_size + 1;
} while (microcode_size < 0x20);
_Str = "}";
}
Reverse engineering de la VM
Structure du microcode
Chaque instruction est un triplet (opcode, dst, src)
de 3 octets. Le compteur microcode_size
commence à 0x305
(773 en décimal) et est décrémenté à chaque itération, tandis que le pointeur microcode
avance de 3 octets à chaque instruction.
Cela nous donne la taille totale du microcode : 773 * 3 = 2319
octets.
Registres et accès mémoire
Le programme utilise une zone mémoire pour stocker les registres. _registers_start
est un pointeur vers le début de cette zone. Tous les registres sont initialisés à zéro avant l’exécution du microcode.
Jeu d’instructions
Opcode | Nom | Équivalent en C |
---|---|---|
0x0B |
ADD | R[dst] = (R[dst] + R[src]) mod 2^32 |
0x16 |
SUB | R[dst] = (R[dst] - R[src]) mod 2^32 |
0x2C |
SHL | R[dst] <<= (src & 0x1F) |
0x37 |
SHR | R[dst] = R[dst] >> (src & 0x1F) |
0x42 |
MUL | R[dst] = (R[dst] * R[src]) mod 2^32 |
0x4D |
OR | R[dst] = R[dst] OR R[src] |
0x58 |
MOVB | R[dst] = (uint8_t)input[src] (zero-extend) |
0x63 |
ADDI | R[dst] = (R[dst] + imm8) mod 2^32 |
Les opérations
mod 2^32
indiquent que nous travaillons avec desuint32_t
qui subissent un wrap-around, tronquant tout dépassement.
Modélisation et résolution symbolique
Choix de l’approche
Pour résoudre ce challenge, nous utiliserons Z3, un solveur SMT, afin de raisonner sur les opérations effectuées par la VM :
- 16
BitVec(8)
pour représenter les octets de la clé d’entrée (16 octets max) - N
BitVec(32)
pour les registres, où N est le nombre maximum de registres utilisés - Contrainte finale :
R0 == 0
après exécution de toutes les instructions
Pour déterminer le nombre de registres nécessaires, nous pouvons utiliser ce script :
import struct
code = open('microcode.bin','rb').read()
it = struct.iter_unpack('<BBB', code)
max_idx = 0
for op, dst, src in it:
max_idx = max(max_idx, dst, src)
num_registers = max_idx + 1
print(num_registers)
Ce script nous donne 254
comme index maximum, donc nous avons besoin de 255 registres (0 à 254).
Construction du solveur
Pour gagner du temps, j’ai utilisé un émulateur partiellement généré par ChatGPT :
#!/usr/bin/env python3
import sys, pathlib, struct, z3, importlib
UINT32 = 32
NB_REG = 256
class VMZ3:
def __init__(self, code: bytes):
if len(code) % 3:
raise ValueError("Le micro-code doit être un multiple de 3 octets")
self.code = code
self.key = [z3.BitVec(f"k{i}", 8) for i in range(16)]
self.reg = [z3.BitVec(f"r{i}", UINT32) for i in range(NB_REG)]
self.solver = z3.Solver()
self._build_constraints()
# ---------------- VM operations ---------------- #
def _wrap32(self, expr):
return z3.Extract(31, 0, expr)
def _ADD (self, d, s): self.reg[d] = self._wrap32(self.reg[d] + self.reg[s])
def _SUB (self, d, s): self.reg[d] = self._wrap32(self.reg[d] - self.reg[s])
def _SHL (self, d, imm): self.reg[d] = self._wrap32(self.reg[d] << imm)
def _SHR (self, d, imm): self.reg[d] = z3.LShR(self.reg[d], imm)
def _MUL (self, d, s): self.reg[d] = self._wrap32(self.reg[d] * self.reg[s])
def _OR (self, d, s): self.reg[d] = self.reg[d] | self.reg[s]
def _MOVB(self, d, idx): self.reg[d] = z3.ZeroExt(24, self.key[idx & 0x0F])
def _ADDI(self, d, imm): self.reg[d] = self._wrap32(self.reg[d] + imm)
# ---------------- Build full trace ------------- #
def _build_constraints(self):
it = struct.iter_unpack("<BBB", self.code)
for op, dst, src in it:
if op == 0x0B: self._ADD(dst, src)
elif op == 0x16: self._SUB(dst, src)
elif op == 0x2C: self._SHL(dst, src & 0x1F)
elif op == 0x37: self._SHR(dst, src & 0x1F)
elif op == 0x42: self._MUL(dst, src)
elif op == 0x4D: self._OR (dst, src)
elif op == 0x58: self._MOVB(dst, src)
elif op == 0x63: self._ADDI(dst, src)
else:
raise ValueError(f"Opcode inconnu 0x{op:02X}")
# condition finale r0 == 0
self.solver.add(self.reg[0] == 0)
def solve(self):
if self.solver.check() != z3.sat:
raise RuntimeError("Aucune clé trouvée")
model = self.solver.model()
return bytes(model.evaluate(k).as_long() for k in self.key)
def main():
if len(sys.argv) < 2:
sys.exit(1)
code = pathlib.Path(sys.argv[1]).read_bytes()
vm = VMZ3(code)
key = vm.solve()
print("Clé trouvée (hex) :", key.hex())
if __name__ == "__main__":
main()
Ce script nous donne une clé qui, après traitement par le microcode, satisfait la condition
R0 == 0
.
Génération du flag final
Normalement, exécuter le programme avec la clé trouvée devrait fonctionner. Cependant, comme je travaille sous Linux avec Wine, ça ne fonctionne pas. J’ai donc décidé de reproduire la fonction sha256_hash
pour calculer le flag :
import binascii
import hashlib
key = binascii.unhexlify("2ab857a5fbe0a867bfd8abebf1e9c831")
digest = hashlib.sha256(key).hexdigest()
print("FCSC{%s}" % digest)
Conclusion
Ce challenge était particulièrement intéressant et instructif. J’ai pu approfondir mes connaissances en reverse engineering et en résolution symbolique. Je pense en revanche qu’il aurait mérité une étoile supplémentaire pour la difficulté à produire un émulateur fonctionnel.