BLOG

PolinRider Caused Dozens of npm, Go, PHP Compromises

DPRK automation enabled malware compromise of 27 legitimate packages without any targeting

By cb482791-4ef1-4762-96ad-b0ca4bdd538e ·

PolinRider Caused Dozens of npm, Go, PHP Compromises

Earlier this week, I read a Socket blog about two compromised Joyfill beta releases. Joyfill is a legitimate digital form and PDF automation platform, and the poisoned versions contain malware that deploys a remote-access trojan (RAT). Socket identified several indicators (including the presence of Tron, Aptos, and BNB Smart Chain transactions) which they connected to DPRK’s PolinRider campaign.

That PolinRider connection grabbed my attention, because that campaign isn’t really known for compromising legitimate packages. Typosquats? Yes. Poisoned GitHub repos? Absolutely. But not account takeovers.

PolinRider has spent at least five months getting into thousands of developer machines any way it can: fake interviews, poisoned forks, malicious VS Code tasks, typosquatted packages. DPRK’s goal is to compromise developer machines and accounts to silently propagate malicious code, clone repositories, and push backdoored commits without active human intervention. Once a maintainer consumes the malware, it finds whatever credentials are sitting there and uses them. They’ve created a human bot net with this persistence, and we don’t yet know what their end goal is. (Side note: If you haven’t checked out To Catch a Thief: North Korea on Our Payroll, the first episode is a fantastic primer on DPRK’s sophisticated cybercrime machine, and why you shouldn’t underestimate them.)

Before starting a deep dive to understand what DPRK might be up to with the Joyfill packages, we looked for recent threat reports in our database that fit the same shape. We quickly found 18 packages to include in the data set. On June 24, 2026, JFrog reported two npm packages with a VS Code task that auto ran the payload disguised as a font file public/fonts/fa-solid-400.woff2) even though the file just contains JavaScript code. They also observed Tron, Aptos, and BNB Smart Chain transactions but didn’t include any attribution, even though these are all well-established PolinRider tactics. JFrog's report was amended on June 26, 2026 to reference Nextron's discovery of 16 Go modules carrying the same fa-solid-400.woff2 file, with matching SHA-256 hashes. 

The dataset

We analyzed 20 packages with PolinRider indicators to determine whether they were intentionally taken over by a threat actor, or if these compromises were a result of how PolinRider operates. 

Click the package names to view the corresponding OpenSourceMalware threat reports.

Package

Version

Maintainer

Discovered by

html-to-gutenberg

4.2.11

digelim

JFrog

fetch-page-assets

1.2.9

digelim

JFrog

@joyfill/components

4.0.0-rc24-2773-beta.4

Joyfill

Socket

@joyfill/layouts

0.1.2-2773.beta.0

Joyfill

Socket

github.com/Barsu5489/commerce

v0.0.0-20231123164829-2eb351369e57

Barsu5489

Nextron

github.com/Setsu548/Logistic

v0.0.0-20240410002038-5b40bed74f90

Setsu548

Nextron

github.com/amantsehay/a2sv-go-course

v0.0.0-20240816090215-c51e2d9214d5

amantsehay

Nextron

github.com/anatoli-derese/a2sv-excercise

v0.0.0-20240805074755-5adbbc600635

anatoli-derese

Nextron

github.com/bm-197/chill

v0.0.0-20241216030053-8573b6044fba

bm-197

Nextron

github.com/dexbotsdev/uniswap-v2-v3-arbitrage

v0.0.0-20231007040503-7b0a4d1c503d

dexbotsdev

Nextron

github.com/glacialspring/go-winsparkle

v0.0.0-20250402002608-ba5501b8ba90

glacialspring

Nextron

github.com/glacialspring/static

v0.0.0-20181015024211-023dc73bc332

glacialspring

Nextron

github.com/hngi/Team-Fierce-Backend-Golang

v0.0.0-20200612135333-4f82269a0a14

hngi

Nextron

github.com/lambda-platform/dan

v0.0.0-20221011015638-695b34fb98d4

lambda-platform

