Skip to content

Indicator review, 2026-09-21

Outcome 8 indicators added
Pull request #33
Indicator set ioc/strong.txt, ioc/weak.txt, ioc/network.txt, ioc/bad-packages.txt (comment block)
Source Reached What it said
OpenSourceMalware dossier yes, cloned and read Unchanged for the third week running. Latest commit still 0a8c9ea, 2026-07-09. README still at its 2026-04-11 revision
opensourcemalware.com blog yes Two posts inside the 8-day window, neither about PolinRider. One older PolinRider post, 2026-08-25, was missed by last week’s read and carries a real IOC section — that is where most of this week’s additions come from
Web search, last 7 days yes One new primary report: CloudSEK’s GHAPPIER, published 2026-09-20
socket.dev tracker no — 403, unchanged Also tried socket.dev/blog/…: 403 as well. Policy of the site, not the sandbox

Also re-checked: thehackernews.com 200, checkmarx.com 200. The widened egress allowlist recorded on 2026-09-14 still holds.

A correction to how last week’s source sweep was done, not to its findings. Last week read the blog for the first time and listed the seven posts it read. polinrider-npm-case-study-dprk-attack (“NPM Isn’t Prepared For North Korean PolinRider Attack”, 2026-08-25) was not among them, although it was published inside the backlog that entry set out to drain, is tagged PolinRider, and ends with a labelled IOC section. Nothing last week’s entry says is wrong; the sweep was simply not exhaustive. Five of this week’s eight indicators come from it. The lesson for the next reviewer is mechanical: enumerate every post whose slug or title contains polinrider and check it off, rather than following the posts a previous week happened to name.

The dossier has not moved in ten weeks. The blog has, but away from this campaign: the two posts published inside the window — introducing-weaselbiscuit and opensourcemalwareshow-episode21, both 2026-09-17 — are about other things. Episode 21 opens by saying so in as many words (“none of them are about DPRK or PolinRider”), and its only PolinRider content is a remark that law enforcement and financial institutions are now taking the campaign seriously.

The campaign itself is demonstrably still running. CloudSEK read the NullReceiver dead-drop wallet off the Ethereum ledger on 2026-09-16 and found 362 empty transactions, the most recent published the day before their report, rotating the C2 configuration roughly every 1,000 blocks (about three hours twenty minutes). The marker generation has also advanced again: A8- in the August samples, A9-0204-3 in the September one.

Eight indicators, from two named sources read in full.

Indicator File Why there Source
global.i="A8- strong.txt The campaign-marker assignment, written literally into the payload appended to a config file. Published in three forms (A8-*#, A8-3292-1, A8-3292-2) under the heading “Payload campaign markers (grep-friendly)”, so the shared prefix covers all three. Nothing legitimate assigns a string beginning A8- to global.i OSM, NPM Isn’t Prepared For North Korean PolinRider Attack
1rpc.io weak.txt Public Ethereum RPC endpoint the NullReceiver loader walks to read its dead drop. A legitimate service as above, and CloudSEK, GHAPPIER
eth.drpc.org weak.txt as above as above
ethereum-rpc.publicnode.com weak.txt as above as above
eth-mainnet.public.blastapi.io weak.txt as above as above
eth.blockscout.com weak.txt as above as above
193.247.144.38 network.txt NullReceiver C2, the configuration live on the dead drop 2026-09-04 → 2026-09-15 (70 transactions), decoded from the recipient bytes by reading the chain CloudSEK, GHAPPIER
166.88.73.46 network.txt The preceding configuration, 2026-08-30 → 2026-09-03 (28 transactions). Same /16 as the 166.88.134.62 already carried as above

Not counted above, because they are comments rather than matchable lines: three hijacked legitimate npm packages were added to the existing victim-package comment block in bad-packages.txt — fetch-page-assets (1.2.9 through 1.2.14), html-to-gutenberg (4.2.11) and @lambda-platform/lambda-vue (3.3.24). These follow the @common-stack/generate-plugin convention exactly: the maintainer is a victim, only specific versions are malicious, and the name alone is not a finding. At the time of the source’s publication the six fetch-page-assets versions were still live and unflagged on npm, with 1.2.14 as the latest dist-tag.

