libzip历史漏洞非常少,一只手能数完。CVE-2017-12858是二次释放导致的漏洞,其补丁代码非常简单:
lib/zip_dirent.c:581 if (!_zip_dirent_process_winzip_aes(zde, error)) { - if (!from_buffer) { - _zip_buffer_free(buffer); - } return -1; }
即删掉了一段释放内存的代码。
如此看来二次释放中其中一次应该来自于此,那另一次呢,发现距离它不远处就有:
lib/zip_dirent.c:570 if (!from_buffer) { _zip_buffer_free(buffer); }
显然,问题的本因是_zip_buffer_free对同一buffer进行了前后两次释放。
不过两次释放都有各自的条件:
第一次要求from_buffer = 0;
第二次要求_zip_dirent_process_winzip_aes返回值为0。
达成第一个条件
两次调用位于_zip_dirent_read,函数开头有对from_buffer的赋值:
_zip_dirent_read(zip_dirent_t *zde, zip_source_t *src, zip_buffer_t *buffer, bool local, zip_error_t *error) { zip_uint8_t buf[CDENTRYSIZE]; zip_uint16_t dostime, dosdate; zip_uint32_t size, variable_size; zip_uint16_t filename_len, comment_len, ef_len; bool from_buffer = (buffer != NULL);
即传入的buffer如果为空,from_buffer就置0。查看libzip的源码,调用_zip_dirent_read时buffer为空的仅一处:
_zip_checkcons(zip_t *za, zip_cdir_t *cd, zip_error_t *error) { zip_uint64_t i; zip_uint64_t min, max, j; struct zip_dirent temp; ... if (_zip_dirent_read(&temp, za->src, NULL, true, error) == -1) { _zip_dirent_finalize(&temp); return -1; } ...
解析zip文件时,要保证进入_zip_checkcons函数,才能传入空buffer到_zip_dirent_read,继续寻找_zip_checkcons的调用位置:
_zip_find_central_dir(zip_t *za, zip_uint64_t len) { ... cdir = NULL; match = _zip_buffer_get(buffer, 0); while ((match=_zip_memmem(match, _zip_buffer_left(buffer)-(EOCDLEN-4), (const unsigned char *)EOCD_MAGIC, 4)) != NULL) { _zip_buffer_set_offset(buffer, (zip_uint64_t)(match - _zip_buffer_data(buffer))); if ((cdirnew = _zip_read_cdir(za, buffer, (zip_uint64_t)buf_offset, &error)) != NULL) { if (cdir) { if (best <= 0) { best = _zip_checkcons(za, cdir, &error); } a = _zip_checkcons(za, cdirnew, &error); if (best < a) { _zip_cdir_free(cdir); cdir = cdirnew; best = a; } else { _zip_cdir_free(cdirnew); } } else { cdir = cdirnew; if (za->open_flags & ZIP_CHECKCONS) best = _zip_checkcons(za, cdir, &error); else { best = 0; } } cdirnew = NULL; } ...
幸运的是,所有的_zip_checkcons调用都集中在此,并且用zipcmp test.zip test.zip或其他方式调用libzip解析zip时,这个while循环体路径是一定会被执行的。现在只要保证while循环时进入_zip_checkcons即可。简单尝试后发现else分支里za->open_flags无论如何都无法包含ZIP_CHECKCONS标记,因而只能诉诸 if(cdir) 成立。cdir初始值为NULL,但在循环末尾有cdir = cdirnew的赋值。所以循环必须执行两次以上,并且第一次cdirnew要被_zip_read_cdir成功赋值,第二次循环时cdir才不为空。
要控制循环,得先搞清楚zip的解析流程。ZIP文件本身由多个PK\x\y的header组成。其中x和y取值不同,header的作用也不一样。一般文件和目录的描述header中x=3,y=4,而文件末尾标记x=5,y=6。另外位于central directory的x=1,y=2。循环时,zip_memmem函数用于定位zip文件的末尾标记EOCD_MAGIC(PK\5\6)。定位后,_zip_read_cdir会从该标记往前找到central directory尝试读取zip中存放的文件内容。
我尝试用winrar构造的zip文件,全都是几个PK\3\4后面接着几个PK\1\2然后就是PK\5\6收尾了。对于这样的zip文件,while循环走一轮就结束,没机会进入_zip_checkcons。如果手动伪造一个PK\5\6添加到末尾,虽然循环会走第二次,但第二次_zip_read_cdir读取失败,仍然走不下去。
最后尝试将一个普通的zip追加到自身末尾,cat xx.zip >> xx.zip,while循环走二轮且_zip_read_cdir返回值也正常了。之后调试跟踪_zip_checkcons,发现已经可以进入第一段释放代码了。
达成第二个条件
接下来要让_zip_dirent_process_winzip_aes返回0,进行第二次释放。跟进_zip_dirent_process_winzip_aes函数,代码如下:
_zip_dirent_process_winzip_aes(zip_dirent_t *de, zip_error_t *error) { zip_uint16_t ef_len; zip_buffer_t *buffer; const zip_uint8_t *ef; bool crc_valid; zip_uint16_t enc_method; if (de->comp_method != ZIP_CM_WINZIP_AES) { return true; }
发现只要de->comp_method为ZIP_CM_WINZIP_AES,后面的代码会有多次校验,有很大几率返回false,导致第二次释放。所以当务之急是将de->comp_method控制为ZIP_CM_WINZIP_AES(0x63)。回到调用_zip_dirent_process_winzip_aes的函数_zip_dirent_read中,在函数开头可以看到相关赋值:
_zip_dirent_init(zde); if (!local) zde->version_madeby = _zip_buffer_get_16(buffer); else zde->version_madeby = 0; zde->version_needed = _zip_buffer_get_16(buffer); zde->bitflags = _zip_buffer_get_16(buffer); zde->comp_method = _zip_buffer_get_16(buffer);
调试定位后,发现只要将zip文件第9个字节改为0x63,zde->comp_method就会变为ZIP_CM_WINZIP_AES了,并且后续_zip_dirent_process_winzip_aes调用果然返回0,导致第二次释放。