Sleeper Infections: PolinRider Is Still Living in 1,000+ GitHub Repositories
By Juan Aguirre
A few days ago my teammate wrote up the CDMBase compromise: twelve malicious versions of ten npm packages, published with stolen tokens, every one carrying the PolinRider loader. Import the package, it reads its C2 address off the Ethereum blockchain, pulls the next stage and runs it in a hidden Node process. If you haven't read that one, go do that first, the indicators and the remediation for those versions live there.
This post is about the part of that story that kept bugging me.
Two days before the first malicious publish, PolinRider pushed its usual set of files into one of the maintainer's own public GitHub repositories. Not a private repo, not a fork somewhere obscure. A public repo, findable with a single GitHub code search, carrying the full implant in a commit titled "0.1.5". It sat there for 32 hours before anything showed up on npm.
So the question I couldn't shake was simple: how many more of those are out there right now? How many developers have this worm in a public repo, and how many of them also hold the keys to something people npm install?
I set out to find the next CDMBase before the attacker decides it's their turn. Here's what I found.
32 hours
All times UTC.
Here's the thing about that first row. Nobody had to reverse anything to spot it. The worm had already announced itself, in public, in a place anyone could search. The npm side of the attack was the second act, and the first act was on GitHub the whole time.
Which means the GitHub side isn't just a symptom you clean up afterwards. It's an early warning system, if somebody is actually looking at it.
The worm that never commits as itself
Before you can go hunting for something, you need to know what it looks like, and PolinRider has one habit that makes it both easy and frustrating to hunt.
It never commits as itself. It runs on the developer's machine (the initial infection comes from a trojanized npm package, a poisoned repository, or one of those "take-home assignment" projects from a fake job interview), finds every git working tree on disk and drops its files into them. Sometimes it then just waits, and the next time the developer commits, for whatever reason, the implant goes along for the ride.
So the commit that infects a repo is the developer's commit, from the developer's machine, under the developer's name, with whatever message they happened to type. "Add Open Graph Image." "fix error massaage." "first commit."
This killed my first idea pretty quickly. I'd been hoping to hunt the worm the way you'd hunt a lot of GitHub-borne malware: weird commit authors, odd commit messages, bursts of pushes at strange hours. There's nothing to find there. No attacker account, no rogue PR, no suspicious branch. Every signal you'd normally reach for points straight back at a real, legitimate developer having a perfectly normal day.
And I did chase one of those leads for a while. A handful of infected repos shared the same commit message, "Update print statement from 'Hello' to 'Goodbye'", and for a second I thought I'd found the worm's fingerprint in the commit history. It's that feeling when you're staring at a packet capture for hours and suddenly the same weird string shows up in three unrelated sessions, and you just know it's the C2 beacon. Then you Google it, and it's a Windows telemetry ping. Mine was the GitHub Copilot onboarding tutorial. Those were students doing their first Copilot exercise on an already-infected laptop. Not a fingerprint, just a very relatable coincidence.
So, no commit metadata. Or so I thought.
The commit that was already there
Then I went back to CDMBase's "0.1.5" commit to pin down the timeline, and the dates didn't agree with each other.
Every git commit carries two timestamps: when it was authored and when it was committed. Normally they're the same. "0.1.5" was authored on September 24 and committed on November 2 at 14:15. Same author, same message, five and a half weeks apart.
That's what you get when you amend a commit. And when I went looking for the original, it got better. In one of the sleeper repos (more on that one later), the latest commit is a pull request merge from March 2025. GitHub still remembers the original merge commit for that PR, and it touched four files. The commit sitting at HEAD today has the same author, the same date, the same message and the same parents, and touches twenty-seven, including .env, .vscode/tasks.json, api.js and a full set of "fonts". Its committer date is October 15, 2026.
So, at least some of the time, PolinRider doesn't wait for your next commit. It takes your last one, folds itself in, keeps your name, your message and your original date, and pushes it back over the top. Open that repo's history and the last change looks like it was made a year and a half ago. Nothing to see here.
It's a little like someone breaking into your house, leaving something in the closet, and then setting every clock back so the security footage shows an empty room. Clever. And not a one-off: in a random sample of about forty infected repos, roughly one in three had an implant commit whose author date sits weeks, months or years before its commit date. The rest could be the ride-along kind, or a rewrite done the same day, there's no way to tell those apart from the outside.
That committer date is the one piece of metadata the worm doesn't fake, and it's why the dates in this post (and the chart further down) use it. So: two things betray this worm, the files it leaves behind, and that one timestamp.
What it leaves behind
Over the course of the hunt, the implants sorted themselves into a handful of shapes. Different delivery, same loader underneath.
The .env loader. The most common one by a distance. A new line in .env that looks like any other API key:
That's base64 for a URL on files[.]catbox[.]moe. Then a small patch to the app's entry point (src/index.ts, server.ts, next.config.ts, wherever the app starts):
It runs every time the app starts. Including on your coworker's machine, after they pull.
The fake font. This is my favourite, in the "I hate that this works" sense. The worm adds a public/fonts/ folder with real Font Awesome files, plus one that isn't: fa-brand-400.orc, about 31 KB of obfuscated JavaScript wearing a font's name. Then it adds a VS Code task:
and flips task.allowAutomaticTasks on in .vscode/settings.json. Nobody has to run the app. Nobody has to install anything. Clone the repo, open the folder in VS Code, and you've run it. Hidden, with no terminal popping up to give it away.
Think about who opens other people's repos in VS Code all day: reviewers, contributors, students cloning course material, anyone evaluating a template they just bought. That's the audience for this one.
The init loader. A file literally called init at the project root (or one per package in a monorepo), with a benign-looking bootstrap, a few hundred spaces of padding to push the interesting part off your screen, and then a small shuffle cipher that rebuilds the loader and runs it through Function, pulled off a "constructor" property so there's no eval anywhere in the file. If that sounds familiar, it should: it's the same cipher my teammate found in half of the CDMBase tarballs. The npm wave wasn't a new variant, just a new delivery channel for the GitHub one.
The package.json hook. How init actually gets run:
Every npm run dev, every build, every CI job.
The padded config file. The 2025-era shape: a postcss.config.mjs, eslint.config.mjs or vite.config.* with the loader appended after a few hundred tabs and a marker like global.i = 'A8-...'. Mostly cleaned up by now, a handful still around.
Whatever the shape, the payload underneath does the same thing as in the npm packages: read the C2 address from the latest transaction of Ethereum wallet 0x1eEf0849EDa15d87f64e51C73C8442Cf3c8bCEF3, fetch /0x/cls and /0x/ls over port 443, XOR-decode them and run the result in a detached node -i process. The wallet has pointed at 95.135.181[.]151 since October 16 and keeps posting a fresh transaction roughly every three hours and twenty minutes. Still saying "helloipbot!!" every time, which I'll never not find funny.
The worm also leaves its own housekeeping behind on Windows victims: temp_auto_push.bat, temp_interactive_push.bat and branch_structure.json. Those are the helpers it uses to push. Leaving your push scripts in the repo you just pushed to is, I have to say, a little careless. It's also very convenient for us.
Building the hunt
The approach itself is not complicated. GitHub code search for each of those structural shapes, fetch every hit at the current HEAD of the default branch, confirm the file is still there and still matches, record it. Run it again later, and anything that disappeared gets marked as cleaned. The same machinery works for Shai-Hulud, GhostAction and Contagious Interview, so we track those too, but PolinRider is where nearly all the volume is.
The emphasis on structure rather than strings is not an accident. It's a lesson.
The false positive problem (or: everyone who writes rules about this worm is now infected)
The early sweeps came back looking terrifying, especially for Shai-Hulud, and some of the biggest hits were on the most security-conscious repos on GitHub: SigmaHQ, Splunk's security content, panther-analysis, Hayabusa, a pile of community scanners.
Of course they were. If you search for the XOR keys, the wallet address or the C2 paths, you find every Sigma rule, every YARA file, every Splunk detection story and every IOC list that quotes them. A rule file in a detection repo is not a victim. It's the opposite of a victim. Same for research repos with decoded samples in a testdata/hostile/ folder, and for knowledge-base projects that scraped a write-up into JSON.
My favourite was an npm package that showed up on the "victims who publish to npm" list, matching beautifully on half the indicator set. A sleeper infection, with a package attached! Then we opened it. It's a malware scanner. It matched on its own IOC list. Classic.
The fix was the same thing we keep relearning. Strings tell you a repo talks about the worm. Structure tells you a repo has it: the cipher inside a file named init, a hidden task that runs a font, a .env value that decodes to a URL feeding an eval. It's the old antivirus problem all over again: an engine that only matches signatures and never looks at behaviour either drowns you in noise or walks right past the interesting hits. Usually both, on the same day. Detection content, scanners and test fixtures get excluded explicitly by repo and path, and anything that only matches on strings doesn't count as a victim on its own. A few edge cases still sneak in, which is why everything below is stated as a floor and why every finding gets a human look before anyone is contacted.
What's out there
Before the numbers, an honest caveat. GitHub code search only indexes default branches, caps every query at 1,000 results, and can't see private repos at all. And the worm's real home is the developer's laptop, which we can't see either. So this is a sample of the infected population, not a census of it. Treat every number here as a floor, mine included, and expect them to have moved by the time you read this.
With that said, at the time of writing:
By shape, the .env loader is the most common, roughly four in ten infected repos, followed by the package.json hook, the fake font plus VS Code task, and the init loader. Lots of repos carry more than one. And the infection dates tell their own story: a slow trickle through 2025 and the first half of 2026, then a sharp jump in August that hasn't let up since. Every month since has been busier than the last. This is not a worm in its retirement era.

