本文档及仓库是 CVE−2022-3602 的分析文章,这是 OpenSSL 中的一个 punycode 缓冲区溢出问题。它是一份“反 POC”(该问题似乎不可利用),面向那些自行维护 OpenSSL 构建的人以及编译器维护者。
同一版本中还有另一个 CVE,CVE-2022-3786,它同样会导致缓冲区溢出,但在那种情况下攻击者无法控制内容。此处没有针对该问题的复现程序,但该问题可能导致崩溃,从而引发拒绝服务。
崩溃和缓冲区溢出从来都不是好事,如果你正在使用 OpenSSL 3.0.x,建议尽快更新。
欢迎通过 GitHub issue 或 pull-request 报告任何错误或遗漏。
ossl_punycode_decode 在处理 punycode 解码时存在一个差一错误,导致 4 字节溢出。只有当 OpenSSL 处理证书链时,该问题才可能出现,并且需要满足两个条件。首先,证书链中的 CA 或中间证书必须包含一个使用 punycode 的 name-constraint 字段。
nameConstraints = permitted;email:xn-maccrthaigh-n7a.com
其次,叶子证书必须包含一个 SubjectAlternateName (SAN) otherName 字段,该字段指定一个 SmtpUTF8Mailbox 字符串。
otherName = 1.3.6.1.5.5.7.8.9;UTF8:[email protected]
触发时,nameConstraints 字段中的 punycode(而非 otherName 字段中的 punycode)将由存在漏洞的 OpenSSL punycode 解析逻辑处理。
David Benjamin 和 Matt Caswell 确认,nameConstraint 检查发生在常规证书链验证和签名验证之后。对大多数应用程序而言,这意味着无法使用自签名证书或无效证书链来触发该问题。
请注意,openssl 的 s_client 和 s_server 应用程序主要用于调试,在证书链无效时不会停止处理。
受信任的 CA 或中间证书必须包含恶意载荷,并且还必须对触发该问题的叶子证书进行签名。
在某些环境中,不受信任的一方可能是 CA 或中间证书,例如支持客户提供私有 CA 的主机服务,但这并不常见。
对于许多应用程序,答案将是“否”,因为编译器对堆栈的布局方式,以及栈金丝雀 / 栈 cookie、填充、PIE、FORTIFY_SOURCE 等其他保护机制的存在。
该问题确实会导致堆栈上发生 32 位溢出。这不足以直接执行 shellcode,但可能足以改变应用程序的控制流。例如,如果嵌入了 X509 证书链中的 shellcode 的数据也被复制到堆栈中的可执行位置,则有可能跳转到该 shellcode。
在我测试过的每个 Linux 平台上,溢出都发生在填充区域中,并且是无害的。理论上,编译器可能会对变量进行布局,使溢出发生在 ossl_a2ulabel 函数中的其他变量之一上。
根据内联情况,存在的完整变量列表为:
outptr, inptr, size, result, tmpptr, delta, seed, utfsize
在我看来,它们似乎都没有为权限提升或有趣的控制流提供明显的路径。
我附加了一个 tarball,其中包含可用于生成复现和溢出的工具,并且尽可能地对全部四个字节进行控制。参考复现字符串(xn--ww90271...aaaa)会以 0xFF 0x0F 0x0F 0x0F 的值溢出这四个字节。如果这不会导致应用程序崩溃,那么该应用程序很可能(?)不存在该漏洞。
shell 脚本 run-poc 可用于生成恶意证书链。恶意 CA 证书由 ca.cnf 生成,触发问题的叶子证书由 leaf.cnf 生成。
CA 证书使用以下参考载荷:
xn--ww902716aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
可以使用一个 Python 脚本为不同的载荷生成其他 punycode 字符串。
执行 run-poc 时,它会运行一个 openssl 客户端和服务器,并尝试利用该问题十次。
存在漏洞的 OpenSSL 很可能会崩溃。这并不意味着该 OpenSSL 版本存在可导致 RCE 的漏洞,因为栈金丝雀和栈保护通常也会导致(更安全的)应用程序崩溃。另请注意,这不会改变同一版本中另一个 CVE 的严重程度。
要实现对所有四个溢出字节近乎完全的控制,其过程出乎意料地精妙,并且需要利用非标准/无效的 punycode 来攻击 OpenSSL 的 punycode 解码器。附加的 tarball 中包含一个脚本,可以构造处理这种精妙性的字符串。以下是其工作原理的解释。
安全问题位于 ossl_punycode_decode() 中:
int ossl_punycode_decode(const char *pEncoded, const size_t enc_len,
unsigned int *pDecoded, unsigned int *pout_length)
ossl_punycode_decode 由 ossl_a2ulabel 调用。pEncoded 缓冲区是一个或多或少任意大小的缓冲区,它来自 X509 证书链。它是 nameConstraint 字段中任何 "xn--" 之后的部分。参见 [reproduction] 了解如何复现此类证书链。
pDecoded 是一个大小为 LABEL_BUF_SIZE 的 unsigned int 数组。LABEL_BUF_SIZE 为 512,在大多数平台上,unsigned int 为 4 字节宽。因此在大多数平台上 pDecoded 的长度为 2048 字节。
在 ossl_punycode_decode() 内部,问题的关键是这个不正确的长度检查:
if (written_out > max_out)
max_out 对应 *pout_length,它始终为 512。而 written_out 跟踪已写入 pDecoded 的 unsigned int 数量。因为 written_out 是在写入之后才递增的,所以这个有缺陷的检查允许向 pDecoded 写入 513 个 unsigned int。最终结果看起来像这样……
pDecoded = [ ... , 'X , 'Y' , 'Z' ] 'P'
// Indices 509 510 511
这里按照 C 的约定,索引是从零开始的,因此槽位 511 是数组中的第 512 个元素。'P' 是一个四字节载荷,它被写出边界,超出了 ossl_a2ulabel() 中 buf 缓冲区在堆栈上分配的空间,而 pDecoded 正指向该缓冲区。
四个字节是一个较小的溢出,不足以携带 nop-sled 或直接执行 shellcode,但足以改变应用程序的控制流。例如,根据这些数据(或这些数据的复制片段)的存储方式以及该内存是否可执行,有可能跳转到嵌入在 x509 证书链中的 shellcode。然而,对于潜在的攻击者来说,仍然存在更多困难。
首先,编译器的堆栈填充和对齐,或栈金丝雀等防御措施,可能使任何利用完全不可能。
其次,只有一条路径可以到达 ossl_punycode_decode(),并且这条路径使用堆栈上的缓冲区。这使得该问题不太可能被用于在不同内存位置进行并发 4 字节溢出。
Punycode 字符串基本上有两种形式。一种是 xn--c1yn36f(點看),另一种是 xn--maccrthaigh-n7a(maccárthaigh)。最后一个 - 分隔符之后的部分是任何不属于基本普通 ASCII 的 unicode 码点以及要插入它们的字符串位置的 36 进制 bootstring 编码。现在重要的是 ossl_punycode_decode() 中的解码过程会产生两个值。一个是 'n',即要插入的 unsigned int 码点值;另一个是 'i',即要插入的缓冲区位置。
写入可以以两种不同的方式发生。如果 i 在字符串的中间某处,则会有一个 memmove(),它首先通过将所有内容向右复制一个槽位来“腾出空间”:
memmove(pDecoded + i + 1, pDecoded + i,
(written_out - i) * sizeof *pDecoded);
然后将 n 写入刚刚腾出的空间:
pDecoded[i] = n;
如果 i 位于字符串末尾,则 memmove() 没有效果,因为最后一个参数将为 0。另一行变成了简单的追加。
现在我们将研究将载荷 'P' 放入溢出位置的三种不同方式,以及这些约束为何会出现。
触发溢出的最简单方法是构造一个包含 511 个 ASCII 字符和两个非 ASCII 字符的 punycode 字符串。诸如 "ÁÁAAAAAAAA...AAA" 这样的 513 字符长字符串的 punycode 编码就可以。在这种情况下,当 written_out 为 510 时,缓冲区布局如下……
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A', ]
// Indices 0 1 ... 509 510 511
这只是已复制进来的基本 ASCII 字符。然后我们解析 punycode bootstring,并在位置 0 插入一个 'Á'。尽管它可以是 0 到 511(含)之间的任何位置。
pDecoded = [ 'Á' , 'A' , ... , 'A' , 'A', 'A' ]
// Indices 0 1 ... 509 510 511
然后我们重复此操作:
pDecoded = [ 'Á' , 'Á' , ... , 'A' , 'A', 'A' ] 'A'
// Indices 0 1 ... 509 510 511 512
这将导致普通的 ASCII 'A' 在被“移过去”时溢出。在这种情况下,四字节载荷变为 0x00 0x00 0x00 0x41。正如我们将看到的,由于 punycode 的工作方式,这是表达任何最后一个字节在 ASCII 范围内的值的唯一方式。
我们需要使用两个非 ASCII 字符,因为对基本字符数量有一个正确的边界检查,所以该数量必须小于 512。
还有一个额外的约束,即最后一个字节的值不能为 46,这是因为 ossl_punycode_decode() 是在字符串中字面量 . 字符之前的部分上被调用的。Punycode 用于域名标签,而域名标签中不能有句点。
触发溢出的下一个最简单的方法是在一个 513 字符的字符串末尾放置一个非 ASCII 字符,例如 "AAAAAAAAAA...AAÁ"。在这种情况下,我们的最后两个步骤将是:
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] // Indices 0 1 ... 510 511
以及
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] 'Á'
// Indices 0 1 ... 510 511
非 ASCII 字符将直接进入溢出位置。OpenSSL 的 punycode 解析器不会强制要求此处的溢出值实际上是有效的 unicode 字符。这或多或少是一个二进制解码过程。但 punycode 解码的微妙之处意味着方法 2 并不像它乍看起来那样灵活。
在 punycode 中,值 n 和 i 都被编码为单个可变长度整数,然后使用 base36 进行 ASCII 编码。将两个不相关的数字编码为单个整数似乎是不可能的,但 punycode 的巧妙之处在于使用(到目前为止)字符串的长度作为隐藏字段。
例如,假设我们有一个 punycode 字符串,其中包含 4 个基本字符和 1 个非基本字符,如 AAÁAA。它将首先仅由基本字符表示……AAAA。'Á' 的 unicode 值为 225,它在字符串中的位置为 2。诀窍是将该值乘以长度加一,然后加上位置。因此它变为 ((225 * (4 +1)) + 2),即 1127,这就是它的编码方式(以可变长度 base36)。
要解码,你反过来。1127 / 5 是 225,1127 % 5 是 2。这就是从一个数字中恢复两个数字的方法。但请注意,字符串越长,值的大小就越受限制,否则这个乘积将无法放入 unsigned int 中。一般来说,如果字符串长度为 M 个字符,那么你就会从该值中失去 log M 位的宽度。
当你处理第 512 个整数时,你失去了 9 位宽度。使用方法 2,一个看似 32 位的载荷实际上可能的最大值是 2^23。甚至不到三个完整字节。方法 2 不是最优的。
要重新获得对 4 个字节的控制,最有效的方法是反复重复载荷字符。到目前为止,我还遗漏了 punycode 处理方式的另外两个相关细节。
第一个细节是,非 ASCII 字符不是按字符串顺序编码的,而是按值的升序编码的。字符串 "ÉÁ" 最终将被编码为 "Á 位于位置 1,É 位于位置 0",因为 Á 的值(225)低于 É(233)。
第二个细节是,非 ASCII 字符不是按字面值编码的,而是相对于最近解码值的增量进行编码。由于第一个值没有可相对的先前值,因此有一个硬编码的起点 128。
这些细微之处使 punycode 非常节省空间,但也意味着非 ASCII 字符根本无法解码为低于 128 的值。最小的增量是 0,并且无法表达负增量。因此,如果你想要一个小于 128 的数字,你必须使用方法 1。
这也意味着,为了尽可能多地控制载荷,最佳策略是让载荷成为整个字符串中的唯一值,这样我们就能从它在编码中的第 0 位位置获得完整的宽度。你编码的字符串最终看起来像这样;
[ 'P', 'P', ... 'P', 'P', 'P' ]
0 1 510 511 512
OpenSSL 会将其解码为……
pDecoded = [ 'P', 'P', ... 'P', 'P' ] 'P'
0 1 510 511 512
其中 P 位于溢出位置,并且能够表示 128 到 (2^32 - 1) 之间的任何值。
所有这些都需要一个非标准的 punycode 编码器,我提供了一个脚本,可以根据需要使用方法 1 或方法 3 来构造载荷。
除了更新 OpenSSL 之外,还有其他缓解措施吗?
在大多数环境中,证书链以明文形式传输,恶意链可以通过拒绝包含 DER 编码的 SubjectAlternateName OtherName 字段中 1.3.6.1.5.5.7.8.9 NID 的 TCP 连接来阻止。
不幸的是,该字段可以在两个或多个数据包之间任意拆分,实际上需要某种有状态的模式匹配器来进行阻止。证书也可以被压缩,但 OpenSSL 3.0.x 目前不支持证书压缩。
此外,使用 TLS1.3 时,客户端证书链在网络上传输时是加密的,而较早版本的 TLS 在重新协商现有连接时支持加密的证书链。这有时用于服务器发起的证书认证。在这些情况下,网络过滤器将不会有效。
如何判断我在静态链接的二进制文件中是否使用了 openssl 3?
readelf -a [binary] | grep -i ossl_punycode_decode
将在静态链接的二进制文件中搜索易受攻击的函数。只有 OpenSSL >= 3.0 包含此函数。