Solution de Toukan pour Magasin à Secret

reverse linux x86/x64

2 septembre 2024

Préliminaires

On ouvre le fichier avec radare2.

r2 -e bin.relocs.apply=true MagasinSecret
[0x00004eb0]> aaaa
[0x00004eb0]> afl > fonctions
[0x00004eb0]> izz

Le fichier fonctions contient maintenant les noms des fonctions, notamment :

sym.chall_ecsc_re::generate_secretholders::h514b87a7f53eafe4
sym.chall_ecsc_re::secret_holder::SecretHolder::decrypt_secret::hf5f32b7a9e6bc86c
sym.chall_ecsc_re::secret_holder::SecretHolder::check_password::habf9fdd677886c4e
sym.chall_ecsc_re::main::h91a329e5f7c3415e

On trouve également un certain nombre de chaînes de caractères intéressantes

...
3057 0x0002c990 0x0002c990 703 704  .rodata     ascii   /usr/src/rustc-1.32 ... 
Utilisation:  "Nom d'utilisateur" "Mot de passe"\n
3058 0x0002cc90 0x0002cc90 58  61   .rodata     utf8    Le mot de passe doit être d'une longueur de 10 caractères\n
3059 0x0002ccd0 0x0002ccd0 144 147  .rodata     utf8    Aucun secret n'est associé à ce couple nom d'utilisateur/mot de passe.\n    Voici vos secrets:\n

Le programme a certainement été écrit en langage rust. Il doit récupérer un nom d’utilisateur et un mdp (de 10 caractères) en argument. L’analyse de la fonction main va nous le confirmer :

[0x00004eb0]> pdf @sym.chall_ecsc_re::main::h91a329e5f7c3415e
...
0x0000708c      ff1526af2300   call qword [sym.std::env::args_os::hce9f729300a1b12b]

La documentation du langage rust nous indique que cette fonction renvoie un itérateur sur les arguments. Les arguments d’index 1 (donc le nom d’utilisateur) et 2 (le mdp) sont récupérés et aux lignes suivantes :

0x000070df      e8fcf6ffff     call fcn.000067e0
0x000070e4      4883f80a       cmp rax, 0xa

nous vérifions que le mdp doit être de 10 (0xa) caractères exactement. En effet la ligne 70df appelle la méthode len qui renvoie probablement la longueur de la chaîne de caractères.

pdf @ fcn.000067e0
...
   └─< 0x000067e0      e98bfaffff     jmp sym  
...    <alloc::vec::Vec<T>>::len::he32f9bfcb9ca7f09

Si la comparaison échoue, le code saute plus bas dans la fonction main, où nous trouvons très rapidement un appel de la fonction exit.

La fonction generate_secretholders

Immédiatement après la vérification du mdp, nous trouvons :

0x000070f3      e888f8ffff     call sym chall_ecsc_re::generate_secretholders::h514b87a7f53eafe4  

On se concentre donc sur la fonction generate_secretholder.

pdf @ sym.chall_ecsc_re::generate_secretholders::h514b87a7f53eafe4

La fonction generate_secretholders commence par répéter 6 fois une série d’instructions assez semblables.

  • allocation d’une chaîne de caractères imprimables de lg variable (attribut 1 d’un objet secret_holder)
  • allocation d’une chaîne de 4 caractères imprimables (attribut 2)
  • allocation d’une chaîne de 64 caractères hexadécimaux (attribut 3)
  • allocation d’une chaîne de caractères hexadécimaux de lg variable (attribut 4)
  • appel de la méthode sym.chall_ecsc_re::secret_holder::SecretHolder::new avec comme arguments les chaînes allouées

La première chaîne allouée (attribut 1) prend les valeurs successives:

  1. “MrFlag”
  2. “Entropie”
  3. “z3r0”
  4. “z3r0”
  5. “LBigBossDu15”
  6. “LBigBossDu15”.

On reconnaît les personnages de l’énoncé.

Un coup d’œil à la fonction check_password montre qu’un hachage (lignes 0x00008ce7, 0x00008d5b) est utilisé, suivi d’un appel à la méthode cmp ( ligne 0x00008db6 ). On suppose donc que le password proposé est haché puis comparé à un hash stocké quelque part (ce qui est la manière classique de vérifier un mdp).

On examine la méthode check_password

