Evil Merge: как малварь пряталась в git merge-коммите 3,5 месяца

в 14:46, , рубрики: evil merge, Git, open source, security, supply chain attack

Несколько месяцев назад я делал плановую проверку кодовой базы на одном из проектов и нашёл обфусцированный код в файле vite.config.js. Он был на той же строке что и закрывающий };, но сдвинут вправо на несколько сотен пробелов — туда, куда ни один diff-вьюер не прокрутит и ни один редактор не покажет без горизонтального скролла.

Я пошёл смотреть через git log — какой коммит это принёс. Оказался merge-коммит. Не обычный коммит в ветке — именно merge. И вот тут началось интересное.

Merge, который не должен был ничего менять

У merge-коммита было два родителя. Я проверил файл в обоих — идентичный. Одинаковое содержимое, одинаковый MD5:

Родитель 1: aa82acb0c335430d8300b6cb306dc824  ← чистый
Родитель 2: aa82acb0c335430d8300b6cb306dc824  ← чистый, идентичный
Merge:      2a54754defae4d13aab39f256738dbbf  ← ДРУГОЙ

Если вы понимаете как работает трёхстороннее слияние в git — вы уже видите проблему. Когда оба родителя содержат одинаковый файл, git просто берёт его как есть. Ему нечего мержить. Единственный способ получить другой результат — вручную отредактировать файл в процессе merge до коммита.

Именно это и произошло. Контрибьютор, нажавший кнопку merge в PR, добавил код которого не было ни в одной из веток. Git записал это как обычный merge-коммит, и никто не обратил внимания.

То же самое было сделано с двумя файлами в двух разных модулях. Идентичный пейлоад в обоих.

Что делал этот код

Я потратил день на деобфускацию. Коротко:

Первый слой восстанавливает строки require, module, constructor через алгоритм перемешивания с числовым сидом. Это позволяет обойти любой статический анализ, который ищет подозрительные ключевые слова. Вместо eval() используется Function constructor — делает то же самое, но grep не поймает.

Второй слой — кастомный декодер на таблице замены символов. Декодирует большой зашифрованный блок в настоящий пейлоад.

Дальше — самое интересное. Пейлоад не содержит ни одного URL и ни одного IP-адреса. Вместо этого он запрашивает TRON-кошелёк на последнюю транзакцию, берёт из неё BSC-хеш транзакции, делает eth_getTransactionByHash JSON-RPC запрос к BSC-ноде и вытаскивает из поля input зашифрованный XOR-ом код. Этот код и выполняется.

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

Есть резервный путь через Aptos на случай если TRON недоступен. Вся инфраструктура — кошельки, транзакции — была подготовлена до внедрения кода.

Финальный этап запускает отдельный фоновый процесс (child_process.spawn с stdio: 'ignore' и detached: true) — он живёт после завершения Vite. Таймер на 30 секунд не даёт коду выполниться повторно в watch-режиме.

Всё это запускалось при каждом npm run dev и npm run build примерно 3,5 месяца.

Почему никто не заметил

GitHub не показывает diff merge-коммитов в PR. Ревью PR показывало изменения ветки — всё чисто. Сама инъекция произошла на шаге merge, который никто не ревьюит. Ну а зачем? Это же просто слияние.

git log показывает merge-коммит, но не то что в нём изменилось. Чтобы увидеть это, нужно запустить git diff <parent1>..<merge> — а так никто в рутинной работе не делает.

Файл в любом редакторе выглядел нормально. Вредоносный код был на той же строке что и легитимный, просто сдвинут вправо пробелами. Его не увидеть, если специально не скроллить за колонку 200.

SAST не поднял тревогу, потому что явных сигнатур не было — ни eval, ни URL, ни base64 в открытом виде. Всё за кастомным энкодером.

Что такое evil merge

После этого инцидента я начал копать — есть ли для этого название и известна ли такая техника. Оказалось, термин есть, и он официальный. Из документации git (gitglossary(7)):

