← Blog

19-21 mars 2026. CanisterWorm, un ver auto-propagateur npm, compromet 64+ packages via des tokens npm voles. Le vecteur initial : la compromission de Trivy (Aqua Security) par le groupe TeamPCP. 75 des 76 tags de trivy-action ont ete force-pushes avec un infostealer. 135+ artefacts malveillants identifies au total.

MUAD'DIB a detecte 7 packages compromis. Le verdict etait DORMANT SUSPECT, pas MALICIOUS. Cet article explique pourquoi, et ce qu'on a corrige.

L'attaque

CanisterWorm est le premier ver npm documente qui utilise un C2 sur la blockchain ICP (Internet Computer Protocol). Le payload vole des tokens npm, puis les utilise pour publier des versions infectees des packages du developpeur compromis. Le ver se propage automatiquement.

Aikido a fait la premiere detection publique le 20 mars a 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 execute un time.sleep(300) avant de contacter le canister C2 sur la blockchain ICP. Cinq minutes.

Ce que MUAD'DIB a detecte

7 packages compromis detectes entre le 20 mars 23:49 UTC et le 21 mars 00:27 UTC, tous confirmes par JFrog XRAY :

PackageJFrog XRAYDetection
@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 remonte 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 ajoute 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 ete envoyes automatiquement.

Le probleme : sandbox score 0

Le verdict final etait 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 cumules). Le malware ne s'active jamais pendant l'analyse.

Le preload.js du sandbox patche les timers Node.js : setTimeout est force a 0, Date.now() est decale. Mais le payload Python est lance 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.

Resultat : sandbox score 0/100 CLEAN. L'analyse statique a score 87, mais sans confirmation sandbox, le verdict reste DORMANT SUSPECT.

La solution : libfaketime (v2.10.7)

libfaketime est une bibliotheque 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 complete en 0.3 secondes reelles.

Architecture

Deux niveaux complementaires :

CoucheMecanismeCible
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 (reseau, fichiers, env vars) et d'accelerer les timers JavaScript. libfaketime gere l'acceleration au niveau systeme pour les processus enfants.

Implementation 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 eviter la double acceleration :

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 (protege 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-acceleration : si MUADDIB_FAKETIME_ACTIVE=1, alors TIME_OFFSET=0. libfaketime gere deja l'offset au niveau C, ajouter l'offset JavaScript causerait un decalage de +144h au lieu de +72h.
  2. Suppression des variables sandbox : LD_PRELOAD, FAKETIME, DONT_FAKE_MONOTONIC, FAKETIME_NO_CACHE sont supprimes de process.env immediatement apres le chargement. libfaketime est deja charge en memoire, les variables ne sont plus necessaires.
  3. Proxy traps enrichis : le Proxy process.env existant recoit 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 detecter LD_PRELOAD. Le readFileSync intercepte ce chemin et filtre les variables sandbox du contenu.

Detection statique : SHELL-019

En complement du sandbox, une nouvelle regle statique detecte 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' }

Regle MUADDIB-SHELL-019, MITRE T1497.003 (Time Based Evasion Checks). Le seuil de 100 secondes evite les faux positifs sur les courts delais legitimes.

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 accelere le temps systeme x1000. Le time.sleep(300) Python se termine en 0.3 secondes, le payload C2 se declenche, et tcpdump/strace capturent la connexion reseau.

Limitations

LangageFonctionne ?Detail
Python (CPython)Ouitime.sleep() accelere. time.monotonic() pas affecte
Bash/shOuisleep 300 accelere
GoNonLink statique libc, bypass LD_PRELOAD
RustNonIdem
Java/JVMRisqueHangs possibles sans DONT_FAKE_MONOTONIC

Go et Rust linkent la libc statiquement par defaut. LD_PRELOAD ne peut pas intercepter leurs appels systeme. C'est une limitation connue et documentee. Pour les malwares Go/Rust, la detection statique reste la seule ligne de defense.

Metriques v2.10.7

MetriqueAvantApres
Regles162 (157+5)163 (158+5)
Tests26432669 (+26)
TPR93.9%93.9%
FPR curated11.0%11.0%

Zero regression. Zero faux positif ajoute. Le seuil de 100 secondes pour SHELL-019 et la restriction aux scripts shell (.sh, shebang) eliminent les cas benins.

Lecon

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 meme que l'interpreteur Python ne demarre.

CanisterWorm illustre un pattern recurrent : les attaquants utilisent volontairement un langage different pour le payload (Python dans un package npm) precisement pour echapper aux sandboxes qui ne patchent que le runtime principal. La detection doit etre polyglotte.

Et soyons honnetes : MUAD'DIB a detecte 7 packages sur 64+ compromis, avec un verdict DORMANT SUSPECT et pas MALICIOUS. L'analyse statique a fonctionne. La sandbox non. C'est exactement le type de gap que libfaketime comble.

Sources