原文地址:https://blog.cloudflare.com/incident-report-on-memory-leak-caused-by-cloudflare-parser-bug/
原作者:John Graham-Cumming
写于 23 Feb 2017

译者:驱蚊器喵#ΦωΦ


上周五,来自谷歌 Project Zero 安全研究团队 的 Tavis Ormandy 联系 了 Cloudflare,报告了在我们边缘服务器上存在的安全问题。他看到一些损坏(corrupted)的网页通过 Cloudflare 运行的 HTTP 请求返回。

事实表明,在一些不寻常的情况下,我们的边缘服务器在超过缓冲区运行,并返回包含私有信息的内存,例如 HTTP cookie,身份验证的 token,HTTP POST 正文和其他敏感数据。其中一些数据已被搜索引擎缓存,下面我将详述细节。

为避免误会,Cloudflare 客户的 SSL 私钥并未泄露。Cloudflare 始终在 NGINX 的隔离实例端结束SSL连接,该实例未受此次漏洞的影响。

我们很快发现了问题,并关闭了三个 Cloudflare 的小功能(电子邮件地址的混淆服务器端剔除自动HTTPS重写),它们都使用了相同的会导致泄漏的 HTML 链解析器。此时,不可能再在 HTTP 的响应中返回内存了。

由于这种错误的严重性,来自旧金山和伦敦的软件工程、信息安全、运营的员工组成一个跨职能团队,以便充分了解潜在的原因,了解内存泄漏的影响,与谷歌和其他搜索引擎协作,删除所有 HTTP 响应的缓存。

拥有一支全球团队意味着,需要每隔12小时在办公室之间交付工作,使员工能够全天24小时的解决问题。这个团队连续不断的工作,确保此错误及其后果能够完全处理。作为服务的一个优点是,从报告错误到修复的时间固定在几分钟到几小时,而不是几个月。为这样的 bug 部署修复的行业标准时间通常为三个月; 我们在7小时内全局完成,初始的缓解时间为47分钟。

该错误是严重的,因为泄露的内存可能包含私人信息,并且这些信息已被搜索引擎缓存。我们还没有发现任何有关该漏洞的恶意攻击证据或是存在的其他报告。

影响最大的时段是2月13日和2月18日,每 3,300,000 个 通过 Cloudflare 的 HTTP 请求中约有1个可能导致内存泄漏(约占请求的0.00003%)。

我们很感激这是由世界顶级的一个安全研究团队发现并向我们报告的。

这篇博文相当长,但是,如同我们的传统一样,我们更倾向于对我们服务中出现问题的技术细节保持开放。

即时解析和修改 HTML

Cloudflare 的许多服务都依赖于解析和修改 HTML 页面,因为这些服务通过我们的边缘服务器。例如,我们可以插入 Google Analytics 代码,安全地重写 http:// 链接到 https://,从恶意访问的机器人中排除部分页面,混淆电子邮件地址,启用 AMP 等等,这些功能的原理是修改页面的 HTML 。

要修改页面,我们需要读取并解析 HTML ,查找需要更改的元素。从 Cloudflare 早期开始,我们就使用了使用 Ragel 编写的解析器。单个 .rl 文件包含一个 HTML 解析器,用于 Cloudflare 执行的所有动态 HTML 即时修改。

大约一年前,我们认为基于 Ragel 的解析器变得太复杂以至于无法维护,我们开始编写一个新的解析器,名为 cf-html ,来替换它。这种流解析器可以与 HTML5 一起正常工作,并且更快,更容易维护。

我们首先将这个新的解析器用于自动化 HTTP 重写功能,并且慢慢地将旧的 Ragel 解析器的功能迁移到 cf-html。

cf-html 和旧的 Ragel 解析器都是作为 NGINX 模块编译到 NGINX 构建中的实现的。这些 NGINX 过滤器模块解析包含 HTML 响应的缓冲区(内存块),根据需要进行修改,并将缓冲区传递到下一个过滤器。

为避免误会:这次 bug 错不在 Ragel 本身,而是因为 Cloudflare 对 Ragel 的使用不当。这是我们的错误而不是 Ragel 的错。

事实证明,导致内存泄漏的底层错误已存在我们基于 Ragel 的解析器中多年,但由于内部 NGINX 缓冲区的使用方式,没有内存泄露。引入cf-html 巧妙地改变了缓冲,即使在 cf-html 本身没有问题的情况下也能导致泄漏。

一旦我们知道错误是由启用 cf-html 引起的(但在我们知道原因之前),我们禁用了导致它被使用的三个功能。Cloudflare 发布的每个功能都有一个相应的功能标志,我们将其称为“全局终止(global kill)”。我们在收到问题的详细信息的47分钟后,激活了“电子邮件混淆”功能的全局终止,并且3小时5分钟之后 自动化 HTTPS 重写全局终止。电子邮件地址混淆功能已经在2月13日变更,并且是泄漏内存的主要原因,因此禁用它会快速停止几乎所有的内存泄漏。

