Skip to content
Research · 2026.10.11

Reactime: the font that wasn’t a font

A North Korean implant in a popular open-source developer tool. Runs on folder open, resolves its C2 from the Ethereum blockchain, rewrites VS Code and Discord to persist. Zero detections across every engine.

Supply chain · Malware · On-chain C2

PolinRider / Reactime: DPRK supply-chain compromise analysis

Key findings

  • Reactime, an open-source React debugging tool under Open Source Labs, was compromised. 20 of 22 branches carry the implant. Only master and dev are clean.
  • The malicious commit is backdated to 2020 and presented as a one-file change. It rewrites the whole tree: 387 files, over 160,000 lines.
  • Opening the repository in VS Code is enough. A .vscode/tasks.json task runs on folderOpen and executes a file disguised as a Font Awesome font. The file is JavaScript.
  • The implant does not hardcode a command server. It reads the C2 IP out of an Ethereum transaction’s address bytes (the NullReceiver technique).
  • On Windows it injects itself into installed Electron apps, VS Code and Discord among them, and re-resolves its C2 on every launch. This persistence is not in the public reporting on the campaign.
  • The implant profiles its victim before it acts. On an Ubuntu VM the C2 answered Blocked (BOT) and stopped. The full execution chain was captured on a Windows decoy built to pass.
  • Five samples submitted to VirusTotal: two loader variants, the backdoor, the trojanized Discord core, the modified VS Code entry. Zero detections on all five. Sandboxes recorded no behavior.
  • Delivery and C2 match the PolinRider campaign and the NullReceiver technique, both publicly attributed to North Korea. We treat attribution as consistent with public reporting, not as our own determination.

The warning

At 11:31 PM on September 30, 2026, Michael Novati, co-founder of Formation, posted a public warning that Reactime appeared to be compromised and that people should stop opening the repository. He did not have the technical details. He had enough to say stop.

The first public signal, before any technical breakdown existed.

Three questions followed: is it real, how does it work, who is behind it. By October 6 the first answer was solid enough to file the formal advisory, Reactime issue #368: twenty branches contain malware, do not open in VS Code or build.

The target

Reactime is a Chrome extension for time-travel debugging and performance monitoring in React applications. About 2,200 stars on GitHub, 210 forks, listed in the Chrome Web Store.

It lives under Open Source Labs, the nonprofit open-source accelerator attached to Codesmith, the software engineering residency. Codesmith residents build and iterate OSLabs tools as part of the program, and Reactime has passed from cohort to cohort for years; its README credits around ninety authors. That is the profile an attacker wants: a trusted name, a long history, many hands in the repository, and a steady stream of new developers cloning it to work on it.

Open Source Labs on GitHub. Reactime is one of its pinned projects.

Confirming the compromise

Nothing was run on a real machine. We pulled a full mirror of the repository into an isolated VM, 80,537 objects, and read history before touching a file.

A bare mirror clone. The tip of staging resolves to f04e99a.

Everything that follows comes out of that one commit.

The forged commit

Open f04e99a on GitHub and it looks like nothing. The message is Update StateRoute.tsx. The author is a real GitHub account, with that account’s avatar. The date is September 2, 2020. On a branch with years of history, a one-file touch from five years ago is the least interesting object on the page.

Every one of those signals is free text. Git does not verify who wrote a commit or when; the author name, email, and timestamp are fields the committer fills in, and GitHub renders whatever it is given. Put a known contributor’s email in the author field and GitHub draws their face next to it. Set the clock to 2020 and GitHub files it under 2020. There is no signature on the commit and no verification badge, and nobody looks for the absence of one.

The number that gives it away is on the same screen: 387 files changed, 165,612 lines added, 156,243 removed. That is not a one-file update. The add and remove counts are nearly equal because the tree was replaced wholesale, not edited; the implant is a 24-line task file and one “font,” carried inside 300,000 lines of churn that no reviewer will read. GitHub also shows a failing check on the commit, one of three. Nobody acted on it.

One-file message, borrowed author, dated 2020. 387 files changed. A failing check nobody looked at.

Because the commit is dated 2020, the branch it sits on reads as dormant. Nothing in the repository’s recent activity points at it. A developer who pulls staging and runs git log sees a five-year-old commit at the top, which is normal for a branch nobody has touched in a while. Rewritten history that looks old and routine is this campaign’s documented signature; it is how it hid in packages before it hid in a repository.

Twenty branches

It is not one poisoned branch. We checked every branch for the implant’s signature, the presence of .vscode/tasks.json. Of 22 branches, 20 carry it. Feature branches, personal patch branches, staging, website. Only dev and master came back clean.

Every branch, one test. Only dev and master are clean.

The loader is byte-identical on all twenty branches: one git blob, referenced from twenty branch tips. One implant, planted twenty times.

