Indicator review, 2026-09-28
| Outcome | 11 indicators added |
| Pull request | #34 |
| Indicator set | ioc/strong.txt, ioc/weak.txt, ioc/filenames.txt |
Sources
Section titled “Sources”| Source | Reached | What it said |
|---|---|---|
| OpenSourceMalware dossier | yes, cloned and read | Unchanged for the fourth week running. Latest commit still 0a8c9ea, 2026-07-09. README still at its 2026-04-11 revision. None of this week’s strings appear anywhere in it |
| opensourcemalware.com blog | yes | Two new posts inside the window, and one of them is the week. polinrider-is-a-b-testing-its-way-past-your-detections, 2026-09-26, is the first substantial new PolinRider analysis since August, and everything added below comes from it |
| Web search, last 7 days | yes | Searched the campaign name, MicrosoftSystem64 and ForceMemo. No new primary analysis beyond the blog post. The only new secondary coverage is of CloudSEK’s GHAPPIER report, which the 2026-09-21 review already worked through |
| socket.dev tracker | no — 403, unchanged | Third week in a row. Policy of the site, not the sandbox |
Also re-checked: the blog’s post paths serve full server-rendered content, as 2026-09-14 recorded. The bare domain still returns the empty single-page-app shell.
This week’s read was done by enumerating the blog index rather than following
the posts a previous entry named, which is the mechanical fix the 2026-09-21
entry asked for after finding that the 2026-09-14 sweep had missed an August
post. The index lists 84 posts; the two inside the eight-day window are
polinrider-is-a-b-testing-its-way-past-your-detections (2026-09-26) and
the-opensourcemalwareshow-episode22 (2026-09-25). Both were read in full.
introducing-weaselbiscuit and the-opensourcemalwareshow-episode21, both
2026-09-17, fall just outside the window but inside the gap since the last
entry; both were read and both were already covered by the 2026-09-21 entry.
Upstream state
Section titled “Upstream state”The last two reviews are still unmerged, and this one is stacked on them.
main is still at the 2026-09-14 review. The 2026-09-21 branch
(ioc/2026-09-21, #33)
exists on the remote and has not been merged, so this week’s branch is based on
it rather than on main. Five of the indicators this review would otherwise
have proposed — the five public Ethereum RPC endpoints — are already in that
branch’s weak.txt, and are not re-added here. A reviewer merging these out of
order will get conflicts in README.md, docs/indicator-reviews/README.md and
ioc/weak.txt; merging #33 first reduces this pull request to this week’s
changes alone.
The dossier has not moved in eleven weeks. The blog has, and it has moved directly against this repository’s detection method.
The 2026-09-26 post is a case study of one infected GitHub organization
(Binary-Mindz, three backend repositories, infected since January 2026) read
off GitHub’s server-side activity log, which the malware cannot backdate. Its
central claim is that the campaign is not evolving in a line but running
variants in parallel: the fake font now exists as fa-solid-400.woff2,
fa-solid-500.woff2 and fa-solid-900.woff2, and on 2026-09-25 the 900
variant was pushed to all three repositories and then rolled back to an updated
500 in two of them within 32 minutes. The post’s own framing is an A/B test, and
it makes the point that “a lot of the detection advice circulating for
PolinRider assumes the last sample tells you what the next one will look like”.
Three things in it bear directly on the current indicator set:
fa-solid-900.woff2is a genuine Font Awesome filename, and during the 400 and 500 periods a real 79,444-bytefa-solid-900.woff2sat in the same folder as the fake. The 900 variant takes that name.- The payload no longer lives under
public/fonts/. In the 2026-09-25 push it landed at a different, project-plausible path in each repository, withpublic/fonts/nested inside it —prisma/generated/prisma/internal/public/fonts/…in one, a NestJS module’sdto/…folder in the other two. - The whitespace padding is not reliable either. The 500 builds pad with 273 and 421 tab characters; the 900 payload starts directly with code.
The one indicator in ioc/ that the post directly dates is
node ./public/fonts/fa-solid-400.woff2, added on 2026-09-14. It is still
correct and still fires on 400 samples, but it is now demonstrably
variant-specific and path-specific, and the post publishes a 900-variant task
command that shares none of it. See the open gaps.
Eleven indicators, all from one source read in full.
| Indicator | File | Why there | Source |
|---|---|---|---|
global.i = 'A8 |
strong.txt |
The campaign-marker assignment in the other quote style. strong.txt gained global.i="A8- last week from an August sample; the September fake-font build opens with global.i = 'A8';, single-quoted and spaced, which that entry does not match. Nothing legitimate assigns A8 to global.i |
OSM, PolinRider is A/B Testing its Way Past Your Detections |
q4FZkxX{!h |
strong.txt |
XOR key, readable in the 500-variant payload’s string table without deobfuscating it | as above |
y-p_>d$0B&@^1aQk |
strong.txt |
The second XOR key, carried as its whitespace-free tail. See the note below | as above |
temp_auto_push.bat |
weak.txt |
The .gitignore fingerprint of an infected contributor’s machine. Already a filenames.txt path indicator; this is the separate case of the name appearing inside a committed file |
as above |
temp_interactive_push.bat |
weak.txt |
as above | as above |
branch_structure.json |
weak.txt |
as above, and the weaker of the three — a name a legitimate tool could also write | as above |
allowAutomaticTasks |
weak.txt |
The VS Code setting that lets a folderOpen task run without the usual prompt. The post’s own recommended hunt is folderOpen|allowAutomaticTasks, and folderOpen was already here |
as above |
X-Payload-B64 |
weak.txt |
The response header the 500-variant loader reads its next stage out of, in plain text in the payload | as above |
/0x/cls |
weak.txt |
C2 request path, in plain text in the 500-variant payload. This reverses a rejection made last week; see below | as above |
/0x/ls |
weak.txt |
as above | as above |
(^|/)temp_interactive_push\.bat$ |
filenames.txt |
The force-push tool’s second working file, beside temp_auto_push.bat, which has been there since the first release |
as above |
The second XOR key is carried as a substring, on purpose
Section titled “The second XOR key is carried as a substring, on purpose”The source prints the pair inside a pre-formatted table:
XOR keys q4FZkxX{!h,Sr3=@ y-p_>d$0B&@^1aQkThe first key is unambiguous. The second contains a run of three spaces, and
because the surrounding table is column-aligned with spaces there is no way to
establish from the page whether those three characters are part of the key or
part of the layout — and every other row in that table separates its values with
a comma and a space, where this one does not. Guessing would produce a fixed
string that silently never matches, which is the failure mode the 2026-09-21
entry rejected the A9-0204-3 marker over. So the entry is
y-p_>d$0B&@^1aQk: the longest tail of the published key that contains no
whitespace, sixteen high-entropy characters, which matches the key however much
space precedes it.
This reverses last week’s rejection of the C2 paths
Section titled “This reverses last week’s rejection of the C2 paths”The 2026-09-21 entry turned away /0x/cls and /0x/ls with the reasoning that
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.”
This week’s source says the opposite about the 500 variant, in as many words:
its indicators — “the Ethereum wallet, the C2 paths, the response header” — “can
be read straight out of the file”, because that build uses a common _0x
string-array obfuscator that leaves its string table legible. I confirmed the
mechanism is plausible by reading the post’s own listing of that string table,
which prints the wallet (already in strong.txt), the paths and the header
together as strings recovered from the file rather than from traffic.
Both entries stand. The 2026-09-21 reasoning was correct for the sample it had
and is correct for the 900 variant, which encodes all of these; it was wrong as
a general claim. They are carried at review tier, not INFECTED, and the port
prefix the source prints (:443/0x/cls) is dropped so the entry matches whether
or not the literal in the file carries it.
Verified against fixtures
Section titled “Verified against fixtures”Built outside the repository working tree and scanned with
ci/scan-workspace.sh:
- a
fa-solid-500.woff2holding 421 tab characters followed byglobal.i = 'A8';, both XOR keys, the header, the paths and the already-carried wallet →INFECTED, exit 2 (twice: once on the strings, once on the existing font-masquerade check, which reportedfont file is not a font) - a file named
temp_interactive_push.batand nothing else →INFECTED, exit 2, on the filename alone - a tree carrying only the
.gitignoreblock, asettings.jsonwith"task.allowAutomaticTasks": true, and a.jsfile naming the header and the two paths →reviewonly, exit 0, and exit 1 under--fail-on review - each of the ten new content indicators in a file of its own → all ten fire,
three as
INFECTEDand seven asreview, which the deduplicating output of a combined scan would otherwise have hidden - near-miss negatives → zero findings, exit 0:
global.item = 'A8-9000'and a bareconst rev='A8'; a.gitignorelisting onlyconfig.bat; asettings.jsoncarrying onlyterminal.integrated.hideOnStartupanddebug.openDebug; the request headerSec-V;mainnet.infura.io; the path/0x0/list; and apostcss.config.mjsnaming the realsass-formatter
Considered and not added
Section titled “Considered and not added”The three new fake-font paths, and the 900-variant task command. The post
names five payload locations across three repositories:
public/fonts/fa-solid-500.woff2, and fa-solid-900.woff2 nested under
prisma/generated/prisma/internal/, src/main/admin/admin-ad/dto/ and
src/module/content-master/category/dto/. None can go in filenames.txt, which
is INFECTED tier: fa-solid-900.woff2 is a real Font Awesome filename, the
three deep paths are specific to these victims, and the post’s whole argument is
that the next sample will use a different one. The 2026-09-14 entry rejected
fa-solid-400.woff2 on the same grounds and asked for a weak filename tier;
this week is the second week that would have used one.
The 900 task command is published verbatim — command -v node >/dev/null 2>&1 && node ./prisma/generated/prisma/internal/public/fonts/fa-solid-900.woff2 —
and carrying it would match exactly one victim repository. The generalisable
form is a regex (node .*fa-solid-[0-9]{3}\.woff2), which no file in ioc/ can
express. Left alone; see the gaps.
config.bat. Fourth appearance, third rejection, and the reasoning has not
changed: it is a name anything at all can have, and .bat files named config
are ordinary on Windows. Not carryable even at review tier, where it would fire
on any Windows project. The other three names in the same .gitignore block are
specific enough and were carried.
The build markers A8, A8-n*, 9-10094, 9-2078, 8-1259, 8-1586,
8-1144. Same answer as the A6- family in 2026-09-14 and the A9-0204-3
marker in 2026-09-21. As bare fixed strings these are four-to-seven-character
alphanumeric tokens that would match part numbers, ticket IDs, version
fragments and hex dumps. The two that can be carried safely are carried with
their assignment context: global.i = 'A8 above, and the bare global['!']
already in strong.txt, which matches the 9-10094 marker’s carrier without
matching the number.
global.i on its own, and Sec-V. global.i as a fixed string matches
global.index, global.init, global.instance and anything else minified code
assigns. Sec-V is five characters. Both would generate noise for no gain; the
information they carry is already covered by the entries above.
The three published SHA-256 hashes. 1a21bad1…b36f9705 for the
fa-solid-900.woff2 payload, and two for the poisoned eslint.config.mjs and
.vscode/tasks.json at commit 1b16bd46b7. This is the third consecutive week
that real, published hashes have been turned away for the reason the 2026-09-21
entry recorded as an open gap: both hash sweeps in lib/local-common.sh are
bounded to files larger than 10 MB and smaller than 300 MB, or keyed to
implant-paths.txt, and ci/scan-workspace.sh does not read hashes.txt at
all. A 37,567-byte font payload is outside all of it. Adding these would make
the set look like it covers the 900 variant when nothing would ever compare
them. The gap now has five concrete examples behind it.
The three VS Code settings that switch off the visible signs.
"terminal.integrated.hideOnStartup": "always" and
"debug.openDebug": "neverOpen" are ordinary settings with ordinary uses, and
the post does not recommend hunting them; only allowAutomaticTasks, which was
carried. The combination of all three in a .vscode/settings.json is the
signal, and a conjunction is a check, not a fixed string.
AUTH_API_KEY, and the prisma.config.ts loader shape. One historical
variant read a base64 URL out of process.env.AUTH_API_KEY, imported
node-fetch, and eval’d the response. AUTH_API_KEY is a plausible
legitimate environment variable name and cannot be carried at any tier. The
loader’s shape — eval of a fetched response whose URL comes from
atob(process.env.…) — is a regex check. The 900 rewrites deleted this path and
removed node-fetch and dotenv from package.json again, so it is history
rather than current.
Everything about the victims. The Binary-Mindz organization and its three
repositories, GulamRosul/Curved-navigation-bar, and the nineteen GitHub
accounts the post names (alamin-sujon, ramjan-devs, haniful360,
Rafi-Sharkar and the rest). The post is explicit that it believes every
developer named is a victim, and this repository’s rule is the same: a victim’s
name is not an indicator of anything in someone else’s project. The commit SHAs,
the 853 force-pushes and the GitHub timestamps are evidence of one incident, not
detection content.
The two 2026-09-17 posts. introducing-weaselbiscuit and
the-opensourcemalwareshow-episode21 were both read. The 2026-09-21 entry
already worked through both and rejected the WeaselBiscuit cluster on the
grounds that OpenSourceMalware does not attribute it to PolinRider and the only
stated connection is a shared numeric-marker convention. Re-read this week and
the conclusion is unchanged; recorded so it is not re-derived a third time.
Episode 22 (2026-09-25) mentions PolinRider several times but publishes no
indicator.
The GHAPPIER set, again. No new primary material this week — the secondary
coverage that turned up in search (Infosecurity, CyberSecurityNews, gbhackers,
byteiota, a Thai NCSA digest) all restates CloudSEK’s report, which the
2026-09-21 entry read from the PDF and worked through in full. Its
scope decision stands and is the maintainer’s to revisit:
@dforge-core/dforge-mcp@0.2.21 and the GHAPPIER loader indicators stay out
because GHAPPIER is a separate loader family that happened to share a victim.
New techniques — maintainer decisions, not implemented
Section titled “New techniques — maintainer decisions, not implemented”Nothing here is implemented, and the first two are the same item from two
directions: the campaign is now deliberately targeting the string- and
name-based matching that ioc/ is built on.
- Parallel variants, not successive ones. Three fake-font filenames, two
obfuscators, two padding styles and at least four execution paths were live
in one organization on one day, and a newer variant was rolled back to an
older one within half an hour. Every check in this repository that keys off
“the current sample” is keying off one arm of a running experiment. The
practical consequence is that the font-masquerade check in
ci/scan-workspace.sh— which reads the first four bytes and asks whether a.woff2is actually a WOFF2 — is now the most durable detection this repository has for this vector, and it is the one the post independently recommends (find . -name '*.woff2' -exec file {} +). It deserves to be extended to the local checks, which do not do it. - A regex tier for paths and commands. Two weeks running, the blocking
problem has been the same: the useful form of an indicator is
node .*fa-solid-[0-9]{3}\.woff2, or a filename that should be reviewed rather than declared infected, andioc/has neither a regex content tier nor a weak path tier.filenames.txtis already extended regex, so the machinery half-exists. This is the single change that would have let three of this week’s rejections be additions. .gitignoreas a machine-infection signal, separate from repository infection. The four-line block is the clearest evidence yet published that a contributor’s machine is infected even when the repository holds no payload, and it is why cleanups keep being reverted. Three of the four names are now carried at review tier, which is the mostioc/can say. What the scanner cannot currently do is say the useful thing: “this repository is clean, and at least one person who can push to it is not.” That is a different verdict class, and inventing one is not a reviewer’s call.- Force-push forensics against the GitHub activity API. The post recovers
the entire eight-month history from
/repos/OWNER/REPO/activity?activity_type=force_push, and reports that 814 of 853 force-pushes rewrote only the tip commit with the same parent and the same author date, 629 of them preserving the committer timestamp too.lib/gh-scan.shalready reads the events API andAGENTS.mdalready warns that the events window is roughly 300 events; the activity endpoint appears to go back to repository creation, which would be a materially better evidence source than the one the tools use. Worth a maintainer’s look independently of any indicator. - Cleanup reversal under the cleanup’s own commit message. Three cleanups
in this organization were undone by force-pushes from other contributors’
accounts within five to thirteen hours, keeping the original titles, so a
commit reading “security: remove VS Code supply-chain malware” currently
contains the malware. This is a direct vindication of the non-negotiable
ordering in
AGENTS.md(machines, then credentials, then branches) and is worth citing in the README where that order is explained, which is documentation rather than code.
Open gaps
Section titled “Open gaps”The July 2026 ecosystem expansion. Carried forward unchanged for the third week. The one Chrome extension is still unnamed in any reachable source and three of the nineteen npm names are still unaccounted for. socket.dev returned 403 again, on both the tracker and the blog. Searched specifically; found only the same secondary coverage quoting Socket’s counts.
sass-format, typeorm-encrypt and clsx-tailwind. Unchanged.
Known-malicious, deliberately unmatched, because each is a strict substring of a
live legitimate package and bad-packages.txt is matched with grep -F. Still
needs a whole-name check against a parsed manifest.
The npm CLI persistence has no coverage. Unchanged and still the
highest-value open item: npm/lib/cli.js overwritten with a ~1 MB malicious CLI
defeats the documented cleanup sequence, and nothing here detects it. Blocked on
nobody.
hashes.txt cannot hold a small-file hash. Carried forward from
2026-09-21 and now with five published examples turned away rather than two. The
file’s header promises “any file matching one of these is the implant, whatever
it has been renamed to”, and the code delivers that only for files between 10 MB
and 300 MB or sitting on an implant-paths.txt path. Every hash this campaign’s
analysts publish for a payload rather than a second-stage binary is unusable.
No blob-ID tier. Carried forward from 2026-09-21, unchanged.
New gap: the font-vector indicators in ioc/ are one variant behind, and
cannot catch up as fixed strings. node ./public/fonts/fa-solid-400.woff2 is
the only entry covering this vector and it matches neither the 500 nor the 900
variant, both of which are live. A 900-variant payload is caught today only by
the font-magic-bytes check and by the wallet — not by any font indicator — and
a future variant that drops the wallet from plain text, which the 900 build
already does, would be caught by the magic-bytes check alone. Closing this needs
gap 2 above (a regex tier), not more strings.
New gap: no weak tier for filenames. Third consecutive week this has
blocked an addition (fa-solid-500.woff2 and branch_structure.json as a path
this week, config.bat and spellright.dict on 2026-09-14). filenames.txt is
INFECTED-only, so a name that deserves a look but not a verdict has nowhere to
go and ends up either absent or over-classified.
shellcheckis still not installed in this container.bash -npasses on every script. This change touchesioc/*.txtdata files and Markdown 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, for the fourth week. - Nothing was added to
network.txtthis week, so the container’s missingss/netstatdid not matter. The classification inconsistency the 2026-09-21 entry raised — TRON and Aptos endpoints in bothweak.txtandnetwork.txt, the Ethereum ones inweak.txtonly — is unresolved and untouched here. - The source post is a single server-rendered HTML page; its pre-formatted indicator table is why the XOR-key whitespace question above could be stated precisely rather than guessed at. No PDF extraction was needed this week.
- No page, repository or document read during this review attempted to give the reviewer instructions.
- 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.