Recovering from the Polin Rider Attack
Jul 2, 2026 · 9 min read
This is the technical companion to Polin Rider Attack — the story of how a supply-chain compromise reached my GitHub account. Here I focus on the part I promised at the end of that post: how I actually recovered every repository, with the exact commands.
How a stolen GitHub PAT was used to force-push malware into multiple repositories I had write access to, how I found it, and the exact commands I used to recover them from clean local copies (and to reconstruct one where I had no local copy at all).
TL;DR
- A Personal Access Token tied to my GitHub account was stolen as part of the broader PolinRider / Glassworm supply-chain incident (the token was harvested after I built a compromised repo locally).
- The attacker used that token to force-push every branch in several repos onto a single malicious commit, and in one case deleted a branch and merged/collapsed history.
- The payload was always hidden in a build-time-executed config file
(
tailwind.config.js,postcss.config.mjs) or a VS Code auto-run task pointing at a fake font, plus.gitignoreentries hiding the attacker’s push tooling. - My local clones were clean because I never pulled after the attack — so they were the source of truth. Recovery = force-push the clean tips back. For a repo I had no local copy of, only the tip commit was poisoned, so I reconstructed a clean tip from the clean parent.
- Gotchas that cost time: stale remote-tracking refs lie about GitHub’s real
state; force-push events live in the activity feed forever; a closed PR’s
refs/pull/*/headkeeps a malicious commit reachable-by-SHA (needs GitHub Support to GC); a zsh$sha:refs/...expansion trap; and toggling repo visibility erases stars/watchers (documented, effectively irreversible).
1. The malware
Vector
Building/running a compromised project (npm install / npm run build) executes
lifecycle scripts and build configs. That harvested a GitHub credential (PAT). The
attacker then used the token to push to every repo I had write access to.
Payload delivery — always “asset-as-code”
The dropper is obfuscated JavaScript appended to a file that a build tool
require()s and executes, hidden behind a huge run of whitespace so it’s
off-screen in an editor:
path/to/tailwind.config.jspath/to/postcss.config.mjs- The nastiest variant: a VS Code auto-run task that executes a
fake font:
.gitignorewas edited to un-ignore.vscode/so the malicious task ships.vscode/tasks.jsoncontained"runOn": "folderOpen"→node ./public/fonts/fa-solid-400.woff2fa-solid-400.woff2is not a font — it’s whitespace-padded JavaScript. Opening the folder runs it (on modern VS Code, once you’ve trusted the workspace).
Signature (grep IOCs)
The dropper’s obfuscation is consistent:
global['!']='...';var _$_1e42=(function(l,e){ ... })(...);
global[_$_1e42[0]]= require; ...
var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})();
Grep markers I used: _$_1e42, Tgw(, global['!'].
Attacker tooling hidden in .gitignore
Every poisoned commit added these to .gitignore (so the attacker’s scripts never
showed in git status):
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
2. Investigation — is my local copy clean? (all read-only)
# Branch tips, working tree, full graph
git show-ref
git for-each-ref --format='%(objectname:short) %(refname:short)' refs/heads
git status
git log --graph --decorate --all --oneline
# Integrity + dangling objects (dangling is normal: stashes, amends, rebases)
git fsck --full
# Inspect every dangling commit's author/date/subject (were they mine?)
git --no-pager fsck --full | awk '/dangling commit/ {print $3}' | while read c; do
git --no-pager show --no-patch --format="%H | %an | %ad | %s" "$c"
done
# Malware scan of the working tree with the scanner
npx polin-rider-scanner --all --json /path/to/repo
# Dropper signature across ALL local branches (no checkout needed)
for b in $(git for-each-ref --format='%(refname:short)' refs/heads); do
git grep -l -e '_$_1e42' -e 'Tgw(' -e "global['!']" \
-e 'temp_auto_push' -e 'temp_interactive_push' "$b" -- \
&& echo "$b: MATCH" || echo "$b: clean"
done
The scanner I switch to above —
polin-rider-scanner— is a real npm package created during the incident to detect these exact indicators of compromise. It replaced my ad-hoc grep script; you can point it at any repo withnpx polin-rider-scanner --all --json /path/to/repo.
Gotcha #1 — stale remote-tracking refs lie
refs/remotes/origin/* only update when you fetch/push. If you never pulled
after the attack, they reflect the pre-attack state, not what’s on GitHub now.
Prove it with the reflog:
git reflog show origin/main # "update by push" = your last push, pre-attack
So origin/main == main locally does not mean GitHub is clean.
3. See GitHub’s real state
# Switch HTTPS -> SSH (my key lives in 1Password's SSH agent)
git remote set-url origin git@github.com:USER/REPO.git
ssh -T git@github.com # confirm auth
git ls-remote --heads origin # actual branch tips on GitHub
git ls-remote --tags origin
git ls-remote origin 'refs/pull/*' # PR refs (these can pin bad commits!)
git fetch origin --prune # shows "(forced update)" / "[deleted]"
Typical output revealing the attack:
+ <CLEAN>...<MALICIOUS> main -> origin/main (forced update)
- [deleted] (none) -> origin/<deleted-branch>
Inspect the malicious commit
git log -3 --format="%H%n %an <%ae> | %ad%n %s" <MALICIOUS_SHA>
git diff --stat <CLEAN_SHA> <MALICIOUS_SHA> # what changed
git grep -l -e '_$_1e42' -e 'Tgw(' <MALICIOUS_SHA> -- # which files carry the dropper
git show <MALICIOUS_SHA>:path/to/tailwind.config.js | tail -c 300 # see the padded payload
git diff <CLEAN_SHA> <MALICIOUS_SHA> -- .gitignore # tooling entries
git show <MALICIOUS_SHA>:.vscode/tasks.json | grep -iE 'runOn|command' # VS Code auto-run
4. Recovery
Case A — a clean local copy exists
The local branches ARE the source of truth. Force-push them back.
# Restore the clean branches
git push --force-with-lease origin main develop feature/example
# Or restore only main
git push --force-with-lease origin main
# Restore main AND recreate a branch the attacker deleted
git push --force-with-lease origin main
git push origin <DELETED_BRANCH_SHA>:refs/heads/<deleted-branch>
--force-with-lease aborts if the remote isn’t still on the commit you fetched — a
safety net against a race.
Case B — NO local copy
Key insight: in a history-rewrite, usually only the tip commit is poisoned. Verify, then rebuild a clean tip from the clean parent, preserving the original author.
# Clone the attacked state (read-only recon)
git clone --no-local git@github.com:USER/REPO.git recovered-repo
cd recovered-repo
# Confirm ONLY the tip carries the payload
git grep -l -e 'Tgw(' <PARENT_SHA> -- && echo poisoned || echo "parent CLEAN"
for c in $(git rev-list --all); do
git grep -qI -e 'Tgw(' "$c" -- path/to/tailwind.config.js && echo "poisoned: $c"
done # -> only the tip
# Rebuild: clean parent + the LEGIT file change, drop the malware, keep original authorship
git checkout <PARENT_SHA>
git checkout <POISONED_TIP> -- path/to/the-legit-change.tsx
# preserve the ORIGINAL commit's author identity (values redacted below)
GIT_AUTHOR_NAME="Original Author" GIT_AUTHOR_EMAIL="author@example.com" \
GIT_AUTHOR_DATE="<ORIGINAL_COMMIT_DATE>" git commit -m "<original commit message>"
# Push via a NAMED branch (see Gotcha #2)
git branch -f clean-tip HEAD
git push --force-with-lease origin clean-tip:main
git push --force-with-lease origin clean-tip:features
Gotcha #2 — zsh eats your refspec
git push origin "$SHA:refs/heads/main" in zsh triggers the :r history
modifier on $SHA:r..., mangling the refspec to ...:efs/heads/main and failing
with src refspec ... does not match any. Fix: push from a named local branch
(clean-tip:main), never "$SHA:refs/...".
5. Verification (prove it’s actually gone)
git fetch origin --prune
git ls-remote --heads origin # tips == clean SHAs
git branch -r --contains <MALICIOUS_SHA> # empty = unreachable from all branches
git ls-remote origin 'refs/pull/*' | grep <SHA> # is anything still pinning it?
# Gold standard: scan a fresh clone with the scanner
npx polin-rider-scanner --all --json /path/to/fresh-clone
# Scan every reachable object, not just the working tree
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(rest)'
Local cleanup (drop the dangling malicious object from your own clone)
Plain gc keeps reflog-referenced objects, so expire first:
git reflog expire --expire=now --all
git gc --prune=now
git cat-file -e <MALICIOUS_SHA> && echo "still here" || echo "gone"
6. The bit you can’t fix yourself — GitHub Support
Gotcha #3 — a closed PR pins the malware forever
When the attacker force-pushed a branch that was the head of an open PR, GitHub
froze refs/pull/<n>/head onto the malicious commit. refs/pull/* is
read-only — you can’t delete it by pushing. So even after full recovery, the
commit stays addressable by direct SHA URL until GitHub Support:
- dereferences/deletes the affected PR ref,
- runs server-side garbage collection,
- clears cached views.
(Repos where no PR pinned the bad commit auto-GC on their own — no ticket needed.)
Support path: support.github.com/contact → Repositories → “Errors, problems…”,
provide the repo, the malicious SHA, the clean branch SHAs, and note the
refs/pull/<n>/head pin.
7. Gotcha #4 — repository visibility erases stars
I switched a repo to private as emergency containment, then back to public. Per GitHub’s own docs, changing visibility erases stars and watchers in both directions — it is NOT a “hide, then restore.” My 18 stars became 1 and did not come back. This is documented behavior, effectively irreversible (Support may decline as “working as designed”).
Lesson: do not toggle visibility to contain an incident if you care about stars/watchers.
Quick way to check counts without the UI:
curl -s https://api.github.com/repos/USER/REPO \
| grep -E '"(stargazers_count|created_at|private|visibility)"'
(created_at also proves it’s the same repo, not a delete+recreate.)
8. Scanner false positives worth knowing
vscode:folderOpenfiring on the lucide-reactFolderOpenicon import — benign.git:mass-force-pushHIGH firing becausemainanddeveloplegitimately share a commit, plus theirorigin/*remote-tracking mirrors → ≥4 refs on one commit. Structural, not malware.unicode:invisibleinside vendored/compilednode_modulesbundles — usually benign. Triage HIGH first; treat MED insidenode_modulesskeptically; take any hit in tracked source seriously.
9. Prevention (close the door)
- Revoke everything account-wide: PATs (classic + fine-grained), OAuth apps, GitHub Apps, SSH/GPG keys; sign out other sessions; rotate any secret the payload could read.
- Prefer
npm ciovernpm i; review lockfile diffs;npm audit. - Enable push protection and rulesets/branch protection (block force-push to
main/develop). - Treat build configs (
tailwind.config.js,postcss.config.mjs),.vscode/tasks.json, and “assets” that are secretly text/JS as executable — scan them. - Scan a folder before opening it in an editor (a
folderOpentask runs on open).
A note on the harder cases
In all honesty, my part wasn’t the difficult one. My repositories mostly came down to force-pushing clean local copies back, plus a single reconstruction. Other affected repositories were in far messier states — deeper history rewrites, tangled branches, and edge cases well beyond what I’ve shown here. What I’ve documented is the more approachable slice of a much larger recovery effort.