That choice is the operator’s read of how people use GitHub. The two branches that stayed clean are the two a maintainer looks at and the two the repository page shows by default. The twenty that were hit are the ones a contributor checks out to pick up a piece of work, and the ones nobody reviews. Infect the quiet branches and the default view stays honest.

The trigger

The implant does not wait to be run. .vscode/tasks.json defines a task labeled eslint-check, a name that belongs in any JavaScript repository, with runOn: folderOpen. Open the folder in VS Code and the task runs.

Labeled eslint-check. Runs on folderOpen. Hidden from every panel.

The task is built to run anywhere and show nothing. The command tries command -v node for macOS and Linux, then where node for Windows, and falls through to echo '' so it never fails loudly. It is marked isBackground and hide; its presentation is reveal: never, echo: false, close: true. The terminal panel never opens. The task runs node against a file nine directories deep, inside a test fixture, in a folder called fonts.

The only gate between a developer and this task is VS Code’s Workspace Trust prompt, which asks whether you trust the authors of the folder. Anyone who cloned Reactime to work on it answers yes. That is what the prompt is for.

The disguise

The file is fa-solid-900.woff2, at tests/automated-tests/hooks-redux-router/Frontend/src/redux/actions/public/fonts/. The filename is Font Awesome’s. The directory is a test fixture nobody opens. In a file tree it is an icon font, and font binaries are the one kind of file no reviewer reads.

The loader, among legitimate font assets.

It is JavaScript. 37,717 bytes, no line breaks, carrying the campaign’s 9-0476-3 build marker. A second variant of the same loader ships as babel.config.js.

Not a font. The hash and build marker identify the loader.
Two delivery variants, one loader. Both hashes are in the published IOC set.

Nobody sees it

We submitted the loader to VirusTotal. 0 of 60 engines.

VirusTotal itself types the “font” as HTML.

The reason is in the file’s properties: HTML and ASCII text, very long lines, no line terminators. There is no plaintext malware for a signature to match, and a text file wearing a font extension does not trip heuristics.

HTML, ASCII text, 37,717 bytes.

Unpacking it

The loader is obfuscated. We beautified it and ran it through webcrack to unwind the string-array encoding, control-flow flattening, and decoder wrappers.

3,290 transforms unwound. The string decoder is named.

Stage 1 fetches a larger stage 2. Rather than let it execute, we hooked the decoder and the dangerous calls, stubbed anything that writes to disk, spawns a process, or touches the network, and let it resolve its strings. Stage 2 came out as inert text.

The payload resolves without running.

The command channel

There is no C2 address in the code. The implant reads it from the Ethereum blockchain.

The configuration has a hardcoded attacker wallet, a nonce to look for, a public blockchain indexer, and a list of legitimate Ethereum RPC providers.

Dead-drop wallet 0xa322...Ef1a and the RPC providers used to read the chain.

It queries the chain for the attacker’s transactions and selects one by sender and nonce.

Walk the block, pick the transaction from the attacker wallet with the target nonce.

Then it takes that transaction’s destination address and reads the C2 IP out of the address bytes. First four bytes, primary. Next four, backup. The command server never appears in code or config, and the lookup looks like ordinary blockchain traffic.

The “wallet address” is the C2 IP. This is NullReceiver in one function.

Independent researchers had already logged the same C2 address and the same wallet, attributed to North Korea.

Same C2, same attacker wallet, same attribution.

Execution and persistence

With a C2, stage 2 fetches the next payload and runs it two ways: in-process with eval, and as a detached child process with the window hidden and output suppressed. The detached process survives the editor closing.

eval(env + code) and a detached node -e with windowsHide: true, stdio: ’ignore’.

The decoy

The loader checks its host before it does anything. Our first run, on October 6 in an isolated Ubuntu VM, got as far as the C2 and no further: the server fingerprinted the machine as an analysis box, answered Blocked (BOT), and withheld everything past stage 1. So we built a Windows machine that looks like a developer’s, a default PC name, a plausible user, Chrome, a wallpaper, no VM tells, and detonated the implant there on October 10. It passed the profiler, ran the full operation, and we captured the complete execution chain from the host side.

What the Windows run showed

The full chain, in the order it happened on the decoy:

  1. VS Code opens the folder. The hidden folderOpen task runs node against the fake font, the stage-1 loader.
  2. The loader resolves its C2 from the Ethereum blockchain and connects to the command server over socket.io, upgrading to a WebSocket.
  3. It rewrites installed Electron applications. VS Code gets a modified main.js and a dropped module, main.inz.cjs. Discord gets a trojanized discord_desktop_core/index.js. An attempt to do the same to npm failed on permissions.
  4. It downloads a private Python 3.12.7 runtime served by the C2 and installs it. The victim never contacts python.org.
  5. The Python stage harvests the machine and exfiltrates the take as encrypted multipart uploads, tagged with the machine’s identity.

