Le code source de la page PHP nous est donné :

<?php
    include('flag.php');
    $banned_secret = 'BreizhCTF-2023-pwd-702';
    $stored_hash = password_hash(hash('gost', $banned_secret, true), PASSWORD_BCRYPT);
    if (isset($_POST['flag']) && $_POST['flag'] != $banned_secret){
        if (password_verify(hash('gost', $_POST['flag'], true), $stored_hash)){
            echo($flag);
            exit();
        }
        die("Erreur !");
    }
    highlight_file(__FILE__);
?>

PHP fait tellement toujours des trucs bizarres et contre-intuitifs, que le premier réflexe à avoir est d’aller regarder la documentation des fonctions principales.

Bien sûr, quelques avertissements attirent l’œil avisé :

L’option Salt est obsolète. Il est préférable d’utiliser simplement le sel qui est généré par défaut. À partir de PHP 8.0.0, un sel explicitement fournit est ignoré.

Ou encore :

L’utilisation de la constante PASSWORD_BCRYPT pour l’algorithme fera que le paramètre password sera tronqué à une longueur maximale de 72 octets.

Mais rien qui ne soit finalement utile dans notre cas.

Maintenant que nous avons vu qu’il n’y a pas de piste triviale, posons le problème.

Grossièrement, le code fait le hash du hash d’un mot de passe, c’est-à-dire en pseudo-code bcrypt(gost(mdp_A)).
Et la vérification effectuée revient à vouloir que bcrypt(gost(mdp_A)) soit égal à bcrypt(gost(mdp_B)) avec mdp_B différent de mdp_A. En théorie, les probabilités d’avoir une collision sont quasi-nulle. Cependant, si c’est un challenge, c’est qu’il doit y avoir moyen de jouer sur un levier pour abuser d’une faiblesse de l’implémentation.

Note : il n’y a pas de comparaison approximative donc pas jonglerie de type.

En faisant quelques recherches sur le sujet, je tombe sur l’excellent article Password hashing: Be careful about what you hash! d’Excellium.

L’article décrit peu ou prou le même problème que celui auquel nous faisons face. Les différences (hash_hmac au lieu de hash et whirlpool au lieu de gost) ne jouent aucune importance.

Le problème vient de l’utilisation de l’option bool $binary = true :

Lorsqu’il vaut true, la sortie sera des données brutes binaires. Lorsqu’il vaut false, la sortie sera des chiffres hexadécimaux en minuscule.

Or, en PHP, beaucoup de fonctions sont des binddings à des fonctions C et donc héritent des problèmes de cet ancien langage, en particulier l’injection d’octet nul, marqueur de fin de chaine de caractères.
Si un octet nul est rencontré, la chaine sera tronquée à l’emplacement de celui-ci.

L’utilisation de la fonction hash avec le mode binaire activé va donc nous permettre de générer des hashs contenant des octets nuls et avec une attaque par force brute d’espérer avoir une collision avec le mot de passe original.

Cependant, une deuxième condition doit être vérifiée, il faut que le hash du mot de passe original contienne lui aussi un octet nul, de préférence assez tôt, afin que l’attaque soit réalisable.

Or, ce n’est pas un hasard si le 2ième octet du hash est nul. Il va donc être très simple de générer une collision.

$ php -r "print(hash('gost', 'BreizhCTF-2023-pwd-702', true));" | xxd
00000000: d400 1341 e5a7 606f 10f0 9b33 bfa5 248e  ...A..`o...3..$.
00000010: 65eb 70b8 defe 704d 6095 234d 1264 e0a5  e.p...pM`.#M.d..

Voici un script PHP qui va essayer tous les mots de passe de rockyou afin d’identifier des mots de passe générant une collision.

<?php
function modifyPassword($password){
  return hash('gost', $password, true);
}
function computeHash($password){
  $modifiedPassword = modifyPassword($password);
  return password_hash($modifiedPassword, PASSWORD_BCRYPT);
}
function verifyHash($password, $hash){
  $modifiedPassword = modifyPassword($password);
  return password_verify($modifiedPassword, $hash);
}

$initialPwd = "BreizhCTF-2023-pwd-702";
$h = computeHash($initialPwd);
echo("\n[+] Initial password:\n$initialPwd\n");
echo("[+] Generated hash:\n$h\n");
echo("[+] Search for matching password:\n");
$handle = fopen("/usr/share/wordlists/passwords/rockyou.txt", "r");
while (($password = fgets($handle)) !== false) {
  $pwd = trim($password);
  if($pwd === $initialPwd){
    continue;
     }     if(verifyHash($pwd, $h)){
         echo("$pwd\n");     }
 }
echo("\n[+] Search finished.");
fclose($handle);
?>

Note : il y a moyen de faire beaucoup plus court et concis, mais j’ai repris et adapté le PoC d’excellium pour gagner du temps.

Voici le résultat de l’exécution du script :

$ php bf.php

[+] Initial password:
BreizhCTF-2023-pwd-702
[+] Generated hash:
$2y$10$VbHqwxZnzw.HeRCDUA0EoObUy/syhg.0Cg5.onGmX.TwISoqfE59G
[+] Search for matching password:
kitkat

Le mot de passe kitkat génère une collision. En effet, les deux premiers octets sont identiques à ceux du hash original :

$ php -r "print(hash('gost', 'kitkat', true));" | xxd
00000000: d400 dabe d059 003e 5c6d 3986 e7a4 9bd1  .....Y.>\m9.....
00000010: 95af 2ce9 d0fa 0587 5416 52b8 0a2a a93a  ..,.....T.R..*.:

Ce drapeau fut bien mérité : BZHCTF{who_you_gonna_call?_GOAT_BUTTER!!}.

Solution de l’auteur du challenge.

BONUS PHP étant le langage de l’obscurantisme, il n’est pas surprenant de voir que la documentation de hash_algos mentionne juste gost et gost-crypto sans préciser si il s’agit de :

  • GOST R 34.11-2012, 256 bit
  • GOST R 34.11-2012, 512 bit
  • GOST R 34.11-94
  • GOST R 34.11-94 CryptoPro
  • etc.

Il semblerait que dans PHP gost soit équivalent à GOST R 34.11-94 et gost-crypto à GOST R 34.11-94 CryptoPro.

Sur les distributions Linux, il est possible de générer de tels hash avec rhash.

$ rhash -m 'kitkat' --gost94
d400dabed059003e5c6d3986e7a49bd195af2ce9d0fa0587541652b80a2aa93a  (message)

$ hash -m 'kitkat' --gost94 -p '%{gost94}'
d400dabed059003e5c6d3986e7a49bd195af2ce9d0fa0587541652b80a2aa93a

Ce script Ruby est donc une alternative beaucoup plus courte au script PHP proposé précédemment :

File.readlines('/usr/share/wordlists/passwords/rockyou.txt').each do |line|
  pass = line.chomp
  puts pass if %x"rhash -m '#{pass}' --gost94 -p '%{gost94}'"[..3] == 'd400'
end

Pour ceux qui préfèrent les langages compilés, voici une adaptation en Crystal :

File.read_lines("/usr/share/wordlists/passwords/rockyou.txt").each do |line|
  pass = line.chomp
  puts pass if %x<rhash -m '#{pass}' --gost94 -p '%{gost94}'>[..3] == "d400"
end

À propos de l’auteur

Article écrit par Alexandre ZANNI alias noraj, Ingénieur en Test d’Intrusion chez ACCEIS.