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) |
Sources
Section titled “Sources”| 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.
Upstream state
Section titled “Upstream state”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.
Verified against fixtures
Section titled “Verified against fixtures”Built outside the repository working tree and scanned with
ci/scan-workspace.sh:
- a
babel.config.cjscarryingglobal.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 indicatorstdio: 'ignore') - a
.jsfile listingA8-3292-1,A8-3292-2,A6-420andA10-3as bare part numbers, with noglobal.i→ zero findings, exit 0. This is the whole reason the indicator carries theglobal.i="context - a config naming
mainnet.infura.ioandcloudflare-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.
Considered and not added
Section titled “Considered and not added”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.
- 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
exportof apostcss.config.mjs, invisible without scrolling right. The run is getting longer, which argues for a threshold rather than a constant.ci/scan-workspace.shalready carrieslength($0) > 500andlength($0) > 4000tests; whether the local checks should too is the open part. - 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.
- Provenance is not integrity, restated. The compromised
@dforge-core/dforge-mcprelease 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 whatci/can promise a user about a signed release.
Open gaps
Section titled “Open gaps”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.
shellcheckis still not installed in this container.bash -npasses on every script. This change touchesioc/*.txtdata 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-nextstepsandselftest-preservepass.selftest-restore,selftest-rewriteandselftest-uifail, and I confirmed they fail identically in a clean worktree of unmodifiedorigin/main. Unchanged from the standing note. network.txtadditions could not be exercised here. The live-connection check reports “neitherssnornetstatavailable, 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
pdftotextnorpoppleris present to handle. I extracted it withpypdf(which needed itscryptographyimport stubbed out, because the container’scryptographymodule 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.