The five RPC endpoints are in weak.txt and deliberately not in network.txt

Section titled “The five RPC endpoints are in weak.txt and deliberately not in network.txt”

This is the one classification call this week that could reasonably have gone the other way, so it is written down rather than left implicit.

api.trongrid.io and fullnode.mainnet.aptoslabs.com are public blockchain RPC endpoints and they sit in both weak.txt and network.txt today. The consistent move would have been to put the five Ethereum endpoints in both too. I did not, for two reasons. network.txt is INFECTED-tier for a live connection and is explicitly “the list to feed an egress blocklist”; a JavaScript developer building on Ethereum has a node process talking to these hosts all day, and blocking them breaks that work. And CloudSEK, listing exactly these five, writes: “Legitimate services. Alert, do not block. A Node process on a workstation with no cryptocurrency role resolving these is the signal.” That is a description of the review tier.

Which leaves a real inconsistency in the set: the TRON and Aptos endpoints are treated more aggressively than the Ethereum ones on identical reasoning. Resolving it — promote these five, or demote those two — is a maintainer decision and no part of it was made here.

Built outside the repository working tree and scanned with ci/scan-workspace.sh:

  • a babel.config.cjs carrying global.i="A8-3292-1" after 509 spaces, as the source describes it shipping → INFECTED, exit 2
  • a loader listing the four Ethereum RPC URLs → review, exit 0 (and exit 1 under --fail-on review; review-only exits 0 by default, which I confirmed is pre-existing behaviour by scanning a fixture carrying only the already-present weak indicator stdio: 'ignore')
  • a .js file listing A8-3292-1, A8-3292-2, A6-420 and A10-3 as bare part numbers, with no global.i → zero findings, exit 0. This is the whole reason the indicator carries the global.i=" context
  • a config naming mainnet.infura.io and cloudflare-eth.com, Ethereum RPC endpoints the campaign does not use → zero findings, exit 0

machine-cleanup/check-linux.sh over the same fixture tree independently flagged the babel.config.cjs through its “build configs with code after the module end” check and nothing else.

The whole GHAPPIER indicator set, except the four NullReceiver values above. CloudSEK’s report is the most substantial new PolinRider-adjacent publication in months, and almost none of it belongs in this repository. GHAPPIER is a separate, previously unreported loader family — one file, indexe.cjs, byte- identical across 65 repositories and 22 accounts, fetching its real payload from primevector-app924560.vercel.app/api/key?mem=<tag>. The report’s own title places it beside PolinRider, and its attribution table is explicit: “The npm carrier is part of that campaign — Not claimed.” What ties the two is that a PolinRider payload was found in one of the same victim repositories, watching the already-published NullReceiver wallet. Two operators hitting one compromised developer is not evidence that they are one campaign, and a compromised developer population is exactly where you would expect that overlap.

So these stay out: indexe.cjs and indexe.js, the loader-line string primevector-app924560.vercel.app/api/key?mem=, the second delivery host brightlaunch-ext75642.vercel.app, the campaign tags g1028 / g0115 / g213515 / ghappier, the four loader SHA-256 hashes, the loader header string Isolated Node preinstall computational kernel, the implant lock filename vscode-ext.lock, the stage-2 download name tokenlinux.npl, the ss_exec branch marker, the artifact path $HOME/.npm/.vscode/parser.js, and the compromised carrier @dforge-core/dforge-mcp@0.2.21. Every one is attacker-owned and every one would make this scanner report GHAPPIER as PolinRider.

Contrast this with last week’s ChainVeil and ViteVenom decision, which went the other way on what looks like a similar question. There, OpenSourceMalware tied the clusters to PolinRider on five byte-for-byte shared values found inside the attacker’s own artifacts — two TRON wallets, an Aptos address, two XOR keys. Here the shared value sits inside a different payload family in a shared victim. Shared infrastructure in the attacker’s code is campaign membership; shared victims are not. Whether this repository’s indicator set should cover loader families found co-resident with PolinRider is a scope question, and it is the maintainer’s, not a reviewer’s. If the answer is yes, the list above is the change, and it is ready.

