Skip to content
KitploitKITPLOIT
ИнструментыБлог
Отправить
ИнструментыБлог
Отправить

Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!

Kitploit — это каталог инструментов для хакинга, кибербезопасности и пентестинга. Находите последние обновления проектов для поиска уязвимостей, анализа систем, автоматизации тестирования и усиления вашей безопасности.

··Ленты·Контакты·Конфиденциальность·© 2026 Kitploit

Каталог инструментов

Категории

Все категории
Loading categories
Инструменты/GitHubGitHub/darklycn1976/cve-2026-19264
Анализ уязвимостейАнализ КодаЭксплуатацияЭксплуатация веб-приложенийВеб-безопасностьОбучение и Образование
GitHubdarklycn1976/cve-2026-19264

CVE-2026-19264

CVE-2026-19264 — Критическая неаутентифицированная уязвимость path traversal, приводящая к полному захвату экземпляра в Postiz (< 2.22.1). Технический разбор: обход порядка декодирования, эскалация через JWT_SECRET и анализ исправления в апстриме.

РепозиторийСайт
21 месяц назадЕщё не проверено

Популярное

Смотреть все →

Откройте для себя самые используемые инструменты нашего сообщества.

Изучить все инструменты

Просмотрите нашу коллекцию инструментов

Смотреть все инструменты →
Поделиться

CVE-2026-19264 — неаутентифицированный обход пути с полным захватом экземпляра Postiz

CVE-2026-19264 — неаутентифицированный обход пути с полным захватом экземпляра Postiz

Автор: Krithik Babu P (@DarkLycn1976) Опубликовано: 2026-08-10 CVE: CVE-2026-19264 Серьёзность: Критическая — CVSS 4.0 9.3 / CVSS 3.1 9.8 CWE: CWE-22 — Некорректное ограничение пути в пределах ограниченного каталога Уязвимые версии: gitroomhq/postiz-app < 2.22.1 Исправлено в: v2.22.1


TL;DR

Postiz отдавал локально хранящиеся медиафайлы через маршрут, который склеивал сегменты пути из URL с каталогом загрузок и стримил результат обратно — без нормализации пути, проверки изоляции и аутентификации.

Очевидный traversal-пейлоад возвращает 404, потому что Next.js схлопывает сегменты маршрутизации. Но и декодируются ещё ровно один раз на пути к вызову файловой системы, восстанавливая обход уже после всех проверок.

../
до
URL-закодированные разделители переживают сопоставление с маршрутом

Неаутентифицированный злоумышленник мог прочитать любой файл, доступный на чтение процессу приложения, — включая его собственное окружение, где хранится секрет подписи JWT. Поскольку Postiz подписывает токены сессий этим секретом и выдаёт их без срока действия (expiry claim), его получение превращает примитив чтения файлов в постоянную подделываемую сессию под любым пользователем, включая администратора.

Один неаутентифицированный GET-запрос — и полный захват экземпляра.


1. Предыстория

Postiz — это открытая платформа для планирования публикаций в социальных сетях — на момент написания примерно 34 000 звёзд на GitHub — построенная на фронтенде Next.js и бэкенде NestJS. Она широко используется для самостоятельного размещения (self-hosting) агентствами и небольшими командами для управления подключёнными аккаунтами соцсетей, запланированным контентом и биллингом.

Самостоятельно размещённые (self-hosted) инсталляции могут хранить загруженные медиафайлы локально, а не в объектном хранилище. Это поведение управляется одной переменной окружения:

root@kitploit:~
STORAGE_PROVIDER=local

Именно это значение поставляется в .env.example, поэтому большинство self-hosted-инсталляций работают именно с ним, если только администраторы сознательно не настроили S3 или Cloudflare R2.

2. Поверхность атаки

Когда активно локальное хранилище, next.config.js переписывает публичный путь /uploads/:path* на внутренний API-маршрут:

root@kitploit:~
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts

