Здравствуйте, дорогие читатели! Это моя первая и надеюсь, что не последняя, публикация на Habr. Буду очень рад возможной конструктивной критике и обратной связи. Эта статья стала итогом моего первого раунда реального пентестирования на живом сайте, поэтому спешу поделиться опытом. Раньше занимался только разработкой, поэтому это был крайне интересным опытом. Людям, которые только хотят вливаться в пентестирование или идти в него из разработки, как я, эта статья может быть крайне полезна. А более профессиональные пентестеры и программисты, возможно, смогут чутка похихикать с неё и узнать себя в начале карьерного пути. В общем, надеюсь, что статья не оставит вас равнодушной. Не буду тянуть и приятного прочтения!
TL;DR. Теперь кратко и по делу. Нашёл на одном сайте (легально, с разрешения владельца, само собой) 500-ю ошибку от одного HTTP‑заголовка. Сначала подумал — баг десериализации, может, RCE. Потом сел, собрал стенд, намерил — и находка усохла до аккуратного «500 вместо 400». По дороге я один раз испугался, что уронил чужой прод (оказалось — перепутал окна терминала). В общем, я заспойлерил всё в самом начале, но история от этого, надеюсь, вышла не менее интересная.
Минимальное воспроизведение, которое можно запустить самому: https://github.com/Artpardsur/next‑rsc-400-repro
Предыстория: три дня, за которые я не нашёл ничего
Начнём с того как я вообще оказался на этом «4-м дне пентестирования». 500-ка не свалилась с неба: ей предшествовали три дня, за которые я не нашёл ни одной серьёзной дыры. И это важная часть истории — именно поэтому, когда наконец мелькнул 500-й код, меня так потянуло сделать из неё целую сенсацию вселенского масштаба.
Всё началось с тестового задания. Знакомый — глава ИБ‑компании — дал мне добро проверить его боевой сайт, за что ему крайне благодарен. Для меня это был шанс показать себя, так что хотелось не просто прогнать сканер, а сработать на совесть: тихо и не спеша, по одному запросу. Тем более что сайт был непростой — на периметре Cloudflare, за ним SOC с ИИ, honeypot’ы и система, которая на шумный скан автоматически шлёт abuse‑жалобу провайдеру атакующего. Один неосторожный nmap — и сгорел бы мой VPN‑адрес. «Громко» было нельзя чисто физически.
День 1 — разведка. Пассивно: заголовки, сертификаты, DNS, публичная документация. Стек — Next.js и headless‑CMS за Cloudflare. В задании была приписка «найти PHP» — и это оказалось первой проверкой на вшивость: PHP на сайте не было вообще, я убедился в этом четырьмя независимыми способами (сканеры, CT‑логи, заголовки, клиентский JS). Будь я понаивнее — пошёл бы перебирать /shell.php и угодил бы ровно в honeypot. Ещё полдня я искал реальный IP origin‑сервера за Cloudflare — Censys, Shodan, crt.sh, история DNS. Не нашёл нигде. Вывод первого дня: инфраструктуру спрятали грамотно.
День 2 — первый аккуратный активный пентест. robots.txt, sitemap, API с легально полученным токеном. И тут я впервые упёрся носом в стекло: один чуть «грязный» запрос — и Cloudflare отдал 403, до приложения даже не дошло. Урок был не про сайт, а про меня: пока я сижу на неразрешённом IP, я тестирую не приложение владельца, а его Cloudflare. Я остановился и согласовал правила игры, вместо того чтобы вслепую менять адреса, — слепая ротация IP выглядит как настоящая атака, и защита владельца так её и прочитала бы.
День 3 — верификация. Вот тут я копал всерьёз: открытый прокси‑эндпоинт (единственная реальная находка дня — без аутентификации и без лимита), проверка на SSRF (нет), на обход авторизации в API (нет), на инъекции в CMS (нет), прощупал даже телеграм‑бота с LLM внутри на prompt‑injection — тот оказался парсером, а не исполнителем, чисто. К вечеру картина сложилась неуютная: сайт просто хорошо сделан. Отчёт вырисовывался честный и скучный — «критичных уязвимостей нет, пара замечаний по харденингу». Для настоящего пентеста это нормальный результат. Но внутри свербело: задание‑то тестовое, хочется принести что‑то весомее, чем «у вас всё в порядке».
День 4. И вот на фоне трёх дней сплошного «всё крепко» я тыкаю битым заголовком в динамическую страницу — и получаю 500. После семидесяти двух часов идеальных 200-к и 403-к серверная ошибка ударила по нервам как джекпот! Я прям ликовал и подумал в тот момент: «Наконец‑то что‑то настоящее». Ровно здесь и начинается самое опасное — желание, чтобы находка оказалась крупной, было у меня в тот момент максимальным за всю неделю. Дальше — о том, как я с этим желанием справлялся.
С чего всё началось
Боевой сайт на Next.js. Обычный GET отдаёт 200. А вот такой запрос:
GET / HTTP/1.1RSC: 1Next-Router-State-Tree: xyzzy
возвращает не 400, как я ожидал от «кривого ввода», а 500 Internal Server Error. Перед origin стоит Cloudflare, и цепочка была 307 → 500. Владелец сайта полез в логи: тело ответа — 21 байт. То есть не портянка со стеком, а какой‑то generic‑ответ.
RSC: 1 и Next-Router-State-Tree — это внутренняя кухня React Server Components: по этим заголовкам Next понимает, что у него просят не целую HTML‑страницу, а «флайт»‑поток для навигации, и Next-Router-State-Tree описывает, в каком состоянии сейчас роутер клиента. Логично, что сервер его парсит. А раз парсит — значит, может подавиться.
И тут из мухи резко вырос слон
В голове сразу всплыла свежая GHSA про desync в обработке RSC‑запросов, где кривое состояние роутера приводило к рассинхрону и потенциально к RCE. Схожий заголовок, схожая 500-ка. Очень захотелось, чтобы это был тот самый класс.
Вот тут — первый важный момент. Когда очень хочется, чтобы находка была крупной, — это ровно тот момент, когда надо замедлиться. Я замедлился.
Первое отрезвление
Я поднял чистый Next.js 16.4.1 локально и начал кормить Next-Router-State-Tree разным мусором:
-
xyzzy— просто строка -
{"a":1}— валидный JSON, но не та структура -
пустая строка
-
["",{— обломанный массив -
8 КБ случайных байт
Все до одного давали одинаковую 500-ку. А если бы это был специфический desync под конкретную структуру массивов из GHSA — падало бы только на определённой форме, а не на любом хламе. Раз падает на всём подряд — это не та уязвимость. Это generic‑обработчик ошибки парсинга. Гипотезу RCE можно закрывать.
Воспроизводимый стенд
Дальше я взял за правило: не «кручу запросы руками и смотрю глазами», а меряю скриптом, который снимает и результат, и эталон одними и теми же числами. Написал маленький Node‑замерщик (без зависимостей, на голом fetch), который на любую цель делает:
-
эталон — обычный GET (ждём 200);
-
эталон с
RSC: 1, но без битого заголовка; -
пять вариантов битого
Next-Router-State-Tree— для каждого код ответа, размер тела, content‑type и проверку тела на признаки стек‑трейса; -
нагрузку — сотни запросов с битым заголовком, чтобы поймать обрывы соединений (крэш) и рост задержки (утечку).
И главное — весь прогон печатает таблицу и короткий вердикт. Конечно, вердикт иногда приходилось правильно интерпретировать, так как не все скрипты выходило писать идеально с первого раза, но до истины я всё же докопался.
Весь стенд и замерщик лежат в репозитории: https://github.com/Artpardsur/next‑rsc-400-repro —
npm install && npm run build && npm start, и можно повторить всё из статьи.
Только перед истиной был вообще очень интересный «инцидент», о котором далее.
Как я чуть не «уронил прод» (а на деле перепутал терминалы)
Проверяя прод‑режим, я в какой‑то момент получил от curl код 000. Для тех, кто не сталкивался: 000 — это не HTTP‑код, это «ответа по HTTP не пришло вообще». Соединение оборвалось. Сердце ёкнуло: неужели один запрос положил сервер? Это же сразу другой класс — DoS.
Стал проверять — даже обычный GET на / даёт 000. Сервер мёртв?
Нет. Сервер был жив. Просто я запускал curl в терминале, который «сидел» в домашней папке C:Usershome, а сервер крутился в другом окне, в папке проекта. Выдало меня вот это при попытке перезапустить:
Need to install the following packages:next@16.4.3Ok to proceed? (y)
Если бы я был в папке проекта, next взялся бы локальный (16.4.1) и ничего бы не спрашивал. А он полез скачивать чужую версию — значит, проекта тут нет, значит, я не в том окне. Весь «DoS» был фантомом из‑за путаницы окон.
Очередной урок, который я вынес из этого факапа: прежде чем делать вывод по результату — убедись, как именно ты этот результат получаешь. Половина «сенсаций» новичка — это артефакты стенда, а не поведение цели. К сожалению, понял это не своём примере, но хотя бы рад, что смог вовремя заметить ошибки и остановить поток когнитивных искажений своего утомлённого мозга. После этого завёл себе железное правило: одно окно — только сервер, другое — только запросы, и никогда не путать.
Изолируем настоящее условие
Когда я перемерил начисто, 500-ка… пропала. На / в проде стабильно 307 → 200, никакого 500. Это сбивало с толку ровно до тех пор, пока я не посмотрел на сборку:
Route (app)┌ ○ /└ ○ /_not-found○ (Static) prerendered as static content
Все маршруты — статические. Для статической страницы RSC‑ответ берётся из готового кэша, и Next-Router-State-Tree даже не разбирается — сервер просто редиректит на закэшированный результат. Парсеру, который падал, тут нечего делать.
А на боевом сайте страницы были динамические. Вот она, недостающая переменная. Добавил в стенд один динамический маршрут:
// app/dyn/[slug]/page.tsximport { Suspense } from 'react';import { connection } from 'next/server';async function Dynamic({ params }: { params: Promise }) { await connection(); // опт-ин в динамический рендер const { slug } = await params; return dyn {slug};}export default function Page({ params }: { params: Promise }) { return <Suspense fallback={loading…}>;}
Пересобрал, стрельнул битым заголовком по /dyn/test — и вот оно. На динамике 500, на статике 200:
Воспроизведение: динамика → 500, статика → 200
Условие точное: динамический маршрут + RSC‑запрос, доведённый до обработчика + невалидный Next-Router-State-Tree.
Что это на самом деле за баг
Заглянул в консоль сервера в момент 500:
Консоль сервера в момент 500
И вот это ставит всё на места. Next намеренно проверяет заголовок и, если не может разобрать, бросает именованную ошибку «router state header … could not be parsed». То есть это не случайное падение где‑то в кишках — это штатная, предусмотренная ветка. Проблема ровно одна: эту ошибку на заведомо клиентский кривой ввод Next отдаёт как 500 (ошибка сервера), тогда как по смыслу это 400 (ошибка клиента).
Прогнал финальный замер по динамике, чтобы охарактеризовать до конца:
Полный замер замерщиком: все варианты → 500, утечки нет, сервер держится
21 байт — то самое generic‑тело, что видел владелец. Стек наружу не уходит (он только в консоли сервера). Сервер не падает и под нагрузкой держится.
Честный вердикт
Критерий
Результат
Воспроизводимость
✅ 100%, минимальный стенд
RCE / desync
❌ нет — падает на любом мусоре, а не на спец‑структуре
Утечка данных / стека
❌ нет — тело 21 байт, generic
Отказ в обслуживании
❌ нет — сервер жив, ресурсы не текут
Суть
⚠️ 500 вместо 400 на невалидный клиентский заголовок, динамический маршрут
Это подтверждённый, но Low / informational баг — некорректный класс кода ответа. Не уязвимость в смысле «можно что‑то украсть или положить».
Нести ли это в Bug Bounty?
Когда я только начал раскручивать эту историю у себя в голове, то в какой‑то момент реально начал грезить о Bug Bounty, не в силах поверить в такую удачу. И, наверное, мой скептицизм как раз и помог всё расставить по полочкам. И хоть насчёт баунти можно сказать чёткое нет, в апстрим как issue эту находку вполне было можно послать, что я и в итоге и сделал.
Аккуратный GitHub‑issue в сам фреймворк с минимальным воспроизведением и предложением «возвращать 400» — нормальный добросовестный вклад, как по мне. Поэтому находке я всё равно жутко рад и это был супер крутой опыт всего за первые 4 дня пентестирования на реальном сайте!
Не став потом тратить время, я перед написанием статьи завёл issue в апстрим с ссылкой на минимальное репро: https://github.com/vercel/next.js/issues/100856.
Граница, которую я для себя провёл: error‑handling становится уязвимостью только когда ошибка что‑то выдаёт (стек, пути, данные), чем‑то управляет (разное поведение как оракул) или что‑то ломает (крэш, утечка, DoS). Я проверил все три — ни одной. Значит, это корректность, а не безопасность.
Что я унёс с собой на 4-й день
-
Когда очень хочется, чтобы это был RCE, — замедлись. Желание крупной находки искажает интерпретацию сильнее любого бага.
-
Меряй эталон и результат одним кодом. «Кажется, 500» и «500 на всех пяти вариантах, тело 21 байт, сервер жив» — это разного качества знание.
-
Сначала разберись, как ты смотришь результат. Мой «DoS» был перепутанным окном терминала.
-
Меняй по одному фактору за раз. Статика/динамика оказалась той самой переменной — я бы её не увидел, меняя всё сразу.
-
Находка, которая не воспроизводится в чистых условиях, — ещё не находка.
Ценность пентестера — не в количестве «сенсаций», а в дисциплине отбраковки. Иногда лучший результат исследования — честное «это не то, чем казалось, и вот почему».
Источник: habr.com