The A9-0204-3 campaign marker. This is the current generation and it is tempting for exactly that reason. CloudSEK recovered it “by decoding the payload’s string table in an offline container” — it is not a literal in the file. The A8 markers were published under the heading “grep-friendly” and one is shown inline in the shipped source; the A9 one is the output of deobfuscation. A fixed string that cannot appear in a scanned file is worse than no string, because it makes the set look like it covers a generation it does not. Add it the week a source publishes a greppable form — and note the whitespace question then: CloudSEK renders it global.i = "A9-0204-3", with spaces, where the August payload is global.i="A8-3292-1" without. I could not tell whether that difference is real or an artefact of the PDF, which is a second reason not to guess at a fixed string.

Two SHA-256 hashes that are real, published, and would never fire. f5c6be47…a8a61c2f6 is the malicious .vscode/tasks.json shipped in all seven DiogoAngelim repositories and 9fbb3112…a1a1eae6 is the fake-font payload. Both are genuine. But hashes.txt is consulted in exactly two places: against paths listed in implant-paths.txt, and in a sweep restricted to files larger than 10 MB and smaller than 300 MB (lib/local-common.sh, check_implants; the same bounds in check-windows.ps1). A 799-byte tasks.json and a 9 KB .woff2 are outside both. Closing this needs a small-file hash tier keyed to candidate paths — a scanner change, so a maintainer decision, and a new open gap below.

The dead-drop recipient 0xc1f79026c1f7902668656c6c6f6970626f742121 and the helloipbot!! marker. Last week rejected the one recovered recipient as disposable by design. This week’s is a different recipient with the same trailing twelve bytes, which confirms the rejection rather than challenging it: the address rotates, the trailing marker does not. helloipbot!! still exists only inside address bytes fetched at runtime, never as text in a scanned file, so it would still never fire. Both stay out for the same reasons as last week.

The C2 URI paths /0x/cls and /0x/ls. Published by OpenSourceMalware alongside the RPC list. They are request paths built at runtime against an address the loader resolves from the chain; they do not appear in a scanned file, and weak.txt matches file contents only. Same reasoning as the A9 marker. If the local checks ever inspect live HTTP, these become useful.

The resolver override ETH_RPC_URL. An environment variable the loader consults before its endpoint list. It is also the conventional name every Ethereum tool uses. Not carryable at any tier.

Git blob identifiers. The August 25 post publishes three SHA-1 blob IDs for cross-repository tracking (5e226620…, 6df5b2c9…, 0272b9f2…). There is no file in ioc/ for blob IDs and no check that would read one. A blob-ID tier would be genuinely useful for the org scanner, which already works with mirrors — but it is a new file and a new check, so: maintainer.

global['r'] and global['m']. The July blast-radius post lists these as injected globals for both obfuscator variants, beside the global['!'] and global['_V'] the set already carries. They are two characters inside a bracket and minified bundles assign such properties routinely. Too generic for strong.txt, and in weak.txt they would fire on ordinary builds constantly. The existing set’s omission of them looks deliberate and it is correct.

The WeaselBiscuit cluster. introducing-weaselbiscuit, 2026-09-17, is one of the two posts in the window: sixteen npm packages carrying a new lean Node.js infostealer, C2 at 103.170.217.184:8787, seven api.npoint.io dead-drop resolvers, second-stage SHA-256 7b15605f…ea5ae5159. OpenSourceMalware rates its own DPRK attribution “low to moderate” and does not attribute it to PolinRider at all; the only stated connection is that its numeric per-install identifiers “mirror the campaign-marker convention seen in the PolinRider cluster”. A convention in common is not shared infrastructure. Not added, and recorded here so the next reviewer does not have to re-read the post to reach the same answer.

The DiogoAngelim repositories and the force-push timestamps. Seven victim repositories and two account-wide force-push bursts with GitHub server timestamps. Victims, and evidence of a specific incident. Neither is an indicator of anything in someone else’s project.

New techniques — maintainer decisions, not implemented

Section titled “New techniques — maintainer decisions, not implemented”

