Доклад Анатолия Катюшина был коротким, но устроен как хороший исследовательский кейс: есть black-box приложение, жёсткие ограничения на активное сканирование, немного статических артефактов и одна странная структура в build manifest, которая неожиданно помогает сузить поиск скрытых routes.
Это история не про «магическую уязвимость в Next.js», а про то, как AppSec-исследователь работает с тем, что доступно снаружи. Если нельзя бесконечно брутфорсить API, приходится смотреть на то, что приложение само отдаёт клиенту.
Black-box, Next.js и почти никакого brute force
Исходные условия были типичными для внешнего анализа: приложение доступно только как black box, стейджа нет, дополнительных учёток не дали, но самостоятельная регистрация разрешена. По поведению было понятно, что приложение написано на Next.js: на фронтенде React, на бэкенде Next.js, а внутри есть какие-то API-ручки для получения информации о пользователе.
Цель исследования формулировалась практически: понять, можно ли добраться до более привилегированного аккаунта или данных, которые обычному пользователю не должны быть доступны. Но прямой brute force быстро упёрся в правила: максимум два потока, секундная задержка, да ещё и только в ограниченное вечернее окно. В таких условиях перебирать тысячи и миллионы путей напрямую бессмысленно.
Оставались косвенные источники:
- статические файлы приложения;
- Google dorks, которые не помогли, потому что приложение было новым и не проиндексированным;
- auto-discovery-подходы;
- артефакты сборки Next.js.
Последний пункт и оказался самым интересным.
Build manifest и routerFilterStatic
У Next.js-приложений можно найти build manifest, обычно по пути вида:
/_next/static/{buildId}/_buildManifest.js
В таких файлах исследователь ожидает увидеть сведения о сборке: routes, чанки, правила маршрутизации, иногда полезные имена файлов. Но в этом кейсе явного списка маршрутов не было. Зато в build manifest обнаружился большой объект routerFilterStatic.
На первый взгляд это выглядело как непонятная закодированная строка с большим количеством символов. Дальше спикер полез в код Next.js и нашёл, что речь идёт о Bloom filter: вероятностной структуре данных, которая в данном случае участвует в маршрутизации.
Ключевая идея Bloom filter проста. Он отвечает на вопрос: «Может ли этот элемент входить в множество?» Ответов всего два:
- «точно нет»;
- «возможно да».
Отсюда важные свойства: false positive возможны, а false negative невозможны. То есть фильтр может ошибочно сказать, что путь, вероятно, существует, хотя его нет. Но если путь действительно был добавлен в фильтр, проверка не должна вернуть «нет».
Внутри используется несколько хеширований; в обсуждаемой реализации всплывает MurmurHash. Для атакующего это не даёт готовый список путей, но даёт локальный оракул: можно не отправлять каждый кандидат на сервер, а сначала проверить его против фильтра у себя.
Как Bloom filter помогает искать routes
В нормальной логике Bloom filter нужен для ускорения. При сборке Next.js прогоняет маршруты через хеш-функции и кладёт их в фильтр. Когда приходит запрос, приложение может быстро понять, стоит ли вообще пытаться обрабатывать путь. Если фильтр говорит «точно нет», можно рано вернуть 404. Если говорит «возможно да», запрос идёт дальше по обычному пайплайну.
С точки зрения защиты это выглядит как оптимизация. С точки зрения исследователя это превращается в способ резко сократить пространство поиска.
Спикер сделал примерно следующее:
- Забрал
routerFilterStaticизbuild manifest. - Собрал известные пути из трафика, в том числе из Burp Target.
- Проверил, что фильтр действительно узнаёт ожидаемые страницы: login, календарь, items и другие уже видимые части приложения.
- Обратил внимание, что в фильтр попадают не только клиентские страницы, но и
API.
Последний пункт был поворотным. Если API-routes присутствуют в Bloom filter, то его можно использовать как предварительный фильтр для словаря ручек. Это особенно ценно при жёстких лимитах на brute force: вместо миллионов запросов к серверу можно локально прогнать миллионы кандидатов через Bloom filter и отправить наружу только те, для которых фильтр ответил «возможно да».
В докладе приводились ориентировочные числа: из словаря и генерации получилось около 2,4 миллиона кандидатов; после локальной проверки осталось 642 записи. Большинство из них всё равно были false positive, но это уже совсем другой масштаб для ручной проверки или аккуратного rate-limited тестирования.
И среди оставшихся путей нашлись настоящие.
Когда фильтр приводит к BOLA
Сам по себе Bloom filter не является уязвимостью уровня «получить данные». Он не обходит авторизацию и не раскрывает тело обработчиков. Но он может подсветить скрытые маршруты, которые иначе пришлось бы долго искать.
В кейсе Анатолия один из найденных API-путей оказался плохо закрыт. Запрос к ручке вернул данные, которые обычному пользователю не должны были быть доступны: по контексту речь шла о списке пользователей и дальнейшем выходе на данные привилегированного аккаунта. Это уже классическая проблема BOLA — broken object level authorization.
Важная граница здесь такая:
routerFilterStaticпомог найти кандидаты на существующиеroutes;- Bloom filter дал способ уменьшить brute force до допустимого объёма;
- реальная уязвимость возникла из-за ошибки авторизации в
API; - без BOLA найденный путь мог бы оказаться просто нерепортабельной утечкой структуры приложения.
Именно поэтому кейс интересен для bug bounty: находка на стыке фреймворка, сборочных артефактов и прикладной ошибки доступа.
Почему API вообще оказался в фильтре
Вопрос из зала хорошо сформулировал суть: разве Bloom filter в build manifest не должен относиться к client-side routes? Почему туда попали API-маршруты?
По объяснению спикера, в типичном Next.js-приложении маршруты могут лежать в разных местах: app, src, pages/api, а иногда, особенно при использовании библиотек и новых структур проекта, папка api может оказаться внутри app. Сборщик видит файлы, строит маршрутизацию и включает соответствующие пути в фильтры.
В докладе отдельно прозвучали Webpack и Turbopack. По словам спикера, на Next 15 использовался Webpack, в Next 16 по умолчанию появляется Turbopack, но Webpack всё ещё можно включить явно. Детали поведения разных версий и сборщиков по транскрипту не стоит расширять: важный вывод доклада в том, что в исследованном варианте Webpack-сборка включала API-пути в Bloom filters.
Демо: локальный перебор вместо запросов в сервер
Для демонстрации спикер собрал маленькое приложение. В нём был случайно названный путь вида api/v1/[id], где id выбирался из большого диапазона. Для одной версии API была включена middleware, ограничивающая частоту запросов и возвращающая 429. Для другой версии оставался более интересный ответ.
Затем сценарий повторял реальный кейс:
- найти
buildId; - забрать
build manifest; - достать Bloom filter;
- локально прогнать кандидаты;
- получить короткий список путей, которые фильтр считает возможными;
- проверить уже этот короткий список.
В демо из примерно 100 тысяч проверенных вариантов осталось 8 кандидатов. Прямой перебор занял бы слишком много времени из-за rate limit, поэтому в демонстрационной части спикер сверял результат внутри контейнера и показывал, что нужный id действительно оказался среди найденных кандидатов. После запроса к правильному пути приложение вернуло флаг.
Что это значит для AppSec и bug bounty
Главный практический вывод: статические артефакты современных фронтенд- и fullstack-фреймворков нужно анализировать не только как «JS с исходниками», но и как источник вспомогательных структур. build manifest, фильтры маршрутизации, чанки, имена динамических страниц и служебные параметры сборки могут давать исследователю достаточно сигнала, чтобы заменить грубый brute force локальной проверкой.
Для атакующего Bloom filter удобен именно вероятностной природой. Он не раскрывает всё множество, но позволяет отбрасывать заведомо невозможные варианты. False positive остаются шумом, зато отсутствие false negative делает найденный короткий список ценным: если реальный путь был в фильтре, локальная проверка должна оставить его среди кандидатов.
Для защитника урок другой. Нельзя полагаться на «скрытость» API-путей, даже если они плохо находятся обычным сканером. Любая ручка должна проходить нормальную проверку авторизации на уровне объекта. Если доступ к /api/users/{id} или похожему эндпоинту ограничен только тем, что путь трудно угадать, рано или поздно его найдут: через Burp, через build manifest, через чанки, через sitemap, через логику клиента или через такой вот Bloom filter.
Для bounty-охоты это тоже не универсальная кнопка. Спикер упомянул, что позже написал скрипт, который искал похожие Next.js-приложения на bug bounty, находил Bloom filter и пытался искать routes. Похожие случаи встречались, но сами по себе не всегда были репортабельны. Нужна прикладная проблема: утечка данных, BOLA, обход доступа, чувствительная операция без проверки прав. Одна только возможность предположить маршрут обычно недостаточна.
Короткий чек-лист для исследователя
- Проверить наличие
/_next/static/{buildId}/_buildManifest.js. - Посмотреть, есть ли в нём
routerFilterStaticили похожие фильтры. - Сравнить ответы фильтра с известными путями из Burp.
- Отдельно проверить, попадают ли в фильтр
API-routes. - Прогнать словарь локально, а не брутфорсить сервер.
- Помнить про шум от
false positive. - Искать не просто скрытый путь, а нарушение авторизации: BOLA, доступ к чужим объектам, административные данные, операции без проверки роли.