Nextron

github.com/lambda-platform/ebarimt-rest-api

v0.0.0-20230429075241-30dbb04b67f7

lambda-platform

Nextron

github.com/lambda-platform/lambda

v0.9.19-...0cf995e71697, v0.9.20-...12b5a6e0c244

lambda-platform

Nextron

github.com/naol7/dist-task-scheduler

v0.0.0-20241120175214-0365b36af82f

naol7

Nextron

github.com/reauheau/goaubio

v0.0.0-20260213144826-0c7c4a5b5859

reauheau

Nextron

github.com/rickt/slack-weather-bot

v0.0.0-20180704165649-55def291ce83

rickt

Nextron

github.com/zainirfan13/graphql-client

v0.0.0-20220912215956-d304e79da123

zainirfan13

Nextron

TL;DR yes it’s PolinRider and no, they aren’t really ATOs

With a total of 20 compromised packages across npm and Go, we had a diverse sampling for analysis. Here’s a summary of what we found:

  • PolinRider attribution confirmed. One XOR key and two TRON wallet addresses are identical across our PolinRider research and all 20 packages.

  • These aren't true account takeovers. Based on campaign behavior, this is the auto-propagation pattern documented in PolinRider Jumps the Fence to Go, Packagist, npm, PyPI.

  • Low-profile packages impacted. Our most recent threat hunt showed 93.5% of PolinRider victims were individual developers, and these packages align to that pattern.

This analysis focuses on how these compromises happened, and how they’re the predictable consequence of PolinRider getting persistence on thousands of developer machines.

When an ATO isn’t an ATO

The researchers who discovered these compromises describe what happened as a compromised maintainer account. Technically, that's correct. In each case, a malicious version was published under the package owner’s identity but without their consent. But "account takeover" implies a decision by the threat actor. That they picked a target, found a way to gain publishing access, and published malicious versions. That’s not what happened with these packages.

Our July hunt found that 93.5% of PolinRider's confirmed victims are individual developer accounts. The twenty packages in this piece fit that pattern.

  • html-to-gutenberg and fetch-page-assets are published by npm user digelim , and together pull down less than 2k downloads/week - indicating they’re useful to a niche audience, but nowhere near the download counts that would make the maintainer a deliberately-chosen target. 

  • The sixteen Go modules read the same way: a Slack weather bot, an arbitrage script, someone's a2sv coursework, a handful of personal utility repos. A few, like the lambda-platform modules, sit under a small team's GitHub organization rather than a lone maintainer, but none are the kind of high-traffic dependency a threat actor would deliberately select if they were working down a target list. 

  • And while Joyfill has a big following, the malicious versions were beta releases that historically have very low consumption of 2-4 downloads per release.

And when a worm isn’t a worm

To understand how PolinRider has been so successful, it’s useful to compare and contrast this behavior with worms.

A worm is a self-replicating piece of malware that steals credentials, uses the credentials to publish new package versions containing the malware, and in doing so seeks out new, unrelated packages and maintainers on its own. Shai-Hulud, Mini Shai-Hulud, and Miasma are notorious examples. Typically “patient zero” of a worm attack is selected by the threat actor because they have access to high-popularity packages, ensuring the most potential victims.

PolinRider doesn't work that way, but the outcome is similar. A human still has to get infected first, through any of PolinRider's attack vectors: fake take home assignments, lure repos, typosquatted packages. From there, the malware exfiltrates credentials and uses them to push malware into whatever the maintainer has access to. Typically that begins with their GitHub repo, and more recently it's been spreading out into their packages. Anyone who uses that developer's poisoned repos or packages becomes affected.

That handoff, from infected machine to poisoned package, is invisible because nothing about it looks like an intrusion. temp_auto_push.bat, the script behind the GitHub and Go compromises, runs locally on the infected machine using the developer's own already-authorized git credentials. It isn't a second party logging in from somewhere new; it's the same device, the same SSH key, the same account behavior GitHub sees from that developer every day. The script even resets the local system clock before it amends a commit, so the timestamp matches what it replaced and a casual git log shows nothing out of place. 

