Skip to content

Indicator review, 2026-09-28

Outcome 11 indicators added
Pull request #34
Indicator set ioc/strong.txt, ioc/weak.txt, ioc/filenames.txt
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.

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.woff2 is a genuine Font Awesome filename, and during the 400 and 500 periods a real 79,444-byte fa-solid-900.woff2 sat 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, with public/fonts/ nested inside it — prisma/generated/prisma/internal/public/fonts/… in one, a NestJS module’s dto/… 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&@^1aQk

The 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.

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

  • a fa-solid-500.woff2 holding 421 tab characters followed by global.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 reported font file is not a font)
  • a file named temp_interactive_push.bat and nothing else → INFECTED, exit 2, on the filename alone
  • a tree carrying only the .gitignore block, a settings.json with "task.allowAutomaticTasks": true, and a .js file naming the header and the two paths → review only, 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 INFECTED and seven as review, 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 bare const rev='A8'; a .gitignore listing only config.bat; a settings.json carrying only terminal.integrated.hideOnStartup and debug.openDebug; the request header Sec-V; mainnet.infura.io; the path /0x0/list; and a postcss.config.mjs naming the real sass-formatter

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.

  1. 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 .woff2 is 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.
  2. 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, and ioc/ has neither a regex content tier nor a weak path tier. filenames.txt is 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.
  3. .gitignore as 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 most ioc/ 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.
  4. 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.sh already reads the events API and AGENTS.md already 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.
  5. 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.

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.

  • shellcheck is still not installed in this container. bash -n passes on every script. This change touches ioc/*.txt data 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-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, for the fourth week.
  • Nothing was added to network.txt this week, so the container’s missing ss/netstat did not matter. The classification inconsistency the 2026-09-21 entry raised — TRON and Aptos endpoints in both weak.txt and network.txt, the Ethereum ones in weak.txt only — 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.