Recovering from the Polin Rider Attack
Jul 2, 2026 · 10 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 four repositories I had write access to, how I found it, and the exact commands I used to recover every repo from a clean local copy (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 org-wide 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).
Repos affected: repo1, repo2, repo3, repo4 (names anonymized throughout).
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:
repo1→app/tailwind.config.jsrepo2→client/postcss.config.mjsrepo3→ 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).
repo4→client/tailwind.config.js
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 our 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 my senior developer built 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>:app/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' # repo3 auto-run
4. Recovery
Case A — clean local copy exists (repo1, repo2, repo3)
The local branches ARE the source of truth. Force-push them back.
# repo1 — three branches
git push --force-with-lease origin main develop feat/remove-credit-system
# repo2 — only main was on GitHub
git push --force-with-lease origin main
# repo3 — restore main AND recreate the 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 (repo4)
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 repo4
cd repo4
# 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. Some of the company’s repositories were in far messier states — deeper history rewrites, tangled branches, and edge cases well beyond what I’ve shown here. Those were handled by our senior developer, whose work made most of this recovery possible. What I’ve documented is the more approachable slice of a much larger effort.