Блог / Разборы CVE

fast-jwt: путаница алгоритмов JWT исправлена на третий раз

fast-jwt трижды патчили одну и ту же путаницу RSA в HS256. CVE-2026-107722 показывает, почему первые два патча закрыли вход, а не правило.

Аспирантура по информационной безопасности завершенаВнутривузовская защита пройдена · Итоговая защита диссертации впереди

Одна и та же ошибка, исправленная трижды

Без пароля. С доступом администратора. Вот что даёт подделанный токен против верификатора RS256 в fast-jwt — баг, который патчили трижды, прежде чем исправление наконец сработало. 8 октября 2026 года в базе GitHub Advisory Database вышла CVE-2026-107722 — отчёт за исправлением, которое наконец сработало. [1] Прочитайте заголовок внимательно: «Incomplete patch of CVE-2026-34950» — неполный патч для CVE-2026-34950. А теперь заголовок самой CVE-2026-34950, от апреля: «Incomplete fix for CVE-2023-48223» — неполное исправление CVE-2023-48223. [3]

Три рекомендации, одна библиотека, один класс уязвимости. Каждое исправление закрывало именно тот вход, который продемонстрировал предыдущий исследователь, и каждый раз приходил кто-то с чуть другим входом и проходил насквозь.

Вот в чём суть этой истории. Сама уязвимость — учебный пример путаницы алгоритмов, и если вы остановитесь на этом, вы обновитесь до 6.3.0 и пойдёте дальше. Интереснее другое: почему два компетентных исправления провалились, а третье — нет. Эта разница применима к вашим собственным патчам уже в понедельник.

Я не воспроизводил это в своей лаборатории. Всё, что ниже, — из рекомендации, из предыдущей рекомендации и из диффа, который всё исправил. [1][2][3]

CVE-2023-48223   the original
      v
CVE-2026-34950   "incomplete fix"   9.1
      v                (added trim)
CVE-2026-107722  "incomplete patch" 9.8

Сервер верит ключу. Атакующий управляет строкой

Путаница алгоритмов JWT в одном предложении: токен сам указывает серверу, как себя проверять. Если приложение подписывает токены RS256, а верификатор можно убедить переключиться на HS256, открытый ключ RSA перестаёт быть открытым ключом и становится общим секретом. Он публичный. Он есть у атакующего. Атакующий подписывает им токен, сервер вычисляет тот же HMAC — и подпись совпадает.

Интереснее всего то, где именно fast-jwt принимает это решение. Библиотека определяет, асимметричный ли у вас ключ, глядя на него как на строку: похож на блок PEM — значит RSA; не похож — значит это сырой секрет HMAC. [1]

Решение разумное, и почти каждая библиотека JWT делает что-то похожее. Но это значит, что классификация зависит от сопоставления текста, а у сопоставления текста есть крайние случаи, которых нет у криптографии.

Почему trim() казался правильным исправлением

В апреле CVE-2026-34950 сообщила, что ведущий пробел обходит проверку PEM. [3] Сопоставитель был регулярным выражением, привязанным к началу строки, поэтому ключ с пробелом впереди не совпадал, классифицировался как сырой секрет — и открывался путь через HMAC.

Исправление в 6.2.0 — это вызов trim() перед проверкой. [1][2] Минимально, очевидно верно для заявленного входа, вышло с регрессионным тестом. Готов спорить, что большинство ревьюеров одобрили бы это меньше чем за минуту, и я, вероятно, был бы одним из них.

Но заявленный вход никогда не был сутью бага. Баг — в привязке. Регулярное выражение, начинающееся с «^», кодирует допущение: заголовок PEM стоит на нулевой позиции. trim() не убирает это допущение, он лишь делает его верным для одной категории символов, потому что String.prototype.trim убирает только то, что спецификация ECMAScript называет пробельными символами. [1]

Всё остальное по-прежнему работает. Регрессионные тесты, вышедшие вместе с настоящим исправлением, перечисляют, что означает «всё остальное» на практике: байт NUL, escape-символ, DEL, символы нулевой ширины, строка комментария, HTTP-обёртка. [2] Любой из них перед заголовком PEM — и ключ снова сырой секрет.

Рекомендация формулирует это лучше, чем я: исправление было узким — только для пробелов, вместо того чтобы устранить весь класс. [1]

// 6.1.0 and earlier
/^-----BEGIN(?: (RSA))? PUBLIC KEY-----/

// 6.2.0: trim() first, same anchor
trimmedKey.match(publicKeyPemMatcher)

// a leading NUL still walks past it

Третье исправление изменило правило, а не вход

Посмотрите, чем 6.3.0 отличается на самом деле: это не более длинный список символов для вырезания. [2]

Привязка к позиции исчезла вовсе. Новый сопоставитель ищет заголовок PEM где угодно в строке и вырезает ключ оттуда. Ключ считается сырым секретом HMAC только если в нём вообще нет заголовка PEM, а если заголовок есть — ключ уже никогда не упадёт обратно в HMAC. Проверка X.509 перешла с includes на startsWith. Ключ, в котором есть маркеры PEM, но который не классифицируется чисто, теперь выбрасывает ошибку вместо тихого падения в ветку HMAC.

Сообщение коммита формулирует новое правило одной строкой, и это та фраза, которую стоит украсть для своего собственного кода: если заголовок присутствует, ключ обязан классифицироваться как асимметричный материал и никогда не может упасть обратно в HMAC. [2]

