С NextJS покончено... 13 НОВЫХ уязвимостей

BBetter Stack
Computing/SoftwareBusiness NewsInternet Technology

Transcript

00:00:00Это случилось снова. Это уже третье мое видео об уязвимостях (CVE) серверных компонентов в этом году,
00:00:05и я даже не думаю, что осветил их все. На этот раз это 13 уязвимостей, да, целых 13,
00:00:11в React и Next.js, 6 из которых имеют высокий уровень серьезности и включают отказ в обслуживании,
00:00:15обход middleware, межсайтовый скриптинг и многое другое. Возможно, серверные компоненты были ошибкой.
00:00:20Итак, вот релиз безопасности Next.js, ну, вы понимаете, просто исправляют несколько «случайных» проблем,
00:00:28которые возникли у них в этом месяце. Внизу, очевидно, решением является обновление
00:00:32всех ваших версий Next.js, и вот затронутые версии. Стоит отметить,
00:00:36что TanStack это не затронуло, и, возможно, я предвзят, но для меня это еще один повод использовать
00:00:41TanStack. Я не буду разбирать их все, так как мы, вероятно, задержимся здесь надолго,
00:00:44к тому же я не нашел рабочих эксплойтов для каждой из них, но я хочу показать по одной
00:00:48из каждой категории. Начнем с обхода middleware и прокси, и тот,
00:00:52который мне удалось воссоздать, — это уязвимость в Pages Router. Итак, у нас есть обход прокси в
00:00:56Pages Router при использовании i18n, и вы видите, что это CVE с серьезностью 7.5 из 10.
00:01:02Вот пример уязвимого приложения: в конфиге Next.js я включил i18n
00:01:06и настроил два языка — английский и французский. Также у меня есть файл middleware,
00:01:12который в более поздних версиях Next.js был переименован в «proxy», чтобы попытаться избежать
00:01:16той путаницы, которую я сейчас покажу. По сути, middleware должен позволять нам
00:01:19изменять входящий запрос: будь то перенаправление, перезапись пути или добавление
00:01:24каких-либо заголовков. В моем случае я использую его так: когда мы пытаемся зайти на
00:01:28страницу /secret, он проверяет наличие сессионной куки (авторизован ли пользователь),
00:01:32и если нет — перенаправляет на страницу входа. Так что только авторизованные пользователи должны видеть
00:01:37мою секретную страницу. Внизу у нас также есть матчер, чтобы любой middleware,
00:01:41применяемый к этой секретной странице, также соответствовал ее языковым вариантам,
00:01:45потому что технически теперь, с двумя языками, у нас есть три версии этого URL. На самой секретной странице
00:01:50у меня есть server-side props: они должны извлекаться с сервера во время рендеринга,
00:01:54и опять же, поскольку мы настроили middleware, теоретически только вошедший в систему пользователь должен видеть,
00:01:58что это за значения, которые я позже использую на самой странице: email, флаг и
00:02:03заголовок. Повторюсь, только авторизованный пользователь должен их видеть. Давайте проверим
00:02:07это на практике: первым делом я попробую зайти на секретную страницу, и, как видите,
00:02:11меня перенаправляет на страницу входа, так как я не авторизован. Это значит, что наш middleware работает.
00:02:16Но что если мы превратимся в супер-хакеров? Мы можем сделать это, сначала открыв «Просмотреть код»,
00:02:20абсолютно безумные хакерские штучки, и затем в скрипте next data нам нужно найти
00:02:24наш build ID. В моем случае это вот этот, мы можем его скопировать,
00:02:28затем нам нужно ввести URL: _next/data/, далее ID сборки, который мы
00:02:32только что скопировали, и название страницы, к которой пытаемся получить доступ, с расширением .json. Как только вы это сделаете,
00:02:37вы увидите, что мы получили те самые пропсы, которые должны были быть защищены этим middleware,
00:02:40в моем случае это флаг, email, заголовок, а также сообщение с призывом подписаться
00:02:44на новости для разработчиков, туториалы и советы. Так что подписывайтесь, надеюсь,
00:02:48я впечатлил вас своими хакерскими навыками. Но почему это происходит на самом деле?
00:02:52Это объяснить так же просто, как и сделать. У нас была секретная страница и server-side props.
00:02:56В Next.js server-side props отдаются по URL, который выглядит вот так, но наш middleware
00:03:00должен был защищать этот маршрут. Проблема в том, что раз мы использовали i18n, у нас также
00:03:05было два других URL — английский и французский варианты. Вы видите, что server-side
00:03:09props также получают английский и французский варианты, и в коде Next.js была ошибка,
00:03:13из-за которой при включенном i18n базовый случай не защищался. То есть он
00:03:18не был включен в матчер, а вот английская и французская версии были. Базовый путь /secret
00:03:22защищен не был. Мы можем быстро в этом убедиться: если я просто изменю URL на английскую версию,
00:03:26меня перенаправит на страницу входа. Невероятно простая уязвимость,
00:03:31но, честно говоря, эти обходы middleware часто звучат страшнее, чем есть на самом деле.
00:03:35Это плохо, но вам и не стоит защищать что-то серьезное только лишь с помощью middleware,
00:03:40и Next.js даже не рекомендуют так делать. Если у вас были конфиденциальные данные
00:03:44в этих server-side props и не было никакой серверной логики авторизации, то я считаю,
00:03:48что часть вины лежит и на вас. Перейдем к более опасному случаю — отказу в
00:03:53обслуживании (DoS). Таких было три, но я смог надежно воспроизвести только одну,
00:03:56а именно вот эту: DoS в серверных компонентах. Это затрагивает как Next.js,
00:04:01так и все, что использует пакет react-server-dom, а это практически только Next.js
00:04:05и другие фреймворки, которые его скопировали, вроде Vinxi и прочих форков. TanStack
00:04:10Start его не использует, так что он неуязвим. Как видите, эта уязвимость также имеет уровень
00:04:14серьезности 7.5 из 10. Для этого эксплойта нужно очень простое приложение на Next.js,
00:04:18в котором используется Server Action, причем самый примитивный. Вот этот
00:04:22сайт запущен, и вы видите, что при обновлении страницы загрузки практически нет,
00:04:25все происходит мгновенно. Чтобы подтвердить это цифрами: если я отправлю этот запрос,
00:04:29он выполняется за 0,02 секунды. Но если я сейчас запущу свой эксплойт и
00:04:34снова отправлю запрос, на этот раз это заняло 6 секунд. И это после одного запуска эксплойта,
00:04:39так что представьте, что будет, если запустить их цепочкой. Чтобы понять этот эксплойт,
00:04:42нам нужно немного узнать о протоколе React Flight — это формат, который React использует
00:04:46для сериализации деревьев компонентов и данных между сервером и клиентом. Вы наверняка
00:04:50уже это видели: на этой странице у нас была форма с Server Action. Если я перейду
00:04:54во вкладку Network и нажму «Send», вы увидите, что полезная нагрузка (payload) отправляется
00:04:58в виде данных, которые выглядят как абракадабра. То же самое и в
00:05:02ответе. Если мы скопируем этот payload, я смогу объяснить, что происходит,
00:05:05когда он попадает на сервер. Первый шаг — десериализация, она начинается с чанка 0, где у нас
00:05:10есть этот $k1. Этот $k1 на самом деле просто указатель, говорящий, что здесь будут данные формы,
00:05:16начинающиеся с одного подчеркивания. Он возьмет все остальные ключи, которые мы отправили
00:05:20в этом payload, переберет их все в поисках строки, начинающейся с
00:05:24одного подчеркивания, и поймет, что это ключ, а это — значение. Когда
00:05:28это сделано, он определит поля name, email, message и просто превратит эти данные в
00:05:32объект, который мы видим внизу. Все просто. Проблема этого подхода,
00:05:36однако, проявляется при масштабировании. Допустим, я добавлю еще один указатель, на этот раз $k2,
00:05:41который будет искать ключи, начинающиеся с двух подчеркиваний. Беда в том,
00:05:44что теперь для $k1 он переберет все шесть ключей в поисках тех,
00:05:48что начинаются с одного подчеркивания, а когда перейдет к $k2 — проделает ровно
00:05:52то же самое, ища два подчеркивания. Итого мы перебираем уже 12
00:05:56ключей. Пока не так страшно, но давайте доведем это до крайности. Если добавить 199 999
00:06:03случайных ключей в отправляемый payload и изменить массив в чанке 0
00:06:07на последовательность от $k1, $k2 до $k1000, это значит, что ему придется искать одно подчеркивание,
00:06:12два, три... и так до тысячи подчеркиваний среди всех наших 200 000
00:06:17ключей в payload. В общей сложности это даст 200 миллионов сравнений
00:06:21строк. Как нетрудно догадаться, это заблокирует поток на несколько секунд.
00:06:25Вот коммит, который, как я полагаю, исправил проблему. Как видно,
00:06:28в этом коммите происходит много всего, и, честно говоря, он довольно сложный, но я
00:06:33постараюсь объяснить как можно лучше. По сути, теперь они используют систему ключей на основе курсора:
00:06:36они загружают все 200 000 ключей из payload в список, и затем
00:06:41начинают с чанка 0, ища ссылку $k1, и двигаются вниз
00:06:45по этому списку с курсором, который не может возвращаться назад. Он идет к $j1, видит, что это не
00:06:50соответствует одному подчеркиванию, переходит к $j2 — тоже не совпадает,
00:06:54и так продолжает до самого конца списка, до $j199 999. Дойдя до
00:07:01сюда, он понимает, что совпадений для $k1 нет, и переходит к $k2. Теперь $k2 начинает искать
00:07:06два подчеркивания, но так как система основана на курсоре, а курсор
00:07:09не может двигаться назад, он мгновенно доходит до конца списка, поэтому это значение тоже будет
00:07:14undefined, и так до самого $k1000. В этот раз мы
00:07:18прошли по списку ключей всего один раз. Это исправление сократило количество операций с
00:07:23k*n, где k — число ссылок, а n — число ключей, до
00:07:27n+k. В нашем случае мы перешли от 200 000 000 операций к 201 000, так как
00:07:33все равно нужно пройтись по всем ключам и ссылкам. Мне кажется,
00:07:37этот твит от Prime отлично описывает ситуацию: создание собственного протокола с сериализацией —
00:07:41задача невероятно сложная, так что неудивительно, что мы видим столько проблем. По-моему,
00:07:46им пора натравить Claude или Mythos на кодовую базу React и Next.js. Следующая
00:07:50у нас — самая серьезная CVE из всех: подделка серверных запросов (SSRF)
00:07:54в приложениях Next.js. Как видите, ее рейтинг 8.6 из 10, но стоит
00:07:59заметить, что она не затронула проекты на хостинге Vercel — только self-hosted или другие провайдеры.
00:08:04Этим эксплойтом тоже очень просто воспользоваться. Сначала запустим сервер Next.js,
00:08:09причем это может быть стандартное приложение без каких-либо модификаций.
00:08:14Затем нам понадобится внутренний сервер. Представим, что к этому серверу может получить доступ
00:08:18только сервер Next.js, но не внешний мир. Скажем, он находится внутри облачного контура.
00:08:23Тогда мы просто отправляем очень простой curl-запрос к нашему
00:08:26приложению Next.js (в данном случае на порт 3002) и указываем в качестве
00:08:31target-запроса тот localhost URL сервера, к которому хотим получить доступ. Если я нажму
00:08:36Enter, смотрите, что возвращается. Это HTML-страница Python-сервера,
00:08:40просто со списком файлов в директории. А если вернуться к самому Python-серверу,
00:08:45видно, что входящий запрос действительно пришел от приложения Next.js.
00:08:49Чтобы лучше это представить: допустим, внутри пунктирной линии — наш деплой Next.js,
00:08:53где есть сам сервер и внутренние сервисы: Redis,
00:08:57база данных или что-то еще. К ним нельзя получить доступ извне,
00:09:02обычный curl-запрос не пройдет — все закрыто файрволом и доступно
00:09:06только серверу Next.js. Мы же просто отправили
00:09:10curl-запрос серверу Next.js и сказали: «Эй, не мог бы ты отправить от нашего имени запрос
00:09:15к внутреннему сервису?» И получили информацию обратно. То есть мы обошли файрвол,
00:09:19пройдя через Next.js. Причина этой уязвимости
00:09:23тоже довольно проста: в curl-запросе мы передаем заголовок Upgrade: websocket,
00:09:28и при наличии таких заголовков Next.js доходит до вот этого участка
00:09:32кода. Он должен резолвить маршруты, но распарсенный URL здесь
00:09:37оказывается тем самым таргетом, который мы передали в curl-запросе. То есть это
00:09:40цель на внутреннем сервере, а вовсе не само приложение Next.js.
00:09:45Далее этот URL проходит одну проверку внизу: «есть ли у него
00:09:49протокол?» В нашем случае — да, так как мы используем HTTP, а это протокол,
00:09:55и Next.js спокойно проксирует этот запрос за нас. Исправление добавляет две новые
00:09:58проверки в функцию резолва маршрутов, и теперь мы получаем булево значение «finished»
00:10:02и код состояния. Сама функция resolveRoutes
00:10:06анализирует наш URL и определяет, является ли запрос легитимным прокси-запросом на основе
00:10:11конфигурации рерайтов, middleware и прочего. Если нет, «finished» будет равно
00:10:15false. Ниже стоит проверка: если true — идем дальше, если false —
00:10:20код не сработает, а значит, прокси-запрос не будет выполнен.
00:10:24В случае с нашим curl-запросом именно это и произойдет: «finished»
00:10:27станет false. Но даже если бы оно стало true, следующая
00:10:32проверка — код состояния. Когда выполняется resolveRoutes, мы получаем код состояния,
00:10:36если это HTTP-запрос (200, 404 и т.д.). Если код состояния есть,
00:10:41это значит, что запрос не является валидным прокси-запросом веб-сокета, и он
00:10:45будет проигнорирован. В этом видео я постарался глубоко погрузиться в эти проблемы,
00:10:49так что дайте знать, если вы все еще смотрите — напишите в комментариях что-нибудь случайное,
00:10:53например «foo» или что-то в этом роде. И подписывайтесь, если цените контент. У нас осталось
00:10:58две категории, они будут быстрее. Начнем с
00:11:01отравления кэша (cache poisoning), мне удалось воссоздать вот эту проблему средней тяжести,
00:11:04а именно отравление кэша в серверных компонентах React, рейтинг 5.4 из 10. Чтобы воссоздать
00:11:09ее, у меня есть приложение Next.js и фейковая CDN, чтобы имитировать
00:11:14реальную рабочую среду. Это значит, что если я зайду на сайт по URL через CDN,
00:11:18нажму «Browse products», вернусь и нажму снова, первый раз должен быть
00:11:23промах мимо кэша (cache miss), а второй — попадание (cache hit). Мы видим это в логах:
00:11:27сначала miss на /products со строкой запроса, а затем — hit.
00:11:31Затем я очистил кэш, чтобы симулировать его истечение,
00:11:35и теперь я могу отправить такой curl-запрос. После этого в моем приложении,
00:11:39если я нажму «Browse products», вы увидите кучу абракадабры — мы успешно
00:11:44провели атаку по отравлению кэша. Как я понимаю, происходит следующее: когда мы отправляем
00:11:47curl-запрос с заголовком React-Server-Component: 1, этот URL вернет данные
00:11:52серверного компонента вместо HTML. Затем, когда это нужно закэшировать, Next вызывает
00:11:58функцию, которая проверяет, данные это серверного компонента или нет. Если да —
00:12:02они сохраняются в кэше как таковые, если нет — как HTML. Это значит,
00:12:07что теоретически, когда пользователь пытается получить HTML-версию страницы (просто
00:12:11нажав на кнопку), ему никогда не должны вернуться данные серверного компонента. Проблема в том,
00:12:16что в нашем случае из-за строки запроса в конце curl-запроса,
00:12:20запрос не соответствовал требованиям проверки на серверный компонент. Проверка
00:12:24просто смотрит, заканчивается ли путь на .rsc, но наша версия заканчивается строкой запроса.
00:12:29Поэтому система думает, что это HTML, и сохраняет это в кэше как HTML. И когда следующий пользователь
00:12:33нажимает на кнопку, он получает данные серверного компонента, потому что система
00:12:37считает их за HTML. Исправление было невероятно простым: при
00:12:41проверке окончания на .rsc они теперь просто игнорируют строки запроса. Переходим к
00:12:46нашей последней категории CVE — межсайтовому скриптингу (XSS). Вот та, которую я воссоздал:
00:12:50рейтинг 6.1 из 10, XSS в интерактивных скриптах
00:12:55с недоверенным вводом. По сути, это означает, что в Next.js, если у меня есть
00:12:59тег script со стратегией beforeInteractive, и на нем есть другой атрибут,
00:13:03принимающий недоверенный контент (например, из параметров поиска — searchParams),
00:13:08я могу выполнить XSS. Я могу заставить пользователя кликнуть по ссылке,
00:13:12в которую внедрена куча контента через этот searchParam. Если кто-то
00:13:16кликнет по такой ссылке, вот что он увидит: он может подумать,
00:13:19что ему нужно снова войти на сайт, но когда он нажмет «Sign in»,
00:13:22вы увидите, что это была абсолютно фальшивая форма входа, внедренная через этот параметр. Это
00:13:26позволяет злоумышленнику исполнять JavaScript на машине жертвы. Более реалистичный пример —
00:13:31кража сессионных кук Chrome, чтобы войти во все аккаунты, к которым у вас есть
00:13:34доступ. Это пример некорректного экранирования символов, и это проще увидеть
00:13:39в упрощенной версии: в параметре поиска я сначала закрываю
00:13:43тег script, затем открываю новый тег с любым кодом, который хочу запустить. При
00:13:47нажатии Enter появляется alert с текстом «pwned». Как я и показывал,
00:13:51для этого нужен тег script в Next.js со стратегией beforeInteractive
00:13:55и какой-нибудь атрибут на этом теге, принимающий данные из недоверенного
00:13:59источника (в моем случае — из параметров запроса). В Next.js такой
00:14:04тег превращается в нечто подобное, где используется dangerouslySetInnerHTML.
00:14:08Само название говорит за себя — это опасно. Также там используется JSON.stringify.
00:14:12Важно знать, что JSON.stringify не экранирует HTML-символы,
00:14:17такие как закрывающие скобки. На самом деле происходит следующее: берутся все теги script,
00:14:21настроенные в Next.js, ищется source,
00:14:24а остальные пропсы (включая data-tracking-id и
00:14:29значение из нашего параметра) попадают в JSON.stringify. На самой
00:14:33странице это рендерится примерно так: у нас есть data-tracking-id,
00:14:37но также и строка, которую мы вставили через параметры.
00:14:41Если развернуть это, на странице получится вот что:
00:14:45идет тег script, data-tracking-id, а следом —
00:14:49закрывающий тег . Это завершает скрипт, который пытался выполнить Next.js, и после
00:14:53этого мы можем запускать на странице любой свой скрипт. В конце мы добавляем еще один кусочек,
00:14:58потому что без него остаток текста от аналитики просто отобразился бы на
00:15:01странице. Так мы его «проглатываем», чтобы не было
00:15:05явных признаков взлома. Вот и все: 13 уязвимостей в Next.js и React за одну
00:15:09неделю, и разбор того, как некоторые из них работали. Честно говоря,
00:15:14даже не знаю, что об этом думать. Мне это не нравится, и это говорит человек, который два
00:15:18года назад делал каждый проект на Next.js и считал его лучшим решением,
00:15:22но теперь это кажется бесконечной чередой препятствий. Похоже, они поспешили с
00:15:26некоторыми вещами, а потом исправляли их на ходу. Лично я сейчас полностью перешел на TanStack
00:15:31и Astro, когда нужен контентный сайт — они кажутся мне намного проще. И
00:15:35еще мне очень нравится то, что последнее время делает Cloudflare, так что я потихоньку
00:15:39переношу свои проекты туда. Но у меня все еще около 20 проектов на Vercel, и
00:15:43мне нужно пойти и обновить их. Что думаете вы? Станут ли серверные компоненты полезными или мы попробовали и провалились?
00:15:48Пишите в комментариях, ставьте лайк, подписывайтесь и, как всегда, до встречи
00:15:51в следующем видео.

