critical

CVE

Not assigned

CWE

CWE-506, CWE-494, CWE-522

Affected Surface

  • mathmain 1.0.0
  • mathsbase 1.0.1
  • math-universe 1.0.0, 1.0.1, and 1.0.2
  • Developer workstations, quantitative tooling, and CI runners that imported those packages and executed lusolve() on attacker-chosen inputs
  • Hosts where the package tree later contained lib/cjs/utils/subwatcher or a LICENSE line ending with REDISTRIBUTION REQUIRES INCLUSION OF THIS LICENSE.

The strongest package security finding from the last three days is the encrypted loader cluster in mathmain, mathsbase, and math-universe. The packages impersonate mathjs style tooling, but the malicious path does not live in package.json scripts or in a visible application entrypoint. It lives in extra CommonJS files that appear only in the published npm tarball and wake up only after a successful call to lusolve() on a specific matrix.

That makes this more than another “malicious package” headline. The trigger is buried in ordinary numerical work. If a quantitative service, local developer tool, or CI job imports one of these packages and solves the right system of equations, the same call that returns a valid answer also decrypts and launches the next stage.

Affected packages and current registry state

The affected package versions are:

  • mathmain 1.0.0
  • mathsbase 1.0.1
  • math-universe 1.0.0
  • math-universe 1.0.1
  • math-universe 1.0.2

Public registry metadata in this environment now shows all three names replaced with security placeholders:

mathmain       latest -> 0.0.1-security
mathsbase      latest -> 0.0.1-security
math-universe  latest -> 0.0.1-security

The package history still preserves the useful timing:

mathmain 1.0.0            2026-09-17T06:53:07Z
mathmain 0.0.1-security   2026-09-21T19:48:19Z

mathsbase 1.0.1           2026-08-27T07:44:22Z
mathsbase 0.0.1-security  2026-09-21T20:05:34Z

math-universe 1.0.0       2026-09-16T12:06:21Z
math-universe 1.0.2       2026-09-16T13:39:19Z
math-universe 1.0.1       2026-09-16T14:14:52Z
math-universe 0.0.1-security 2026-09-21T20:06:03Z

GitHub malware advisories for all three names were published on 21 September. That is the useful takeaway for defenders today: the package pages now mostly show cleanup, not exposure. There is no trustworthy upgrade line within these package names. The safe action is to remove them and use the legitimate upstream library or another verified dependency instead.

The trigger sits after the solver finishes

The most interesting part of this case is where execution happens.

According to the published reversing, a call to math.lusolve(A, b) follows the normal mathjs flow long enough to compute a correct answer. Only after the linear algebra is done does the injected path run:

math.lusolve(A, b)
  -> math.lup(A)
  -> _lusolve()
  -> removeSolveValidation(L._data)
  -> isGraph(JSON.stringify(L._data))
  -> decrypt staged blob
  -> require() decrypted module

The added loader path is visible in the injected helper:

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;
}

The detail that matters is the password source. The package does not ship a static key. It derives the decryption key from the victim’s own solver state:

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

That design gives the attacker two advantages at once. A normal code review will miss the trigger because the package still behaves like a math library. A sandbox run will also miss it unless the analysis happens to feed the solver an input that produces the expected lower triangular matrix.

JFrog’s reconstruction says the trigger matrix was:

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]]

Very Serious Systems also showed a concrete input that reaches the same lower triangular form during lusolve:

math.lusolve([[1,2,0],[1,4,0],[1,3,0]], [1,2,3])

The practical consequence is blunt. A package can return the mathematically correct answer and still turn the same call into the malware launcher.

The tarball carries files that do not exist in upstream mathjs

JFrog’s writeup is useful because it names the extra files under lib/cjs/utils/ that should not exist in a clean mathjs derived package:

event.js             AES-256-GCM loader
graph.js             encrypted stage
fraction.js          encrypted Slack tasking agent
bignumber/type.js    bundled ethers payload

The injected files split the operation into small jobs.

The first stage loader in event.js decrypts graph.js and executes it with require(). The second stage reports host details, marks the infection in LICENSE, detaches into the background, and prepares blockchain based tasking. The third stage in fraction.js polls Slack for operator commands and rebuilds a payload from chat messages. The large file under bignumber/type.js ships a bundled ethers client so the implant can talk to public Sepolia RPC endpoints without adding another obvious dependency.