npm packages get the same treatment through a different credential. Once the malware has harvested a maintainer's ~/.npmrc token, it either publishes directly, or it simply waits and lets the maintainer's own next npm publish carry the payload along. Either way, the registry sees a normal, authenticated publish from the account that's always owned that package.

None of this required a human decision at any point after the initial infection. The automation chose these 20 packages the same way it chooses every package it touches: because they were there.

Go modules

Go modules don't have a separate publishing step. A module's identity is its repository path, and a version is a git tag. That means the moment PolinRider force-pushed a maliciously tagged commit to a compromised repo, proxy.golang.org served it to anyone running go get. No token, no upload, no registry account required.

We analyzed the 16 Go modules, and confirmed the payload dropped into each module is DPRK's obfuscated JavaScript loader. It resolves a second-stage payload from blockchain RPC infrastructure (TRON, Aptos, BNB Smart Chain), decrypts it with an embedded XOR key, and executes it with eval() for remote code execution and persistence through detached background processes. The follow-on payloads are the familiar Lazarus toolkit: DEV#POPPER, OmniStealer, and InvisibleFerret, covering credential theft, browser-data theft, crypto-wallet exfiltration, and keylogging.

Propagation runs through temp_auto_push.bat, the same git history-rewriting script we first documented in March. It changes the local system clock to match a commit's original timestamp, amends the commit with the malicious payload, force-pushes, and restores the clock, so a casual git log review shows nothing out of place.

The code signatures have rotated since March rmcej%otb% became Cot%3t=shtP; the decoder function $1e42 became MDy), consistent with an operator retooling against published detection rules rather than abandoning the campaign.

The pseudo-version timestamps are forged

There's a detail buried in those version strings worth pulling out on its own. A Go pseudo-version like v0.0.0-20241216030053-8573b6044fba looks like it's telling you when the code was written. Immediately after the - in this example are the digits 20241216. It’s a timestamp, telling you the version was pushed on December 16, 2024. That timestamp is the commit's committer date, and the committer date is set by whoever made the commit, not by any server. But GIT_COMMITTER_DATE is a normal environment variable and anyone with push access can set it to any date they want before committing.

Paul McCarty, our head researcher, didn't want to take these dates at face value, so he checked them. For each pseudo-version's claimed timestamp, he pulled the corresponding hour from GH Archive, which records every GitHub push event server-side and can't be altered. He looked for the claimed commit inside that hour's actual push activity. Here’s how it’s interpreted:

  • If it's there, the timestamp is honest. 

  • If the repository demonstrably pushed something that hour and the claimed commit is nowhere in it, the timestamp is forged. 

  • If nothing was archived for that repository in that hour at all, the result is inconclusive.

Of the 16 Go modules in this piece, 11 came back forged. Four were inconclusive, with no archived push in the claimed hour to check against. Only lambda-platform/lambda checked out mostly honest, with timestamps landing inside the campaign's known active window rather than years in the past.

One case, bm-197/chill, we can date exactly. The pseudo-version claims a December 2024 commit. GH Archive shows the actual push happened on June 8, 2026, eighteen months later, appended cleanly onto the developer's genuine final 2024 commit with no force push and no rewritten history. It's a completely ordinary-looking fast-forward push, except that the commit it delivered carries a timestamp claiming to be a year and a half older than the push that created it.

Paul ran the same check against a set of OpenSourceMalware's broader Go-ecosystem PolinRider tracking, 64 modules total, not just the 16 tied to this incident, and the pattern held: 43 forged, 5 honest, 16 inconclusive. Every honest timestamp fell inside a seven-week window in May-June 2026. Every claimed date from 2018-2025 that could be checked came back forged, 42 for 42. A developer's ordinary commit-then-push habits don't sort themselves neatly by year. Deliberate backdating does.

None of this trips force-push detection, because none of it is a force push. The malicious commit's parent is genuinely the developer's own last commit; nothing was rewritten or overwritten. The only thing wrong is the date it claims to have been made, and that's not a field any of the standard integrity checks look at.

