本不该发生:一次漏洞事件复盘
作者:Tavis Ormandy,Project Zero
引言
这是一篇不同寻常的博客文章。我通常写文章是为了强调一些隐藏的攻击面或有趣而复杂的漏洞类别。但这次,我想讨论一个两者都不是的漏洞。这个漏洞最引人注目的地方在于它的简单性。它本应更早被发现,我想探讨一下为什么没有。
在2021年,所有好的漏洞都需要一个吸引人的名字,所以我称这个为“BigSig”。
首先,让我们看看这个漏洞,我会解释我是如何发现它的,然后试着理解为什么我们这么长时间都没有发现它。
分析
Network Security Services(NSS)是 Mozilla 广泛使用的跨平台密码学库。当你验证一个 ASN.1 编码的数字签名时,NSS 会创建一个 VFYContext 结构来存储必要的数据。这包括公钥、哈希算法和签名本身等。
struct VFYContextStr {
SECOidTag hashAlg; /* 哈希算法 */
SECKEYPublicKey *key;
union {
unsigned char buffer[1];
unsigned char dsasig[DSA_MAX_SIGNATURE_LEN];
unsigned char ecdsasig[2 * MAX_ECKEY_LEN];
unsigned char rsasig[(RSA_MAX_MODULUS_BITS + 7) / 8];
} u;
unsigned int pkcs1RSADigestInfoLen;
unsigned char *pkcs1RSADigestInfo;
void *wincx;
void *hashcx;
const SECHashObject hashobj;
SECOidTag encAlg; / 加密算法 */
PRBool hasSignature;
SECItem *params;
};
图 1. 来自 NSS 的 VFYContext 结构。
这个结构能处理的最大签名大小取决于联合体中最大的成员,在这个例子中是 RSA,大小为 2048 字节。也就是 16384 位,大到足以容纳即使是最大得离谱的密钥生成的签名。
好吧,但是如果你只是……创建一个比这更大的签名会发生什么?
嗯,事实证明答案是内存损坏。是的,真的。
不受信任的签名被简单地复制到这个固定大小的缓冲区中,用攻击者控制的任意数据覆盖了相邻的成员。
这个漏洞很容易复现,并且影响多种算法。最容易演示的是 RSA-PSS。实际上,只需要这三条命令:
我们需要 16384 位来填满缓冲区,然后 32 + 64 + 64 + 64 位来溢出到 hashobj,
它包含函数指针(更大的也可以,但生成时间更长)。
$ openssl genpkey -algorithm rsa-pss -pkeyopt rsa_keygen_bits:$((16384 + 32 + 64 + 64 + 64)) -pkeyopt rsa_keygen_primes:5 -out bigsig.key
用该密钥生成一个自签名证书
$ openssl req -x509 -new -key bigsig.key -subj "/CN=BigSig" -sha256 -out bigsig.cer
用 NSS 验证它...
$ vfychain -a bigsig.cer
Segmentation fault
图 2. 用三个简单的命令复现 BigSig 漏洞。
导致损坏的实际代码因算法而异;这里是 RSA-PSS 的代码。漏洞在于根本没有边界检查;sig 和 key 是任意长度、由攻击者控制的二进制数据块,而 cx->u 是一个固定大小的缓冲区。
case rsaPssKey:
sigLen = SECKEY_SignatureLen(key);
if (sigLen == 0) {
/* error set by SECKEY_SignatureLen */
rv = SECFailure;
break;
}
if (sig->len != sigLen) {
PORT_SetError(SEC_ERROR_BAD_SIGNATURE);
rv = SECFailure;
break;
}
PORT_Memcpy(cx->u.buffer, sig->data, sigLen);
break;
图 3. 签名大小必须与密钥大小匹配,但没有其他限制。cx->u 是一个固定大小的缓冲区,而 sig 是一个任意长度、由攻击者控制的二进制数据块。
我认为这个漏洞立即引发了几个问题:
- 这是一个最近才出现的代码变更或回归问题,存在时间不够长所以没被发现吗?不是,原始代码是在 2003 年 10 月 17 日提交的,支持 ECC,但直到 2012 年 6 月的某些重构后才变得可利用。2017 年,添加了 RSA-PSS 支持并犯了同样的错误。
- 这个漏洞是否需要很长时间来生成一个能触发漏洞的密钥?不需要,上面的例子生成了一个真实的密钥和签名,但它也可以是垃圾数据,因为溢出发生在签名检查之前。几千字节的 'A' 就可以正常工作。
- 到达易受攻击的代码是否需要一些模糊测试器和静态分析器难以合成的复杂状态,比如哈希值或校验和?不需要,它只需要是格式良好的 DER,仅此而已。
- 这是一个不常见的代码路径吗?不是,Firefox 不使用这个代码路径来处理 RSA-PSS 签名,但 NSS 中证书验证的默认入口点
CERT_VerifyCertificate()是易受攻击的。 - 它特定于 RSA-PSS 算法吗?不是,它也影响 DSA 签名。
- 它是不可利用的,或者影响有限吗?不是,
hashobj成员可以被破坏。该对象包含函数指针,并且会立即被使用。
这不是流程上的失败,供应商做的一切都是正确的。Mozilla 拥有一个成熟的世界级安全团队。他们开创了漏洞赏金计划,投资于内存安全、模糊测试和测试覆盖率。
NSS 是最早被纳入 oss-fuzz 的项目之一,至少从 2014 年 10 月起就得到了官方支持。Mozilla 自己也用 libFuzzer 对 NSS 进行模糊测试,并贡献了他们自己的变异器集合和精炼的覆盖率语料库。NSS 有广泛的测试套件和每日的 ASAN 构建。
我通常对静态分析持怀疑态度,但这似乎是一个简单的缺失边界检查,应该很容易被发现。Coverity 至少从 2008 年 12 月起就开始监控 NSS,但似乎也未能发现这个问题。
直到 2015 年,Google Chrome 使用 NSS,并维护着自己独立于 Mozilla 的测试套件和模糊测试基础设施。如今,Chrome 平台使用 BoringSSL,但 NSS 端口仍在维护。
- Mozilla 对易受攻击的区域有良好的测试覆盖率吗?有。
- Mozilla/Chrome/oss-fuzz 在他们的模糊测试语料库中有相关的输入吗?有。
- 有能够扩展 ASN1_ITEM 的变异器吗?有。
- 这是一个对象内溢出,还是 ASAN 难以检测的其他形式的损坏?不是,这是一个教科书式的缓冲区溢出,ASAN 可以轻松检测。
我是如何发现这个漏洞的?
我一直在尝试用替代方法来测量代码覆盖率,看看是否有任何方法在模糊测试中有实际用途。发现这个漏洞的模糊测试器结合使用了两种方法:栈覆盖率和对象隔离。
栈覆盖率
测量代码覆盖率最常用的方法是块覆盖率,或者在有源代码时使用边覆盖率。我一直好奇这是否总是足够的。例如,考虑一个简单的分发表,其中包含受信任和不受信任参数的组合,如图 4 所示。
#include <stdio.h>
#include <string.h>
#include <limits.h>
static char buf[128];
void cmd_handler_foo(int a, size_t b) { memset(buf, a, b); }
void cmd_handler_bar(int a, size_t b) { cmd_handler_foo('A', sizeof buf); }
void cmd_handler_baz(int a, size_t b) { cmd_handler_bar(a, sizeof buf); }
typedef void (* dispatch_t)(int, size_t);
dispatch_t handlers[UCHAR_MAX] = {
cmd_handler_foo,
cmd_handler_bar,
cmd_handler_baz,
};
int main(int argc, char **argv)
{
int cmd;
while ((cmd = getchar()) != EOF) {
if (handlers[cmd]) {
handlerscmd, getchar());
}
}
}
图 4. 命令 bar 的覆盖率是命令 foo 的超集,因此在语料库最小化过程中,包含后者的输入会被丢弃。存在一个通过命令 bar 无法到达的漏洞,可能永远不会被发现。栈覆盖率可以正确地保留两个输入。[1]
为了解决这个问题,我一直在尝试在执行过程中监控调用栈。
简单的实现太慢而不实用,但经过大量优化后,我提出了一个库,它足够快,可以集成到覆盖率引导的模糊测试中,并且正在测试它在 NSS 和其他库中的表现。
对象隔离
许多数据类型是由较小的记录构成的。PNG 文件由块组成,PDF 文件由流组成,ELF 文件由节组成,X.509 证书由 ASN.1 TLV 项组成。如果模糊测试器对底层格式有一定的了解,它可以隔离这些记录,并提取导致发现新栈跟踪的那个(或那些)记录。
我正在使用的模糊测试器能够隔离和提取有趣的新 ASN.1 OID、SEQUENCE、INTEGER 等。一旦提取出来,它就可以随机组合或插入到模板数据中。这并不是一个新想法,但这是一个新的实现。我计划将来开源这段代码。
这些方法有效吗?
我希望我能说发现这个漏洞验证了我的想法,但我不确定是否如此。我做了一些相对新颖的模糊测试,但我认为没有理由这个漏洞不能用更基本的模糊测试技术更早发现。
经验教训
广泛的、定制的、拥有令人印象深刻的覆盖率指标的模糊测试,怎么会没能发现这个漏洞?
出了什么问题
问题 #1 缺少端到端测试。
NSS 是一个模块化的库。这种分层设计反映在模糊测试方法中,因为每个组件都是独立进行模糊测试的。例如,QuickDER 解码器经过了广泛的测试,但模糊测试器只是创建并丢弃对象,从不使用它们。
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
char *dest[2048];
for (auto tpl : templates) {
PORTCheapArenaPool pool;
SECItem buf = {siBuffer, const_cast<unsigned char *>(Data),
static_cast
PORT_InitCheapArena(&pool, DER_DEFAULT_CHUNKSIZE);
(void)SEC_QuickDERDecodeItem(&pool.arena, dest, tpl, &buf);
PORT_DestroyCheapArena(&pool);
}
图 5. QuickDER 模糊测试器只是创建并丢弃对象。这验证了 ASN.1 解析,但没有验证其他组件是否正确处理了生成的对象。
这个模糊测试器可能生成了一个可以到达易受攻击代码的 SECKEYPublicKey,但由于结果从未被用于验证签名,因此永远无法发现这个漏洞。
问题 #2 任意的大小限制。
模糊测试的输入有一个任意的 10000 字节限制。NSS 内部没有这样的限制;许多结构可以超过这个大小。这个漏洞表明错误发生在极端情况下,因此应该慎重选择这个限制。
一个合理的选择可能是 2^24-1 字节,这是在 TLS 握手协商期间服务器可以呈现的最大可能证书大小。
虽然 NSS 可能处理比这更大的对象,但 TLS 不可能涉及其中,从而降低了任何被遗漏漏洞的整体严重性。
问题 #3 误导性的指标。
oss-fuzz 将所有 NSS 模糊测试器的覆盖率指标合并表示,而不是单独表示每个模糊测试器的覆盖率。事实证明这些数据具有误导性,因为易受攻击的代码被广泛地进行了模糊测试,但使用的模糊测试器不可能生成相关的输入。
这是因为像 tls_server_target 这样的模糊测试器使用固定的、硬编码的证书。这练习了与证书验证相关的代码,但只模糊测试了 TLS 消息和协议状态变化。
哪些方面做得好
mozilla::pkix验证库的设计防止了这个漏洞变得更糟。不幸的是,除了 Firefox 和 Thunderbird 之外,它没有被使用。
这究竟是运气好还是不好,尚有争议。尽管目前没有,但似乎 mozilla::pkix 最终可能会允许 RSA-PSS。
建议
这个问题表明,即使是维护得非常好的 C/C++ 代码也可能存在致命的、微不足道的错误。
短期
- 将 libFuzzer 生成的 ASN.1 对象的最大大小从 10,000 提高到 2^24-1 = 16,777,215 字节。
- QuickDER 模糊测试器在销毁任何成功创建的对象之前,应该调用一些相关的 API。
- oss-fuzz 的代码覆盖率指标应该按模糊测试器划分,而不是按项目划分。
解决方案
此漏洞编号为 CVE-2021-43527,已在 NSS 3.73.0 中修复。如果您是在产品中分发 NSS 的供应商,很可能需要更新或向后移植该补丁。
致谢
如果没有我的 Chrome 同事 Ryan Sleevi 和 David Benjamin 的帮助,我不可能发现这个漏洞,他们帮助回答了我的 ASN.1 编码问题,并就这个话题进行了深思熟虑的讨论。
感谢 NSS 团队,他们帮助分类和分析了这个漏洞。
[1] 在这个最小示例中,如果源代码可用,一个变通方法是结合使用 sancov 的数据流插桩选项,但在更复杂的变体上也会失败。