An evil merge is a merge that introduces changes that do not appear in any parent.

Термин закреплён в официальном глоссарии git. В 2013 году Junio C Hamano — нынешний мейнтейнер git — написал единственный серьёзный пост на эту тему, объясняя как evil merge может возникнуть случайно при семантических конфликтах. То что я нашёл — не случайность.

На Хабре по этой теме нет ни одной публикации. На dev.to — одно упоминание в комментарии. На HN — пара реплик в чужих тредах. Тема, которую мейнтейнер git описывал ещё в 2013-м, в 2024-м году практически неизвестна разработчикам.

С инструментами ещё хуже. Есть evilmergediff — Python 2 скрипт, последний коммит в 2013 году, нет exit-кода, нет CI-интеграции. Есть пара bash-гистов на GitHub. Встроенный git show --remerge-diff может показать проблему, но только если вы вручную запустите его на конкретный коммит. Инструмента который сканирует всю историю репозитория автоматически — не было.

Детектор

После того как паника улеглась и мы поменяли все секреты которые смогли найти, я начал думать — а можно ли это было поймать автоматически?

Логика простая: для каждого merge-коммита восстановить то что git должен был сгенерировать — чистое трёхстороннее слияние деревьев родителей через общего предка — и сравнить с тем что merge-коммит реально содержит. Любое расхождение означает что файл редактировался вручную в процессе merge.

Из этого получился Evil Merge Detector — CLI на Go:

evilmerge scan /path/to/repo

Он проходит по всем merge-коммитам, сравнивает ожидаемое дерево с реальным и выводит расхождения. Есть --format=sarif для интеграции с GitHub Code Scanning и уровни severity (конфликтные разрешения которые были ручными — ожидаемы, инструмент их отличает от правок файлов без конфликта).

Есть GitHub Action для добавления в CI:

- uses: fimskiy/Evil-merge-detector@v1
  with:
    fail-on: warning

И GitHub App — устанавливается на репозиторий и автоматически проверяет каждый PR через Checks API. При первой установке он сканирует всю историю репозитория — на случай если инцидент уже произошёл.

Для GitLab, Bitbucket или self-hosted git-серверов — готовые шаблоны в директории examples/, включая pre-receive хук, который блокирует push с evil merge прямо на уровне сервера.

Шире

Атаки на supply chain через зависимости open source получают много внимания. Атаки через contributor-доступ к приватным репозиториям — почти никакого.

Паттерн простой: получить доступ контрибьютора, несколько месяцев делать легитимные коммиты, потом внедрить код во время merge. PR чистый. Merge-коммит выглядит как рутинная интеграция.

Стандартный совет — требовать линейную историю (squash/rebase only) или поставить pre-receive хук на своём git-сервере. Оба варианта меняют workflow или требуют self-hosted инфраструктуру. На GitHub это не так просто.

Я отправил репорт в GitHub Security. Полный ответ:

«This is an intentional design decision and is working as expected. We may make this functionality more strict in the future, but don’t have anything to announce right now.»

В качестве существующей митигации указали “Dismiss stale reviews” — правило защиты ветки, которое требует повторного ревью при каждом новом коммите в PR-ветку.

“Dismiss stale reviews” действительно усложняет атаку. Но это prevention, не detection. Он не сканирует существующую историю на предмет прошлых инъекций. Требует ручной настройки на каждый репозиторий. И может быть отключён любым администратором. Если правило не было включено до инцидента — или он произошёл в репозитории без активного мониторинга — вы об этом не узнаете.

GitHub оставил дверь открытой для будущих изменений, но ничего конкретного не анонсировал. Пока — merge workflow работает так же.

Я не знаю насколько это распространено. Может наш случай редкий. Но то что это работало 3,5 месяца в репозитории с CI, code review и несколькими разработчиками которые работали с ним каждый день — говорит о том что мы, возможно, проверяем не то.

Автор: fimskiy

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js