DEV Community

Cover image for Evil Merge: The Git Attack That Hid Malware in Plain Sight for 3.5 Months
fimskiy
fimskiy

Posted on

Evil Merge: The Git Attack That Hid Malware in Plain Sight for 3.5 Months

A few months ago I was doing a routine check on a codebase I work on and found obfuscated code in a Vite config file. It was sitting right there in vite.config.js, on the same line as the closing };, but pushed hundreds of characters to the right — way past anything you'd ever see in a diff or an editor without horizontal scrolling.

When I traced it back through git log, the commit that introduced it was a merge. Not a regular commit on a feature branch — a merge commit. And that's when things got weird.

The merge that shouldn't have changed anything

The merge had two parents. I checked the file in both parents — identical. Same content, same MD5 hash:

Parent 1: aa82acb0c335430d8300b6cb306dc824
Parent 2: aa82acb0c335430d8300b6cb306dc824
Merge:    2a54754defae4d13aab39f256738dbbf
Enter fullscreen mode Exit fullscreen mode

If you know how git merge works, you know this shouldn't happen. When both sides have the same file, git just takes it as-is. There's nothing to merge. The only way to get a different result is to open the file during the merge and edit it by hand.

That's what happened. The contributor who clicked "merge" in the PR added code that wasn't in either branch. Git recorded it as a normal merge commit, and nobody looked twice.

This was done to two files in two different modules. Same payload in both.

What the code did

I spent a day deobfuscating it. The short version:

The first layer reconstructs strings like require, module, constructor through a shuffling algorithm with a numeric seed. This gets it past any static analysis that looks for suspicious keywords. It uses Function constructor instead of eval() — same thing, but harder to grep for.

Second layer is a custom decoder — lookup table, character substitution, back-references. It decodes a big blob into the actual payload.

Here's where it gets interesting. The payload doesn't contain any URLs or IP addresses. Instead, it queries a TRON wallet for its latest transaction, reads a BSC transaction hash from the data field, then does a eth_getTransactionByHash JSON-RPC call to a BSC node. The actual malicious code is stored in the input field of that transaction, XOR-encrypted.

So the C2 channel is the blockchain. You can't take it down, you can't block it by domain, and nothing in the source code points to a server.

There's a fallback path through Aptos in case TRON is unreachable. The whole infrastructure — wallets, transactions — was set up before the code was injected.

The final stage spawns a detached background process (child_process.spawn with stdio: 'ignore', windowsHide: true) that survives after Vite exits. There's a 30-second cooldown timer so it doesn't re-trigger on watch mode rebuilds.

This ran on every npm run dev and npm run build for about 3.5 months.

Why nobody saw it

I kept asking myself how this sat there for months. Here's what I came up with:

GitHub doesn't show merge commit diffs in PRs. The PR review showed the branch changes, which were clean. The actual injection happened in the merge step, which nobody reviews because... why would you? It's just merging.

git log shows the merge but not what changed in it. You'd need git diff <parent1>..<merge> to see it, and nobody runs that routinely.

The file itself looked completely normal in any editor. The malicious code was on the same line as legitimate code, just pushed far to the right with spaces. Even if you opened the file, you wouldn't notice unless you scrolled horizontally past column 200.

And SAST tools didn't flag it because there were no obvious signatures — no eval, no URLs, no base64 blobs visible in the source. Everything was behind the custom encoder.

What is an "evil merge" exactly

After the incident I went looking for prior art and found that this technique has an official name. From the git glossary (gitglossary(7)):

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

The term has been in git's official documentation since early on. In 2013, Junio C Hamano — the current git maintainer — wrote the only serious public post about it, explaining how evil merges can arise accidentally from semantic conflicts. What I found was not accidental.

The existing tooling is essentially nonexistent. There's evilmergediff — a Python 2 script from 2013 that hasn't been updated since, with no exit codes and no CI integration. There are a couple of bash gists. Git itself has git show --remerge-diff which can surface the problem, but it requires manual inspection per commit. I couldn't find a single tool that scans a repository's full history automatically and integrates into a CI pipeline.

So I wrote one

After the initial panic subsided and we rotated every secret we could find, I started thinking about whether this could be caught automatically.

The idea is simple: for every merge commit, reconstruct what git should have produced — a clean three-way merge of the two parent trees using the common ancestor — and compare it against what the merge commit actually contains. Any difference means someone edited files during the merge.

That became Evil Merge Detector. It's a Go CLI:

evilmerge scan /path/to/repo
Enter fullscreen mode Exit fullscreen mode

It walks every merge commit, diffs the expected tree against the actual tree, and flags anything that doesn't match. There's a --format=sarif output for GitHub Code Scanning integration, and severity levels (some merge edits are conflict resolutions, which are expected — the tool distinguishes those from edits to files that had no conflict).

I also wrapped it as a GitHub Action so you can add it to CI:

- uses: fimskiy/Evil-merge-detector@v1
  with:
    fail-on: warning
Enter fullscreen mode Exit fullscreen mode

And there's a GitHub App that does it automatically on every PR if you don't want to touch your workflow files. When you first install it, it scans the full repository history — so if something already happened, you'll know.

For GitLab, Bitbucket, or self-hosted git servers, there are ready-to-use templates in the examples/ directory, including a pre-receive hook that blocks pushes containing evil merges at the server level.

The bigger picture

The attack pattern is straightforward: get contributor access, make normal commits for a while, then slip code in during a merge. The contributor in our case had been active for months before the injection. The PR that was merged was legitimate — the evil part was only in the merge step.

Supply chain attacks through open source dependencies get a lot of attention. Attacks through contributor access to private repositories get almost none. The two mitigations people usually suggest — require linear history (squash/rebase only), or use pre-receive hooks — both require changing your workflow or running your own git server.

I filed this with GitHub Security as a vulnerability report. Their response in full:

"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."

They pointed to "Dismiss stale reviews" as the existing mitigation — a branch protection rule that forces re-review when new commits are pushed to a PR branch.

"Dismiss stale reviews" does make the attack harder to execute going forward. But it's prevention, not detection. It doesn't scan your existing history for past injections. It requires manual setup on every repository. And it can be disabled by any admin at any time. If it wasn't enabled before the incident — or if it happened in a repository without active monitoring — you'd never know.

GitHub left the door open to future changes but has nothing to announce. Until then, the merge workflow will keep working this way.

I don't know how common this is in the wild. Maybe our case was unusual. But the fact that it sat undetected for months in a repository with CI, code review, and multiple developers working on it daily makes me think we're probably not checking the right things.

If you want to check your repos, evilmerge scan . takes a few seconds. I'd rather find nothing than find something three months late.

Top comments (0)