在几秒钟内,这些功能在全球范围内被禁用。我们确认我们没有通过测试 URI 看到内存泄漏,然而谷歌再次确认他们看到了同样的事情。

然后我们发现了第三个功能,即服务器端排除,也是存在漏洞的,并且没有全局终止开关(它实在是太老了,出现在实施全局终止之前)。我们为服务器端排除实施了全局终止,并在全球范围内为我们的集群部署了补丁。从了解到服务器端排除功能是一个问题到部署补丁,花了大约三个小时。但是,服务器端排除很少使用,仅针对恶意 IP 地址启用。

这个bug的根本原因

Ragel 代码转换为生成的 C 代码,然后进行编译。C 代码以经典的 C 方式,使用指向正在解析的 HTML 文档的指针,Ragel 本身为用户提供了对这些指针移动的大量控制。由于指针错误而发生底层 bug。

1
2
3
/* generated code */
if ( ++p == pe )
goto _test_eof;

该错误的根本原因是,使用了等于的运算符检查是否到达缓冲区的末尾,并且指针能够越过缓冲区的末尾。这称为缓冲区溢出。如果检查是使用>=而不是==跳过缓冲区末端将被捕获。等式检查由 Ragel 自动生成,并不是我们编写代码的一部分。这表明我们没有正确使用Ragel。

我们编写的 Ragel 代码包含一个错误,该错误导致指针跳过缓冲区的末尾,并超过了相等性检查以发现缓冲区溢出的能力。

这是一段 Ragel 代码,用于在 HTML <script> tag 中使用属性。第一行意为,它应该尝试找到零或多个unquoted_attr_char后跟(即:>>连接运算符)空格,正斜杠或然后>表示标记的结尾。

1
2
3
4
5
script_consume_attr := ((unquoted_attr_char)* :>> (space|'/'|'>'))
>{ ddctx("script consume_attr"); }
@{ fhold; fgoto script_tag_parse; }
$lerr{ dd("script consume_attr failed");
fgoto script_consume_attr; };

如果属性符合规则,则 Ragel 解析器将移动到执行 @{ } 块内的代码。如果属性无法解析(这是我们今天讨论的错误的开始),则使用$lerr{ }块。

例如,在某些情况下(详见下文),如果网页以破坏的 HTML 标记结束,如下所示:

1
<script type=

$lerr{ }块将被使用,并且缓冲区溢出。在这种情况下,$lerr会执行dd(“script consume_attr failed”);(这是一个调试日志语句,它是生产环境中的 nop),然后执行 fgoto script_consume_attr;(转换script_consume_attr的状态,以便解析下一个属性)。
根据我们的统计数据,似乎 HTML 结尾处的此类损坏标记出现在大约 0.06% 的网站上。

如果你眼睛很敏锐,你可能已经注意到@{ }代码块的执行过程中,在执行fgoto之前执行了一次fhold,但是$lerr{ }代码块没有。这个缺失的fhold导致了内存泄漏。

在内部,生成的 C 代码有一个名为指针的指针 p,该指针指向 HTML 文档中正在检查的字符。 fhold等同于p--,并且必不可少,因为当错误条件发生时,p指向的字符将导致script_consume_attr失败。

它非常重要,因为如果此错误发生在包含了 HTML 文档的缓冲区末尾,那么p将在文档结束之后(p将在内部进行pe + 1),并且随后检查是否到达缓冲区的末尾将失败,p将在缓冲区外运行。

fhold添加到错误处理程序就可以修复这次的问题。

为什么是现在才暴露

以上解释了指针如何在缓冲区的末尾运行,但没有解释,为什么现在问题突然出现。毕竟,这段代码已经投入生产环境使用,并且稳定运行了多年。

回到上面script_consume_attr的定义:

1
2
3
4
5
script_consume_attr := ((unquoted_attr_char)* :>> (space|'/'|'>'))
>{ ddctx("script consume_attr"); }
@{ fhold; fgoto script_tag_parse; }
$lerr{ dd("script consume_attr failed");
fgoto script_consume_attr; };

当解析器在解析属性时耗尽要解析的字符时,无论当前正在解析的缓冲区是否是最后一个缓冲区,会发生什么情况。如果它不是最后一个缓冲区,则不需要使用$lerr,因为解析器不知道是否发生了错误,属性的其余部分可能在下一个缓冲区中。

但如果这是最后一个缓冲区,则$lerr部分会被执行。以下是代码最终跳出文件结尾,并在内存中运行的方式。

解析函数的入口点是ngx_http_email_parse_email(名称定义是历史遗留下来的,实际上它能够完成的比解析电子邮件更多的事)。

