通过修改函数签名修复安全漏洞 – 攻击与防御(存档)
Mozilla
菜单
关于 Mozilla
产品
捐赠
发现 Firefox
攻击与防御(存档)
本博客已迁移至 https://attackanddefense.dev/
探索
搜索本站
搜索
分类:
漏洞赏金 Firefox 内部原理
修复安全漏洞:仅需修改函数签名
Molly Howell
2021年9月29日
或:C语言本身就是安全风险,案例编号 #958,738
本文面向开发者,但假设读者不了解C语言或诸如符号扩展等底层细节。换句话说,如果您是经验丰富的专业人士,对内存安全漏洞了如指掌,那么这里的内容对您来说将是熟悉的领域;我们的目标是深入探讨整数溢出在真实代码中如何发生,并为不熟悉这方面安全知识的读者详细解析这一主题。
漏洞详情
2020年7月,我收到了Mozilla漏洞报告1653371(后分配CVE-2020-15667)。报告者发现解析MAR文件¹的库中存在堆溢出导致的段错误。MAR是Firefox/Thunderbird应用程序更新系统中使用的自定义包格式。这听起来不太妙。(剧透:实际情况没有听起来那么糟糕,因为溢出发生在MAR文件签名验证之后。)
修复方案
我为这个漏洞编写的补丁完全是在一个C源文件中修改了一个函数签名,从:
|
|
改为:
|
|
我发誓这就是整个补丁。我只需要将函数的一个参数类型从int改为uint32_t。这样的更改真的能修复安全漏洞吗?可以,而且确实修复了,我将解释原因。不过,我们首先需要了解一些背景知识。
背景知识
问题归根结底在于数字以及计算机如何处理它们,因此我们先来讨论一下这一点²。由于漏洞出现在用C语言编写的文件中,我们的讨论将从C语言的角度出发,但我会尽量解释,以便您不需要了解C语言或太多底层编程细节就能理解发生了什么。
二进制数
计算机要处理的任何数字都必须以二进制位的形式存储。二进制的工作原理并不像看起来那么复杂。
想想您如何用十进制数字书写数字。如果我们要写一千三百一十二,我们需要四个数字:1,312。每个数字代表什么?最右边的2表示…2。但旁边的1并不表示1,而是表示10。您取数字本身并乘以10得到该位置表示的值。然后,随着您遍历其余数字,每个数字再乘以10的更高次幂。3既不表示3也不表示30,而是表示300,因为它被乘以100。最左边的1被乘以1000。
猜猜看?二进制数的工作方式相同。唯一的区别是,由于二进制只有两个不同的数字0和1,使用10的幂没有意义;我们将无法表示许多数字,任何大于1但小于10的数字都无法表示。因此,我们使用2的幂。每个连续的数字不是乘以1、10、100、1000等,而是乘以1、2、4、8等。
让我们看几个例子。这是十二的二进制表示:1100。为什么?让我们做与十进制示例相同的事情,乘以每个数字。这次我写出来:
|
|
我们得到了12!对于每个数字,我们将其值乘以其位置值的2的幂(乘法非常简单,因为数字只有0和1),然后将所有这些结果相加。就是这样!
二进制加法
现在,如果我们需要做一些数学运算呢?毕竟,计算机最擅长的就是这个。假设我们想给一个二进制数加一些东西。
我们知道如何在十进制中做到这一点:从最低位开始逐位相加,并在必要时进位到下一个数字。如果您阅读了上一节,您可能能猜到我接下来要说什么:在二进制中也是如此。只不过再次因为只有两个不同的数字而变得更加简单。
让我们再举一个简单的例子,13 + 12。首先,我们必须将这两个数字都写成二进制;我们已经知道12是1100,所以13应该只是比它多1,即1101。我们将以手动相加十进制数字的相同方式将它们相加:
|
|
前两位很容易,0 + 1 = 1,0 + 0 = 0。
|
|
但现在我们有1 + 1。我们该怎么处理?没有2。就像在十进制中一样,我们必须从该数字进位;二进制中1和1的和是10(因为这就是二进制的2),所以这意味着我们需要在该列写0,并进位1。
|
|
只剩一位了。又是1 + 1,但现在我们有一个从上一位进位过来的1。所以实际上我们必须做1 + 1 + 1,即3,但在二进制中是11。这是最后一列,所以我们不必再担心进位,我们可以直接写下来:
|
|
我们完成了!1100 + 1101 = 11001。为了证明我们得到了正确答案,让我们将11001转换回十进制,方式与之前相同:
|
|
所以现在我们知道了我们是对的;12 + 13 = 25,且1100 + 1101 = 11001。这就是二进制加法的方式。
有符号整数和二进制补码
到目前为止,我们只讨论了正数,但这不是计算机能处理的全部;有时您也需要负数。但您不希望每个数字都可能是负数;程序中需要跟踪的许多种东西根本不可能为负数,有时(正如我们将看到的)允许某些东西为负数可能 actively harmful。
因此,计算机(以及许多语言,包括C)提供了两种不同的整数类型,程序员在需要整数时可以在它们之间选择:“有符号”或“无符号”。“有符号”意味着数字可以是负数或正数(或零),“无符号”意味着它只能是正数(或零)³。
我们到目前为止讨论的是无符号整数,那么有符号整数是如何工作的呢?首先,数字的第一位不再是数字本身的一部分,而是“符号位”。如果符号位是0,数字是非负的(零或正数),如果符号位是1,数字是负数。但是,当符号位是1时,我们需要几个额外的步骤来在二进制和十进制之间转换。以下是过程。
- 在做任何其他事情之前丢弃符号位。
- 反转数字中的所有其他位,即将每个1变为0,反之亦然。
- 以通常的方式将该二进制数(位翻转后的那个)转换为十进制。
- 将该结果加1。
这个操作,包括反转和加1,称为“二进制补码”,它将为您提供负数的值。让我们再举一个简单的例子。
假设我们有一个有符号的8位整数,值为11010110。这在十进制中是多少?我们立即看到符号位被设置,因此我们需要取二进制补码。首先,我们需要翻转除符号位之外的所有位,因此我们得到0101001。现在我们将它转换为十进制并加1。
|
|
现在只需记住加上负号,我们得到-42。这就是我们的数字!11010110解释为有符号整数是-42。
为什么?
我们为什么要费心做这些?为什么不做一些简单的事情,比如有一个符号位,然后是常规数字⁴?嗯,二进制补码表示法有一个巨大的优势:您在进行基本算术时可以完全忽略它。完全相同的硬件和逻辑可以对无符号数和有符号二进制补码数进行算术运算⁵。这意味着硬件更简单,这意味着它更小、更便宜、更快。这在数字计算机的早期更为重要,这就是为什么二进制补码成为标准,并且至今仍与我们同在。
符号扩展
二进制补码还让我们可以做另一个巧妙的技巧,我们需要讨论一下。计算机中的整数具有固定的“宽度”,或用于表示它们的位数。更宽的整数可以表示更大(或更负)的数字,但会占用计算机内存中更多的空间。因此,为了平衡这些 concerns,像C这样的语言为程序员提供了几种不同的位宽供其整数选择。
那么,如果我们需要在不同宽度的整数之间进行一些算术运算,或者只是将一个整数传递给一个比函数预期更窄的函数,会发生什么?我们需要一种使整数更宽的方法。如果它是无符号的,那很容易;将相同的值复制到较低(右侧)的位中,然后用0填充新的高位,您将得到相同的值,只是现在有更多的位。
但是,如果我们需要加宽一个有符号整数呢?二进制补码在这里通过称为“符号扩展”的解决方案来拯救我们。事实证明,我们只需要将相同的值复制到低位,然后用符号位的副本填充新的高位。就是这样。
如果我们思考二进制补码的工作原理,就很容易看出这是正确的。如果数字是正数(符号位为0),那么它与无符号数相同,我们将用全零填充新空间,没有任何变化。如果数字是负数(符号位为1),那么我们将用1位填充新空间,但二进制补码操作意味着当我们需要获取数字的值时,这些位都会被反转为0,所以仍然没有任何变化。这些简单、高效的操作是二进制补码如此巧妙的原因,尽管起初看起来奇怪且过于复杂。
十六进制数
我将在本文中使用一些十六进制数字,但别担心,我不会试图再次教您如何在一个完全不同的数字系统中工作。您可以将十六进制视为二进制数字的简写。十六进制(简称“hex”)使用十进制数字0-9以及字母A-F,总共16个可能的数字。由于每个数字可以有16个值,每个数字可以代表四个二进制数字。
此外,C及其他地方的十六进制数字以0x开头。这不是数字的一部分,只是告诉您后面的内容是用十六进制编写的,以便您知道如何阅读它。
您不需要知道如何直接对十六进制数字进行任何算术运算或类似的事情,只需了解它们如何转换为二进制位。以下是单个十六进制数字到二进制位的转换:
|
|
C语言中的隐式转换
在C语言中,与某些语言不同,有许多不同的类型表示存储数字的不同方式;基本上,CPU可以处理的每一种和大小的数字在C中都有自己的类型。还有一个“默认”整数类型,称为int。int中有多少位取决于您使用的C编译器(及其设置)⁶,但语言标准保证它是有符号的。
由于C有这么多不同种类的数字,因此经常需要在它们之间进行转换。事实上,这太常见了,以至于语言设计者决定使这些转换 mostly automatic。这意味着,例如,这段代码可以编译并运行,如您所料:
|
|
尽管该代码中的类型都不匹配,但编译器只是为我们搞定了一切。很贴心,是吧?这些自动“修复”称为隐式转换,它们如何工作的规则很长且并不总是非常直观。这是C编程的一个主要陷阱,因为它发生在程序员甚至看不到的情况下,您只需要知道这些事情正在发生并意识到所有含义。
漏洞如何工作
这应该是我们理解这里出了什么问题所需的所有背景知识。现在,让我们再看一下原始的、未打补丁的函数声明:
|
|
前两个参数是内部数据结构和文本字符串,它们在这里不相关。但之后我们看到一个int参数,它旨在包含字符串参数的长度(在C中,字符串不知道自己的长度,程序员如果需要必须自己跟踪)。
在mar_insert_item函数的几行之后,我们找到了这个调用:
|
|
在继续之前,我将解释这一行的用途。mar_insert_item函数是读取MAR包中包含的所有文件索引的过程的一部分(它有点像ZIP文件,可以包含一堆不同的文件并压缩它们 all,您可以提取整个内容或仅提取单个文件)。mar_insert_item被重复调用,每个压缩文件一次,每次调用都会向逐渐构建的索引中添加一个条目。这一特定行只是将文件名复制到该索引条目中;memcpy当然是“memory copy”的缩写,其参数是要复制到的目标(即我们添加到索引中的项目的名称字段)、要复制自的源(名称字符串最初传递给mar_insert_item)以及需要复制的内存量(以字节为单位)。最后一个参数是一切出错的地方。
您认为如果mar_insert_item被调用时namelen设置为它可以存储的最高正值,即0x7fffffff,会发生什么?那么,在这一行代码中,程序做了所有这些事情:
- 给
namelen加1⁷。但我刚说namelen已经有它可以存储的最高正值,所以必须有所让步。C语言标准没有定义在这种情况下会发生什么,但在实践中,您在大多数计算机上得到的是…加法仍然发生。所以我们得到值0x80000000。但namelen是一个有符号整数,该值的符号位被设置!我们给一个正数加了1,它变成了一个负数。精确地说是-2,147,483,648⁸。计算机很怪。我们甚至还没完。 memcpy接受一个64位值,因此我们的临时值必须从32位扩展到64位。这意味着符号扩展;我们取最高有效位,即1,并将其复制到32个新位中,得到值0xFFFFFFFF80000000。请记住,符号扩展保留了二进制补码值,因此该数字的十进制版本仍然是-2,147,483,648,在此步骤中没有变化。memcpy接受的长度参数也应该是无符号的,因此现在该值已扩展到64位,我们取这些位并将它们解释为一个无符号数。我们不再有-2,147,483,648,我们现在有正数9,223,372,036,854,775,807。作为字节长度,那是超过一万亿太字节⁹。公平地说,这比我们真正打算在这里复制的字节要多。- 最后,调用
memcpy,它开始尝试从name复制到item->name。但由于符号扩展和无符号重新解释,我们可以看到它将尝试复制 waaaaay 比实际存在更多的字节。因此memcpy最终做的是复制所有存在的字节(memcpy即使我们喂它垃圾也会尽力为我们服务),然后…使程序崩溃。
这就是漏洞;更新程序就在这里崩溃。
修复如何工作
现在,有了所有这些背景知识,修复就完全合理了。更改参数的类型意味着在调用mar_insert_item时发生到无符号的转换,此时传入的值仍然是一个正数,因此此时转换是无害的(事实上,该操作在此时根本不做任何事情)。然后对无符号数进行+1操作,因此也是无害的,并且由于传递给memcpy的东西不再是有符号的,因此永远不需要进行符号扩展。一切都变得更容易理解,同时更加正确。
经验教训
不要使用C
隐式转换是一个错误的功能。它们在便利性方面给您带来的好处被 invisible bugs 的潜力所抵消。最近设计的语言往往对此类事情更加严格,例如Rust根本没有这些隐式转换,但C来自1970年代,而且当时有意义™。但在C中,这些事情无法真正避免,它们已融入语言中。我非常建议您为任何新程序使用另一种语言,出于这个原因以及其他各种原因¹⁰。
安全层
这个漏洞在实践中无法被利用,部分是因为它处于一个尴尬的利用位置,但也因为Firefox要求更新文件必须由Mozilla数字签名,否则不会被读取(超出检查签名所需的最低限度),更不用说应用了。这意味着任何想通过此