Несколько месяцев назад я делал плановую проверку кодовой базы на одном из проектов и нашёл обфусцированный код в файле 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)


  1. Mingun
    01.04.2026 17:02

    Diff-viewer, который не показывает всю строку? Выглядит, как фантастика


    1. fimskiy Автор
      01.04.2026 17:02

      Выглядит, как фантастика, но легко воспроизводится


      1. Mingun
        01.04.2026 17:02

        Пример программы/веб-сервиса приведете? Хотя бы скриншот?


        1. fimskiy Автор
          01.04.2026 17:02

          Вот полный пример: https://github.com/fimskiy/evil-merge-demo
          В PR виден только "superuser", но в итоговом файле в main есть оба токена — "hacked" и "superuser". Можешь сам посмотреть merge-коммит и убедиться.


          1. Mingun
            01.04.2026 17:02

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

            туда, куда ни один diff-вьюер не прокрутит и ни один редактор не покажет без горизонтального скролла.

            что при ревью этого не видно поскольку вам программа не показывает хвост где-то там за границами экрана и полосы прокрутки тоже нет. Вот к этому у меня большие вопросы – что это за программа такая, которая так делает. К слову, GitHub делает переносы строк, поэтому никаких секретных данных вы за границами экрана не спрячете.


            1. fimskiy Автор
              01.04.2026 17:02

              В статье я допустил неточную формулировку про скролл. Суть проблемы не в этом. Дело в том, что GitHub не показывает вообще строку в PR-diff. Это не про длинные строки, а про то, что изменения, добавленные в самом merge-коммите (поверх результата слияния), не отображаются в PR.
              GitHub показывает diff: feature branch vs main.
              GitHub не показывает: что было добавлено в merge-коммите после слияния.

              Спасибо за уточнение, я обновил формулировку в статье.


  1. house2008
    01.04.2026 17:02

    Поэтому я всегда включаю режим soft-wrap в IDE)


  1. maksl
    01.04.2026 17:02

    Вроде не перевод, а как будто перевод.

    Я, все таки, не доктор, но разве разрешение конфликтов не делает именно это? Меняет файлы в процессе мёрджа.
    Или это вопрос исключительно к утверждению MR/PR в гитхаб/гитлаб?


    1. DimNS
      01.04.2026 17:02

      Я так понял весь смысл что именно во время разрешения конфликта есть возможность внедрить вредонос и обычно ревьювер уже дал свой аппрув типа: "Всё ок, вот аппрув, только разреши конфликты и вольём" и вот тут всплывает проблема что ревьюверу больше не требуется снова делать ревью (решение что дала поддержка GitHub заставит ревьювера снова смотреть diff и давать аппрув)


      1. fimskiy Автор
        01.04.2026 17:02

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

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


    1. fimskiy Автор
      01.04.2026 17:02

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


  1. Sazonov
    01.04.2026 17:02

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


    1. fimskiy Автор
      01.04.2026 17:02

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


      1. DimNS
        01.04.2026 17:02

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


  1. ArtemD
    01.04.2026 17:02

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


    1. fimskiy Автор
      01.04.2026 17:02

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


      1. amakhrov
        01.04.2026 17:02

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

        Почему проблема именно с мерж коммитами?


        1. fimskiy Автор
          01.04.2026 17:02

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


    1. Sazonov
      01.04.2026 17:02

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


      1. LaserPro
        01.04.2026 17:02

        Код ревью мешает


        1. Sazonov
          01.04.2026 17:02

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


          1. amakhrov
            01.04.2026 17:02

            Я ревьюю не коммиты, а ПР в целом.

            Если из-за здоровенных мерж коммитов из левых веток в ПР 100500 измененных строчек - это мало чем отличается от просто создания ветки с 100500 изменений, в которых я где-то спрятал бэкдор.

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