1
2
3
4
ngx_int_t ngx_http_email_parse_email(ngx_http_request_t *r, ngx_http_email_ctx_t *ctx) {
u_char *p = ctx->pos;
u_char *pe = ctx->buf->last;
u_char *eof = ctx->buf->last_buf ? pe : NULL;

您可以看到,p指向缓冲区中第一个字符,pe指向缓冲区结束后的字符,如果这是链中的最后一个缓冲区(由last_buf的布尔值表示),则将eof设置为 pe,否则为 NULL。

在请求处理期间,当旧的和新的解析器都存在时,诸如此类的缓冲区将被传递给上面的函数:

1
2
3
4
5
6
7
8
9
10
11
(gdb) p *in->buf
$8 = {
pos = 0x558a2f58be30 "<script type=\"",
last = 0x558a2f58be3e "",

[...]

last_buf = 1,

[...]
}

这里是数据,last_buf的值为1.当新解析器不存在时,包含数据的最终缓冲区如下所示:

1
2
3
4
5
6
7
8
9
10
11
(gdb) p *in->buf
$6 = {
pos = 0x558a238e94f7 "<script type=\"",
last = 0x558a238e9504 "",

[...]

last_buf = 0,

[...]
}

最后一个空的缓冲区(poslast都为 NULL,并且last_buf = 1)都将跟随该缓冲区,但如果缓冲区为空ngx_http_email_parse_email将不会被调用。

因此,在仅存在旧解析器的情况下,包含数据的最终缓冲区已将last_buf 赋值为 0.这意味着eof的值将为 NULL。现在,当尝试处理script_consume_attr在缓冲区末尾未完成的标记时,$lerr将不会执行,因为解析器认为(因为last_buf)可能还会有更多的数据到来。

当两个解析器都存在时,情况就不同了。last_buf的值是 1,eof设置为pe,以及运行了$lerr代码块。为它生成的代码如下:

1
2
3
4
5
6
7
8
9
10
/* #line 877 "ngx_http_email_filter_parser.rl" */
{ dd("script consume_attr failed");
{goto st1266;} }
goto st0;

[...]

st1266:
if ( ++p == pe )
goto _test_eof1266;

解析器在尝试执行script_consume_attr时会消耗完需要解析的字符,并且这种情况发生时p会成为pe。当代码跳转到st1266时,p递增超过了pe,而且那时候没有fhold(可能会p--)。

然后它就不会跳转到 _test_eof1266(执行 EOF 检查的地方),并且将继续尝试解析 HTML 文档的缓冲区末尾。

因此,这个bug已经休眠多年,直到 cf-html 的引入改变了 NGINX 过滤器模块之间传递的缓冲区的内部风水。

开始围剿bug行动

IBM 在20世纪60年代和70年代的研究表明,错误往往集中在所谓的“容易出错的模块”中。由于我们在 Ragel 生成的代码中发现了一个讨厌的指针溢出,因此谨慎寻找其他bug很有必要。

信息安全团队的一部分人开始模糊测试(fuzzing)生成的代码,寻找其他可能的指针溢出。另一个团队从在野发现的格式错误网页构建了测试用例。软件工程团队开始手动检查生成的代码以查找问题。

此时,决定向生成代码中的每个指针访问添加显式指针检查,防止将来出现任何问题并记录在野外看到的任何错误。产生的错误被提供给我们的全球错误记录设施中,用于分析和趋势分析。

1
2
3
4
5
6
7
8
9
10
#define SAFE_CHAR ({\
if (!__builtin_expect(p < pe, 1)) {\
ngx_log_error(NGX_LOG_CRIT, r->connection->log, 0, "email filter tried to access char past EOF");\
RESET();\
output_flat_saved(r, ctx);\
BUF_STATE(output);\
return NGX_ERROR;\
}\
*p;\
})

我们开始看到如下日志行:

1
2017/02/19 13:47:34 [crit] 27558#0: *2 email filter tried to access char past EOF while sending response to client, client: 127.0.0.1, server: localhost, request: "GET /malformed-test.html HTTP/1.1”

每个日志行都表示可能泄漏私有内存的HTTP请求。通过记录问题发生的频率,我们希望得到 HTTP 请求在出现错误时泄漏内存的次数。

为了让内存泄漏,以下规则必须发生:

  • 包含数据的最后一个缓冲区必须以格式不正确的脚本或 img 标记结束
  • 缓冲区长度必须小于4k(否则 NGINX 会崩溃)
  • 客户必须开启电子邮件地址混淆功能(因为当我们转换时,它会同时使用旧的和新的解析器),
  • … 或是 开启了 自动HTTPS重写/服务器端排除(使用新的解析器)与另一个使用旧解析器的Cloudflare功能相结合。
  • … 并且 服务器端仅在客户端IP信誉不佳时才排除执行(即,它不对大多数访问者生效)。

