0x00 内容简介
最近openssl又除了一系列问题,具体可以看这里。CVE-2016-0799只是其中一个比较简单的漏洞。造成漏洞的原因主要有两个。
doapr_outch
中有可能存在整数溢出导致申请内存大小为负数
doapr_outch
函数在申请内存失败时没有做异常处理
0x01 源码分析
首先,去github上找到了这一次漏洞修复的commit,可以看到主要修改的是doapr_outch
函数。
有了一个大致的了解之后,将代码切换到bug修复之前的版本。函数源码如下:
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 30 31 32 33 34 35 36 37 38 39 40
| 697 static void 698 doapr_outch(char **sbuffer, 699 char **buffer, size_t *currlen, size_t *maxlen, int c) 700 { 701 702 assert(*sbuffer != NULL || buffer != NULL); 703 if (*buffer == NULL) { 704 705 assert(*currlen <= *maxlen); 706 707 if (buffer && *currlen == *maxlen) { 708 *maxlen += 1024; 709 if (*buffer == NULL) { 710 *buffer = OPENSSL_malloc(*maxl 711 712 return; 713 } 714 if (*currlen > 0) { 715 assert(*sbuffer != NULL); 716 memcpy(*buffer, *sbuffer, *currlen); 717 } 718 *sbuffer = NULL; 719 } else { 720 *buffer = OPENSSL_realloc(*buffer, *maxlen); 721 if (!*buffer) { 722 723 return; 724 } 725 } 726 } 727 728 if (*currlen < *maxlen) { 729 if (*sbuffer) 730 (*sbuffer)[(*currlen)++] = (char)c; 731 else 732 (*buffer)[(*currlen)++] = (char)c; 733 } 734 735 return; 736 }
|
我是看完了一篇国外的分析文章之后了解了整个漏洞的流程,这里我就试图反向的思考一下这个漏洞。希望可以提高从代码补丁中寻找重现流程的能力。
1.1 寻找内存改写的方式
因为通过补丁已经知道是doapr_outch
函数导致的堆腐败问题,所以doapr_outch
一定存在改写数据的代码段。可以看到除了728-734行代码是对内存的改写外,没有其他地方操作内存的内容了。
1 2 3 4 5 6
| 728 if (*currlen < *maxlen) { 729 if (*sbuffer) 730 (*sbuffer)[(*currlen)++] = (char)c; 731 else 732 (*buffer)[(*currlen)++] = (char)c; 733 }
|
这里改写内存的方式可以用伪代码简单总结一下:
所以想要向指定的内存写入数据的话需要控制base
与offset
两个参数。而写入的数据是c
。如果控制了base
与offset
那么每次调用函数就可以改写一个字节。
如果是有经验的开发人员可以很容易看出外部在调用的时候一定是循环调用了doapr_outch
,看一看函数调用处的代码。
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
| 425 static void 426 fmtstr(char **sbuffer, 427 char **buffer, 428 size_t *currlen, 429 size_t *maxlen, const char *value, int flags, int min, int max) 430 { 431 int padlen, strln; 432 int cnt = 0; 433 434 if (value == 0) 435 value = "<NULL>"; 436 for (strln = 0; value[strln]; ++strln) ; 437 padlen = min - strln; 438 if (padlen < 0) 439 padlen = 0; 440 if (flags & DP_F_MINUS) 441 padlen = -padlen; 442 443 while ((padlen > 0) && (cnt < max)) { 444 doapr_outch(sbuffer, buffer, currlen, maxlen, ' '); 445 --padlen; 446 ++cnt; 447 } 448 while (*value && (cnt < max)) { 449 doapr_outch(sbuffer, buffer, currlen, maxlen, *value++); 450 ++cnt; 451 } 452 ... 453 }
|
可以看到,确实是通过循环来改写内存的。
1.2 副作用编程
函数副作用会给程序设计带来不必要的麻烦,给程序带来十分难以查找的错误,并且降低程序的可读性。严格的函数式语言要求函数必须无副作用。
副作用编程带来的不必要麻烦有一句更通俗的话可以来说明。开发一时爽,调试火葬场。这里再来看一下
doapr_outch
的函数声明
1
| static void doapr_outch(char **, char **, size_t *, size_t *, int);
|
从声明不难看出sbuffer
,buffer
,currlen
,maxlen
这几个参数在函数第n次运行时候如果被改变了,那么第n+1次运行的时候,这些参数将使用上次改变了的值。
再结合代码写入处内存改写的方式,就可以肯定sbuffer
和buffer
一定有一个或者全部被改写了,导致进入了意料之外的逻辑。
1 2 3 4 5 6
| 728 if (*currlen < *maxlen) { 729 if (*sbuffer) 730 (*sbuffer)[(*currlen)++] = (char)c; 731 else 732 (*buffer)[(*currlen)++] = (char)c; 733 }
|
因为Malloc
或者Realloc
出来的地址一定不是可控的,而系统传进来的sbuffer
也一定不可控,再结合上面的代码,如果sbuffer
或者buffer
指向NULL
的话,基址就是固定的了。
718行的代码会将sbuffer
设置为空指针。而buffer
编程空指针只能是申请内存失败的时候。
在结合上728-733行代码,要做到这一步一定要满足的条件是*sbuffer
与*buffer
都指向NULL
,导致代码进入改写*buffer
为基址的内存块。其他任何情况都无法做到内存开始地址可控。
所以再分代码,看流程是否可能将*sbuffer
与*buffer
赋值为NULL。
1.3 改写sbuffer与buffer
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 30 31 32
| 697 static void 698 doapr_outch(char **sbuffer, 699 char **buffer, size_t *currlen, size_t *maxlen, int c) 700 { 701 702 assert(*sbuffer != NULL || buffer != NULL); 703 if (*buffer == NULL) { 704 705 assert(*currlen <= *maxlen); 706 707 if (buffer && *currlen == *maxlen) { 708 *maxlen += 1024; 709 if (*buffer == NULL) { 710 *buffer = OPENSSL_malloc(*maxl 711 712 return; 713 } 714 if (*currlen > 0) { 715 assert(*sbuffer != NULL); 716 memcpy(*buffer, *sbuffer, *currlen); 717 } 718 *sbuffer = NULL; ... 728 if (*currlen < *maxlen) { 729 if (*sbuffer) 730 (*sbuffer)[(*currlen)++] = (char)c; 731 else 732 (*buffer)[(*currlen)++] = (char)c; 733 } 734 735 return; 736 }
|
在循环调用doapr_outch
之后,当*currlen == *maxlen
成立的时候就会进入内存申请模块,因为*buffer
还没有申请过所以进入上面一个分支,申请内存后将*sbuffer
设为NULL。
还需要将*buffer
设为NULL。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 707 if (buffer && *currlen == *maxlen) { 708 *maxlen += 1024; 709 if (*buffer == NULL) { 710 *buffer = OPENSSL_malloc(*maxl 711 712 return; 713 } 714 if (*currlen > 0) { 715 assert(*sbuffer != NULL); 716 memcpy(*buffer, *sbuffer, *currlen); 717 } 718 *sbuffer = NULL; 719 } else { 720 *buffer = OPENSSL_realloc(*buffer, *maxlen); 721 if (!*buffer) { 722 723 return; 724 } 725 } 726 }
|
再一次*currlen == *maxlen
之后,又会进入内存分配阶段,这次会进入Realloc
的分支,那么只要realloc
失败的话,*buffer
就会被赋值为NULL。
最简单的情况就是堆上内存用完了,这个时候buffer就是NULL了,这个时候就可以根据currlen以及后续的c来改写目标地址的数据了。但是堆上内存用完,导致申请内存返回NULL,是一件不可控的事情。
那么除了这种情况,还有什么情况下,realloc会返回NULL呢。
1 2 3 4 5 6 7 8 9
| 375 void *CRYPTO_realloc(void *str, int num, const char *file, int line) 376 { 377 void *ret = NULL; 378 379 if (str == NULL) 380 return CRYPTO_malloc(num, file, line); 381 382 if (num <= 0) 383 return NULL;
|
可以注意到在708行,对*maxlen做了增加1024的操作,那么如果maxlen怎么1024之后超过int的范围,就会导致realloc传入的size是一个负数。这个时候buffer就会因为realloc的参数错误被设置为NULL。然后因为出错,函数退出。
1.3 出错不处理
1 2 3 4
| 448 while (*value && (cnt < max)) { 449 doapr_outch(sbuffer, buffer, currlen, maxlen, *value++); 450 ++cnt; 451 }
|
从这里可以看到,*buffer
被设置为NULL,返回出来了。但是外面的循环什么都没干,又继续执行了。
这个时候就可以做内存改写了。currlen与c都是与我们传递的字符串相关的,这个很好理解了。
0x02 小结
- 开发过程中出错一定要处理
- 数据类型不同,在隐形的转换时,一定要小心
接下来要做的事情就是根据对漏洞的理解编写一个POC来调试。这样可以加深对漏洞的理解。在开发中也能更好的引以为戒。
0x03 参考
1.OpenSSL CVE-2016-0799: heap corruption via BIO_printf
https://guidovranken.wordpress.com/2016/02/27/openssl-cve-2016-0799-heap-corruption-via-bio_printf/
PS:
这是我的学习分享博客http://BLOGIMAGE/
欢迎大家来探讨,不足之处还请指正。