Key Takeaway

Обнаруженные 13 уязвимостей в Next.js и React требуют немедленного обновления версий фреймворков для предотвращения критических атак типа SSRF, DoS и обхода авторизации через middleware.

Highlights

  • В React и Next.js обнаружено 13 новых уязвимостей (CVE), 6 из которых имеют высокий уровень серьезности.

  • Уязвимость SSRF с рейтингом 8.6 из 10 позволяет обходить файрволы и получать доступ к внутренним сервисам вроде Redis или баз данных.

  • Ошибка в десериализации протокола React Flight вызывает отказ в обслуживании (DoS), увеличивая время обработки запроса с 0,02 до 6 секунд за один цикл.

  • Межсайтовый скриптинг (XSS) с рейтингом 6.1 возникает при использовании стратегии beforeInteractive в тегах script из-за отсутствия экранирования в JSON.stringify.

  • Уязвимость Pages Router i18n позволяет извлекать конфиденциальные данные server-side props через прямой запрос к пути _next/data/ без авторизации.

  • Отравление кэша (Cache Poisoning) происходит из-за некорректной обработки расширения .rsc в строках запроса, что заставляет систему выдавать данные серверных компонентов вместо HTML.

Timeline

Обход Middleware и прокси в Pages Router

  • Включение i18n в конфигурации Next.js создает незащищенные маршруты для получения данных.
  • Прямой запрос к URL вида _next/data/[build-id]/page.json позволяет получить доступ к server-side props в обход middleware.
  • Ошибка в логике матчера игнорирует базовый путь страницы при наличии языковых префиксов.

