接上文:
在本系列关于解释型语言底层攻击面的第一篇文章中,我们了解到,即使在Javascript、Python和Perl等解释型语言的核心实现中,内存安全也不是无懈可击的。
在本文中,我们将更加深入地探讨,在通过外部函数接口(Foreign Function Interface,FFI)将基于C/C++的库“粘合”到解释语言的过程中,安全漏洞是如何产生的。正如我们之前所讨论的,FFI充当用两种不同语言编写的代码之间的接口。例如,使一个基于C语言的库可用于Javascript程序。
FFI负责将编程语言A的对象翻译成编程语言B可以使用的东西,反之亦然。为了实现这种翻译,开发人员必须编写特定于语言API的代码,以实现两种语言之间的来回转换。这通常也被称为编写语言绑定。
从攻击者的角度来看,外部语言绑定代表了一个可能的攻击面。当处理一个从内存安全语言翻译成内存不安全语言(如C/C++)的FFI时,开发者就有可能引入内存安全漏洞。
即使高层的语言被认为是内存安全的,同时目标外部代码也经过了严格的安全审查,但是,在两种语言之间架起桥梁的代码中,仍可能潜伏着可利用的漏洞。
在这篇文章中,我们将仔细研究两个这样的漏洞,我们将一步步地了解攻击者如何评估你的代码的可利用性的。本文的目的是提高读者对exploit开发过程的理解,而不仅仅是针对一个具体的案例,而是从概念的角度来理解。通过了解exploit开发人员如何思考您的代码,帮您建立防御性的编程习惯,从而编写出更安全的代码。
在我们的案例研究中,我们将考察两个看起来非常相似的bug,然而只有一个是bug,而另一个则是一个安全漏洞。两者都存在于绑定Node.js包的C/C++代码中。
node-sass
Node-sass是一个库,它将Node.js绑定到LibSass(一款流行的样式表预处理器Sass的C版本)。虽然node-sass最近被弃用了,但它每周仍有500万次以上的下载量,所以,它是一个非常有价值的审计对象。
当阅读node-sass绑定时,我们注意到以下代码模式:
- int indent_len = Nan::To
- Nan::Get(
- options,
- Nan::New("indentWidth").ToLocalChecked()
- ).ToLocalChecked()).FromJust();
- [1]
- ctx_w->indent = (char*)malloc(indent_len + 1);
- strcpy(ctx_w->indent, std::string(
- [2]
- indent_len,
- Nan::To
- Nan::Get(
- options,
- Nan::New("indentType").ToLocalChecked()
- ).ToLocalChecked()).FromJust() == 1 ? '\t' : ' '
在[1]处,我们注意到一个受控于用户输入的32位整数值被用于内存分配。如果该用户提供的整数为-1,则整数算术表达式indent_len + 1的值将变成0。在[2]处,原始负值用于创建由indent_len字符组成的制表符或空格字符串,其中indent_len值为负,现在将变成一个相当大的正值,因为std::string构造函数期望接收无符号的长度参数,其类型为size_t。
在JS API级别,我们注意到indentWidth的检索方式如下所示:
- /**
- * Get indent width
- *
- * @param {Object} options
- * @api private
- */
- function getIndentWidth(options) {
- var width = parseInt(options.indentWidth) || 2;
- return width > 10 ? 2 : width;
- }
此处的目的是确保indentWidth >= 2或 <= 10,但实际上这里仅检查了上界,并且parseInt允许我们提供负值,例如:
- var sass = require('node-sass')
- var result = sass.renderSync({
- data: `h1 { font-size: 40px; }`,
- indentWidth: -1
- });
这将触发一个整数溢出,从而导致分配的内存不足,并进一步导致后续的内存被破坏。
为了解决这个问题,node-sass应该确保在将用户提供的indentWidth值传递给底层绑定之前,先检查该值的下界和上界。
全面地检查输入,并明确地将它们的取值范围限制在对程序逻辑有意义的范围内,这将很好地帮助您养成一种通用的防御性编程习惯。
所以我们来总结一下。这里的bug模式是什么?整数溢出,导致堆分配不足,其后的内存填充可能会破坏相邻的堆内存。听起来确实值得分配CVE,不是吗?
然而,虽然这个整数溢出确实会导致堆内存分配不足,但这个bug并不代表就是一个漏洞,因为这个样式表输入很可能不是攻击者控制的,并且在任何堆破坏发生之前,都会抛出std::string异常。即使发生了堆损坏,也只是一个非常有限的控制覆盖(借助于一个非常大的indent_len的制表符或空格字符),所以,实际被利用的可能性很低。
- anticomputer@dc1:~$ node sass.js
- terminate called after throwing an instance of 'std::length_error'
- what(): basic_string::_S_create
- Aborted (core dumped)
结论:只是一个bug。
那么,什么情况下攻击者才会对这样的bug感兴趣呢?攻击者能够对触发bug的输入施加影响。在这种情况下,不太可能有人为node-sass绑定提供受控于攻击者的输入。同时,内存破坏原语本身的控制能力也会非常有限。虽然确实存在这样的情况:即使是非常有限的堆损坏也足以充分利用某个缺陷,但通常攻击者会更乐于寻求具有某些控制权的情形,比如可以控制用于破坏内存的东西,或者可以控制覆盖的内存数量。最好是两者兼而有之。
在这种情况下,即使std::string构造函数没有退出,攻击者也必须用空格或制表符进行大规模的覆盖,以控制进程。虽然这并非完全不可能,但考虑到对周围内存布局的足够影响和控制,可能性仍然偏低。
在这种情况下,我们通常可以通过回答下面的三个问题,来进行一个简单的可利用性“嗅觉测试”:
- 攻击者是如何触发这个bug的?
- 攻击者控制了哪些数据,控制到什么程度?
- 哪些算法受到攻击者控制的影响?
除此之外,可利用性主要取决于攻击者的目标、经验和资源。这些我们可能一无所知。除非您花了很多时间实际编写exploit,否则很难确定某个问题是否可利用。特别是当您的代码被其他软件使用时,即您编写的是库代码,或者是一个更大系统中的一个组件。在一个孤立的环境中,某个错误看起来只是bug,在更大的范围内可能就是安全漏洞。
虽然常识对于确定可利用性有很大的帮助,但在时间和资源允许的情况下,任何可以由用户控制(或影响)的输入触发的bug都是潜在的安全漏洞,因此,将其视为安全漏洞是非常明智的做法。
png-img
对于我们的第二个案例研究,我们将考察GHSL-2020-142。这个bug存在于提供libpng绑定的node.js png-img包中。
当加载PNG图像进行处理时,png-img绑定将使用PNGIMG::InitStorage函数来分配用户提供的PNG数据所需的初始内存。
- void PngImg::InitStorage_() {
- rowPtrs_.resize(info_.height, nullptr);
- [1]
- data_ = new png_byte[info_.height * info_.rowbytes];
- [2]
- for(size_t i = 0; i < info_.height; ++i) {
- rowPtrs_[i] = data_ + i * info_.rowbytes;
- }
- }
在[1]处,我们观察到为一个大小为info_.height * info_.rowbytes的png_byte数组分配了相应的内存。其中,结构体成员height和rowbytes的类型都是png_uint_32,这意味着这里的整数算术表达式肯定是无符号32位整数运算。
info_.height可以直接作为32位整数从PNG文件提供,info_.rowbytes也可以从PNG数据派生。
这种乘法运算可能会触发整数溢出,导致data_内存区域分配不足。
例如,如果我们将info_.height设置为0x01000001,而info_.rowbytes的值为0x100,那么生成的表达式将是(0x01000001 * 0x100) & 0xffffffff ,其值为0x100。这样的话,data_将作为一个0x100大小的png_byte数组来分配内存,这明显不够用。
随后,在[2]处,将使用行数据指针填充rowPtrs_array,这些指针指向所分配的内存区的边界之外,因为for循环条件是对原始的info_.height值进行操作的。
一旦实际的行数据被从PNG文件中读取,任何与data_区域相邻的内存都可能被攻击者控制的行数据覆盖,最高可达info_.height * info_.rowbytes字节,这给任何潜在的攻击者提供了大量可控的进程内存。
需要注意的是,根据攻击者的意愿,可以通过不从PNG本身提供足够数量的行数据来提前停止覆盖,这时libpng错误例程就会启动。任何后续处理错误路径的程序逻辑都会在被破坏的堆内存上运行。
这很有可能导致一个高度受控(无论是内容还是大小)的堆溢出漏洞,我们的直觉是,这个bug可能是一个可利用的安全漏洞。
下面,让我们来回答可利用性问题,以确定这个bug是否对攻击者具有足够的吸引力。
攻击者是如何触发该bug的?
这个bug是由攻击者提供的PNG文件触发的。攻击者可以完全控制在png-img绑定中作用于PNG的任何数据,并废除文件格式完整性检查所施加的任何限制。
因为攻击者必须依赖于加载的恶意PNG文件,我们可以假设任何利用逻辑都可能必须包含在这个单一的PNG文件中。这意味着,攻击者与目标Node.js进程反复交互的机“可能”更少,例如,实施信息泄露,以帮助后续的漏洞利用过程绕过任何系统级别的缓解措施,如地址空间布局随机化(ASLR)。
我们说“可能”,是因为我们无法预测png-img的实际使用情况。换句话说,也可能存在这样的使用情况:存在可重复的交互机会,来触发该bug或进一步帮助利用该bug。
攻击者能够控制哪些数据,控制到什么程度?
攻击者可以提供所需的height和rowbytes变量,以便对整数运算和后续的整数封装(integer wrap)进行精细控制。被封装的值用于确定data_数组的最终分配内存的大小。它们也可以通过PNG图像本身提供完全受控的行数据,这些数据通过rowPtrs数组中的越界指针值填充到越界内存中。他们可以通过提前终止提供的行数据,精细控制这个攻击者提供的行数据有多少被填充到内存中。
简而言之,攻击者可以通过精细控制内容和长度来覆盖任何与data_相邻的堆内存。
哪些算法会受到攻击者控制的影响?
由于我们处理的是堆溢出,攻击者的影响扩展到任何涉及被破坏的堆内存的算法。这可能涉及Node.js解释器代码、系统库代码,当然还有绑定代码和任何相关库代码本身。
小结
在本文中,我们将深入地探讨,在通过外部函数接口(Foreign Function Interface,FFI)将基于C/C++的库“粘合”到解释语言的过程中,安全漏洞是如何产生的。由于篇幅过长,我们将分为多篇进行介绍,更多精彩内容,敬请期待!
本文翻译自:https://securitylab.github.com/research/now-you-c-me-part-two