Frankenshtein 🇫🇷

April 12, 2026 FCSC 2026 #reverse #python

Analyse du problème

On nous donne un script qui a trois parties

  • des fonctions pseudoaléatoires qui font de l’arithmétique
  • une fonction _ que l’on renommera obfus, qui va tenter d’exécuter des fonctions
  • le code qui appelle cette fonction avec le mot de passe

Et on voit que notre input est transformé en deux parties les 32 premiers charactères et les suivants.

Les 32 premiers sont utilisés comme clef pour déchiffrer l’hexadécimal, et trouver les 32 fonctions a exécuter avec les charactères de la fin du mot de passe.

On peut donc se focaliser sur récupérer ses 32 premiers chars.

Première moitié du flag

Quand on lance le programme on tombe sur

 python frankenshtein.py
>>> a
Traceback (most recent call last):
  File "/home/FCSC/rev/frankenshtein/frankenshtein.py", line 598, in <module>
    obfus(bytes(x ^ L[ 0] for x in bytes.fromhex('31242a330d1233242d381b2524223a037c3a1c3d28062d3a26333e3c2c28720d2138013d')), L[32:]),
    ~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/FCSC/rev/frankenshtein/frankenshtein.py", line 581, in obfus
    return eval('fcsc_' + f.decode())(*args)
           ~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 1
    fcsc_PEKRlsRELYzDEC[b[}\IgL[GR_]MIl@Y`\
                         ^
SyntaxError: invalid non-printable character U+001D

On va donc extraire les noms de fonctions obfusqués:

cat frankenshtein.py | grep -Po "bytes\.fromhex\('\w+\'\)" | awk '{ print $1."," }'
echo 'pool = [' $(cat frankenshtein.py | grep -Po "bytes\.fromhex\('\w+\'\)" | awk '{ print $1."," }') ']'

Puis essayer de les décoder, on va y aller à la force brute puisqu’il n’y a que 255 possibilités différentes pour une lettre.

Je vais donner le code progressivement pour que ce soit plus lisible.

La première étape est de supprimer toutes les chaînes de charactères qui ne sont pas printables. On réalise rapidement après qu’il faut en plus que les fonctions n’aient que des lettres ou des chiffres.

for seed in pool:
    candidates: set[str] = set()
    cand_func: set[str] = set()
    for i in string.printable:
        func_end =  bytes(x ^ i.encode()[0] for x in seed).decode()
        if not func_end.isprintable():
            continue
        if any(c not in string.ascii_letters + string.digits for c in func_end):
            continue

Ensuite on va essayer de lancer la fonction _ que l’on a renommé en obfus pour pouvoir l’importer

Analyse de obfus

La fonction est simplement:

  • on essaie de lancer la fonction qui a le nom donné en entrée,
  • si cette fonction n’existe pas on utilise la feature python qui permet de nous donner la fonction qui a le nom le plus proche,

Concrètement pour une telle fonction qui est imprimable mais pas définie (sélectionnée par le code ci-dessus) on va se retrouver avec cette erreur.

Traceback (most recent call last):
  File "/home/jns/Downloads/CTF/FCSC/rev/frankenshtein/frankenshtein.py", line 581, in obfus
    return eval('fcsc_' + f.decode())(*args)
           ~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 1, in <module>
NameError: name 'fcsc_zoaxFYxofsPnoiqH7qWvcMfqmxuwgc9FjsJv' is not defined. Did you mean: 'fcsc_uNxFYzofsP9nouH7qWwcMfqmxuwch9FjsJv'?

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'fcsc_zoaxFYxofsPnoiqH7qWvcMfqmxuwgc9FjsJv' is not defined

Dans laquelle l’interpréteur python nous dit quelle est la fonction qui a le nom le plus proche, et le code de obfus va le récupérer.

On peut donc légèrement modifier cette fonction pour qu’elle nous renvoie le nom des fonctions correctes

def just_get_name(f, *args) -> str:
    try:
        eval('fcsc_' + f.decode())
        return f'fcsc_{f.decode()}'
    except NameError as e:
        z = s(r"[^:]+: ['\"](.+?)['\"]", t(e)[-1]).group(1)
        return z

En utilisant ceci on peut donc décoder toute la fin de la première moitié du mot de passe en ajoutant ceci à la boucle initiale.

        try:
            _ = obfus(func_end.encode(), 0)
        except SyntaxError:
            print(f'Eliminated {i} due to syntax error')
            continue
        except AttributeError:
            #print(f'Eliminated {i} due to attribute error')
            continue
        except (IndexError, TypeError):
            candidates.add(i)
            real_func = just_get_name(func_end.encode(), 0)
            cand_func.add(real_func)

Deuxième moitié du flag

Une fois qu’on a récupéré toutes les fonctions et le début du flag il faut maintenant L[32:].

On voit que l’on va avoir du mal à bruteforcer puisque visiblement on a plus d’une dizaine d’opérations.

L’objectif ici est de trouver quels sont les entrées qui permettent d’avoir le résultat des sommes et produits toutes égales à 0.

Comme d’hab on a deux solutions

z3 ouuu

Bon on est allé vers z3.

Il y a deux étapes

  • extraire les coefficients
  • les transformer en équations pour z3

Extraction

with open('frankenshtein.py', 'r') as f:
    content = f.read()

pattern = re.compile(
    r'def fcsc_\w+\(L\): return \((\d+) \+ sum\([^[]+\[([\d,]+)'
)

functions = {}
for match in pattern.finditer(content):
    func_name = match.group(1)
    constant = int(match.group(2))
    coeffs = [int(x.strip()) for x in match.group(3).split(',')]
    functions[func_name] = (constant, coeffs)

Enfin on ne garde que celles qui nous intéressent (cf partie 1)

funcs_qui_sont_selectioneeeeees = {contenu_des_func[i] for i in noms_des_func}

Résolution

Avec ceci on extrait facilement toutes les fonctions dans un format plus lisible.

Pour z3 il faut décider de ce que seront les types des inconnus. Ici on sait que tout est fait modulo 256, donc on peut prendre un BitVec de taille 8 bits comme ça on a l’énooorme avantage d’avoir un modulo naturellement : dès qu’une opération dépasse 256 le bit surnuméraire sera perdu.

nb_dinconnues = len(funcs_qui_sont_selectioneeeeees[0][1])
L_vars = [z3.BitVec(f'L_{i}', 8) for i in range(nb_dinconnues)]

Ensuite on sait que les charactères doivent être imprimables donc on peut lui demander de restreindre entre 32 et 127.

for var in L_vars:
    solver.add(z3.UGT(var, 31))
    solver.add(z3.ULT(var, 127))

Puis lui donner les équations à résoudre

for (constant, coeffs) in funcs_qui_sont_selectioneeeeees:
    eq_sum = constant
    for var, coeff in zip(L_vars, coeffs):
        eq_sum += var * coeff

    solver.add(eq_sum == 0)

Le reste du code est juste pour récupérer la solution

model = solver.model()
solution = [model[var].as_long() for var in L_vars]
end_of_password = ''.join(chr(x) for x in solution)
print('2eme moitié du flag:', end_of_password)

Bref un chall très amusant sur le fait que python essaie d’être “friendly” avec le dev en trouvant la fonction la plus proche de celle qui a été appelée.

Script complet

Il faut rajouter ça dans frankenshtein.py


def obfus(f, *args) -> int:
    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)})")

def just_get_name(f, *args) -> str:
    try:
        eval('fcsc_' + f.decode())
        return f'fcsc_{f.decode()}'
    except NameError as e:
        z = s(r"[^:]+: ['\"](.+?)['\"]", t(e)[-1]).group(1)
        return z
import re
import string

import z3
from frankenshtein import *

pool = [
    bytes.fromhex('31242a330d1233242d381b2524223a037c3a1c3d28062d3a26333e3c2c28720d2138013d'),
    bytes.fromhex('180615101818124f454f2f301c371922101912372e061f0e37351e211b3b'),
    bytes.fromhex('21343731243731372e300f2f22062d1d7215223032722f3f28153d151d2027332b'),
    bytes.fromhex('302d3527381b30221a2311271b392626300505331427263b016e6014263e1219263d3611320e12'),
    bytes.fromhex('0c040337250a15153a1a0c27053f24380b22580c1c21040637190a211b2e1e1d0c19'),
    bytes.fromhex('5e525e6770617654475958406a580079645e0754666a767559675b5d414357594376645a57'),
    bytes.fromhex('051a160a170a2f1f2b0f18232618240c01590a1d1c1a0317081c3b2d24061407'),
    bytes.fromhex('49595d7f4355500447457241505144766a4172677f4346435a495740577f644651634a5255595b465b'),
    bytes.fromhex('1f00172523073328523601360230160811125f12330e053e003352122e17032712'),
    bytes.fromhex('2f2c0e7b3e1f382f1c323b293e2d3a110d2c320225301a0e3c3a2318052b3c1f22093d7f02'),
    bytes.fromhex('1b0e3c07053a3e0c015f3e0e230b0f1e1f002d3110191d031f1a1c313f1a070c5c02180906'),
    bytes.fromhex('383319043e272135393d3a382e3b01033204223723013b230c3b3d37243d363a3b2c113f2636'),
    bytes.fromhex('041800341c4b08063111023418041f20450715032b4b3c0b181319250437151c1a'),
    bytes.fromhex('1c331e0c2b160e3625272e171c120e12150b2d123f2c1c2c161e3204133e5f2e04'),
    bytes.fromhex('080e3f3b271d183500151a1435250404002b1c282001231939001f04090509541805'),
    bytes.fromhex('3629193b0c3e13001331003e3d2e350f1b21370a29083f370a3f081b0a08283d0f13'),
    bytes.fromhex('0d0b290e051135270b2f0e032a58140407110e2b0510102d1b2915550f310e340527'),
    bytes.fromhex('30222b1a2309252e13733f252539240c7318222b3d0b281f3b29022d733f20793f1d'),
    bytes.fromhex('0f04003900185d04003c262208070c0f360a5a28202314050139060f0f'),
    bytes.fromhex('18040e120908090b352c2a21112c1137511a0718080830120f1529052f0c212a211a362c3b'),
    bytes.fromhex('080f290c04140107001b1404033a18032a110c0f14140c0c2f30243000321a120c142c2f'),
    bytes.fromhex('2a242032350b760c7234001528290d0b3537112378130035302a31230904780f391528'),
    bytes.fromhex('1b0411010c31383a0c1e062207025f0d023a1a050e051f1c0d253a0a3106241c'),
    bytes.fromhex('302172142f0d332229140d283f3e28292c321e372d272f3703217f12110c28311f3135'),
    bytes.fromhex('0c1c040a0c282b1d15120802060d10021d0f1024373c240131160e3c1310331500'),
    bytes.fromhex('1d0f03051d0b0a3009011b0f115c22301b07230b515c512d102503123f05'),
    bytes.fromhex('1e1b3624090a1411120a181d39444032112a1b1d1b3e141c0b390309473805'),
    bytes.fromhex('352933040707051105673608691b203b28052504023f33633405250524052013243631'),
    bytes.fromhex('25293d2b112d2e71313e26272e0b0d1c3e3c00323e0e2d383c231c022d1a3e3e03291e'),
    bytes.fromhex('180c0d0b070d1f3d123f08001b0b1c592013011e1f032c09181e10091f1d09120e05073d332c333a'),
    bytes.fromhex('1c131c1d37001f0422420f121111381b0e17112f3a3307100e1e301b133a'),
    bytes.fromhex('27282b3d75382314152d273f211e25211936782435363c3c38353615393a27'),
]

reconstructed: list[str] = []
noms_des_func: set[str] = set()
for seed in pool:
    candidates: set[str] = set()
    cand_func: set[str] = set()
    for i in string.printable:
        func_end = bytes(x ^ i.encode()[0] for x in seed).decode()
        if not func_end.isprintable():
            continue
        if any(c not in string.ascii_letters + string.digits for c in func_end):
            continue

        # print('TRYING', i, func_end)
        try:
            _ = obfus(func_end.encode(), 0)
        except SyntaxError:
            print(f'Eliminated {i} due to syntax error')
            continue
        except AttributeError:
            # print(f'Eliminated {i} due to attribute error')
            continue
        except (IndexError, TypeError):
            candidates.add(i)
            real_func = just_get_name(func_end.encode(), 0)
            cand_func.add(real_func)

    if len(candidates) == 1:
        # print('FOUND CANDIDATE', candidates)
        reconstructed.append(candidates.pop())
        noms_des_func.add(cand_func.pop())
    else:
        print('MULTIPLE CANDIDATES', candidates, cand_func)
print('RECONSTRUCTED', ''.join(reconstructed))
print('FUNCTIONS', noms_des_func)
beginning = ''.join(reconstructed)


with open('frankenshtein.py', 'r') as f:
    content = f.read()

pattern = re.compile(
    r'def (fcsc_\w+)\(L\): return \((\d+) \+ sum\([^[]+\[([\d,]+)',
)

contenu_des_func: dict[str, tuple[int, tuple[int]]] = {}
for match in pattern.finditer(content):
    func_name = match.group(1)
    constant = int(match.group(2))
    coeffs = tuple(int(x.strip()) for x in match.group(3).split(','))
    contenu_des_func[func_name] = (constant, coeffs)

funcs_qui_sont_selectioneeeeees = {contenu_des_func[i] for i in noms_des_func}

_nb_dinconnues = {len(coeffs) for _, coeffs in funcs_qui_sont_selectioneeeeees}
if len(_nb_dinconnues) != 1:
    print('Error: Not all selected functions have the same number of unknowns!')
    exit(1)

nb_dinconnues = _nb_dinconnues.pop()

solver = z3.Solver()

L_vars = [z3.BitVec(f'L_{i}', 8) for i in range(nb_dinconnues)]
for var in L_vars:
    solver.add(z3.UGT(var, 31))  # var >= 32
    solver.add(z3.ULT(var, 127))  # var <= 126

for constant, coeffs in funcs_qui_sont_selectioneeeeees:
    eq_sum = constant
    for var, coeff in zip(L_vars, coeffs):
        eq_sum += var * coeff

    solver.add(eq_sum == 0)

if solver.check() == z3.sat:
    model = solver.model()
    solution = [model[var].as_long() for var in L_vars]
    end_of_password = ''.join(chr(x) for x in solution)
    print('SOLUTION:', end_of_password)
else:
    print('No solution found!')

print('SOLUTION COMPLETE')
print(f'mdp: {beginning + end_of_password}')
print(f'FLAG: FCSC{{{beginning + end_of_password}}}')