Shahad Pichen
Back to blog

Recovering from the Polin Rider Attack

Jul 2, 2026 · 9 min read

securitygitsupply-chainrecovery

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 .gitignore entries 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/*/head keeps 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.js
  • path/to/postcss.config.mjs
  • The nastiest variant: a VS Code auto-run task that executes a fake font:
    • .gitignore was edited to un-ignore .vscode/ so the malicious task ships
    • .vscode/tasks.json contained "runOn": "folderOpen"node ./public/fonts/fa-solid-400.woff2
    • fa-solid-400.woff2 is 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 with npx 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:

  1. dereferences/deletes the affected PR ref,
  2. runs server-side garbage collection,
  3. 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:folderOpen firing on the lucide-react FolderOpen icon import — benign.
  • git:mass-force-push HIGH firing because main and develop legitimately share a commit, plus their origin/* remote-tracking mirrors → ≥4 refs on one commit. Structural, not malware.
  • unicode:invisible inside vendored/compiled node_modules bundles — usually benign. Triage HIGH first; treat MED inside node_modules skeptically; 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 ci over npm 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 folderOpen task 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.