最近GD库拒绝服务漏洞分析与EXP构造(CVE-2018-5711)

0x00 前言

最近爆出PHP GD库拒绝服务攻击漏洞,影响的版本比较多。官方上有漏洞的报告,但是看下来还是有不懂的地方,于是下载源码自己分析下。

0x01 漏洞分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
There is a do-while in file `ext/gd/libgd/gd_gif_in.c` and function `LWZReadByte_`
do {
sd->firstcode = sd->oldcode =
GetCode(fd, &sd->scd, sd->code_size, FALSE, ZeroDataBlockP);
} while (sd->firstcode == sd->clear_code);
https://github.com/php/php-src/blob/c5767db441e4db2a1e07b5880129ad7ce0b25b6f/ext/gd/libgd/gd_gif_in.c#L460
The implementation of `GetCode` is in `GetCode_`
static int
GetCode_(gdIOCtx *fd, CODE_STATIC_DATA *scd, int code_size, int flag, int *ZeroDataBlockP)
{
int i, j, ret;
unsigned char count;
...
if ((count = GetDataBlock(fd, &scd->buf[2], ZeroDataBlockP)) <= 0)
scd->done = TRUE;
...
}
https://github.com/php/php-src/blob/c5767db441e4db2a1e07b5880129ad7ce0b25b6f/ext/gd/libgd/gd_gif_in.c#L376
As you can see, `GetDataBlock` will read the image data and return the length. If EOF, returned -1. But the variable `count` is `unsigned char`, will always be positive value. So the line `scd->done = TRUE` will never be executed.

根据官方的报告,LWZReadByte_ 这个函数会造成死循环,原因是由于count变量是unsigne char,永远不会是负数,从而无法判断图片是否读取完毕,造成scd->done = TRUE无法执行,一开始没有想到这个报告很懒,还疑问那岂不是所有的GIF图片都会造成拒绝服务了(还真去拿普通的GIF图片试了试)。

其实还要满足sd->firstcode == sd->clear_code才能造成死循环。

1
2
3
4
do {
sd->firstcode = sd->oldcode =
GetCode(fd, &sd->scd, sd->code_size, FALSE, ZeroDataBlockP);
} while (sd->firstcode == sd->clear_code);

那为什么报告中要指出scd->done = TRUE无法执行。看这个函数上面,发现有一个if的判断,如果scd->doneTrue,则会直接返回-1。那么sd->firstcode == sd->clear_code永远不会成立了,造成循环退出。所以scd->done一定不能为True
gd_gif_in.c#L389

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if ( (scd->curbit + code_size) >= scd->lastbit) {
if (scd->done) {
if (scd->curbit >= scd->lastbit) {
/* Oh well */
}
return -1;
}
scd->buf[0] = scd->buf[scd->last_byte-2];
scd->buf[1] = scd->buf[scd->last_byte-1];
if ((count = GetDataBlock(fd, &scd->buf[2], ZeroDataBlockP)) <= 0)
scd->done = TRUE;
scd->last_byte = 2 + count;
scd->curbit = (scd->curbit - scd->lastbit) + 16;
scd->lastbit = (2+count)*8 ;
}

上面仅仅是为了满足不返回-1,但是还要满足返回结果等于sd->clear_code。接下来的ret结果由下面的代码控制。通过构造GIF,可以控制ret的返回结果。而sd->clear_code也是可以控制。从而达到死循环。
gd_gif_in.c#L407

1
2
3
4
5
6
7
8
9
10
11
if ((scd->curbit + code_size - 1) >= (CSD_BUF_SIZE * 8)) {
ret = -1;
} else {
ret = 0;
for (i = scd->curbit, j = 0; j < code_size; ++i, ++j) {
ret |= ((scd->buf[i / 8] & (1 << (i % 8))) != 0) << j;
}
}
scd->curbit += code_size;
return ret;

0x02 EXP构造

漏洞成因分析完了,知道EXP的关键点是控制sd->clear_codeGetCode_函数返回结果一致。

1.控制sd->clear_code

首先分下sd->clear_code是从哪里获取的。

获取函数的参数input_code_size,然后再把1左移input_code_size位。得到sd->clear_code
gd_gif_in.c#L431