None of it is remediated by taking the repo down, either. proxy.golang.org is a write-once cache. By design, the first time anyone requests a module version, the proxy fetches and stores it permanently. This ensures Go builds stay reproducible even after the source disappears. Eight of the thirteen accounts behind these modules are gone now, whether suspended by GitHub or deleted by the developer once they learned what happened. None of that mattered to the cache. Every forged module is still downloadable right now, through go get, regardless of what's happened on the GitHub side.

A benefit of adding all these threat reports to our database is we can immediately see when a maintainer has been compromised beyond a single package. We found that a single account's footprint often runs larger than what any one report captures. Pivoting on the hngi account showed six more malicious Go module threat records in our database:

digelim packages

npm works differently from Go, where publishing requires a credential separate from git access. PolinRider's toolkit has to go get it specifically. Generally this requires the victim to consume malware through one of PolinRider's upstream tactics, such as forking a poisoned repo or consuming a typosquatted package. We saw the mechanism for that step in the ChainVeil packages Checkmarx analyzed in June, where DPRK's malware credential harvester runs harvest("~/.npmrc", ".npmrc") right alongside the code stealing SSH keys and browser passwords. Once that token is on hand, publishing a poisoned release doesn't need the maintainer's cooperation at all.

html-to-gutenberg and fetch-page-assets show that same family, one step further along. The blockchain resolution structure, Tron primary, Aptos fallback, BSC as a third RPC, is identical to what we catalogued in March, down to reusing one of the original two XOR keys. The second key here, ThZG+0jfXE6VAGOJ, is new to our own dossier, but it isn't new to the campaign: it shows up in the ChainVeil and Joyfill packages too, another rotation shared across incidents rather than something unique to these two packages. What is actually novel is the entry point. Where March's campaign reached GitHub users directly through git commits, here the same .vscode/tasks.json folderOpen trick reaches through a compromised npm package instead, which only works if someone has already cloned or installed the maintainer's poisoned repo or package.

The full chain JFrog documented runs in five stages:

  1. Autorun. A committed .vscode/tasks.json with runOptions.runOn: 'folderOpen' executes the moment the project folder opens in VS Code, Cursor, Antigravity, or GitHub Desktop, bypassing npm v12's lifecycle-script protections entirely.

  2. The loader. public/fonts/fa-solid-400.woff2 isn't a font. It's JavaScript that reads XOR-encrypted payloads from on-chain transactions on Tron (via api.trongrid.io) and Aptos (via fullnode.mainnet.aptoslabs.com), with bsc-dataseed.binance.org and bsc-rpc.publicnode.com as additional RPC infrastructure.

  3. The backdoor. A socket.io channel gives the actor interactive command execution, file upload and download, and clipboard access.

  4. The bootstrap. The malware installs Node dependencies and a portable Python interpreter, pulled from bootstrap.pypa.io, into paths including %LOCALAPPDATA%\Programs\Python\Python3127.

  5. The stealer. A reversed-base64, zlib-wrapped Python infostealer harvests browser credentials, crypto wallets, password managers, developer tool configs, and OS keystores, exfiltrating over both the HTTP C2 and a Telegram bot.

Joyfill compromise

Joyfill's chain is the same family as the packages above, run through a different entry point. There's no git commit or repo fork here at all: the malicious code is appended directly after the legitimate package code and executes at require() time, which sidesteps npm install --ignore-scripts entirely since there's no install script to skip. It also checks the hostname before doing anything, staying quiet on github-runner, buildbot, buildkitsandbox, and microsoft-standard-WSL2, an evasion aimed squarely at CI systems, something neither html-to-gutenberg nor fetch-page-assets bothered with.

From there it's the familiar chain: Tron and Aptos addresses resolve a BSC transaction containing an XOR-encrypted payload, using the same two keys documented in OpenSourceMalware's original PolinRider infrastructure. That spawns a detached process pulling a payload from hxxp://23[.]27[.]13[.]43/$/boot with a Sec-V: A9-0135-3 marker header. What lands is a 77KB build of InvisibleFerret (also tracked as DEV#POPPER RAT), with a Python infostealer component assessed as OmniStealer. Persistence comes from injecting into VS Code, Cursor, Discord Desktop, GitHub Desktop, and the global npm CLI.