pdf @ sym.chall_ecsc_re::secret_holder::SecretHolder::check_password::habf9fdd677886c4e
...
0x00008c88    call qword [sym.__D_as_digest::digest::Digest_::new::h5e74f92edcde4c94]
...

récursivement :

pdf @ sym.__D_as_digest::digest::Digest_::input::h43159f435b09d51d
...
0x0000acd7      ff159b6c2300   call qword [sym.__sha2::sha256::Sha256_as_digest::Input_::input::ha9fa24ae5920bb74]

Le hachage est de type sha256. Le hash pourrait donc bien être la chaîne de 64 caractères que nous trouvons en 3è attribut de chaque secret_holder. Comme l’énoncé indique qu’il y a le même mdp pour chacun, et que ces hash sha256 diffèrent, un sel a probablement été utilisé, ce qui serait cohérent avec le fait que la fonction check_password utilise une concaténation (ligne 0x00008cbb ). Ce sel pourrait être la chaîne attribut 2, tj de la même longueur (4 caractères).

On suppose donc qu’un objet de type secret_holder contient :

  1. le nom d’utilisateur, par ex. MrFlag, …, z3r0, LBigBossDu15
  2. le sel pour la vérification du mdp
  3. le hash sha256 du mdp correct, salé
  4. un contenu supplémentaire qui contiendrait le ou les secrets à garder.

On constate que les personnages z3r0 et LBigBossDu15 ont tous les deux un secret_holder avec un attribut 4 de exactement 40 caractères ce qui correspondrait au fait qu’ils ont mis tous les deux le même mdp netflix.

Il faudrait donc parvenir à percer le secret du 4è attribut, notamment pour le secret_holder attribué au personnage MrFlag.

La fonction decrypt_secret

Intéressons-nous à la fonction decrypt_secret. On y trouve une boucle avec un certain nombre d’appels de fonctions aux noms évocateurs : next, string.as_bytes, bitxor. Cela fait penser à déchiffrement par l’opérateur xor, octet par octet, sur deux chaînes (dont une générée par une concaténation effectuée ligne 0x00008e73). De plus, une instruction de division (ligne 0x00008f4c ) est effectuée avec utilisation du reste, ce qui suggère l’utilisation d’une clé de déchiffrement plus courte que le texte chiffré.

call qword [sym.hex::decode::hc8c2821102ac3f66] 
...
call sym _<core::iter::Enumerate<I> as core::iter::iterator::Iterator>::next::hfc69d1dcfc6c26b0
...
call sym alloc::string::String::as_bytes::h8b9bfd04b2c892c7
...
div rcx
cmp rdx, r14
jae 0x8fde
movzx esi, byte [r12 + rdx]
mov rdi, rbp     
call sym _<&'a u8 as core::ops::bit::BitXor<u8>>::bitxor::h5e3a781ff899464b

Premiers succès

On tente quelques essais à l’aveugle en faisant des xor entre les attributs 1 et 4 (on exclut 2 et 3 qui seraient utilisés seulement pour le check_password). Les essais sont en python.

# attribut 4 du SecretHolder de LBigBossDu15
a = bytes.fromhex("39381c533a0a164a251c46003416060a4f0c5431")
# attribut 4 du SecretHolder de z3r0 
b = bytes.fromhex("0f4907043912005c590012451004195c05557036")
# essais de déchiffrement 
bytes(x^y for x, y in zip(a, b"LBigBossDu15"))
b'uzu4xee9aiw5'
bytes(x^y for x, y in zip(b, b"z3r0"))
b'uzu4'

Les résultats des déchiffrements sont égaux sur leurs 4 premiers caractères. Cela pourrait être les 4 premiers caractères du mdp netflix.

c = bytes.fromhex("211a1908000f061f040c5e060a160c50341d7e18090224")
# MrFlag
bytes(x^y for x, y in zip(c, b"MrFlag"))
b'lh_dah'

Le résultat commence par “lh_” ce qui correspondrait au flag.

En poursuivant dans la même idée (nom d’utilisateur XOR attribut 4 du secret_holder de ce même utilisateur), on trouve "J<3Maman" pour LBigBossDu15 et “test” pour l’utilisateur “Entropie”.

