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.

SecuritySupply chainGitHubVS CodeIncident responseGit

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:

  1. the fake .woff2 plus the folderOpen task — the only variant that runs on its own;
  2. a ~9 KB line appended after the closing ]; of eslint.config.js, after a long run of whitespace;
  3. 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:

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

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.

Sources

Keep reading