1
2
3
4
5
6
7
8
9
10
11
12
static int
LWZReadByte_(gdIOCtx *fd, LZW_STATIC_DATA *sd, char flag, int input_code_size, int *ZeroDataBlockP)
{
int code, incode, i;
if (flag) {
sd->set_code_size = input_code_size;
sd->code_size = sd->set_code_size+1;
sd->clear_code = 1 << sd->set_code_size ;
sd->end_code = sd->clear_code + 1;
sd->max_code_size = 2*sd->clear_code;
sd->max_code = sd->clear_code+2;

再追踪下调用LWZReadByte函数的地方,并且flagTRUE。这里看到input_code_sizec
gd_gif_in.c#L586

1
2
3
if (LWZReadByte(fd, &sd, TRUE, c, ZeroDataBlockP) < 0) {
return;
}

再追踪下c从哪里来的,通过ReadOKfd获取到的。其实也就是读取GIF图片里面一个字节。
gd_gif_in.c#L569

1
2
3
if (! ReadOK(fd,&c,1)) {
return;
}

前面使用ReadOK函数读取GIF图片的一些信息,比如GIF89a之类的。到这里读取到是哪个字节?读取的是UBYTE LZWMinimumCodeSize。如下图所示:

1.png

所以更改UBYTE LZWMinimumCodeSize的值则可以控制sd->clear_code的值。

2.控制GetCode_返回结果ret

接下来就是控制GetCode_的返回结果ret,由如下代码控制。
gd_gif_in.c#L389

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
if ( (scd->curbit + code_size) >= scd->lastbit) {
if (scd->done) {
if (scd->curbit >= scd->lastbit) {
/* Oh well */
}
return -1;
}
scd->buf[0] = scd->buf[scd->last_byte-2];
scd->buf[1] = scd->buf[scd->last_byte-1];
if ((count = GetDataBlock(fd, &scd->buf[2], ZeroDataBlockP)) <= 0)
scd->done = TRUE;
scd->last_byte = 2 + count;
scd->curbit = (scd->curbit - scd->lastbit) + 16;
scd->lastbit = (2+count)*8 ;
}
if ((scd->curbit + code_size - 1) >= (CSD_BUF_SIZE * 8)) {
ret = -1;
} else {
ret = 0;
for (i = scd->curbit, j = 0; j < code_size; ++i, ++j) {
ret |= ((scd->buf[i / 8] & (1 << (i % 8))) != 0) << j;
}
}
scd->curbit += code_size;
return ret;

最为关键的是如下代码。

1
2
3
for (i = scd->curbit, j = 0; j < code_size; ++i, ++j) {
ret |= ((scd->buf[i / 8] & (1 << (i % 8))) != 0) << j;
}

scd->buf是通过GetDataBlock获取到如下图data蓝色部分。内容全部为一样,因为可以使scd->buf[i / 8]保证获取到一个固定值。便于控制ret的结果。

2.png

还有(1 << (i % 8)),这个值是1、2、4、8、16、32、64、128的循环。综合这两点,是可以控制ret值了。

比如:如果想返回结果为2code_size控制为2的时候,再scd->buf[i/8]= 0xAA满足下面条件就可以返回2的结果。

1
scd->buf[i/8]&1==0 and scd->buf[i/8]&2!=0 and scd->buf[i/8]&4==0 and scd->buf[i/8]&8!=0 and scd->buf[i/8]&16==0 and scd->buf[i/8]&32!=0 and scd->buf[i/8]&64==0 and scd->buf[i/8]&128!=0

3.完整构造EXP过程

LZWMinimumCodeSize设置为1。那么sd->clear_code值为2。这个时候GetCode返回的值也必须是2

1
2
3
4
5
do {
sd->firstcode = sd->oldcode =
GetCode(fd, &sd->scd, sd->code_size, FALSE, ZeroDataBlockP);
} while (sd->firstcode == sd->clear_code);
return sd->firstcode;

此时code_size2

1
2
3
for (i = scd->curbit, j = 0; j < code_size; ++i, ++j) {
ret |= ((scd->buf[i / 8] & (1 << (i % 8))) != 0) << j;
}

意味着或运算两次。我们把第一次((scd->buf[i / 8] & (1 << (i % 8))) != 0) << j结果控制为0,第二次结果控制为2。这样0或2结果还是2。

1
2
ret=ret|((scd->buf[i / 8] & (1 << (i % 8))) != 0) << j;
ret=ret|((scd->buf[i / 8] & (1 << (i % 8))) != 0) << j;

那该怎么做尼?scd->buf[i/8]一直是固定值,(1 << (i % 8)))1、2、4、8、16、32、64、128的循环(至于为什么是这个循环,有点复杂,枯燥而且很长,由兴趣的可以自己下载php源码进行调试),写个python脚本遍历一下。寻找scd->buf[i / 8]为哪个固定值的时候,可以满足上面提出的条件。下面python脚本跑出结果x的值是170(0XAA)

1
2
3
for x in range(0,255):
if(x&1==0 and x&2!=0 and x&4==0 and x&8!=0 and x&16==0 and x&32!=0 and x&64==0 and x&128!=0):
print(x)

于是对正常的图片进行如下填充就完成EXP的构造了
3.png

再看下官方给出的EXP
4.png

code_size4,所以python脚本如下:

1
2
3
for x in range(0,255):
if(x&1==0 and x&2==0 and x&4==0 and x&8!=0 and x&16==0 and x&32==0 and x&64==0 and x&128!=0):
print(x)

跑出结果x136(0x88)。图片里面也是用0x88填充的。

0x03参考

https://bugs.php.net/bug.php?id=75571