JFrog also published the wallet bootstrap logic:

const walletPassword = triggerPassword + "*" + getNPwd();
// getNPwd() reads the victim package's assert devDependency version
// the decrypted contract wallet is used for later check-in and tasking

That means the trigger password alone is not enough. The package combines a runtime derived secret with local package metadata before it can unwrap the wallet used for the next step.

Sepolia handled one command path and Slack handled another

The on host behavior is not just “decrypt blob, run blob.” The code builds a small control plane.

JFrog says graph.js posts a system report to Slack and Telegram, then enrolls with a verified smart contract on Sepolia. After that, the operator can store encrypted task payloads on chain and signal the implant by contract events. Fraction.js runs a second path in parallel by polling Slack conversations.history every ten seconds, reassembling chunked messages, and writing the result to a dropped executable called subwatcher.

The file write is direct enough to quote in reduced form:

fs.writeFileSync(savePath, assembled, "utf8");
// fs.chmodSync(savePath, "755");
// runProcess(savePath);

There is also a simple infection marker. The malware appends a line to the package LICENSE file:

REDISTRIBUTION REQUIRES INCLUSION OF THIS LICENSE.

If you find that line at the end of a package LICENSE, or an executable named lib/cjs/utils/subwatcher, you are past the “suspicious dependency” stage and into host triage.

GitHub source review would have missed it

This incident is another reminder that repository review and tarball review are not the same thing.

Both JFrog and Very Serious Systems report that the public GitHub repositories for the fake math packages looked like long running forks or clones of legitimate projects. The malicious code did not live in those repositories. It lived only in the published npm tarball.

That distinction matters because many teams still treat these checks as interchangeable:

look at package homepage
  -> click repository
  -> review source tree
  -> conclude the package is clean

That workflow fails here. The relevant files are registry only artifacts. If your dependency review never compares the served tarball with the claimed source repository, this family walks straight through it.

This was not an isolated three package cluster

JFrog tied the math packages to a longer running campaign that also touched names such as matrixflow-js, matrixkit-js, mutex-forge, mutex-thread, graphcore-js, graphlib-js, indexed-btree, and btree-core.

The useful point is not the exact campaign count. It is the operating pattern:

  • disposable npm publisher accounts
  • clean looking source repositories
  • malicious tarball only payloads
  • inflated download counts
  • command and control over public blockchain infrastructure and chat services

The package name changes from wave to wave. The control pattern does not.

What to hunt in real repositories and hosts

Start with dependency files, lockfiles, caches, and SBOM material:

rg -n 'mathmain|mathsbase|math-universe' \
  package.json package-lock.json pnpm-lock.yaml yarn.lock bun.lock . 2>/dev/null

Then look for the tarball only file paths and infection marker:

rg -n 'lib/cjs/utils/(event|graph|fraction|subwatcher)\\.js|REDISTRIBUTION REQUIRES INCLUSION OF THIS LICENSE\\.' \
  node_modules . 2>/dev/null

If you keep network or process logs from build agents and developer machines, hunt for the control plane shape as well:

rg -n 'conversations\\.history|slack\\.com/api|api\\.telegram\\.org|sepolia|alchemy|infura' \
  ~/.npm/_logs . 2>/dev/null

The point is not that every hit proves compromise. The point is that this family leaves a distinctive combination: math library package names, unexpected crypto helpers, Slack polling, and Sepolia RPC traffic coming from a machine that should have been doing routine development work.

Response guidance

Treat confirmed use of these package versions as a host compromise, not as a routine dependency cleanup task.

  1. remove the packages from manifests, lockfiles, caches, and base images
  2. rebuild affected environments from a known good state
  3. rotate secrets from a clean machine, including Git, npm, CI, SSH, cloud, and chat credentials that were reachable from the host
  4. review network logs for Slack, Telegram, and Sepolia RPC activity after any import or solver run that could have triggered the loader
  5. compare published tarballs against source repositories for any other math, DeFi, or utility package that shows implausible download growth with little or no dependent usage

The core lesson is simple. Install script controls do not help if the malicious path starts later, inside normal application logic. Here the attacker put that path behind a legitimate looking call to lusolve(), waited for the right matrix, and let the victim’s own numerical workload unlock the implant.

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.

References