One honest caveat on that chart: it only counts repos that are still infected, so older months are thinned out by everyone who has since cleaned up, and the last few months haven't had that time yet. Some of the jump is that. But going from twenty-something a month to over 150, overnight, in August, is not a cleanup artifact.
Who's infected
Mostly individual developers. Students, bootcamp grads, freelancers, a lot of them in South Asia and Africa, which lines up with where PolinRider's fake-job-interview lures have been circulating. Portfolio sites, final-year projects, take-home assignments, client work.
On its own, an infected portfolio site is a bad day for one person. But a few groups on the list have a much bigger blast radius than their star counts suggest:
Cleaned, and cleaned again
This is the finding I keep thinking about.
Around a hundred of the still-infected repos have something in common: the last commit that touched the implant files has a message like "security: remove PolinRider malware", "chore: remove malware (1 file)" or "Remove malicious payload (security cleanup)". Somebody noticed. Somebody tried. And the implant is still sitting at HEAD.
Some of those are partial cleanups: the commit removed the .bat helper or the VS Code task and left the .env line and the patched entry file behind. But plenty are something worse. We watched repos get a "remove malware" commit, drop off our list, and come back within a couple of hours with a fresh copy of the implant. Because the repo was never the problem. The laptop was still infected, and the very next commit carried the files right back in.
It's like mopping the bathroom floor while the tap is still running. You can be really, really good at mopping. The floor is going to be wet again.
I say this in almost every post, and it's never been more literal than here: removing the files does not fix this. A clean repo on an infected machine is a temporary state. It was the first rule we baked into the hunt: a file disappearing is not a cleanup, it's a data point, and a repo that drops off the list gets watched for a while before anyone believes it.
The sleeper list
Now the question this whole thing started with. Which of these people can publish software to npm?
Matching repos to npm packages is easy to get wrong. Plenty of infected repos have a package.json with a generic name, and plenty of those names are taken on npm by somebody completely unrelated. So a package only counts if the registry's own repository link points back at the same GitHub owner. That leaves a couple dozen npm packages maintained from machines that are, as far as we can tell, infected right now.
Then we pulled every one of them. The latest release, plus every version published since August, downloaded and scanned against the same signature set.
All clean.
Including releases cut from the infected machines in September and October, after the implant was already sitting in the repo.
Before you read that and relax, think about what that list actually is. These are packages whose maintainers have a worm on their development machine, a worm whose job includes stealing npm tokens. The package being clean today tells you the attacker hasn't used that token yet. It doesn't tell you they don't have it.
CDMBase was clean too. Right up until the 3rd of November at 22:00.
That's why we've started calling them sleeper infections. The token is already gone. The package is one attacker decision away from turning. And you can't see any of it from npm, because nothing has happened on npm yet.
Most of the list is tiny, a few dozen downloads a week. But the top of it isn't:
The first one is the one that keeps me up. vitest-ctrf-json-reporter, around twenty-five thousand downloads a week, a test reporter, which means it runs inside CI pipelines. Its repo has carried the full implant since mid-October, and it's the one from earlier with the rewritten 2025 merge commit, so its GitHub history still looks untouched since March 2025. Its last release predates the infection by a year, so the code on npm is clean, and the maintainer has every reason to never think about it. If PolinRider ever decides to spend that token, the next release lands in CI systems everywhere, and CI systems are full of exactly the kind of secrets this worm likes.
I also checked how far a bad release would travel through the dependency tree, the way other researchers have charted Shai-Hulud's reach by dependency depth. Here it doesn't travel at all, at least not that way: according to deps.dev, none of the sleeper packages has a single published package depending on it. They're leaves, things people install directly into apps and pipelines. So the blast radius here isn't measured in dependent packages, it's measured in the projects and CI runners pulling them in every week.
To be really clear: we have no evidence that any of these tokens have been used. That's the whole point. This is a list of doors that are unlocked, not doors that have been opened.
What we're doing about it
Three things.
Watching maintainers, not just packages. The sweep feeds a watchlist: every npm package published from an infected repo, every package whose repo was "cleaned" recently (the token outlives the file), and the packages that share publish rights with the CDMBase accounts. A few hundred packages in total. Any new release from any of them gets a full scan the moment it hits the registry, pre-releases included, and a human looks at it. We're not waiting for the package to look suspicious, we already know whose laptop it came from.
Notifying people. We're contacting the maintainers on the sleeper list and the organizations with the most infected repos directly, starting with the highest reach. If your repo or package is on our list, expect to hear from us.
Keeping the hunt running. The sweep runs continuously, so new infections show up within a day and re-infections show up as re-infections. Adding another worm family is a row in a table, which, given the year we've had, I suspect we'll be doing a lot.
If you think you're on the list
init at the root of any project, .vscode/tasks.json with "runOn": "folderOpen" and "hide": true, any *.orc file, AUTH_API_KEY= in a .env whose value starts with aHR0, node init && in package.json scripts, and temp_auto_push.bat, temp_interactive_push.bat or branch_structure.json anywhere.git log --format='%h %ad %cd %s' --date=short. A recent committer date on an old commit you don't remember amending is a red flag.node -i processes with no terminal attached and connections to the IPs below, but don't treat finding nothing as an all-clear..env files, wallet keys. Rotate first on an infected machine and the worm just picks up the new ones.Indicators
This worm changes shape every few weeks, so a list of file names and strings goes stale fast. The file-level signs to check today are in the checklist above, and the loader-level indicators (XOR keys, cipher constants, markers, hashes) are in our CDMBase post. What changes slowest is the plumbing: one wallet, and the handful of C2 addresses it has pointed at:
Public Ethereum RPC endpoints (1rpc[.]io, eth[.]drpc[.]org, ethereum-rpc[.]publicnode[.]com and friends) are legitimate services. Calls to them from a dev box or build runner that has no business talking to Ethereum are the signal.
How Can Safety Help Protect You?
Everything in this post happens before a malicious package exists. By the time a sleeper infection turns into a CDMBase, the token is spent and the release is live, and the only question left is whether your install pulls it before somebody notices. The Safety Firewall checks every package installation request before it reaches the public registry, and the packages on our watchlist get scanned the minute a new version appears, so a compromised release from a known-infected maintainer gets blocked on developer machines and in CI, not discovered in your lockfile a week later.
Want to try Safety Firewall? You can sign up and get started yourself, no call required.
Stay curious. And if a recruiter sends you a take-home project, maybe peek at .vscode/tasks.json before you open it on your work laptop, the one with all the tokens.