BLOG
A Developer's Guide to Getting Rid of PolinRider
How you get infected with DPRK's PolinRider, why just rotating creds and wiping laptops doesn't stop it from coming back, and the field-tested steps that do.
By cb482791-4ef1-4762-96ad-b0ca4bdd538e ·
PolinRider is a campaign run by North Korea (DPRK) that targets software developers. We first documented PolinRider in early March 2026, which at the time had hit 300+ GitHub owners. As of our most recent hunt in July, that footprint has grown to 2000+ owners, with more than 4000 repos compromised.
This campaign overwhelmingly targets individual developers rather than organizations. Victims are infected via one of three vectors: a fake job interview with a booby-trapped coding test, a malicious package disguised as a legitimate developer tool, or a fellow developer’s repo/package/commit that’s been infected.
Once it’s on your machine, the malware steals things like browser credentials, cryptocurrency wallet data, SSH keys, AWS credentials, environment variables and API keys, git credentials, and tokens. Then it self-spreads in a worm-like manner, using your credentials to plant the same malicious code into your other projects, repositories, and accounts. After the initial infection, human attackers don’t have to do anything.
We often hear from infected developers who did everything “right”. They rotated every credential, wiped the laptop, etc. but the malware came back anyway, sometimes several months later. Because this campaign is both hard to detect and difficult to remediate, it’s provided DPRK with persistence on thousands of developer machines, effectively forming what we’re calling a “human botnet.” We don’t know what they plan to do with this infrastructure, but given their history of cybercrime, it’s nothing good!
This post covers the ways people actually get infected, why the standard remediation playbook keeps failing against this specific campaign, and a cleanup guide built and field-tested by developers who lived through it themselves.
Three ways to become a PolinRider victim
Vector 1. Forking a DPRK-owned repo
The Contagious Interview campaign, where DPRK personas have posed as recruiters since at least 2023, uses social engineering to convince targets they’re interviewing for a high-paying software development job. The lure hasn’t changed much over the years, but the payload increasingly leans on a malicious .vscode/tasks.json file to do the actual infecting. If you were hit through a fake interview, a poisoned tasks.json file is the most likely reason why.
StakingGame, a fake Web3/blockchain-deployment assessment, illustrates how it works. Its .vscode/tasks.json contains a "runOn": "folderOpen" task that pipes curl from an attacker Vercel subdomain straight into bash/sh. The machine is compromised the moment the candidate opens the folder in VS Code, before you even write any code.
Vector 2. Installing a DPRK-owned malicious package
PolinRider also ships trojanized packages directly to public registries in the form of typosquats of popular packages. The flagship example is the npm package tailwindcss-style-animate (and sibling names like tailwind-mainanimation, tailwind-autoanimation). Installing it, directly or transitively as a dependency, injects the PolinRider JS loader during the build.
Vector 3. Trusting a PR, fork, or package from another victim
When a developer is infected with PolinRider, it acts a little bit like an account takeover (compromise of legitimate account). But instead of locking them out and pushing poisoned versions, PolinRider quietly self-propagates using the victim’s own legitimate access:
The malware’s infostealer stage silently harvests any local credentials that developer has on their machine… If the malware finds GitHub credentials, it can use those to automatically create new malicious GitHub repos, etc, but even if the malware doesn’t find a PAT, it can still poison GitHub. Here’s how: The malware finds any local git repos on the compromised workstation, and it looks for one of these JS config files: postcss.config.mjs, eslint.config.mjs, tailwind.config.js, vite.config.js. If it finds one, it appends some white space to the end of the file, and then tacks on a malicious payload. If it doesn’t find one of the files in the list, it just creates one in the root of the git repo, and moves on to the next repo. The threat actors use a neat little trick to hide this payload: they overwrite the last git commit with the new changes using a git commit –amend –no-verify command. Then the threat actor pushes the changes to origin, or if they don’t have GitHub credentials, they just wait for the victim to create new changes, and their malicious payload will ride along If the developer is also a maintainer, the malware can get pushed into npm, Go, Packagist (PHP), and PyPI packages they own.
Why PolinRider keeps coming back
The propagation script runs locally, not from some server the attacker controls
The mechanism behind PolinRider’s persistence is a script called temp_auto_push.bat, and it’s not a remote operation. It runs on the infected device itself, using whatever git credentials are already cached there. It isn’t a second party logging in from somewhere new. Rather, it’s your same device, your SSH key, the same account behavior GitHub sees from you every day. The script resets the local clock, amends a commit so the timestamp matches the one it’s replacing, and force-pushes. A casual git log shows nothing wrong. None of that requires a credential to leave the machine. It runs, does its work, and runs again whenever whatever triggers it fires again, a scheduled task, a detached background process, or a developer simply opening an infected project a second time.
That’s enough on its own to explain most of what looks like reinfection, without needing to imagine an attacker manually reusing an old credential from somewhere else:
The persistence artifact from the original infection was never actually removed. If a detached process, a scheduled task, or a launch agent from the initial infection is still alive, it doesn’t need a human to tell it to push again. It just does, automatically, using whatever credentials are currently valid on that machine, including ones that were just rotated in.
A “new” machine gets infected the same ordinary way the first one did. If the original poisoned repos or forks were cleaned locally but never fully scrubbed from GitHub itself, cloning or opening one of them again on the replacement machine restarts the exact same local, automated cycle from scratch. This also explains a branch that was never checked out locally still showing up force-pushed. This is because pushing to a named remote branch doesn’t require having that branch checked out first, only a valid credential and a repo that already knows about the remote.
A rotation is only as complete as the list of things you rotate
The stealer takes more than the credentials a checklist usually covers. Standard remediation advice, including our own, says to rotate GitHub password, PATs, SSH keys, and API keys, the things a person thinks of as “my dev access.” That’s not the full list of what got taken, and anything left unrotated, a password manager vault, a browser-saved login, an old cloud key sitting in an .env file, is still usable by whoever took it.
GitHub gives out more kinds of access than “password, PAT, SSH key”
A standard rotation checklist covers the credentials a developer thinks about day-to-day. It’s worth also checking the ones that are easy to forget exist at all. This includes deploy keys added directly to a single repository, OAuth Apps or GitHub Apps authorized once and never revisited, and CI/CD platforms like Vercel, Netlify, or GitHub Actions holding their own separate copy of push or publish credentials. None of these show up when you generate a new SSH key pair or revoke a PAT.
The payload keeps changing shape, so “I don’t see the old marker” doesn’t mean clean
We’ve seen PolinRider rotating its own signatures, which is common for threat actors looking to avoid detection. The original rmcej%otb% marker and _$_1e42 decoder function gave way to Cot%3t=shtP and MDy. That’s consistent with an operator retooling against published detection rules rather than abandoning the campaign.
The command-and-control (C2) side rotates too. Instead of a domain or server, PolinRider fetches its payloads from encrypted blockchain transactions, TRON primarily, Aptos as a fallback, infrastructure that can’t be seized or taken down the way a server can. We recently discovered DPRK’s new NullReceiver technique, which replaces the fixed, publicly-known EtherHiding burn address with a brand new, disposable destination address on every lookup. A payload that no longer matches last month’s grep patterns, and that resolves its C2 through Ethereum instead of TRON or Aptos, reads less like a new, unrelated malware family and more like the third generation of the same one.
Some of it, you can’t fully undo
Even a perfect rotation doesn’t reach everywhere the campaign has already spread. Go’s module proxy, proxy.golang.org, caches the first version it ever sees of a module permanently, by design, so builds stay reproducible. That means none of it is remediated by taking the repo down. A malicious commit that got tagged and fetched once is downloadable through go get forever, regardless of what happens to the source repository afterward.
And because propagation follows the shape of a developer’s actual working relationships, not just their own account boundary, cleaning your own side of a collaboration doesn’t close the loop if the other side is still infected. A collaborator invite, an organization membership, or a shared CI pipeline all extend the automation’s reach past any one person’s credentials. If the account on the other end of that relationship is still running the same resident propagation, repositories you didn’t personally touch can still pick up the payload through access that was granted long before either of you knew anything was wrong.
How to actually get over PolinRider
On March 6, 2026, a developer named robellorin opened a GitHub Community discussion asking why obfuscated code kept appearing in next.config.js and postcss.config.js across repos they hadn't touched, code that had first shown up the previous November. Over the next five months the thread grew to 17 participants, developers comparing partial pictures from different repos and different machines until they added up to a full one. A developer posting as semitha-dev posted a genuinely useful and clear write-up of how the payload hides inside a config file, a table of indicators of compromise, exact byte-size thresholds for spotting an infected file, a cleanup sequence organized by what to do first, and a section on what not to waste time on.
What follows combines that guide with our own PolinRider research. If you've been infected, work through it in order.
Nothing on this list works in isolation. Rotate credentials before the containment stages, and a resident process just re-harvests the new credentials on its next run. Clean the local repo without checking what's actually on GitHub, and the version everyone else pulls is still poisoned. Rotate everything except a password manager vault that was harvested at the same time, and the credentials sitting behind that one lock are still exactly as exposed as they were on day one. Assuming you follow these steps in order, you don't need to factory reset a machine, because the payload doesn't persist as a file on disk between runs.
Stage 1: Prepare
1. Turn off automatic task execution
If a .vscode/tasks.json with a folderOpen trigger shows up in a clone you didn't write, that's not a productivity script. Before you open the project again for any reason, including to inspect or clean it, go to VS Code settings, search "task," and set Task: Allow Automatic Tasks In Folder to off or prompt. Do this globally, not just in the one project. It costs nothing and it means every remaining step below can't accidentally re-trigger the thing you're trying to remove. Learn more about why this matters in How Malware Abuses NPM Lifecycle Scripts and VS Code Tasks.
2. Freeze repo access before you start cleaning
The rest of this list takes real time, sometimes hours, sometimes days. Every hour your repo is open is another hour the malware can keep pushing or rebuilding through channels you haven't gotten to yet. A few immediate, low-effort locks buy you that time:
Turn on branch protection that blocks force-pushes on every repo the account can reach. The whole propagation mechanism depends on
git commit --amendplus a force-push to cover its tracks. A repo that requires a pull request and has "do not allow force pushes" enabled stops that technique cold, regardless of which credential is doing the pushing.Turn on branch protection that requires signed commits. If the malware is amending and force-pushing with cached git credentials but doesn't also have your commit signing key, a forged commit gets rejected outright or lands visibly marked "Unverified" instead of blending in. This only helps if your signing key isn't cached in the same place the malware is already harvesting from, so it's a tripwire, not a guarantee. But it's also a best practice so just do it!
Pause CI/CD auto-deploy (Vercel, Netlify, GitHub Actions, or equivalent). A poisoned push that can't trigger a rebuild can't spread through the built artifact while you're still cleaning.
Suspend any OAuth App or GitHub App authorization you don't recognize or don't currently need. Full credential rotation is step 8 and takes real time to do right across every ecosystem. Yanking an app authorization takes thirty seconds and closes a specific door immediately.
If a public repo is a published package or something others actively pull from, think carefully before making it private. It limits new exposure, but it doesn't clean anything, and it can cause more disruption for downstream users than it prevents.
3. Notify your collaborators and anyone who forked your repos
This campaign spreads through the shape of your actual working relationships, not just your own account. A collaborator invite, an organization membership, or a shared CI pipeline all extend its reach past your credentials alone. That means a collaborator who's still infected can reinfect you right back after you've finished cleaning, and a fork that syncs from a poisoned upstream can carry the payload in either direction.
Reach out to:
Every collaborator and org member with access to affected repos
Known forks, checkable through the repo's fork network
Downstream consumers, if you maintain a published package. GitHub's Security Advisory feature is the built-in way to notify people who pulled a poisoned version but never had repo access in the first place
Do this now, not after you've finished cleaning. The longer it waits, the longer everyone downstream of you is exposed without knowing it. Disclosures suck, but it's the right thing to do.
4. Block known indicators at the network layer
There are several known malicious IPs worth an interim block at the firewall level while you work through remediation: 166.88.54.158, 198.105.127.210, 23.27.202.27, 154.91.0.103, 136.0.9.8, 166.88.4.2, 23.27.120.142, 202.155.8.173, 166.88.134.82, 188.43.33.249, 23.27.13.43.
If you publish to npm and want to check for a specific, newer variant, also watch for outbound traffic to a Telegram bot API, one documented sample exfiltrates over both HTTP and a Telegram channel in addition to the usual blockchain C2. Treat this list as a snapshot, not a permanent one, indicators keep rotating, for the reasons covered earlier. We maintain a current set in the PolinRider GitHub repository.
Stage 2: Contain
5. Find the malicious files
This campaign hides in more places than one obvious signature covers, and missing any of them is how a rotation later still ends in reinfection.
Config files. The payload gets appended to the
export default config;line in files such aspostcss.config.js,tailwind.config.js,eslint.config.mjs,next.config.mjs, andvite.config.tsafter roughly 280 spaces, invisible in an editor or a normal diff unless you scroll right. A clean-looking config that's 5,000 bytes instead of the usual 80 to 200 is the tell. Originally these files were only saved in/index/but lately we've seen them saved anywhere there's a JavaScript or TypeScript config file.Font and dictionary files. Commonly, this campaign hides its loader inside a file made to look like a font.
fa-solid-400.woff2is the name to search for specifically, usually underpublic/fonts/or similar asset directories. It reads as binary and gets skipped by most manual review entirely. There are also examples of the loader hiding inspellright.dict. These files contain the same hidden payload that's in the poisoned config files.tasks.jsonfiles. Turning off automatic execution in step 1 stops it from firing again, but the file is patiently waiting to deploy a fake font or dictionary file. Anytasks.jsonfile containing"command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./public/fonts/fa-solid-400.woff2) || echo ''"needs to be identified.npm's own
cli.js. On several infected machines,npm/lib/cli.js(normally a few hundred bytes and four lines) had been overwritten with a malicious version of the npm CLI that's designed to maintain persistence. The malicious npm CLI is ~1 MB, with the payload appended after a long run of whitespace starting on line 5. This one matters more than a poisoned config file, because it means everynpm,npx, ornpm execcall re-spawns the malware, and it survives a reboot, since it's living in the tool you use to start anything else, not in a process that dies when the machine restarts. One developer traced their reinfection to an editor or extension silently runningnpm exec <package>@latestin the background. You'll have to uninstall npm and node, make sure everything is killed, then you can reinstall and confirm the size is correct (comparenpm/lib/cli.jsagainst a known-good copy, or check its size and first several lines directly).Propagation artifacts. Look for
temp_auto_push.batandconfig.batsitting anywhere on disk.Amended commits. Run
git reflogand check for commits you don't remember making.
We wrote a scanner to help with this process. The polinrider-scanner.sh (find it in our PolinRider dossier repo) checks config files for the obfuscated payload signature, looks for the propagation artifacts, and checks git reflog for amended commits. It exits 0 clean, 1 infected, 2 error, so it drops easily into a CI check.
git clone https://github.com/OpenSourceMalware/PolinRider
cd PolinRider
./polinrider-scanner.sh /path/to/your/projectsThe script is built against the original March signature (rmcej%otb%), not the rotated one documented in July (Cot%3t=shtP), and it doesn't currently check cli.js, tasks.json, or npm-token indicators. Treat a clean result as a first pass, not a clean bill of health, and check the rest of this list by hand regardless of what it reports.
6. Kill the live process and check for second stage
If a detached process, a scheduled task, or a launch agent from the initial infection is still alive, it doesn't need a human to tell it to push again. It just does it automatically, using whatever credentials are currently valid on the machine. Rotating first and checking for persistence second means finding out the hard way that the new credentials are already gone.
On Windows, list running node processes and their command lines with Get-CimInstance Win32_Process -Filter "Name='node.exe'", then Stop-Process on anything running node -e against obfuscated content. On macOS and Linux, ps aux | grep -E "(python|node).*-(c|e)" | grep -v grep catches the equivalent.
This matters, but it's not the fix on its own. The payload isn't a resident infection with a foothold that killing a process removes for good. It re-fetches and re-executes from the blockchain dead-drop the next time the infected config file loads, whether that's the next npm run dev, the next build, or the next time CI pulls the repo. Killing the process buys you a clean moment. It doesn't buy you a clean repo.
Repeated macOS Keychain prompts asking for GitHub credential access, continuing even after being denied, are a sign of a detached process looping to re-acquire cached credentials. Denying the prompt doesn't kill the process asking for it, it just retries. Run these before assuming you're clear.
Suspicious interpreter processes:
ps aux | grep -E "(python|node).*-(c|e)" | grep -v grepAmended commits that don't match what you remember committing:
git reflogLaunch agents and daemons the malware may have dropped (macOS):
ls -la ~/Library/LaunchAgents/
ls -la /Library/LaunchAgents/
ls -la /Library/LaunchDaemons/Shell startup files for injected commands:
grep -E "(curl|wget|eval|base64)" ~/.zshrc ~/.bashrc ~/.profile 2>/dev/nullScheduled jobs:
crontab -l7. Clean your machine and GitHub repos
Everything you just found in step 5 needs to be cleaned in two places: your machine and your GitHub repos. One of the top reasons we see people getting reinfected with PolinRider is because they clean their machine, then git clone and re-download their branches. Every time the payload runs, it scans the machine for other JavaScript projects, injects itself into those too, and pushes to GitHub using whatever git credentials are already cached locally, whether or not you've opened those other repos yourself recently. That's why what's sitting on GitHub can differ from what's on disk: the process reaches further than your own editor does, sometimes into repos you haven't opened locally in months.
To prevent getting immediately infected via that git clone, be sure to clean infected files (everything listed in step 5) through GitHub's own web editor, repo-by-repo, rather than trusting a local git pull to surface everything.
Stage 3: Secure
8. Rotate every credential the malware could reach
The stolen credential behind a compromise isn't always the obvious one. Some victims see no foreign logins in their GitHub security log at all because the credential taken wasn't a GitHub password or PAT, it was a token for a connected CI/CD platform like Vercel, Netlify, Railway, or Render. Those platforms hold their own GitHub App installation with push access, so a poisoned commit never has to touch GitHub's login page at all. Rotating GitHub credentials alone leaves that path open. Start with this list when you're ready to rotate:
GitHub password, PATs, SSH keys, and authorized OAuth Apps and GitHub App installations (not just the keys, the app grants too)
Cached git credentials (
cmdkey /delete:git:https://github.comon Windows; on macOS, open Keychain Access, search "github," and remove any internet password entries, then re-add access only via SSH key)If you use the GitHub CLI, it likes to stash credentials in
~/.config/gh/hosts.ymlCI/CD platform tokens for anything connected to your repos: Vercel, Netlify, Railway, Render, and equivalents
Every secret in every
.envfile the malware's build-time process could read throughprocess.envCloud credentials (AWS, GCP, or equivalent) and database credentials
Browser-saved passwords and, if you use one, your password manager vault. If the vault itself was harvested at the time of infection, treat everything stored in it as compromised, not just the credentials you happen to remember were in there
Your npm registry token, if you publish packages, even before you have confirmation it was used. Waiting for proof doesn't help: by the time a poisoned version shows up on the registry, it's already out
What you're protecting differs by ecosystem. If you maintain a Go module, there's no separate publish credential to protect, because there isn't one. Git push access to your repository is the entire control surface, so hardware-backed MFA on GitHub and clean SSH key hygiene matter more here than anything registry-side ever could.
9. Audit every repo the account can reach
Step 7 cleaned everything you found. This step is about finding out if there's anything else, and confirming the credential rotations actually held. Every repository an infected account has write access to can get force-pushed in a single pass, same commit author, same dates, nothing visibly edited. The only local tell is an "updated x hours ago" marker on GitHub and branches coming back force-pushed on the next pull, because PolinRider rewrites git history and back-dates commit metadata specifically so a normal git log shows nothing wrong.
There's one place that spoofing doesn't reach. Check the pull request activity log for a line reading "[username] force pushed the branch from [commit] to [commit]." That's a GitHub-generated audit entry tied to the real pushing identity, not to whatever author name got written into the commit. If you're trying to figure out which credential is still live, that line is more trustworthy than anything in the commit itself.
If you normally sign your commits, also scan for any marked "Unverified" where your history otherwise shows "Verified." That's a second tell, visible right on the commit itself, and it doesn't require digging into any activity log at all.
10. Run antivirus (YMMV)
"Running more antivirus scans" as your main strategy is ineffective because the payload is decrypted from a blockchain transaction into memory and never touches disk as an executable in most variants. However, at least one variant has been flagged by Windows Defender (as Trojan:JS/PolinRider.DB!MTB), so "antivirus won't see it" isn't a rule you can rely on across every variant. Run a scan anyway and just don't treat a clean result as proof you're clear.
Learn more
Hundreds of GitHub Repos Compromised By DPRK’s PolinRider Campaign, March 8, 2026
TasksJacker DPRK Attack Compromises GitHub Users Via VS Code Tasks, March 31, 2026
PolinRider DPRK Attack Expands Across GitHub, April 12, 2026
How Malware Abuses NPM Lifecycle Scripts and VS Code Tasks, May 14, 2026, by Jenn Gile
PolinRider Jumps the Fence to Go, Packagist, npm, PyPI, July 8, 2026
PolinRider Confirmed Footprint Grows 6.5x Since March, July 15, 2026, by Paul McCarty
PolinRider Caused Dozens of npm, Go, PHP Compromises, July 30, 2026, by Jenn Gile
NullReceiver’s Blank Crypto Transfers Solves the Challenges of EtherHiding, August 1, 2026, by Paul McCarty
Obfuscated code suddenly appearing in next.config.js / postcss.config.js without direct file changes, GitHub Community Discussion #188732