Etape 1 : reconnaissance du terrain.
Il s’agit d’un programme Python.
Le nom du programme est en général un indice mais cela ne me dit rien initialement. Je reviendrai dessus un peu plus loin.
En survolant le code, on comprend globalement l’idée du programme :
- on doit entrer un flag dont on comprend qu’il est composé de deux parties de 32 + 32 caractères ;
- les 64 caractères doivent être « imprimables » donc ils sont compris entre les codes ASCII 32 et 126 ;
- chacun des 32 premiers caractères du password sert comme un masque
XOR contre une string donnée en hexadécimal. Par exemple pour le
premier caractère du flag
L[0]:_(bytes(x ^ L[ 0] for x in bytes.fromhex("31242a330d1233242d381b2524223a037c3a1c3d28062d3a26333e3c2c28720d2138013d")), L[32:]),Le résultat sert à construire dynamiquement le nom d’une fonction PYTHON contenue dans le script. C’est la fonction_()qui se charge de cela. Elle fait uneval()du nom obtenu ce qui exécutera le code de cette fonction. - Chaque fonction dynamiquement appelée (par exemple
fcsc_qiLjdoEUA7gmHYxmJxmReh7icLYMpLrsiJM()) prend en paramètre les 32 caractères de la seconde moitié du flag. Un calcul arithmétique modulo 256 est effectué, le résultat est envoyé dans un « set » PYTHON. - Au final, le set ne doit contenir que le seul élément 0
ce qui signifie que les 32 appels de fonctions
fcsc_xxxxxx()doivent tous retourner 0.
Etape 2 : recherche des 32 premiers caractères du flag.
Cela semble simple de bruteforcer la recherche du début du flag.
Pour chacun des 32 premiers caractères, on teste les valeurs ASCII imprimables de 32 à 127 et une de ces valeurs finira bien par appeler une fonction du script PYTHON (parce que c’est un challenge ayant donc une solution).
Pour faire cela, je change un peu le code du script (un coup de parser ad-hoc le réécrit facilement - exercice laissé au lecteur) :
#!/usr/bin/env python3.13
from re import search as s
from traceback import format_exception as t
flag = []
def fcsc_qiLjdoEUA7gmHYxmJxmReh7icLYMpLrsiJM(i,L):
print(f"MATCH qiLjdoEUA7gmHYxmJxmReh7icLYMpLrsiJM for input[{i}] == {chr(L)}")
flag.append(chr(L))
def fcsc_hPcawhvjJ4hCcvErPWUMgKEoYzVEVVKUogL(i,L):
print(f"MATCH hPcawhvjJ4hCcvErPWUMgKEoYzVEVVKUogL for input[{i}] == {chr(L)}")
flag.append(chr(L))
...
xor = [
"31242a330d1233242d381b2524223a037c3a1c3d28062d3a26333e3c2c28720d2138013d",
"180615101818124f454f2f301c371922101912372e061f0e37351e211b3b",
"21343731243731372e300f2f22062d1d7215223032722f3f28153d151d2027332b",
...
]
def _(f, *args):
try:
return eval("fcsc_" + f.decode())(*args)
except NameError as e:
obj = s(r"[^:]+: ['\"](.+?)['\"]", t(e)[-1])
if ( obj ):
z = obj.group(1)
return eval(f"{z}({','.join(repr(_) for _ in args)})")
pass
except SyntaxError as e:
pass
for i in range(32):
for L in range(32, 127):
_(bytes(x ^ L for x in bytes.fromhex(xor[i])), i, L)
print(f"RECOVERED = FCSC{{{ "".join(flag) }")
Si l’on regarde plus en détails les 32 opérations XOR effectuées,
on constate que les strings en hexadécimal ne sont pas toutes de
la même longueur (voir les 3 premières entrées dans mon tableau
xor). Elles sont impaires nécessairement mais pas
toujours de longueur 70. Pourquoi 70 ? Parce que les fonctions
fcsc_xxxx() ont toutes leur partie xxxx qui fait 35 de long.
Si l’on voulait construire les noms exacts des fonctions présentes
dans le script, on devrait avoir des strings de 70 caractères sur
lesquelles faire le XOR. Ce n’est pas le cas.
Donc il y a une subtilité.
Pour comprendre exactement comment cela peut et doit marcher
(parce que c’est un challenge donc avec une solution), il
faut déconstruire le fonctionnement de la fonction _() qui n’a
pas une forme simple. Le code initial est :
from re import search as s
from traceback import format_exception as t
...
def _(f, *args):
try:
return eval("fcsc_" + f.decode())(*args)
except NameError as e:
z = s(r"[^:]+: ['\"](.+?)['\"]", t(e)[-1]).group(1)
return eval(f"{z}({','.join(repr(_) for _ in args)})")
L’importation du module traceback est un premier indice
qui doit attirer notre attention sur le traitement de
l’exception.
La fonction propose un traitement d’exception de première approche assez abscons. Remplaçons cette fonction par une autre proche :
from re import search as s
from traceback import format_exception as t
...
def _(f, *args):
try:
return eval("fcsc_" + f.decode())(*args)
except NameError as e:
print(t(e))
obj = s(r"[^:]+: ['\"](.+?)['\"]", t(e)[-1])
if ( obj ):
z = s(r"[^:]+: ['\"](.+?)['\"]", t(e)[-1]).group(1)
return eval(f"{z}({','.join(repr(_) for _ in args)})")
pass
except SyntaxError as e:
pass
Si je bruteforce, je vois passer des stack traces PYTHON. Le code
s’intéresse aux exceptions NameError. Mon code en provoque de 2 types
parce que je fais du bruteforce :
['Traceback (most recent call last):\n',
' File "/tmp/solve.py", line 1186, in _\n return eval("fcsc_" + f.decode())(*args)\n ~~~~^^^^^^^^^^^^^^^^^^^^^^\n',
' File "<string>", line 1, in <module>\n',
"NameError: name 'fcsc_npcfnnd939YFjAoTfodAXpixAChWmM' is not defined. Did you mean: 'fcsc_Exfnni939YFjEUAoTfdAXwpgiRkxAChWmxM'?\n"]
['Traceback (most recent call last):\n',
' File "/home/tbesancon-adm/zt/2026-07-03/FCSC/./solve1.py", line 1186, in _\n return eval("fcsc_" + f.decode())(*args)\n ~~~~^^^^^^^^^^^^^^^^^^^^^^\n',
' File "<string>", line 1, in <module>\n',
"NameError: name 'fcsc_oqbgooe828XGk' is not defined\n"]
L’erreur qui donne la compréhension du challenge est la première erreur.
Le calcul du XOR permet de construire un nom de fonction qui n’existe en aucune façon dans le code PYTHON mais ce nom erroné est suffisamment proche d’un des noms de fonctions présentes dans le code pour que PYTHON propose de lui même le vrai nom existant proche.
Tout s’éclaire. PYTHON doit utiliser quelque chose comme une « distance de Levenshtein » pour trouver le vrai nom de la fonction à exécuter.
Du coup le nom du challenge s’explique ;
Frankenshtein, Levenshtein…
Je n’ai pas percuté initialement alors que je connaissais la
distance de Levenshtein parce que je l’ai toujours écrite à
tort « Levenstein »…
Le handler d’exception est alors construit pour trouver sur
la dernière ligne de la stacktrace (t(e)[-1]) le nom de
la bonne fonction à lancer.
Il ne reste plus de difficulté à bruteforcer. Mon handler
d’exception prend en compte la seconde erreur que j’ai
rencontrée ou d’autres erreurs potentielles et toutes
ces erreurs, on les traite en les ignorant (pass).
Le bruteforce donne alors :
MATCH uNxFYzofsP9nouH7qWwcMfqmxuwch9FjsJv for input[0] == K
MATCH Exfnni939YFjEUAoTfdAXwpgiRkxAChWmxM for input[1] == v
MATCH T7rtarrkuJcgCqhX7Pcuw7jzbzmPPXebdgn for input[2] == E
MATCH CUoLguMMFLnKqgpRRdC9qV97CqiENqaFeYE for input[3] == W
MATCH nCXJezpUbcHp7iPKWdM7csNkiXvRNtAnycv for input[4] == o
MATCH RNmTCRUEgtksYk3JMWJ4UYEFpThnrpjEWid for input[5] == 3
MATCH kutxnydAqNEavMHvJTo7dsrtmaygfUCJCzi for input[6] == n
MATCH bqnLpc7vAcbwEYrATLwzwsdLWaubPyajhuh for input[7] == 3
MATCH nTHCcEaUN4PgPneVpnwt9yUhJcXfU4tHqeA for input[8] == f
MATCH fLFW3vWpTzsavrYEwJJRFthkPkMctWjAR7J for input[9] == H
MATCH wdfToTRVi7VsKckvPhEYxukmrtYWror4jpn for input[10] == h
MATCH CJMPjCskamnzoUXWfPvoUowXoi3pibnxErb for input[11] == T
MATCH VHrFn9zyqCcpFoTmR7urgYzY9NyjaWvEgnh for input[12] == r
MATCH AuUxMphAhPCAHqz7zthtsKYJzJpxTbWX9Hb for input[13] == f
MATCH aXRVJruXmxmxXcHiqmFqEMNTmridyhd9tuh for input[14] == m
MATCH iCAqTtKXKiXfevKmjWWCyRqP7RgPCRPpeWK for input[15] == X
MATCH 9WEHoVqpTFjNoK9uefuoTJdqqLgHt4nPoUF for input[16] == a
MATCH LAaPiCWdY9uoqF9RhawA3bKUqcHNg9ux3uW for input[17] == J
MATCH ymjnTWnmv3jnRHLfibaXd94FNwsMzkoWRha for input[18] == n
MATCH iipkjXiWNHCsNsU3aoCjKRpwK7MCHCxTkNY for input[19] == b
MATCH MuKYfv3cbviaXzaHbnmvvnnMRFRbPxcavNM for input[20] == b
MATCH YCqhJ7M3xAThLJtvCqPb9RAkkEpbHE9NxTi for input[21] == A
MATCH vwyRidYPRvmYJo7ea3jRrmfmwteMRTYnsLy for input[22] == h
MATCH xn4RiqKudoRKnyxvojtXqkNEg9TCWzJnwYy for input[23] == F
MATCH f7aocMNuxNkPmchM9gqxjuARYAdTskYvuVp for input[24] == e
MATCH qYkmRucbXawcsuy4JUXsaHKR9w49ExMkzWm for input[25] == h
MATCH HAEWzygbFaHyNxJ73AbYhhMgxoxJez4KWvc for input[26] == s
MATCH PdcTWWUAU7X9KxkxUuTRoc3pfUuUtUCjtat for input[27] == P
MATCH XUcYep99yvofgCE7TvtHdvFptkTJRvevKaV for input[28] == H
MATCH wHgmguWxUbvqNav3JktuiFcrzcwcxoWYFYP for input[29] == j
MATCH rhFjkAv4irTp4ndPgNmxagYzLWEqfhFmeiL for input[30] == v
MATCH Ntdgq9UXYaXksmRimNUz4hyzppNiyzYuvks for input[31] == L
RECOVERED = FCSC{KvEWo3n3fHhTrfmXaJnbbAhFehsPHjvL
Etape 3 : recherche des 32 derniers caractères du flag.
On a ici une partie « cryptographie » à résoudre.
Chaque fonction fcsc_xxxx() fait un calcul arithmétique
modulo 256. Comme expliqué ci-dessus, le calcul doit donner
0 modulo 256.
Pour résoudre cette partie, je fais assez classiquement appel
au solveur SMT Z3 que j’aime bien et qui va se charger de
convertir chaque exécution de fonction fcsc_xxxx() en
une contrainte sur les 32 derniers caractères du flag.
Le solveur va se charger de résoudre cet ensemble de
contraintes de façon efficace.
Pour faire cela, je change un peu le code du script (un coup de parser ad-hoc le réécrit facilement - exercice laissé au lecteur) :
#!/usr/bin/env python3.13
import math
from z3 import *
flag = []
solver = Solver()
Q = [ Int("q_%s" % i) for i in range(32) ]
#-------------------------------------------------------------------------------
# On convertit comme suit les 571 fonctions fcsc_xxxx()
#-------------------------------------------------------------------------------
def fcsc_qiLjdoEUA7gmHYxmJxmReh7icLYMpLrsiJM(): solver.add( ((65 + sum(a*x for a,x in zip(Q,[75,213,67,74,3,94,9,237,163,43,96,114,57,231,156,200,248,107,16,45,118,181,12,34,12,157,180,19,25,30,165,173]))) % 256 ) == 0 )
def fcsc_hPcawhvjJ4hCcvErPWUMgKEoYzVEVVKUogL(): solver.add( ((90 + sum(a*x for a,x in zip(Q,[66,130,20,175,66,84,225,42,143,202,30,116,245,53,41,102,146,128,145,228,209,198,11,113,28,111,135,32,169,101,127,33]))) % 256 ) == 0 )
...
#-------------------------------------------------------------------------------
# On appelle les 32 fonctions trouvées dans la partie précédente.
# Chaque appel construit une contrainte présentée au solveur.
#-------------------------------------------------------------------------------
fcsc_uNxFYzofsP9nouH7qWwcMfqmxuwch9FjsJv()
fcsc_Exfnni939YFjEUAoTfdAXwpgiRkxAChWmxM()
...
fcsc_rhFjkAv4irTp4ndPgNmxagYzLWEqfhFmeiL()
fcsc_Ntdgq9UXYaXksmRimNUz4hyzppNiyzYuvks()
#-------------------------------------------------------------------------------
# Résultat trouvé par le solveur..
#-------------------------------------------------------------------------------
if solver.check() == sat:
m = solver.model()
for i in range(32):
flag.append( chr(m.evaluate(Q[i]).as_long() % 256) )
print(f"RECOVERED = { "".join(flag) }}}")
else:
print("failed to solve")
En environ 4 secondes, Z3 trouve la solution :
$ ./solve-step2.py
RECOVERED = EaaMRiitwouqqEotJNejHeu9Cb4nqUnu}
Etape 4 : flag.
Le flag est donc
FCSC{KvEWo3n3fHhTrfmXaJnbbAhFehsPHjvLEaaMRiitwouqqEotJNejHeu9Cb4nqUnu}.
Preuve que oui :
$ ./frankenshtein.py
>>> KvEWo3n3fHhTrfmXaJnbbAhFehsPHjvLEaaMRiitwouqqEotJNejHeu9Cb4nqUnu
Congrats! You can validate the challenge with:
FCSC{KvEWo3n3fHhTrfmXaJnbbAhFehsPHjvLEaaMRiitwouqqEotJNejHeu9Cb4nqUnu}
Voir les 2 fichiers joints solve-step1.py et solve-step2.py.
QED.
🐒