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в следующем видео.