Galaxy Lab

focus on information security

用一张GIF图片挂起网站

前段时间,台湾安全大牛Orange Tsai发现了PHP GD拓展库的一个拒绝服务漏洞CVE-2018-5711;Orange Tsai本人简单介绍了该漏洞形成原理,Freebuf对该漏洞也进行了报道;根据这两篇文章的描述,漏洞影响了众多系列和版本的PHP:

PHP 5 < 5.6.33

PHP 7.0 < 7.0.27

PHP 7.1 < 7.1.13

PHP 7.2 < 7.2.1

我在PHP 5.6.31上进行了验证,发现PHP占了极高的CPU使用率:

PID    USER    PR   NI   VIRT    RES   SHR  S  %CPU  %MEM  TIME+  COMMAND

18023  root     20   0  235044   9160  5416  R  99.0   0.9    0:19.98   php

1、问题

按照Orange Tsai和Freebuf的文章所述,漏洞根本上是GetCode_()函数里的count变量类型为unsigned char所导致的:

《用一张GIF图片挂起网站》

而GetDataBlock()所包装的真正函数GetDataBlock_():

《用一张GIF图片挂起网站》

返回值为-1,0到255;所以,如果GetDataBlock_返回-1,则第399行中的scd->done将会被设置为True,并停止下面LWZReadByte_()里的while循环;但是现在count的类型是unsigned char,取值范围是0到255,所以这种循环停止动作是不会被触发执行的。

《用一张GIF图片挂起网站》

对于上述的描述,我并不理解:如果GetDataBlock_返回0,第399行的scd->done也会设置为True,那么也应该可以使得上面的while循环停止?我感觉是针对GetDataBlock_()返回-1这种情况,count的值由-1强制转化为255时,发生了什么,导致GetCode_返回的值始终是sd->clear_code。

2、GIF格式和LZW压缩算法

在分析该漏洞之前,需要先了解下GIF格式,GIF文件的格式总体如下图:

《用一张GIF图片挂起网站》

这里就不分析文件头,图像控制拓展,注释拓展,图像文本拓展,应用程序拓展等结构了。我们要注意到:

  • 逻辑屏幕描述符中的m表示其后是否紧跟着全局颜色列表;而2^(pixel+1)表示全局颜色列表中的索引数;而在图像描述符中,有着类似的成员来规定其局部颜色列表;
  • 全局颜色列表中,用3个字节表示一种颜色;而每张图片中的局部颜色列表和全局颜色列表结构一致;
  • 图像数据部分,基于全局颜色列表或者局部颜色列表,并且采用了LZW压缩算法,其第一个字节(Code Size)说明了表示图片里不同颜色至少需要多少个bit;
  • 未编码之前数据中每个byte表示图像中一个像素点的颜色在颜色列表中的索引;对这些bytes进行LZW编码,编码长度为(Code Size+1)bit到12bit,将编码转换为bytes后,再分成N个数据块,每个数据块长度小于256字节;

对于LZW压缩算法,其主要原理是:

一开始基于原数据中每个不同的byte初始化一个编码表(编码表的编码会动态增加),然后定义一个Current_Prefix(初始化为NULL),读取原数据中的一个byte:Current_Byte,Current_String=Current_Prefix+Current_Byte:

1、若发现Current String在编码表中,则Current_Prefix=Current_String,然后操作下一个byte;

2、如不在编码表中,则会输出Current_Prefix所对应的编码code_x,Current_Prefix=Current_Byte,然后将Current_String编码为code_y,加到编码表中;显然,用code_x来表示原数据中的bytes能够减少数据长度。而且,code_x所对应的bytes_x是code_y对应的bytes_y的前缀,在后面的编码过程中,编码器会在Current_Prefix=bytes_y时输出,而不会停留在bytes_x。

GIF使用的LZW压缩算法针对上述标准的LZW算法进行了调整;

  • 定义Clear_Code=2^(Code Size),在新开始一个编译表时,输出Clear_Code;那么解码时,如果遇到这个值,需要初始化编译表;(所以前文的while循环可能为了跳过编码数据里最开始的Clear_Code);
  • 定义End_Code=Clear_Code+1,在输出块终结器(0x00)之前输出该值;
  • 定义Max_Code=Clear_Code+2,显然编码过程中出现的新的编码值从Max_Code开始;
  • 每个编码长度在Code Size+1到12之间,当新的编码值出现使得编码长度不够表示所有编码时,需要增加编码长度;

关于GIF格式和LZW压缩算法更细致的介绍请见《gif格式图片详细解析》;

3、答案

首先看下在解析GIF图片时整个调用流程;

《用一张GIF图片挂起网站》

再看下GetCode_()的代码:

《用一张GIF图片挂起网站》

根据前文的描述,GetCode_()其实从scd->buf里读取code_size个bit,408-413行代码的作用就是如此;

scd->curbit则记录当前处理的bit位置,scd->lastbit记录scd->buf最后一个bit;

而388-404行代码,(scd->curbit + code_size) >= scd->lastbit成立的话,则说明需要调用GetDataBlock_()读取

新的数据块到scd->buf中,其中要小心处理一个code在两个数据块的中情况;

我们还注意到一个细节,读取新的数据块之前,没有清空上一次读取到scd->buf中已经处理过的数据;

根据Orange Tsai提供的poc(长度0x6c3个字节):

《用一张GIF图片挂起网站》

故有Code Size=3,所以有编码长度code size=4,clear code=8;

  • 第一个数据块count=255,而且数据部分0xff个字节都是0x88;所以,GetCode_()在处理第一个数据块时,返回的都是clear code;
  • 处理第2到0x0A个数据块时,都是count=0x88,0x88个字节都是0x88;

C、最后一个数据块为count=0x88,但实际数据只有0x3F个字节;这样,在GetDataBlock_()的343行中就会返回-1,但此时-1被强制转换成了255;虽然GetDataBlock_()调用失败,我们注意到前面提到的细节:scd->buf在读取新的数据块之前并没有reset,他里面都是0x88,故后续对scd->buf的操作等同于数据块count=255时的情况;

D、当前数据块(最后一个数据块)处理结束后,会再次进入GetCode_(),调用GetDataBlock_()来继续读取最后一个数据块,但又再次进入C的流程;这样就一直循环尝试读取解析最后一个数据块。

其实根据前面的分析,可以让poc更小一些,直接使第二个数据块是最后一块,数据块长度count>0,但实际的数据部分无任何数据;

《用一张GIF图片挂起网站》