Первые два исправления внесли вход в чёрный список. Третье внесло форму в белый список. В этом вся разница, и именно поэтому оно закрывает весь класс, а не ещё один его экземпляр.

// 6.3.0 - find it anywhere
/-----BEGIN [A-Z0-9 ]+?-----/

const { pem, isRawSecret } =
  locatePem(trimmedKey)

if (isRawSecret) return hsAlgorithms
// unless algorithms explicitly pins HS256 - that still skips this check

Закрепить алгоритм полезно. Закрепить не тот — нет

Вот место, на котором, готов спорить, многие попадутся. Рекомендация перечисляет уязвимые конфигурации: отсутствие списка разрешённых algorithms, асинхронный коллбэк для ключа, или algorithms, установленный в HS256. Защищённой конфигурацией названа algorithms, установленный в RS256. [1]

Закреплять алгоритм — стандартный совет против путаницы алгоритмов, и совет верный. Но команда, которая бегло читает рекомендацию, видит HS256 в описании атаки и закрепляет HS256, на самом деле закрепила верификатор на выборе атакующего. В списке разрешённых должен стоять тот алгоритм, которым вы реально подписываете, а не тот, о котором вы прочитали в разборе.

Когда я проверяю код аутентификации, именно эту форму я ищу первой: защитный механизм, настроенный по следам разбора инцидента, а не по собственному замыслу системы. Он проходит ревью, потому что видимо является механизмом защиты, — и при этом смотрит не в ту сторону.

Действительно ли ваш сервис уязвим?

Уже, чем намекает оценка 9.8, и должны сойтись три условия. [1]

Первое: установленная версия fast-jwt — между 6.2.0 и 6.2.4, но это для именно этой CVE; 6.1.0 и более ранние тоже не в безопасности — там действует вторая уязвимость в этой цепочке, CVE-2026-34950. Проверьте, что реально установил npm, а не то, что просит package.json, и проверьте транзитивную зависимость: fast-jwt — это бэкенд JWT по умолчанию для @fastify/jwt, так что немало команд используют её, даже не выбирая сами. [1]

Второе: верификатор настроен одним из уязвимых способов выше. Третье — и это единственное, что за вас никто не проверит, — ваш настроенный ключ несёт символ без пробела перед заголовком PEM. Чистый ключ не затронут вообще.

Это последнее условие — свойство того, как ключ попадает в процесс, а не вашего кода. Прочитан из файла, вставлен в переменную окружения, получен из менеджера секретов, склеен с меткой скриптом развёртывания. У меня нет данных о том, как часто это случается в реальных системах, и у рекомендации их тоже нет, так что относитесь к этому как к месту для проверки, а не как к вероятности.

Что проверить сегодня

Три вещи, в том порядке, в котором я бы их сделал сам.

Обновитесь до 6.3.0 и подтвердите установленную версию после этого. Затем задайте явный список разрешённых algorithms, называющий тот асимметричный алгоритм, который реально использует ваш сервис. И затем — это то, что переживёт саму эту CVE, — проверяйте форму вашего ключа верификации при старте, вместо того чтобы доверять всему, что вам отдал файл или переменная окружения.

Третья проверка стоит одной строки, и она сделала бы последние две из этих CVE неэксплуатируемыми в вашем сервисе — включая следующую. (Первая, CVE-2023-48223, была другой формы — валидный ключ, который старый сопоставитель не распознавал, а не мусорный префикс перед ним, — так что именно эта проверка её бы не поймала.)

В этом и есть настоящий урок. Библиотека, которая трижды патчила один и тот же класс, вероятно, вернётся снова. Загрузчику ключей, который отказывается от всего, что не является в точности блоком PEM, будет всё равно.

# the version that actually loads
npm ls fast-jwt

# pin the algorithm you really use
{ algorithms: ['RS256'] }

# assert the key's shape at boot
key.startsWith('-----BEGIN')

Патчите правило, а не отчёт

Каждое из этих исправлений сделал компетентный человек, отвечая на реальный отчёт, с тестом. Провал был не в небрежности. Он был в объёме: отчёт описывал вход, поэтому исправление закрыло вход, а правило, из-за которого этот вход был опасен, осталось ровно таким же.

Вопрос, который я задаю на ревью патча, нарочно неудобный: к какому классу входов относится этот отчёт, и закрывает ли исправление весь класс? Если ответ «нет» — вы не предотвратили следующую CVE, а назначили ей дату.

Этот вопрос — почти вся суть secure code review. Проследить отчёт обратно до решения, которое сделало его возможным, а затем проверить, сдвинуло ли исправление это решение или только симптом, — именно эту работу я регулярно делаю на коде клиентов, и именно такое сканер никогда не отметит, потому что номер патченой версии выглядит правильно. Если вы хотите, чтобы на исправление посмотрели глазами атакующего раньше, чем брешь в нём найдёт кто-то другой, — вот разговор, с которого стоит начать.

Источники

Нужна такая проверка для вашего продукта?

Аудит безопасности кода
Все статьи

08Контакт

Выпускаете продукт с данными пользователей?

Пришлите короткое описание. Я отвечу в течение трёх дней вопросами или наброском предложения, а стоимость согласуем письменно до начала работ.