Несколько месяцев назад я делал плановую проверку кодовой базы на одном из проектов и нашёл обфусцированный код в файле vite.config.js. Вредонос был добавлен не в коммитах, видимых в PR, а во время разрешения конфликта прямо в merge-коммите. GitHub показывает в PR только diff ветки, но не diff самого merge-коммита, поэтому добавленный код остался невидим.
Я пошёл смотреть через 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 и несколькими разработчиками которые работали с ним каждый день — говорит о том что мы, возможно, проверяем не то.
Комментарии (22)

maksl
01.04.2026 17:02Вроде не перевод, а как будто перевод.
Я, все таки, не доктор, но разве разрешение конфликтов не делает именно это? Меняет файлы в процессе мёрджа.
Или это вопрос исключительно к утверждению MR/PR в гитхаб/гитлаб?
DimNS
01.04.2026 17:02Я так понял весь смысл что именно во время разрешения конфликта есть возможность внедрить вредонос и обычно ревьювер уже дал свой аппрув типа: "Всё ок, вот аппрув, только разреши конфликты и вольём" и вот тут всплывает проблема что ревьюверу больше не требуется снова делать ревью (решение что дала поддержка GitHub заставит ревьювера снова смотреть diff и давать аппрув)

fimskiy Автор
01.04.2026 17:02Именно так. Классический сценарий: ревьюер одобрил PR, попросил только разрешить конфликты — и после этого повторного ревью не требуется. Атакующий в момент разрешения конфликта добавляет произвольный код в merge-коммит, и этот код уходит в main без чьего-либо взгляда.
▎ Что интересно — решение, которое GitHub упомянул как возможное направление (“may make this functionality more strict”), как раз и означало бы: после изменений в merge-коммите аппрув сбрасывается и требуется повторное ревью. Пока этого нет,
единственная защита — инструментальная проверка самого merge-коммита.

fimskiy Автор
01.04.2026 17:02При разрешении конфликта все изменения видны в diff PR’а — ревьюер может их проверить. Evil merge — это изменения, которых нет ни в одной из веток-родителей и которые не являются конфликтом. Они добавляются напрямую в merge-коммит после того, как git уже сделал слияние. GitHub в таком случае показывает в PR только diff относительно base branch — и эти изменения в нём не видны совсем.

Sazonov
01.04.2026 17:02Меня в своё время посадили на rebase + fast forward ( + cherry pick при крайней необходимости). Merge коммиты запрещены на уровне репозитория. Правда делалось это изначально с другой целью - иметь чистую историю изменений, которая 1-в-1 берётся в качестве changelog при релизах.

fimskiy Автор
01.04.2026 17:02Rebase + fast-forward полностью закрывает эту атаку — merge-коммитов нет, вектора нет. Чистая история как бонус. Если команда может себе это позволить, это лучшее решение. Проблема в том, что большинство крупных проектов используют merge-коммиты — GitHub создаёт их по умолчанию, и далеко не все меняют эту настройку. Для них и нужен инструментальный контроль.

DimNS
01.04.2026 17:02Скажу даже больше, после этой статьи я пошел искать эту настройку и нашел далеко не с первого раза, потому что эта галочка появляется только при условии включения другой галочки и простой поиск по странице ничего не находит

ArtemD
01.04.2026 17:02Эээ разве эта ситуация не решается запретом коммитить в мастер-ветку? (в результате чего merge-коммит делается автоматически, без возможности что-то туда добавить)

fimskiy Автор
01.04.2026 17:02Да, это усложняет атаку в GitHub, но полностью проблему не убирает. При достаточных правах правила можно обойти. И это не защищает от уже существующих изменений в истории.

amakhrov
01.04.2026 17:02Так а если есть права для обхода запрета - разве это само по себе не дает злоумышленнику запушить какой угодно зловред? Вообще без мерж коммитов.
Почему проблема именно с мерж коммитами?

fimskiy Автор
01.04.2026 17:02Проблема именно с merge-коммитами, потому что в GitHub UI можно редактировать конфликты прямо в браузере, добавляя extra code. При rebase + fast-forward конфликты разрешаются локально, и такой фокус не пройдёт.

Sazonov
01.04.2026 17:02Так ничего не мешает в свою ветку намешать левых мерж коммитов из других веток. И если вкоммичтвание мерж коммитов явно не запрещено.

LaserPro
01.04.2026 17:02Код ревью мешает

Sazonov
01.04.2026 17:02Вы часто ревьювите именно мерж коммиты? Особенно если это вливание больших фичей. Статья ведь как раз об этом.

amakhrov
01.04.2026 17:02Я ревьюю не коммиты, а ПР в целом.
Если из-за здоровенных мерж коммитов из левых веток в ПР 100500 измененных строчек - это мало чем отличается от просто создания ветки с 100500 изменений, в которых я где-то спрятал бэкдор.
В статье речь исключительно про изменения, вносимые после ревью кода. Предполагается что ревью было тщательное и в вдиффе действительно ничего криминального нет
Mingun
Diff-viewer, который не показывает всю строку? Выглядит, как фантастика
fimskiy Автор
Выглядит, как фантастика, но легко воспроизводится
Mingun
Пример программы/веб-сервиса приведете? Хотя бы скриншот?
fimskiy Автор
Вот полный пример: https://github.com/fimskiy/evil-merge-demo
В PR виден только "superuser", но в итоговом файле в main есть оба токена — "hacked" и "superuser". Можешь сам посмотреть merge-коммит и убедиться.
Mingun
Я не спорю с тем, что создать такую ситуацию возможно, это же штатный механизм разрешения конфликтов. Но вы утверждаете
что при ревью этого не видно поскольку вам программа не показывает хвост где-то там за границами экрана и полосы прокрутки тоже нет. Вот к этому у меня большие вопросы – что это за программа такая, которая так делает. К слову, GitHub делает переносы строк, поэтому никаких секретных данных вы за границами экрана не спрячете.
fimskiy Автор
В статье я допустил неточную формулировку про скролл. Суть проблемы не в этом. Дело в том, что GitHub не показывает вообще строку в PR-diff. Это не про длинные строки, а про то, что изменения, добавленные в самом merge-коммите (поверх результата слияния), не отображаются в PR.
GitHub показывает diff: feature branch vs main.
GitHub не показывает: что было добавлено в merge-коммите после слияния.
Спасибо за уточнение, я обновил формулировку в статье.