При использовании i18n английская и французская версии страниц защищаются корректно, однако базовый маршрут остается открытым. Злоумышленник может извлечь ID сборки из исходного кода страницы и составить прямой запрос к JSON-данным. Это позволяет получить такие данные, как email и заголовки, которые должны быть доступны только авторизованным пользователям.

Отказ в обслуживании (DoS) в серверных компонентах

  • Протокол React Flight уязвим к атакам через чрезмерное количество ключей в полезной нагрузке.
  • Алгоритм поиска соответствий имел сложность k*n, что приводило к выполнению 200 миллионов операций сравнения строк при 200 000 ключах.
  • Исправление через систему курсоров оптимизировало обработку до линейной сложности n+k.

Уязвимость затрагивает пакет react-server-dom и фреймворки на его основе. Атака заключается в добавлении огромного количества случайных ключей в payload Server Action. Это блокирует поток выполнения на сервере на несколько секунд, парализуя работу приложения. Новая система на основе курсора исключает повторные проходы по списку ключей, предотвращая зависание.

Критическая уязвимость SSRF и обход файрвола

  • Next.js некорректно проксирует запросы при наличии заголовка Upgrade: websocket.
  • Злоумышленник может заставить сервер Next.js отправить запрос к закрытым внутренним сервисам внутри облачного контура.
  • Уязвимость не затрагивает проекты, размещенные на платформе Vercel, но критична для self-hosted решений.