Два свойства делают этот маршрут интересным ещё до рассмотрения какой-либо ошибки:

  1. Он неаутентифицирован. Никакое фронтенд-промежуточное ПО (middleware) его не защищает. Раздача публичных медиа — это задуманная функция, поэтому сессия не требуется.
  2. Это catch-all. Необязательный сегмент [[...path]] означает, что все оставшиеся компоненты пути приходят в виде массива, который обработчик волен интерпретировать.

Когда STORAGE_PROVIDER имеет любое значение, кроме local, перезапись указывает на /404, и обработчик недостижим. Этот конфигурационный переключатель — единственное, что отделяет инсталляцию от данной уязвимости.

3. Уязвимый код

Обработчик до версии v2.22.1:

root@kitploit:~
export const GET = async (request: NextRequest, context) => {
  const { path } = await context.params;
  const filePath =
    process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');

  const response  = createReadStream(filePath);
  const fileStats = statSync(filePath);
  // ... stream the file back to the caller
};

Три дефекта в четырёх строках:

  • Нет нормализации. path.normalize(), path.resolve() — не вызывается ни одна. Какие бы сегменты ни пришли, они склеиваются как есть.
  • Нет проверки изоляции. Ничто не проверяет, что итоговый filePath по-прежнему находится внутри UPLOAD_DIRECTORY.
  • Конкатенация строк, а не сборка пути. + '/' + обрабатывает компоненты как текст, а не как путь с семантикой.

Результат попадает напрямую в createReadStream(), и байты стримятся вызывающему с MIME-типом, определённым по имени файла. Нет ни списка разрешённых расширений, ни фильтра содержимого.

4. Почему очевидный пейлоад не работает

Атака из учебника выглядит так:

root@kitploit:~
GET /uploads/../../../etc/passwd

На Postiz это возвращает 404, и именно этот 404 — вся причина, по которой эта ошибка так долго оставалась необнаруженной.

Next.js нормализует путь запроса во время маршрутизации. Сырые сегменты ../ схлопываются до того, как роутер решает, какой обработчик вызвать. К моменту, когда запрос достигает catch-all, обход уже устранён — либо путь резолвится в место без подходящего маршрута, либо обратно внутрь /uploads, но уже без точечных сегментов.

Для того, кто тестирует быстро, этот 404 читается как «фреймворк сам это обрабатывает». Это настоящая, работающая защита. Проблема не в том, что её нет, — а в том, на каком этапе конвейера она срабатывает.

5. Обход — расхождение в порядке декодирования

Сопоставление с маршрутом и обработчик запроса выполняют разное количество проходов процентного декодирования.

Если разделители процентно закодированы, во время сопоставления с маршрутом эта последовательность не является разделителем пути. %2e%2e%2f — просто непрозрачная строка, инертный текст, которого нормализатору незачем касаться. Она беспрепятственно проходит маршрутизацию, попадает под catch-all и декодируется по пути в params обработчика, где снова становится ../.

На этом этапе она конкатенируется с UPLOAD_DIRECTORY и передаётся в createReadStream() — в обход маршрутизации, в обход нормализации, в обход всех проверок, которые могли бы её остановить.

Рабочие варианты:

root@kitploit:~
GET /uploads/%2e%2e%2fsecretdir%2fsecret.txt        → 200, file outside the upload directory
GET /uploads/..%2f..%2f..%2fetc%2fpasswd            → 200
GET /uploads/%2e%2e%2f%2e%2e%2f...%2fetc%2fpasswd   → 200, returned the real /etc/passwd

Двойное кодирование не работает — %252e остаётся литеральным на протяжении единственного прохода декодирования и никогда не становится точкой. Ровно один слой кодирования — это оптимальная точка, что служит полезным напоминанием: «закодируй сильнее» — не стратегия.

Инвариант, который стоит запомнить:

Проверка, выполняющаяся до завершения декодирования, не защищает sink.

6. Эскалация — от произвольного чтения к захвату экземпляра