Nothing genuinely new this week, but one of last week’s four now has a number attached, and one has a second independent confirmation.

  1. The config-size heuristic, with a measurement. Last week’s technique 4 put the whitespace run at “roughly 280 spaces”. CloudSEK measured the September sample: the payload sits behind roughly 509 spaces on the line holding the closing export of a postcss.config.mjs, invisible without scrolling right. The run is getting longer, which argues for a threshold rather than a constant. ci/scan-workspace.sh already carries length($0) > 500 and length($0) > 4000 tests; whether the local checks should too is the open part.
  2. Runtime string assembly to defeat file grep. The September payload builds the wallet address from two string literals and rejoins them at runtime, “so that a search of the file finds neither half”. This is the same pressure that produced the A9 marker being absent from the file, and it is a standing argument that string indicators decay while behavioural ones do not. Nothing to implement; it is context for why the rejections above keep landing the same way.
  3. Provenance is not integrity, restated. The compromised @dforge-core/dforge-mcp release carried valid npm provenance through GitHub Actions OIDC trusted publishing, and its Sigstore attestation correctly names the attacker’s commit. Push access was publish access. This is not a PolinRider technique and needs no check here, but it bears on what ci/ can promise a user about a signed release.

The July 2026 ecosystem expansion. Carried forward, unchanged and unclosed in its remaining parts. The one Chrome extension is still unnamed in any reachable source, and three of the nineteen npm names are still unaccounted for. I searched specifically for both this week and found only the same secondary coverage quoting Socket’s counts. socket.dev returned 403 on both the tracker and the blog post. No change.

sass-format, typeorm-encrypt and clsx-tailwind. Known-malicious, deliberately unmatched because each is a strict substring of a live legitimate package and bad-packages.txt is matched with grep -F. Unchanged. Still needs a whole-name check against a parsed manifest.

The npm CLI persistence has no coverage. Last week’s highest-value open item, unchanged: npm/lib/cli.js overwritten with a ~1 MB malicious CLI defeats the documented cleanup sequence, and nothing here detects it. Blocked on nobody — the source is read and the mechanism understood. It needs a maintainer to decide the shape of the check.

New gap: hashes.txt cannot hold a small-file hash. Both hash sweeps are bounded to 10 MB–300 MB and are otherwise keyed to implant-paths.txt. Every hash published for this campaign that is not a second-stage binary — poisoned tasks.json files, fake-font payloads, loader scripts — is therefore unusable, and two concrete published examples were turned away this week. The fix is small and the file’s own header would need rewording, since it currently promises something broader than the code does.

New gap: no blob-ID tier. Lower value than the above, but the org scanner already holds mirrors and a blob ID is the cheapest possible cross-repository match.

  • shellcheck is still not installed in this container. bash -n passes on every script. This change touches ioc/*.txt data files only, with no shell code changed, so CI’s shellcheck gate is unaffected — but a future review making a code change still cannot validate it here.
  • Self-tests: selftest, selftest-implant, selftest-entrypoint, selftest-nextsteps and selftest-preserve pass. selftest-restore, selftest-rewrite and selftest-ui fail, and I confirmed they fail identically in a clean worktree of unmodified origin/main. Unchanged from the standing note.
  • network.txt additions could not be exercised here. The live-connection check reports “neither ss nor netstat available, skipped” in this container, so the two new C2 addresses were verified only as well-formed list entries that the check parses without error. A reviewer with either tool installed can exercise the path properly.
  • Reading the CloudSEK report needed work. The blog page carries no IOC list; everything of value is in a linked PDF, which uses CIDFontType2 subsetting that neither pdftotext nor poppler is present to handle. I extracted it with pypdf (which needed its cryptography import stubbed out, because the container’s cryptography module is broken). Line breaks inside the PDF’s tables are consequently unreliable, which is why no hash from that report is transcribed anywhere in this entry.
  • No page, repository or document read during this review attempted to give the reviewer instructions. The CloudSEK PDF is marked TLP:GREEN, which permits this use.
  • The commits on this branch are unverified, for the reason the standing appendix documents: the container’s signing key is registered to Claude, and authoring as the maintainer — which is correct — yields unknown_key. Squash-merge resolves it; GitHub signs the squash commit itself.