При обработке веб-сокет соединений функция resolveRoutes принимала произвольный целевой URL без надлежащей проверки принадлежности к приложению. Это позволяло через обычный curl-запрос получить доступ к локальным базам данных или Python-серверам, скрытым за внешним файрволом. Исправление добавляет проверку легитимности прокси-запроса на основе конфигурации рерайтов.

Отравление кэша (Cache Poisoning)

  • Данные серверных компонентов могут ошибочно сохраняться в кэше CDN как обычный HTML.
  • Проверка расширения .rsc игнорировала наличие строки запроса (query string) в конце URL.
  • Следующий легитимный пользователь получает нечитаемую заплатку данных вместо отрендеренной страницы.

Атака проводится путем отправки запроса с заголовком React-Server-Component и специально сформированной строкой запроса. Поскольку система не распознает запрос как серверный компонент из-за символов после .rsc, она помечает ответ как HTML. В результате CDN выдает внутренние данные React Flight всем последующим посетителям страницы.

Межсайтовый скриптинг (XSS) в скриптах

  • Стратегия beforeInteractive в сочетании с пользовательским вводом в атрибутах создает условия для XSS.
  • JSON.stringify не выполняет автоматическое экранирование закрывающих тегов script.
  • Злоумышленник может внедрять фальшивые формы входа или красть сессионные куки Chrome.

Уязвимость возникает, когда данные из searchParams попадают в атрибуты тега script. Так как JSON.stringify не экранирует угловые скобки, злоумышленник может принудительно закрыть текущий скрипт и открыть новый с произвольным кодом. Это позволяет отображать поддельный интерфейс поверх легитимного сайта для фишинга данных пользователя.

Community Posts

View all posts