通过修改函数签名修复安全漏洞 – 攻击与防御(存档)
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应该比它多一,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有害。
因此,计算机(和许多语言,包括C)提供了两种不同的整数类型,程序员在需要整数时可以在它们之间选择:“有符号”或“无符号”。“有符号”意味着数字可以是负数或正数(或零),“无符号”意味着它只能是正数(或零)。
我们到目前为止讨论的是无符号整数,那么有符号整数如何工作?首先,数字的第一位不再是数字本身的一部分,它现在是“符号位”。如果符号位是0,数字是非负的(零或正数),如果符号位是1,数字是负数。但是,当符号位是1时,我们需要几个额外的步骤来在二进制和十进制之间转换。以下是过程。
- 在做任何其他事情之前丢弃符号位。
- 反转数字中的所有其他位,意思是使每个1变为0,反之亦然。
- 以通常的方式将该二进制数字(位翻转后的那个)转换为十进制。
- 给该结果加1。
这个操作,带有反转和加1,称为“二进制补码”,它将得到负数的值。让我们再举一个简单的例子。
假设我们有一个有符号的8位整数,值是11010110。那在十进制中是什么?嗯,我们立即看到符号位被设置了,所以我们需要取二进制补码。首先,我们需要翻转除符号位之外的所有位,所以我们得到0101001。现在我们将其转换为十进制并加1。
|
|
现在只需记住加回负号,我们得到-42。那就是我们的数字!11010110解释为有符号整数是-42。
为什么?
我们为什么要费心做这些?为什么不做一些简单的事情,比如有一个符号位然后只是常规数字?嗯,二进制补码表示有一个巨大的优势:您在进行基本算术时可以完全忽略它。完全相同的硬件和逻辑可以对无符号数和有符号二进制补码数进行算术运算。这意味着硬件更简单,这意味着它更小、更便宜、更快。这在数字计算机的早期更重要,这就是为什么二进制补码作为标准流行起来,并且今天仍然与我们同在。
符号扩展
二进制补码让我们做的另一个巧妙把戏我们需要谈谈。计算机中的整数具有固定的“宽度”,或用于表示它们的位数。更宽的整数可以表示更大(或更负)的数字,但占用计算机内存中更多空间。因此为了平衡这些关注点,像C这样的语言让程序员可以选择几种不同的位宽用于他们的整数。
那么,如果我们需要在不同宽度的整数之间进行一些算术运算,或者只是将一个整数传递给一个比函数期望的更窄的函数,会发生什么?我们需要一种使整数更宽的方法。如果它是无符号的,那很容易;将相同的值复制到较低(右侧)位,然后用0填充新的高位,您将得到相同的值,只是现在有更多位。
但如果我们需要加宽一个有符号整数呢?二进制补码在这里用一个称为“符号扩展”的解决方案来拯救我们。事实证明,我们要使一个二进制补码整数更宽所需要做的就是将相同的值复制到低位,然后用符号位的副本填充新的高位。就是这样。
如果我们思考二进制补码的工作原理,很容易看出为什么这是正确的。如果数字是正数(符号位是0),那么它与无符号数相同,我们将用全零填充新空间,没有任何变化。如果数字是负数(符号位是1),那么我们将用1位填充新空间,但二进制补码操作意味着当我们需要获取数字的值时,这些位都被反转为0,所以仍然没有任何变化。这些简单、高效的操作是为什么二进制补码如此巧妙的原因,尽管起初看起来奇怪且过于复杂。
十六进制数字
我将在本文中使用一些十六进制数字,但别担心,我不会试图再次教您如何用一整个不同的数字系统工作。您可以将十六进制视为二进制数字的简写。十六进制(简称“hex”)使用十进制数字0-9以及字母A-F,总共16个可能的数字。由于每个数字可以有16个值,每个可以代表四个二进制数字。
此外,C及其他地方的十六进制数字以0x开头。那不是数字的一部分,它只是告诉您之后的东西是用十六进制写的,以便您知道如何阅读它。
您不需要知道如何直接对十六进制数字进行任何算术运算或类似的事情,只需看看它们如何转换为二进制位。以下是单个十六进制数字到二进制位的转换:
|
|
C中的隐式转换
在C中,与某些语言不同,有许多不同的类型表示存储数字的不同方式;基本上,CPU可以处理的每一种和大小的数字在C中都有自己的类型。还有一个“默认”整数类型,称为int
。int
中有多少位取决于您使用的C编译器(及其设置),但语言标准保证它是有符号的。
由于C有这么多不同种类的数字,经常需要在它们之间转换。事实上如此常见,以至于语言设计者决定使这些转换大部分自动。这意味着,例如,这段代码编译并运行如您可能期望的那样:
|
|
尽管该代码中的类型都不匹配,编译器只是为我们使一切工作。它很好,是吧?这些自动“修复”称为隐式转换,它们如何工作的规则很长且并不总是非常直观。这是C编程的一个主要陷阱,因为它发生在程序员甚至看不到的情况下,您只需要知道这些事情正在发生并意识到所有含义。
漏洞如何工作
这应该是我们理解这里出了什么问题所需的所有背景知识。现在,让我们再看一下原始的、未打补丁的函数声明:
|
|
前两个参数是一个内部数据结构和文本字符串,它们在这里不相关。但之后我们看到一个int
参数,它旨在包含字符串参数的长度(在C中,字符串不知道自己的长度,程序员如果需要必须跟踪它)。
在mar_insert_item
函数的几行中,我们找到了这个调用:
|
|
在我们继续之前,我会解释这一行的用途。mar_insert_item
函数是读取MAR包中包含的所有文件索引的过程的一部分(它有点像ZIP文件,可以包含一堆不同的文件并压缩它们所有,您可以提取整个东西或只是单个文件)。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
。但由于符号扩展和无符号重新解释,我们可以看到它将尝试复制远远超过实际存在的字节。所以memcpy
最终做的是复制所有存在的字节(memcpy
即使我们喂它垃圾也尽力为我们服务),然后…使程序崩溃。
这就是漏洞;更新程序就在这里崩溃。
修复如何工作
现在,有了所有这些背景知识,修复就完全合理了。更改参数的类型意味着转换为无符号发生在调用mar_insert_item
时,此时传入的值仍然是正数,因此此时转换是无害的(事实上它什么也不做,该操作在此时根本不做任何事情)。然后对无符号数进行+1,也是无害的,并且因为没有符号扩展,传递给memcpy
的东西不再是有符号的。一切都变得更容易理解,同时更正确。
经验教训
不要使用C
隐式转换是一个错误特性。它们在便利性上给予您的东西被潜在不可见漏洞的可能性所抵消。最近设计的语言往往对此类事情更严格,例如Rust根本没有这些隐式转换,但C来自1970年代,当时它是有意义的™。但在C中,这些事情无法真正避免,它们已融入语言中。我非常建议为您处理的任何新程序使用另一种语言,出于这个和各种其他原因。
安全层
这个漏洞在实践中无法利用,部分是因为它只是在一个尴尬的地方利用,但也因为Firefox要求更新文件由Mozilla数字签名,否则不会被读取(超出检查签名所需的最低限度),更不用说应用了。这意味着任何想通过此漏洞攻击Firefox用户的人还必须破坏Mozilla的构建基础设施并使用它来签署他们自己的恶意MAR文件。拥有那额外的安全层使得围绕MAR文件的大多数问题变得非常非常不令人担忧。
您可以进行系统编程
我希望传达的一点(我承认这可能不是提出这一点的理想主题,但对我来说这是一个重要的点)是,低级(“系统”)编程不是魔法或以任何方式真正特殊。确实有很多事情在进行,有很多小细节,但对于任何类型的编程,或任何涉及计算机的事情来说都是如此。这里