The status column below is the whole argument in one table. Two indicators unique to Joyfill; the rest are wallets and C2 infrastructure we and three other research teams have now watched this same actor reuse for five months.

Indicator

Value

Status

166.88.134.62:443 / :80

primary C2

novel to this incident

23.27.13.43

stage 2 loader

novel to this incident

198.105.127.210:443 / :80

C2

shared with PolinRider infrastructure

23.27.202.27:443 / :27017

C2

shared with PolinRider infrastructure

TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP

Tron wallet

shared with PolinRider infrastructure

TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG

Tron wallet

shared with PolinRider infrastructure

TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v

Tron wallet

shared with PolinRider infrastructure

Crossover into PHP

Just as we pivoted on a Go maintainer identity to find more affected Go modules, our platform quickly showed us that 23.27.202.27:443 / :27017 is shared infrastructure with another known compromised package: roberts/leads. In yet another example of an account takeover that wasn’t really an ATO, this package was discovered in May by Socket with many of the same PolinRider indicators. They hypothesized, and we agree, that the maintainer was likely compromised through a fake interview lure.

Packagist operates similarly to Go, where the registry stores metadata that points at your GitHub repo. A GitHub webhook tells Packagist to re-read the repo whenever you push, and composer require downloads the code as a zipball of the tagged commit straight from GitHub. Again: no separate artifact, no separate upload credential. The repo is the source of truth.

PolinRider attribution

JFrog's investigation into html-to-gutenberg and fetch-page-assets is detailed work: a five-stage chain from VS Code autorun through a fake font-file loader to a Python infostealer, with a full C2 path and Telegram exfiltration channel mapped out. They cited Nextron's discovery of 16 Go modules carrying the identical payload.

Socket's Joyfill writeup matched the loader's multi-chain resolution order, decryption keys, and marker structure against PolinRider-family indicators from its own prior research.

Here's what lines up against our own PolinRider infrastructure:

  • XOR key 2[gWfGj;<:-93Z^C appears across this campaign's infrastructure, and in our own March 8 report on the original GitHub-based campaign.

  • TRON wallets TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP and TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG are the same dead-drop addresses we published in March, reused across every incident in this piece.

  • ThSec-V marker header carries an A9-0135-3 value in Joyfill and an A8-** prefix in JFrog's findings. We've tracked this as a versioning convention rather than separate operators (the ChainVeil typosquat packages surfaced A6- markers back in June).

  • The fake font-file and .vscode/tasks.json combination (a JavaScript loader disguised as fa-solid-400.woff2, triggered by a folderOpen task) is PolinRider's signature delivery mechanism. JFrog documented it independently, without naming the actor. Nextron's Go module discovery is built on the same file: their scanner found matching SHA-256 hashes 53abf37710d6f2e35694fbe7cfaf1108127cbc001ce3e6bf994d0486cae5a0e8, 13e9a3c41e038bf9d8fcb0831305819819e4f7f4452bc20a04b9bf2756ee22e8) for fa-solid-400.woff2 inside the Go modules, which is almost certainly what JFrog's "same payload" credit is based on, even though neither report says so in prose.

  • temp_auto_push.bat, the propagation script behind the 16 Go modules, is the same git history-rewriting tool we first documented in March, now running rotated code signatures rmcej%otb% to Cot%3t=shtP) consistent with the retooling we've tracked since.

Put together, this isn't overlapping tools with multiple hands on them. The infrastructure, the XOR key, the wallets, the marker convention, predates all three of these incidents by months, so it can't be coincidental reuse discovered after the fact. The file match ties the Go modules to the exact npm loader JFrog documented, not just a similar one. And the backdating pattern, the multi-repo bursts, and the lateral movement into another developer's repository are the kind of precise, repeated, cross-account behavior one operator running one toolkit produces, not what sixteen independent compromises coincidentally reusing a public sample would look like. That's what makes this PolinRider, and not just PolinRider-shaped.