Примитив чтения файлов сам по себе — это уровень High. Критическим его делает то, до чего он добирается.

Шаг 1 — читаем окружение. Собственная конфигурация Node-процесса лежит на диске в корне развёртывания. Из .env можно получить, среди прочего:

  • JWT_SECRET — ключ подписи токенов сессий
  • DATABASE_URL — полные учётные данные Postgres
  • OAuth-секреты подключённых провайдеров и ключи биллинга

Шаг 2 — подделываем сессию. Postiz подписывает токены сессий ключом JWT_SECRET алгоритмом HS256 через jsonwebtoken. Критически важно, что токены выдаются без expiresIn, поэтому подделанный токен действителен бессрочно.

Шаг 3 — становимся кем угодно. Аутентификационное промежуточное ПО заново извлекает пользователя из базы данных по claim'у id. Оно сознательно не доверяет таким claim'ам из токена, как isSuperAdmin, — хороший дизайн, — но это усиление защиты не имеет значения, если вы можете подписывать произвольный id. Подпись { id: <victim user id> } создаёт сессию, неотличимую от легитимного входа:

root@kitploit:~
read .env  →  JWT_SECRET  →  sign({ id: victim })  →  authenticated as victim, forever

Я проверил это на реальной логике верификации проекта с использованием настоящей зависимости jsonwebtoken: токен, подписанный восстановленным секретом, был принят, а тот же токен, подписанный неверным секретом, был отклонён. Контрольный случай важен — без него у вас предположение, а не находка.

Шаг 4 — параллельный путь. Одного DATABASE_URL достаточно для прямого доступа к Postgres: прочитать все подключённые аккаунты или напрямую переключить флаг администратора.

Никакого пароля. Никакого предварительного доступа. Никакого взаимодействия с пользователем. Один неаутентифицированный HTTP-запрос.

7. Влияние и оценка

root@kitploit:~
CVSS 4.0  9.3  AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1  9.8  AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

PR:N и UI:N — две метрики, которые делают основную работу. Маршрут не требует ни сессии, ни взаимодействия с жертвой — атакующий действует в одиночку, по сети, против конфигурации по умолчанию.

Единственный честный ограничитель — конфигурационный переключатель: инсталляции на S3 или R2 не уязвимы, поскольку маршрут переписывается на /404. Это уменьшает число затронутых, но не серьёзность для тех, кто в их числе, — а local поставляется по умолчанию.

8. Исправление

Патч мейнтейнеров (7936062) — восемь строк, и его стоит прочитать, потому что он корректен так, как эти исправления часто не бывают:

root@kitploit:~
+import { resolve, sep } from 'path';
...
-  const filePath =
-    process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
+  const base = resolve(process.env.UPLOAD_DIRECTORY!);
+  const filePath = resolve(base, (path ?? []).join('/'));
+  // Confine reads to UPLOAD_DIRECTORY. resolve() collapses any `..` segments
+  // (including URL-decoded ones), so this blocks every path-traversal variant.
+  if (filePath !== base && !filePath.startsWith(base + sep)) {
+    return new NextResponse('Not found', { status: 404 });
+  }

Две вещи, которые здесь сделаны правильно:

  1. Нормализация происходит в sink. resolve() схлопывает .. после завершения декодирования, поэтому неважно, как обход был протащен через маршрутизацию. Теперь проверка находится там, где опасность.
  2. Сравнение идёт с base + sep, а не с base. Наивный filePath.startsWith(base) принял бы /app/uploads-evil/x за путь внутри /app/uploads — классический обход через совпадение префикса. Добавление разделителя закрывает эту возможность, а условие filePath !== base сохраняет допустимость самого каталога.

Это правильная форма проверки изоляции: сначала resolve, затем сравнение с базой и завершающим разделителем.

9. Хронология раскрытия

Всё время в UTC, 2026-07-20, если не указано иное.

