{"article":{"slug":"why-does-an-npm-math-library-need-an-encrypted-loader","title":"Why Does an npm Math Library Need an Encrypted Loader?","subtitle":null,"summary":"SafeDep reverse-engineers a malicious npm math package: encrypted loader, trigger matrix, remote access payload, and indicators of compromise for defenders.","content_type":"blog_post","language":"en","canonical_url":"https://safedep.io/mathmain-encrypted-loader/","author":{"name":"SafeDep Team","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"SafeDep","url":"https://safedep.io/","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"Open Source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Engineering","slug":"engineering","url":"https://listedarticles.com/topics/engineering"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2385,"reading_minutes":10,"published_at":"2026-09-18T00:00:00.000Z","added_at":"2026-09-22T00:21:21.311Z","updated_at":"2026-09-22T00:21:21.311Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/why-does-an-npm-math-library-need-an-encrypted-loader","markdown_url":"https://listedarticles.com/articles/why-does-an-npm-math-library-need-an-encrypted-loader.md","example":false,"citation":"SafeDep Team, SafeDep. \"Why Does an npm Math Library Need an Encrypted Loader?.\" 18 Sept 2026. https://safedep.io/mathmain-encrypted-loader/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://safedep.io/mathmain-encrypted-loader/"},"body_markdown":"# Why Does an npm Math Library Need an Encrypted Loader?\n\nOn this page\n\nWe 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.\n\nWe 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.\n\n## A solver calls a type check\n\nNear 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()`:\n\nHere, `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.\n\nWe 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:\n\n`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()`.\n\nWe 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.\n\n## The matrix data becomes a password\n\nIn `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):\n\nThe 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.\n\nFor 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.\n\nThe 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.\n\n## The loader writes and runs the file\n\nThe `event()` helper decrypts the file, writes the result to disk, and returns the output path:\n\nIf 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.\n\nThree added files contain base64 data instead of normal JavaScript:\n\n| Path under `lib/cjs/utils/` | Size of ciphertext in bytes | \n|---|---|\n| `graph.js` | 20,918 | \n| `fraction.js` | 9,084 | \n| `bignumber/type.js` | 1,179,416 | \n\nWe 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.\n\nWe 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.\n\n## The same loader appears in two more packages\n\nWe 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.\n\nThe 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.\n\nOn 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`.\n\nOn 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.\n\n| Package | Reported downloads | \n|---|---|\n| `mathmain` | 605,157 | \n| `mathsbase` | 1,923,059 | \n| `math-universe` | 569,730 | \n\nnpm also reported zero downloads across the entire registry for September 17. 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.\n\n## The loader is not in the GitHub source\n\nThe `mathsbase` and `math-universe` packages each link to a public GitHub repository. We read both. Neither repository holds the loader:\n\n| Repository | Reviewed commit | \n|---|---|\n| `github[.]com/tinystar8/mathsbase` | `560d97e66140dbf817e04284a7a0c58757d1202e` | \n| `github[.]com/mathubio/math-universe` | `da99dd46501c75ba6102a51ef60ebb922174da32` | \n\nThe public `math-universe` source ends its solver like this:\n\nThe 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.\n\nWe 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.\n\n## Our first attempts did not find the password\n\nWe tested 16,922 possible passwords against the encrypted filename. Some came from matrices with zero diagonal entries, which our earlier search had left out.\n\nIn 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.\n\nSome passwords appeared in both searches.\n\nWe 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.\n\n## Cracking the encryption\n\nThe password is the JSON form of a matrix lower factor `L`. JFrog first reported the input that produces it. We then reproduced the result against the `mathmain` files.\n\nThe recovered trigger is a 3 by 3 Pascal matrix.\n\nA 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.\n\nWe confirmed this against the real files. The password is the JSON form of `L`.\n\nThe 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.\n\n## Payload analysis\n\nThe 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.\n\nThe 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.\n\nThe decrypted `bignumber/type.js` is a copy of the `ethers` library. The file carries the marker `ethers/5.7.2` and the `JsonRpcProvider` and `secp256k1` symbols. The implant uses this library to read the smart contract.\n\nThe decrypted `fraction.js` is the command agent. It decrypts a Slack bot token and a channel id at run time. It needs a `CHAT_PASSWORD` value from the environment. Without that value, the agent exits. It polls the Slack `conversations.history` endpoint every 10 seconds. It checks whether a message comes from the operator. It then runs the message content as a shell command with `execSync` or `spawn`.\n\nThe package does not store the operator’s commands. The agent reads them from Slack at run time. So the live commands stay off the registry and out of our copy.\n\nTogether, these files let an operator run shell commands on any host that triggers the loader. The commands travel over a public chat service and a blockchain test network. The package is a remote access implant.\n\n## How the trigger reaches a victim\n\nThe loader stays inert until a caller runs the solver with an input that produces the factor `L`. A normal import does not reach the added code. A normal `lusolve()` call with other data fails the authentication check and writes nothing. The password is the JSON form of `L`, so the Pascal matrix is not the only trigger. A caller can pass `L` as the coefficient matrix, because its own lower factor is `L` again. A caller can also supply `L` through the object form of `lusolve()`. Both inputs give the same password.\n\nThe attack works in two parts. One package holds the encrypted payload. A second package, or a compromised caller, runs the solver with the trigger matrix. The math library looks like a popular, trusted dependency. The caller supplies the key.\n\nWe did not find that caller in public code. Our GitHub and dependency searches returned no project that calls the solver with the trigger. Private code and removed projects stay outside that search.\n\n## Investigation Timeline\n\nAll times are in Coordinated Universal Time (UTC). We took publication times from the npm registry metadata.\n\n| Date and time | Event | \n|---|---|\n| August 26, 2026, 08:14 | `[email protected]` published. No matching loader found. | \n| August 27, 2026, 07:44 | `[email protected]` published with the loader and encrypted files. | \n| September 15, 2026, 03:38 | `[email protected]` published without the matching loader. | \n| September 16, 2026, 12:06 | `[email protected]` published with the loader. | \n| September 16, 2026, 13:39 | `[email protected]` published with the loader. | \n| September 16, 2026, 14:14 | `[email protected]` published with the loader, after`1.0.2` . | \n| September 17, 2026, 06:53 | `[email protected]` published with matching files. | \n| September 17, 2026 | Source review, consumer searches, and further decryption attempts completed. No plaintext recovered. | \n| September 19, 2026 | npm reported 605,157 downloads of `mathmain` for September 18. | \n| September 21, 2026 | JFrog published its analysis. It recovered the trigger matrix. | \n| September 21, 2026 | SafeDep reproduced the decryption and read the `mathmain` payload. | \n\n## Indicators of compromise\n\nUse these indicators to find the packages, the loader, and the decrypted implant. A match on a package or a hash does not prove the code ran on a host. A caller must first trigger the loader.\n\n### Malicious packages\n\nSafeDep analyzed the loader in these versions. The archive SHA-256 comes from the npm tarball.\n\n| npm package version | Archive SHA-256 | \n|---|---|\n| `[email protected]` | `1723a0df210ac61281a504f3a07ec3605d20151631e0635cc344cacc71019135` | \n| `[email protected]` | `03e13cdedd9c33e6fed25092b1ec7dcf11cc5962c0fbb6b3e90ba95bfec1b034` | \n| `[email protected]` | `7e5e1bcdc6a7b0e3437269a236b49ef2be4f77081c7de5130d503c103fd6be69` | \n| `[email protected]` | `bfe772e7ee044fd6f0bdf53e83f44aad7c9ee1925baf0cf4d884a312aa9ba50e` | \n| `[email protected]` | `4eb1d59df7dc80dbe3ec154481e61e8824422037092c188f0cc146b543615a66` | \n\n### The trigger and the password\n\nA caller activates the loader with one matrix. The password is the JSON form of that matrix LU lower factor.\n\n| Item | Value | \n|---|---|\n| Trigger matrix | `[[1,1,1],[1,2,3],[1,3,6]]` | \n| Password | `[[1,0,0],[1,1,0],[1,0.5,1]]` | \n| Stage-1 blob | `IapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==` | \n\n### Loader files\n\nThese two file hashes match across all analyzed versions.\n\n| File | SHA-256 | \n|---|---|\n| `lib/cjs/utils/event.js` | `ab66c98e8ed5235feb963ec8845765f62f5f26b1c58c266c409767e53bcb5ccd` | \n| `lib/cjs/utils/is.js` | `5d9e952c51875d2b897eedc22b002b94ab21c8004d513bc99ce3a885f8a01dae` | \n\n### Encrypted payload files\n\nThe base64 blobs sit under `lib/cjs/utils/`. `[email protected]` and the three `math-universe` versions share one set. `[email protected]` ships a different `graph.js` blob.\n\n| File | SHA-256 | Deployment | \n|---|---|---|\n| `graph.js` | `ed9b078594393d09d91ee008366ca75e3017cca18c79af99c5b294c61db67f06` | A | \n| `fraction.js` | `09773ee7db70216b778b15cfcd94cb1df8963eddd4a47b801c96f986651699e6` | A | \n| `bignumber/type.js` | `ca4fe552da461fec5b51d5964d979699f06888499be442f209125853dff3e0e1` | A | \n| `graph.js` | `0aa46d32e4b479cc09f97cc66a1f12ca96b0497a7eb6ffc6b46ed3ef80e3c83b` | B | \n\n### Decrypted payload files\n\nThese hashes cover the plaintext we recovered from Deployment A. A host where the loader ran may hold files with these hashes on disk.\n\n| File | SHA-256 | \n|---|---|\n| `graph.js` | `1e0f09c84aaf573627c003ce0f086517c3ea980cbea02f8ff918b1cc0d7e0bbb` | \n| `fraction.js` | `6fd655d7196880fc5783f9dbb62b428baf220c2781970be54044376330be7af3` | \n| `bignumber/type.js` | `6b1ad71bc3765dd272ea2ac63c1ea6ed97091ba0067d0b3e2294e1b34177cb25` | \n\n### Command and control\n\nWe recovered these endpoints from the decrypted `graph.js` files. Deployment A covers `mathmain` and `math-universe`. Deployment B covers `[email protected]`. We redact the secret half of the two bot tokens.\n\n| Deployment | Type | Value | \n|---|---|---|\n| A | Smart contract | `0xac0bfC4C48A679b667732128278EACBA1c191894` (Base Sepolia) | \n| A | RPC (Infura) | `base-sepolia.infura[.]io/v3/dc7257d09fab42eca2c354c32fec1938` | \n| A | RPC (Alchemy) | `base-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI` | \n| A | Telegram bot | `8961878831` (token redacted) | \n| A | Slack bot | `xoxb-11307403103236-11289767127959-...` (secret redacted) | \n| B | Smart contract | `0xE390863Dac96a7118C71227C2b099B50cF602D31` (Ethereum Sepolia) | \n| B | RPC (Alchemy) | `eth-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI` | \n| B | Slack bot | `xoxb-11307403103236-11289767127959-...` (secret redacted) | \n\nBoth deployments use the same Alchemy project key `D2-TbkB2m05WXSnSDOCDI`. This key ties the two deployments to one operator.\n\n- npm\n- supply-chain\n- package-analysis\n\n### Author\n\n#### SafeDep Team\n\nsafedep.io\n\n### Share\n\n## The Latest from SafeDep blogs\n\nFollow for the latest updates and insights on open source security & engineering\n\n## OpenAI Agents Turned RubyGems Into a Scraping Proxy\n\nBetween 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...\n\n## Deep-Live-Cam Supply Chain Attack: Technical Analysis\n\nA malicious dependency in Deep-Live-Cam loads a clipboard hijacker. We trace the installation trigger, Telegraph delivery, and Windows and macOS persistence.\n\n## The Agentic IDE Extension Blind Spot\n\nCursor 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...\n\n## Introducing SafeDep Threat Intel\n\nSafeDep 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.\n\n## Ship Code.\n\n## Not Malware.\n\nStart free with open source tools on your machine. Scale to a unified platform for your organization.","body_html":"<h1 id=\"why-does-an-npm-math-library-need-an-encrypted-loader\">Why Does an npm Math Library Need an Encrypted Loader?</h1>\n<p>On this page</p>\n<p>We found a remote access implant hidden inside <code>[email protected]</code>, an npm package that copies the popular <code>mathjs</code> 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.</p>\n<p>We started with a SafeDep analysis of <code>mathmain</code> on September 17, 2026. The package looked like a copy of <code>mathjs</code> with a different name and obfuscated code. One added call in the solver led us to the loader.</p>\n<h2 id=\"a-solver-calls-a-type-check\">A solver calls a type check</h2>\n<p>Near the end of <code>lusolve()</code>, 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 <code>removeSolveValidation()</code>:</p>\n<p>Here, <code>l</code> holds the lower triangular matrix and <code>x</code> holds the result. The solver returns <code>x</code> unchanged. It assigns the extra call’s return value to <code>q</code>, but does not use <code>q</code> again.</p>\n<p>We followed <code>removeSolveValidation()</code> to <code>isGraph(x)</code> in <code>lib/cjs/utils/is.js</code>. This file contains checks such as <code>isMatrix</code> and <code>isNumber</code>. The added <code>isGraph()</code> function decrypts and loads code:</p>\n<p><code>isGraph()</code> 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 <code>event()</code>, and loads the returned path with <code>require()</code>.</p>\n<p>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.</p>\n<h2 id=\"the-matrix-data-becomes-a-password\">The matrix data becomes a password</h2>\n<p>In <code>lib/cjs/utils/event.js</code>, we found the decryption functions. They use <code>scrypt</code> 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):</p>\n<p>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.</p>\n<p>For calls through the solver, the password is <code>JSON.stringify(L._data)</code>. A caller can supply <code>L</code> through the object form of <code>lusolve()</code>. So the caller must pass matrix data that produces the correct password. We found no password stored in the visible loader.</p>\n<p>The encrypted filename has eight bytes of ciphertext. We suspect it names <code>graph.js</code>, a file beside the loader whose name also takes eight bytes. We could not confirm this because we did not recover the password.</p>\n<h2 id=\"the-loader-writes-and-runs-the-file\">The loader writes and runs the file</h2>\n<p>The <code>event()</code> helper decrypts the file, writes the result to disk, and returns the output path:</p>\n<p>If the filename has no <code>enc_</code> prefix, the helper overwrites the encrypted file with the decrypted code. The <code>require()</code> call in <code>isGraph()</code> then loads it. That code would run with the same permissions as the Node.js process.</p>\n<p>Three added files contain base64 data instead of normal JavaScript:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Path under <code>lib/cjs/utils/</code></th><th>Size of ciphertext in bytes</th></tr></thead><tbody><tr><td><code>graph.js</code></td><td>20,918</td></tr><tr><td><code>fraction.js</code></td><td>9,084</td></tr><tr><td><code>bignumber/type.js</code></td><td>1,179,416</td></tr></tbody></table></div>\n<p>We found no reference to the last two files in the visible loader. The decrypted <code>graph.js</code> loads them as later stages, as the payload section shows.</p>\n<p>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, <code>validEvent()</code> fails its authentication check before the helper writes any file.</p>\n<h2 id=\"the-same-loader-appears-in-two-more-packages\">The same loader appears in two more packages</h2>\n<p>We searched the npm registry and found two more packages: <code>mathsbase</code> and <code>math-universe</code>. Across five versions, we found identical loader files, trigger code, solver changes, and two large encrypted files.</p>\n<p>The encrypted <code>graph.js</code> in <code>[email protected]</code> 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.</p>\n<p>On September 17, npm served <code>[email protected]</code> as the default release. That version did not contain this loader. Checking only the default version would have missed the code in <code>1.0.1</code>.</p>\n<p>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.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Package</th><th>Reported downloads</th></tr></thead><tbody><tr><td><code>mathmain</code></td><td>605,157</td></tr><tr><td><code>mathsbase</code></td><td>1,923,059</td></tr><tr><td><code>math-universe</code></td><td>569,730</td></tr></tbody></table></div>\n<p>npm also reported zero downloads across the entire registry for September 17. This makes the earlier zero for <code>mathmain</code> 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.</p>\n<h2 id=\"the-loader-is-not-in-the-github-source\">The loader is not in the GitHub source</h2>\n<p>The <code>mathsbase</code> and <code>math-universe</code> packages each link to a public GitHub repository. We read both. Neither repository holds the loader:</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Repository</th><th>Reviewed commit</th></tr></thead><tbody><tr><td><code>github[.]com/tinystar8/mathsbase</code></td><td><code>560d97e66140dbf817e04284a7a0c58757d1202e</code></td></tr><tr><td><code>github[.]com/mathubio/math-universe</code></td><td><code>da99dd46501c75ba6102a51ef60ebb922174da32</code></td></tr></tbody></table></div>\n<p>The public <code>math-universe</code> source ends its solver like this:</p>\n<p>The extra <code>removeSolveValidation()</code> 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.</p>\n<p>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.</p>\n<h2 id=\"our-first-attempts-did-not-find-the-password\">Our first attempts did not find the password</h2>\n<p>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.</p>\n<p>In a second search, we tested 533 possible passwords against all five distinct encrypted blobs, including the older <code>graph.js</code>. We tried common passwords and numeric arrays from the solver’s tests. Neither search found a password that passed the authentication check.</p>\n<p>Some passwords appeared in both searches.</p>\n<p>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.</p>\n<h2 id=\"cracking-the-encryption\">Cracking the encryption</h2>\n<p>The password is the JSON form of a matrix lower factor <code>L</code>. JFrog first reported the input that produces it. We then reproduced the result against the <code>mathmain</code> files.</p>\n<p>The recovered trigger is a 3 by 3 Pascal matrix.</p>\n<p>A caller passes <code>A</code> to <code>lusolve()</code>. The solver runs an LU decomposition of <code>A</code>. The lower triangular factor <code>L</code> becomes <code>[[1, 0, 0], [1, 1, 0], [1, 0.5, 1]]</code>. The loader turns <code>L</code> into a JSON string. That string is the password.</p>\n<p>We confirmed this against the real files. The password is the JSON form of <code>L</code>.</p>\n<p>The password decrypted the filename to <code>graph.js</code>. It also decrypted the three payload files in <code>mathmain</code> and in <code>math-universe</code>. The <code>mathmain</code> payload matches the <code>math-universe</code> payload byte for byte. <code>[email protected]</code> uses the same password with different encrypted data. So the same password unlocks the whole family.</p>\n<h2 id=\"payload-analysis\">Payload analysis</h2>\n<p>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.</p>\n<p>The decrypted <code>graph.js</code> is the first stage. The loader runs it with <code>require()</code> after decryption. It reads host data with <code>os</code> and <code>fs</code>. It generates an X25519 key pair with the Node <code>crypto</code> module through <code>generateKeyPairSync</code> and <code>diffieHellman</code>. It runs shell commands with <code>child_process</code> through <code>spawn</code> and <code>execSync</code>. It reads a smart contract on the Base Sepolia test network with a bundled copy of <code>ethers</code>. It reports to Slack <code>chat.postMessage</code> and to <code>api.telegram.org</code>. It then loads <code>bignumber/type.js</code> and <code>fraction.js</code> as later stages. The network details sit in the file as base64 text.</p>\n<p>The decrypted <code>bignumber/type.js</code> is a copy of the <code>ethers</code> library. The file carries the marker <code>ethers/5.7.2</code> and the <code>JsonRpcProvider</code> and <code>secp256k1</code> symbols. The implant uses this library to read the smart contract.</p>\n<p>The decrypted <code>fraction.js</code> is the command agent. It decrypts a Slack bot token and a channel id at run time. It needs a <code>CHAT_PASSWORD</code> value from the environment. Without that value, the agent exits. It polls the Slack <code>conversations.history</code> endpoint every 10 seconds. It checks whether a message comes from the operator. It then runs the message content as a shell command with <code>execSync</code> or <code>spawn</code>.</p>\n<p>The package does not store the operator’s commands. The agent reads them from Slack at run time. So the live commands stay off the registry and out of our copy.</p>\n<p>Together, these files let an operator run shell commands on any host that triggers the loader. The commands travel over a public chat service and a blockchain test network. The package is a remote access implant.</p>\n<h2 id=\"how-the-trigger-reaches-a-victim\">How the trigger reaches a victim</h2>\n<p>The loader stays inert until a caller runs the solver with an input that produces the factor <code>L</code>. A normal import does not reach the added code. A normal <code>lusolve()</code> call with other data fails the authentication check and writes nothing. The password is the JSON form of <code>L</code>, so the Pascal matrix is not the only trigger. A caller can pass <code>L</code> as the coefficient matrix, because its own lower factor is <code>L</code> again. A caller can also supply <code>L</code> through the object form of <code>lusolve()</code>. Both inputs give the same password.</p>\n<p>The attack works in two parts. One package holds the encrypted payload. A second package, or a compromised caller, runs the solver with the trigger matrix. The math library looks like a popular, trusted dependency. The caller supplies the key.</p>\n<p>We did not find that caller in public code. Our GitHub and dependency searches returned no project that calls the solver with the trigger. Private code and removed projects stay outside that search.</p>\n<h2 id=\"investigation-timeline\">Investigation Timeline</h2>\n<p>All times are in Coordinated Universal Time (UTC). We took publication times from the npm registry metadata.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Date and time</th><th>Event</th></tr></thead><tbody><tr><td>August 26, 2026, 08:14</td><td><code>[email protected]</code> published. No matching loader found.</td></tr><tr><td>August 27, 2026, 07:44</td><td><code>[email protected]</code> published with the loader and encrypted files.</td></tr><tr><td>September 15, 2026, 03:38</td><td><code>[email protected]</code> published without the matching loader.</td></tr><tr><td>September 16, 2026, 12:06</td><td><code>[email protected]</code> published with the loader.</td></tr><tr><td>September 16, 2026, 13:39</td><td><code>[email protected]</code> published with the loader.</td></tr><tr><td>September 16, 2026, 14:14</td><td><code>[email protected]</code> published with the loader, after<code>1.0.2</code> .</td></tr><tr><td>September 17, 2026, 06:53</td><td><code>[email protected]</code> published with matching files.</td></tr><tr><td>September 17, 2026</td><td>Source review, consumer searches, and further decryption attempts completed. No plaintext recovered.</td></tr><tr><td>September 19, 2026</td><td>npm reported 605,157 downloads of <code>mathmain</code> for September 18.</td></tr><tr><td>September 21, 2026</td><td>JFrog published its analysis. It recovered the trigger matrix.</td></tr><tr><td>September 21, 2026</td><td>SafeDep reproduced the decryption and read the <code>mathmain</code> payload.</td></tr></tbody></table></div>\n<h2 id=\"indicators-of-compromise\">Indicators of compromise</h2>\n<p>Use these indicators to find the packages, the loader, and the decrypted implant. A match on a package or a hash does not prove the code ran on a host. A caller must first trigger the loader.</p>\n<h3 id=\"malicious-packages\">Malicious packages</h3>\n<p>SafeDep analyzed the loader in these versions. The archive SHA-256 comes from the npm tarball.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>npm package version</th><th>Archive SHA-256</th></tr></thead><tbody><tr><td><code>[email protected]</code></td><td><code>1723a0df210ac61281a504f3a07ec3605d20151631e0635cc344cacc71019135</code></td></tr><tr><td><code>[email protected]</code></td><td><code>03e13cdedd9c33e6fed25092b1ec7dcf11cc5962c0fbb6b3e90ba95bfec1b034</code></td></tr><tr><td><code>[email protected]</code></td><td><code>7e5e1bcdc6a7b0e3437269a236b49ef2be4f77081c7de5130d503c103fd6be69</code></td></tr><tr><td><code>[email protected]</code></td><td><code>bfe772e7ee044fd6f0bdf53e83f44aad7c9ee1925baf0cf4d884a312aa9ba50e</code></td></tr><tr><td><code>[email protected]</code></td><td><code>4eb1d59df7dc80dbe3ec154481e61e8824422037092c188f0cc146b543615a66</code></td></tr></tbody></table></div>\n<h3 id=\"the-trigger-and-the-password\">The trigger and the password</h3>\n<p>A caller activates the loader with one matrix. The password is the JSON form of that matrix LU lower factor.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Item</th><th>Value</th></tr></thead><tbody><tr><td>Trigger matrix</td><td><code>[[1,1,1],[1,2,3],[1,3,6]]</code></td></tr><tr><td>Password</td><td><code>[[1,0,0],[1,1,0],[1,0.5,1]]</code></td></tr><tr><td>Stage-1 blob</td><td><code>IapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==</code></td></tr></tbody></table></div>\n<h3 id=\"loader-files\">Loader files</h3>\n<p>These two file hashes match across all analyzed versions.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>File</th><th>SHA-256</th></tr></thead><tbody><tr><td><code>lib/cjs/utils/event.js</code></td><td><code>ab66c98e8ed5235feb963ec8845765f62f5f26b1c58c266c409767e53bcb5ccd</code></td></tr><tr><td><code>lib/cjs/utils/is.js</code></td><td><code>5d9e952c51875d2b897eedc22b002b94ab21c8004d513bc99ce3a885f8a01dae</code></td></tr></tbody></table></div>\n<h3 id=\"encrypted-payload-files\">Encrypted payload files</h3>\n<p>The base64 blobs sit under <code>lib/cjs/utils/</code>. <code>[email protected]</code> and the three <code>math-universe</code> versions share one set. <code>[email protected]</code> ships a different <code>graph.js</code> blob.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>File</th><th>SHA-256</th><th>Deployment</th></tr></thead><tbody><tr><td><code>graph.js</code></td><td><code>ed9b078594393d09d91ee008366ca75e3017cca18c79af99c5b294c61db67f06</code></td><td>A</td></tr><tr><td><code>fraction.js</code></td><td><code>09773ee7db70216b778b15cfcd94cb1df8963eddd4a47b801c96f986651699e6</code></td><td>A</td></tr><tr><td><code>bignumber/type.js</code></td><td><code>ca4fe552da461fec5b51d5964d979699f06888499be442f209125853dff3e0e1</code></td><td>A</td></tr><tr><td><code>graph.js</code></td><td><code>0aa46d32e4b479cc09f97cc66a1f12ca96b0497a7eb6ffc6b46ed3ef80e3c83b</code></td><td>B</td></tr></tbody></table></div>\n<h3 id=\"decrypted-payload-files\">Decrypted payload files</h3>\n<p>These hashes cover the plaintext we recovered from Deployment A. A host where the loader ran may hold files with these hashes on disk.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>File</th><th>SHA-256</th></tr></thead><tbody><tr><td><code>graph.js</code></td><td><code>1e0f09c84aaf573627c003ce0f086517c3ea980cbea02f8ff918b1cc0d7e0bbb</code></td></tr><tr><td><code>fraction.js</code></td><td><code>6fd655d7196880fc5783f9dbb62b428baf220c2781970be54044376330be7af3</code></td></tr><tr><td><code>bignumber/type.js</code></td><td><code>6b1ad71bc3765dd272ea2ac63c1ea6ed97091ba0067d0b3e2294e1b34177cb25</code></td></tr></tbody></table></div>\n<h3 id=\"command-and-control\">Command and control</h3>\n<p>We recovered these endpoints from the decrypted <code>graph.js</code> files. Deployment A covers <code>mathmain</code> and <code>math-universe</code>. Deployment B covers <code>[email protected]</code>. We redact the secret half of the two bot tokens.</p>\n<div class=\"table-wrap\"><table><thead><tr><th>Deployment</th><th>Type</th><th>Value</th></tr></thead><tbody><tr><td>A</td><td>Smart contract</td><td><code>0xac0bfC4C48A679b667732128278EACBA1c191894</code> (Base Sepolia)</td></tr><tr><td>A</td><td>RPC (Infura)</td><td><code>base-sepolia.infura[.]io/v3/dc7257d09fab42eca2c354c32fec1938</code></td></tr><tr><td>A</td><td>RPC (Alchemy)</td><td><code>base-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI</code></td></tr><tr><td>A</td><td>Telegram bot</td><td><code>8961878831</code> (token redacted)</td></tr><tr><td>A</td><td>Slack bot</td><td><code>xoxb-11307403103236-11289767127959-...</code> (secret redacted)</td></tr><tr><td>B</td><td>Smart contract</td><td><code>0xE390863Dac96a7118C71227C2b099B50cF602D31</code> (Ethereum Sepolia)</td></tr><tr><td>B</td><td>RPC (Alchemy)</td><td><code>eth-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI</code></td></tr><tr><td>B</td><td>Slack bot</td><td><code>xoxb-11307403103236-11289767127959-...</code> (secret redacted)</td></tr></tbody></table></div>\n<p>Both deployments use the same Alchemy project key <code>D2-TbkB2m05WXSnSDOCDI</code>. This key ties the two deployments to one operator.</p>\n<ul><li>npm</li><li>supply-chain</li><li>package-analysis</li></ul>\n<h3 id=\"author\">Author</h3>\n<h4 id=\"safedep-team\">SafeDep Team</h4>\n<p>safedep.io</p>\n<h3 id=\"share\">Share</h3>\n<h2 id=\"the-latest-from-safedep-blogs\">The Latest from SafeDep blogs</h2>\n<p>Follow for the latest updates and insights on open source security &amp; engineering</p>\n<h2 id=\"openai-agents-turned-rubygems-into-a-scraping-proxy\">OpenAI Agents Turned RubyGems Into a Scraping Proxy</h2>\n<p>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&#39;s servers, then shipped...</p>\n<h2 id=\"deep-live-cam-supply-chain-attack-technical-analysis\">Deep-Live-Cam Supply Chain Attack: Technical Analysis</h2>\n<p>A malicious dependency in Deep-Live-Cam loads a clipboard hijacker. We trace the installation trigger, Telegraph delivery, and Windows and macOS persistence.</p>\n<h2 id=\"the-agentic-ide-extension-blind-spot\">The Agentic IDE Extension Blind Spot</h2>\n<p>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...</p>\n<h2 id=\"introducing-safedep-threat-intel\">Introducing SafeDep Threat Intel</h2>\n<p>SafeDep Threat Intel gives SOC and cyber defense teams the malicious package intelligence behind SafeDep&#39;s platform, to query or to push into the tools they already run.</p>\n<h2 id=\"ship-code\">Ship Code.</h2>\n<h2 id=\"not-malware\">Not Malware.</h2>\n<p>Start free with open source tools on your machine. Scale to a unified platform for your organization.</p>","headings":[{"level":1,"text":"Why Does an npm Math Library Need an Encrypted Loader?","id":"why-does-an-npm-math-library-need-an-encrypted-loader"},{"level":2,"text":"A solver calls a type check","id":"a-solver-calls-a-type-check"},{"level":2,"text":"The matrix data becomes a password","id":"the-matrix-data-becomes-a-password"},{"level":2,"text":"The loader writes and runs the file","id":"the-loader-writes-and-runs-the-file"},{"level":2,"text":"The same loader appears in two more packages","id":"the-same-loader-appears-in-two-more-packages"},{"level":2,"text":"The loader is not in the GitHub source","id":"the-loader-is-not-in-the-github-source"},{"level":2,"text":"Our first attempts did not find the password","id":"our-first-attempts-did-not-find-the-password"},{"level":2,"text":"Cracking the encryption","id":"cracking-the-encryption"},{"level":2,"text":"Payload analysis","id":"payload-analysis"},{"level":2,"text":"How the trigger reaches a victim","id":"how-the-trigger-reaches-a-victim"},{"level":2,"text":"Investigation Timeline","id":"investigation-timeline"},{"level":2,"text":"Indicators of compromise","id":"indicators-of-compromise"},{"level":3,"text":"Malicious packages","id":"malicious-packages"},{"level":3,"text":"The trigger and the password","id":"the-trigger-and-the-password"},{"level":3,"text":"Loader files","id":"loader-files"},{"level":3,"text":"Encrypted payload files","id":"encrypted-payload-files"},{"level":3,"text":"Decrypted payload files","id":"decrypted-payload-files"},{"level":3,"text":"Command and control","id":"command-and-control"},{"level":3,"text":"Author","id":"author"},{"level":3,"text":"Share","id":"share"},{"level":2,"text":"The Latest from SafeDep blogs","id":"the-latest-from-safedep-blogs"},{"level":2,"text":"OpenAI Agents Turned RubyGems Into a Scraping Proxy","id":"openai-agents-turned-rubygems-into-a-scraping-proxy"},{"level":2,"text":"Deep-Live-Cam Supply Chain Attack: Technical Analysis","id":"deep-live-cam-supply-chain-attack-technical-analysis"},{"level":2,"text":"The Agentic IDE Extension Blind Spot","id":"the-agentic-ide-extension-blind-spot"},{"level":2,"text":"Introducing SafeDep Threat Intel","id":"introducing-safedep-threat-intel"},{"level":2,"text":"Ship Code.","id":"ship-code"},{"level":2,"text":"Not Malware.","id":"not-malware"}]}}