Full IOC set

Everything relevant across all three compromises, consolidated in one place.

XOR keys

  • 2[gWfGj;<:-93Z^C

  • ThZG+0jfXE6VAGOJ

TRON wallets

  • TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP

  • TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG

  • TA48dct6rFW8BXsiLAtjFaVFoSuryMjD3v

Aptos addresses

  • 0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e

  • 0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3

  • 0x533b2dbcaeff19cd1f799234a27b578d713d8fcaa341b7501e4526106483e0b1

File hashes

  • fa-solid-400.woff2 — SHA-256 53abf37710d6f2e35694fbe7cfaf1108127cbc001ce3e6bf994d0486cae5a0e8, 13e9a3c41e038bf9d8fcb0831305819819e4f7f4452bc20a04b9bf2756ee22e8

C2 infrastructure

  • 166.88.134.62:443 / :80 — novel to Joyfill

  • 23.27.13.43 — novel to Joyfill

  • 198.105.127.210:443 / :80 — shared PolinRider infrastructure

  • 23.27.202.27:443 / :27017 — shared PolinRider infrastructure

RPC endpoints

  • api.trongrid.io, fullnode.mainnet.aptoslabs.com, bsc-dataseed.binance.org, bsc-rpc.publicnode.com

C2 paths

  • /$/boot, /$/{id}, /verify-human/{channel}, /snv, /u/e, /u/f, /d/python.zip, /d/python.7z, /d/7zr.exe

Marker convention

  • Sec-V HTTP header, A#- prefix versioning: A8- (JFrog), A9-0135-3 (Socket/Joyfill). Same scheme, not separate operators.

Propagation artifacts

  • temp_auto_push.bat, config.bat — local git history rewriting and force-push

  • Code signature: rmcej%otb% (original, March) rotated to Cot%3t=shtP (since June); decoder $1e42 rotated to MDy

Telegram exfiltration

  • Bot token prefix 7870147428:AAGbYG..., chat ID 7699029999

Malware family naming

  • InvisibleFerret, DEV#POPPER RAT, OmniStealer (Python infostealer component). Same or overlapping payload family across reports.

What this means for maintainers

If you maintain a package in any of these ecosystems, the fastest way to find out where you stand is to check your own machine and repositories, not wait for a registry notice.

Paul McCarty, OpenSourceMalware's head researcher, wrote a scanner for exactly this: polinrider-scanner.sh, in the PolinRider dossier repo. Run it against your own projects with ./polinrider-scanner.sh /path/to/projects, and it checks your config files postcss.config.mjs, tailwind.config.js, eslint.config.mjs, and similar) for the obfuscated payload signature, looks for the temp_auto_push.bat and config.bat propagation artifacts, and checks your git reflog for amended commits: the same mechanism behind the Go module compromises above. It exits 0 clean, 1 infected, 2 error, so it drops easily into a CI check.

One limitation worth knowing before you rely on it: it's built against the original March signature rmcej%otb%), not the rotated one we documented in July Cot%3t=shtP), and it doesn't check for this campaign's blockchain-resolved or npm-token indicators. Treat it as a first pass, then check your own projects against the IOC tables above by hand, especially the .woff2 loader pattern and any sign your ~/.npmrc was touched if you publish to npm.

Beyond the scan itself, what you should protect 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. Hardware-backed MFA on GitHub and clean SSH key hygiene do more for you here than anything registry-side ever could.

If you maintain an npm package, rotate your registry token as a matter of course any time the scanner, or anything else, suggests your machine was compromised, even before you know how. Waiting for confirmation that your token was actually used doesn't help. By the time you'd see that, the package is already out.

And regardless of ecosystem: if a .vscode/tasks.json with a folderOpen trigger shows up in a clone you didn't write, that's not a productivity script. Turn off automatic task execution Task: Allow Automatic Tasks In Folder set to off or prompt) before you open anything you didn't build yourself.

Learn more