Il est donc extrêmement probable que l’attribut 4 d’un secret_holder contienne le ou les secrets de l’utilisateur correspondant, et qu’il se déchiffre avec une clé qui commence par le nom de l’utilisateur. Seulement, pour le secret de “MrFlag”, par ex, nous avons un secret de 40 caractères hexa, donc 20 octets, mais une clé de seulement 6 octets. Il nous manque donc 14 octets de clé. De même pour l’utilisateur z3r0.

Calcul des 12 premiers octets de la clé

Heureusement, les propriétés de l’opérateur XOR nous permettent de deviner les 12 premiers octets de la clé utilisée par z3r0 pour chiffrer le mdp netflix.

clé_z3r0 XOR secret_chiffré_z3r0 = mdp_netflix

donc

mdp_netflix XOR secret_chiffré_z3r0 = clé_z3r0

bytes(x^y for x, y in zip(a, b"LBigBossDu15"))
uzu4xee9aiw5

bytes(x^y for x, y in zip(b, b"uzu4xee9aiw5"))
b'z3r0Awee8iep'

On confirme qu’il s’agit bien d’une clé probable en faisant XOR avec le 2è secret_holder de z3r0 :

bytes(x^y for x, y in zip(bytes.fromhex("195b1d4522050a104c0c"), b"z3r0Awee8iep"))
b'choucroute'

Ce serait peu probable de tomber par hasard sur le nom de ce plat traditionnel alsacien.

On essaye en concaténant avec “MrFlag” :

bytes(x^y for x, y in zip(c, b"MrFlagAwee8iep"))
b'lh_dahGhaifoof'

Ce n’est pas le bon flag… normal, la chaîne à déchiffrer compte 20 octets, et la clé seulement 14 (6 pour “MrFlag”, 8 pour “Awee8iep”). Il manque 6 octets qu’il va falloir trouver quelque part.

On finit à l’aide de la force brute

À partir de là, on regarde d’où vient cette chaîne “Awee8iep”. En analysant le binaire, même sans comprendre le fonctionnement du programme, ce qui apparaît, c’est qu’elle n’est nulle part dans la section .rodata, ni ailleurs dans le fichier. Quelques tests montrent qu’elle n’y est pas non plus sous forme chiffrée avec César ou qqch de simple. On ne trouve aucun code typique de fabrication d’une clé XOR (aléatoire, par ex.).

En fait, comme toutes les autres chaînes de caractères présentes dans le programme sont déjà utilisées autrement, il faut supposer que c’est une chaîne fournie au moment de l’exécution. Comme le nom d’utilisateur a déjà été utilisé, ce serait donc le mdp. Autrement dit, pour déchiffrer le secret d’un utilisateur, il faudrait faire un XOR avec une clé composée du nom d’utilisateur et du mdp corect de cet utilisateur. C’est cohérent, puisque cela évite qu’une analyse du binaire ne suffise à déchiffrer le secret.

Or, nous savons que le mdp correct fait exactement 10 caractères. Nous en avons 8 (“Awee8iep”). Il manque donc 2 B.

Pour trouver le mdp correct (le même pour tous les utilisateurs selon l’énoncé), nous avons le hash, le sel, et 8 caractères sur les 10.

Deux solutions simples sont possibles :

  1. hash_enregistré = sha256( sel (4 octets) + Awee8iep (8 octets) + ?? (2 octets) )
  2. hash_enregistré = sha256( (Awee8iep + ?? + sel )

Une attaque en force brute est envisageable pour seulement 2 caractères

alph = bytes(x for x in range(32, int("7e", 16)))
suites = []
for x in alph:
    for y in alph:
        suites.append(x+y)


for s in suites:
    essai = b"Awee8iep" + s.encode("utf-8") + b"@Hf2"
    if hex(int.from_bytes(h.sha256(essai).digest())) == objectif:
        print(essai)
        break

On trouve alors que

‘Awee8iepee@Hf2’ donne bien le hash stocké dans le secret_holder de z3r0

Si l’on suit l’hypothèse que ce serait le mdp, et qu’ils ont tous le même mdp, il suffit de faire

./MagasinSecret LBigBossDu15 Awee8iepee

Voici vos secrets:
J<3Maman
uzu4xee9aiw5uacowe1A

./MagasinSecret MrFlag Awee8iepee

Voici vos secrets:
lh_dahGhaifoofi5yo8thee

C’est le flag.