ВремяСобытие
05:55Уведомление об уязвимости отправлено команде Postiz
07:44Подтверждено и проверено мейнтейнерами
12:18Исправление закоммичено, проверено и опубликовано
2026-08-07 14:13CVE-2026-19264 присвоен организацией Postiz (CNA)
2026-08-07 14:15Опубликован GitHub Security Advisory

Шесть часов двадцать три минуты от сообщения до выпущенного патча — на открытом проекте без программы bug bounty. У меня были отчёты, которые месяцами лежали без ответа в организациях с выделенными командами безопасности. Признательность Enno Gelhaus за координацию и Nevo David за устранение уязвимости.

10. Выводы

Проверка, проходящая ваш тест, не означает, что она находится в правильном месте. 404 был настоящим. Next.js действительно схлопывает ../. Защита просто срабатывала до того, как ввод был полностью декодирован, а значит, она защищала маршрутизатор, а не вызов файловой системы. Когда вы находите защитную меру, спрашивайте, когда она выполняется относительно sink, — а не только, существует ли она.

Кодирование — это слой, и слои снимаются с разной скоростью. Каждый раз, когда два компонента конвейера запроса расходятся во мнении о том, сколько раз нужно декодировать, разрыв между ними эксплуатируем. Сопоставители маршрутов, промежуточное ПО и обработчики часто расходятся во мнениях.

Оценивайте примитив чтения файлов по тому, до чего может дотянуться процесс, а не по самому примитиву. «Произвольное чтение файлов» звучит как раскрытие информации. Критическим оно стало потому, что окружение читаемо, секрет из него подписывал сессии, а эти сессии никогда не истекали. Проследите всю цепочку, прежде чем оценивать.

Бессрочные токены превращают утечку в постоянную компрометацию. Раскрытие ключа подписи при короткоживущих токенах — плохой день. Без expiresIn ситуация невосстановима без ротации секрета — и большинство операторов даже не узнают, что им это нужно.

Проводите контрольный тест. Проверка того, что токен, подписанный неверным секретом, отклоняется, — это то, что отличает подтверждённую находку от предположительной.

11. Ссылки

  • Запись CVE — https://www.cve.org/CVERecord?id=CVE-2026-19264
  • GitHub Security Advisory — https://github.com/gitroomhq/postiz-app/security/advisories/GHSA-4hgh-5rhf-4qpm
  • Уведомление Postiz (CNA) (PSA-2026-TH12B7) — https://gadvisory.org/advisories/PSA-2026-TH12B7
  • Коммит с исправлением — https://github.com/gitroomhq/postiz-app/commit/7936062
  • Исправленный релиз v2.22.1 — https://github.com/gitroomhq/postiz-app/releases/tag/v2.22.1
  • CWE-22 — https://cwe.mitre.org/data/definitions/22.html

Рекомендации по устранению

Если вы самостоятельно размещаете Postiz:

  1. Обновитесь до v2.22.1 или новее. Это и есть исправление.
  2. Считайте JWT_SECRET скомпрометированным, если вы запускали уязвимую версию на публично доступном хосте с STORAGE_PROVIDER=local. Выполните его ротацию. Поскольку токены не имеют срока действия, ротация — единственный способ аннулировать возможные подделки.
  3. Выполните ротацию учётных данных DATABASE_URL и всех OAuth-секретов подключённых провайдеров, хранящихся в том же окружении.
  4. Проверьте журналы доступа на наличие GET-запросов к /uploads/, содержащих %2e или %2f.

Исследование проведено независимо и раскрыто вендору в рамках скоординированного раскрытия. Опубликовано после выхода исправления и публикации уведомления. Никакие сторонние системы не затрагивались — вся валидация выполнялась на локальном экземпляре, собранном из исходного кода проекта.


Лицензия

Этот разбор лицензирован по CC BY 4.0 — вы можете свободно делиться и адаптировать его с указанием авторства. Фрагменты кода из gitroomhq/postiz-app процитированы в целях анализа безопасности и остаются под лицензией того проекта.

Krithik Babu P — @DarkLycn1976

Скачать инструмент