Steps 3 and 4 are the parts not in the public reporting. PolinRider was documented as a package-delivered stealer; on this machine it also became a persistent implant that reinfects itself from two applications a developer opens every day.

Inside the injected module

main.inz.cjs is the backdoor. It labels itself by the host application it was injected into. On every launch of VS Code or Discord it independently re-resolves the command server from the blockchain and reconnects, so the implant survives the original clone being deleted and the C2 address changing. Its strings are hidden with a cipher of its own, a stream cipher stepped by the golden ratio, distinct from the seeded character shuffle the loader uses; the two were built to be unwound separately. From its decoded string table we recovered the full list of Ethereum RPC providers it rotates through, including two that had not been logged in public reporting at the time.

We submitted the backdoor, the trojanized Discord core, and the modified VS Code entry.

main.inz.cjs. 0 of 57.
The hash, for matching against your own telemetry.
No detections, no MITRE signatures, no IDS or Sigma hits, no dropped files, no network. The sandbox cannot stand up a real VS Code to inject into, so the logic never fires.
Discord index.js. 0 of 61.
VS Code main.js. 0 of 62.

Five samples. Not one detection on any engine. Signature antivirus is blind to this campaign. Detection has to come from behavior and from rules, which is why we published both.

Timeline

DateEvent
Late Sept 2026Implant pushed to twenty Reactime branches, commits backdated to 2020
2026-09-30Michael Novati’s public warning
2026-10-06Static analysis and first controlled run; loader submitted to VirusTotal, 0 of 60; advisory filed as issue #368
2026-10-10Full detonation on a Windows decoy; stealer behavior captured
2026-10-11Stage 2 deobfuscated; attribution checked against public reporting; three injected samples submitted to VirusTotal, all 0 detections; findings repository published

Attribution

Consistent with public reporting. Not an independent determination.

PolinRider is the name Socket gave, in July 2026, to a North Korea-linked supply-chain campaign against open-source developers: by Socket’s count, 162 malicious release artifacts across 108 packages, starting in npm and spreading to Go modules, Packagist, and a Chrome extension. Its documented signature is compromised maintainer access and rewritten history, so the change looks old and routine. Reactime matches that signature. What is different is the delivery: no package was poisoned, and the published npm package stayed clean. The repository itself was the vector, and the editor was the trigger.

The blockchain dead-drop is the NullReceiver technique, already named and documented. The C2 address and attacker wallet recovered from the sample match what other researchers have published.

Observed in the sample and not in the public reporting at the time of writing:

  1. Electron-application injection for persistence. VS Code and Discord rewritten so the implant re-runs on every launch. Electron injection is a known technique in general; its use by this campaign appears undocumented.
  2. Per-launch C2 re-resolution inside the injected module, independent of the loader.
  3. A custom stream cipher for string obfuscation in the injected module, distinct from the loader’s scheme.
  4. A C2 that serves its own Python runtime to the victim rather than letting the victim fetch one.

Reported as observations, not as a new technique.

Public references:

Are you affected

If you cloned Reactime from any branch other than master or dev since late September 2026 and opened it in VS Code, treat the machine as compromised. The published npm package, reactime 3.1.1, was clean; the exposure is the repository, not the install.

  • Rotate every credential that machine could reach.
  • Check VS Code and Discord installations for modified entry points. Hashes are in the IOC set.
  • Match main.inz.cjs and the loader hashes against your telemetry.
  • Audit .vscode/tasks.json for runOn: folderOpen across cloned repositories. It is a legitimate feature. It should not be pointed at a font.

Method

Isolated lab throughout. Full mirror clone, read before run. A controlled run of stage 1 on an isolated Linux VM with the TLS session keys logged, then a full detonation on a purpose-built Windows decoy once the Linux run was refused. Stage 2 was recovered separately in a neutered harness with the decoder hooked and every disk, process, and network call stubbed. Every artifact is hashed; all five samples were submitted to VirusTotal and are reported here as returned. Attribution is deferred to public reporting, and our own observations are labeled as such.

Why it matters

Cloning a repository is now execution. A folderOpen task means the trust decision happens at git clone, not at npm start. Signature detection returned zero on all five samples; if your controls stop at antivirus, this campaign is invisible to you. A public blockchain is a C2 channel that cannot be taken down and reads as normal traffic; blocking the IP does nothing, the next transaction moves it. And the implant decides whether you are worth infecting before it does anything, so a generic sandbox sees a clean file. The behavior report has to come from a machine the malware believes.

Evidence and disclosure

Indicators, YARA and Sigma rules, the deobfuscated backdoor, and all hashes:

https://github.com/deepwoodssec/polinrider-reactime-analysis

Public advisory: Reactime issue #368, filed by Deep Woods Security on October 6, 2026.

Reported to GitHub, CISA, and the FBI.

Michael Novati, co-founder of Formation, raised the first public alarm. This analysis exists because he spoke up before anyone had the details.