// HACKER NEWS — CYBERSECURITY
Why Does an NPM Math Library Need an Encrypted Loader?
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 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.
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():
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:
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.
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):
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.