这就解释了为什么导致内存泄漏的缓冲区溢出很少发生。

此外,电子邮件地址混淆功能(这会同时使用两种解析器,并且使该错误发生在大多数 Cloudflare 站点上)仅在2月13日(Tavis报告的前四天)启用。

涉及三个特征的发布时间如下。最早的日期,可以导致泄露内存的时间是2016-09-22。

2016-09-22 自动化 HTTP 重写功能 已启用
2017-01-30 服务器端排除功能 已迁移至新的解析器
2017-02-13 电子邮件地址混淆功能 部分迁移到新的解析器
2017-02-18 Google 向 Cloudflare 报告问题,并且泄漏已停止

由于自动化 HTTP 重写未被广泛使用,而服务器端排除仅对恶意IP地址启用,因此从2月13日开始的四天内发生了最大的潜在影响。

bug的内部影响

Cloudflare 在边缘机器上运行多个独立的进程,这些进程提供处理进程和内存隔离。泄露的内存来自基于NGINX的执行HTTP处理的进程。它与执行 SSL,图像的重新压缩以及缓存的进程有一个单独的堆,这意味着我们很快就能够确定属于我们客户的SSL私钥不可能被泄露。

但是,泄漏的内存空间仍然包含敏感信息。一条明显的泄露信息是用于保护 Cloudflare 机器之间连接的私钥。

处理客户网站的 HTTP 请求时,我们的边缘机器与在机架内、数据中心内以及数据中心之间的服务器相互通信,以便记录日志、缓存,并从原始 Web 服务器获取网页。

为了应对对互联网公司的监控活动的高度关注,在2013年我们决定对 Cloudflare 机器之间的所有连接进行加密,以防止这种攻击,即使机器位于同一机架中。

泄露的私钥是用于这样的机器和其他机器加密的。Cloudflare 内部还有少量密钥用于验证。

外部影响和缓存清除

更加让人担忧的是,对于 Cloudflare 客户的大量即时 HTTP 请求存在于转储内存中。这意味着本应属于私人的信息可能被泄漏。

这包括 HTTP header 头,POST 数据块(可能包含密码),API 调用的 JSON,URI 参数,cookies 和用于身份验证的其他敏感信息(例如 API keys 和 OAuth tokens)。

由于 Cloudflare 运行着大型共享基础架构,因此,对易受此问题影响的 Cloudflare 网站的 HTTP 请求可能会泄露有关其他未关联的 Cloudflare 站点的信息。

另一个问题是 Google(和其他搜索引擎)通过正常的抓取和缓存处理缓存了一些泄露的内存。我们希望确保在公开披露问题之前,从搜索引擎缓存中清除此内存,以便第三方无法搜索这些敏感信息。

我们的倾向自然是尽快获得有关漏洞的消息,但我们觉得我们有责任确保在公告前清除搜索引擎缓存。

信息安全团队致力于识别搜索引擎缓存中泄漏内存的 URI,并将其清除。在 Google,Yahoo,Bing 和其他人的帮助下,我们发现了770个已缓存且包含泄漏内存的唯一 URI。这770个唯一URI包含在161个唯一域名中。在各个搜索引擎的帮助下,泄漏的内存已经被清除。

我们还进行了其他的搜索测探,寻找像 Pastebin 这样的网站上可能泄露的信息,但没有找到任何东西。

一些教训

负责开发新的HTML解析器的工程师一直非常担心存在影响我们服务的错误,他们花了几个小时来验证它没有包含安全问题。

不幸的是,这是一个老旧的包含潜在的安全问题的软件,而这个问题只是出现在我们正在从它迁移离开的过程中。我们的内部信息安全团队现在正在开展一个项目,对老旧的软件进行模糊测试,以寻找其他的潜在安全问题。

详细的时间轴

我们非常感谢 Google 的同事们就此问题与我们联系,并通过其解决方案与我们密切合作。所有这些发生的事情,都没有任何外部各方已经确定问题的或是利用漏洞的报告。

以下所有时间都是 UTC 格式。

2017-02-18 0011 Tavis Ormandy 的推文请求获取 Cloudflare 联系信息
2017-02-18 0032 Cloudflare 收到来自 Google 的错误详细信息
2017-02-18 0040 旧金山的跨职能团队召集
2017-02-18 0119 全球禁用电子邮件地址混淆
2017-02-18 0122 伦敦团队加入
2017-02-18 0424 全球禁用自动HTTPS重写
2017-02-18 0722 在全球部署的 cf-html 解析器 实施全局关闭

2017-02-20 2159 全局部署 SAFE_CHAR 修复补丁

2017-02-21 1803 自动HTTPS重写,服务器端排除和电子邮件混淆功能 在全球重新启用

注意:此帖子已更新,以反映最新更新的信息。