2023 Seccon Final Pwn Bomberman
Game + Pwn, 挺有意思的一题, 赛时找到了关键漏洞之一, 但还是差点蛮多, 没做出来. 复现一波.
题目给了源代码, 而且远程是 ssh 上去连上去交互. 代码是 cpp 实现的一个炸弹人小游戏.
代码不长, 看一遍玩一下就大概知道了. 墙不能被炸开, flag 被墙包围着, 走到 flag 才能拿 flag.
看完一个奇怪的地方是 tick()
里面对 _bomb
的处理, 是炸弹爆炸时, delete _bomb.get()
, 过 1s 火焰消失后, 再去 _bomb.release()
:
|
|
这和正常操作智能指针的情况相反.
注意到放置和拾取炸弹用的是 std::move()
, 所以能够想到的是炸弹爆炸后, 再想办法捡起来, 就能够回到 Player
中, 造成悬垂指针.
赛时想的很天真, tick()
最后处理的炸弹, 看 main()
函数接着就是处理可能的输入, 然后 draw()
绘制界面, 那不是直接卡一个时机捡起来就完事了. 但是 draw()
中其实还含有一部分逻辑处理, 角色碰到火焰死亡的部分. 虽然捡起了炸弹, 但是火焰已经生成了, 并且角色就站在火焰上, 导致游戏结束. 然后就吐槽了一下绘制里塞逻辑的写法导致我做不出题, 后来想想其实放在 tick()
处理也是里一样的, 都不能来到下一次输入, 逃离火焰. (虽然这种写法看起来也是真的难受, 逻辑全在 tick()
处理了不好吗结构清晰)
然后看了半天也没看出来哪里还有问题就忙活其他事去了. 后来看 wp 确实关键的一步就是这个 UAF.
问题出在火焰的处理部分:
|
|
burn(x, y)
是判断是否与角色碰撞的, 如果角色在 (x, y) 上, 那么就会导致游戏结束. 运行游戏尝试可以得知, 这里火焰有两个阶段, 前 0.5s 是只有一格, 后 0.5s 会呈 “十” 字扩散到四周一格. 不过 仔细观察 的话能够看到, 这里用的是 else if
, 也就是说其实 0.5s - 1s 的时候, 中间这格并不会燃烧. 可是是这和游戏表现的不一样啊? 往上面看一点, 答案就出来了:
|
|
如果当前位置是炸弹并且在爆炸状态, 那么这里直接就绘制了一个火焰, 所以即使后面没有绘制中心点, 依旧能够看到完整的 “十” 字.
真搞啊.
更蠢的是我 “仔细” 审过这部分代码, 然后自己在脑袋里把 else
给删掉了, 因为我觉得这样写很合理, 他一定也是这样写的 :(
所以只要卡一个时机, 在 0.5s 之后, 下一次判断之前, 移动角色进入中心, 并捡起炸弹, 就能够在 Player
里获得一个含有悬垂指针的 _bomb
.
然后思考可以利用这个炸弹干什么. 我们的操作十分有限, 只有移动和放置 / 拾取.
移动会同步炸弹和玩家的坐标, 将 bomb._x
, bomb._y
改变:
|
|
而放置会将 bomb._timer
置零.
|
|
这两个操作都能改变 next
, 但是十分有限, 难以利用.
不过注意到除了改变指针, put_bomb()
还会将炸弹所在处设置为 OBJECT_BOMB
. bomb 被释放后这个 “坐标” 是什么呢?
gef➤ p _bomb.get()
$2 = (Bomb *) 0x555555588f10
gef➤ telescope 0x555555588f10
0x0000555555588f10│+0x0000: 0x0000000555555591
0x0000555555588f18│+0x0008: 0x72ac76b4c5584dd1
0x0000555555588f20│+0x0010: 0x0000000000000000
0x0000555555588f28│+0x0018: 0x00000000000000c1
0x0000555555588f30│+0x0020: 0x0404040404040404 ← $rcx
0x0000555555588f38│+0x0028: 0x0101040404040404
0x0000555555588f40│+0x0030: 0x0000000505000102
0x0000555555588f48│+0x0038: 0x0004000401040400
0x0000555555588f50│+0x0040: 0x0404000400040004
0x0000555555588f58│+0x0048: 0x0000000000050500
gef➤ p *_bomb.get()
$1 = {
_timer = 0x55555591,
_x = 0x5,
_y = 0x0
}
恰好是 (5, 0)!, 这是外墙的位置!
所以可以尝试放一个炸弹, free 后捡起来, 再放一个炸弹, 这样他会被放在 (5, 0), 然后走进去捡起来避免 double free, 这样就打破了墙!
但是 F
始终被包围着, 这里的墙无法打破. 无法绕过墙走到 F
这里. 观察一下内存, 发现 _field
上方就是 _bomb
以及 Player
, _bomb
tcache bin 中的 key 字段还保留着随机值, 运气好的话撞一下 key 上有 0x03 这个字节就出了.
但是上方并没有 0x03 (OBJECT_FLAG
), 一个想法是利用角色或者炸弹的坐标来构造, 然后走到这里. 不过这样操作比较困难, 需要计算一下如何达到. 自己是没找到刚好 x 或者 y 坐标是 3 还能走到的操作. 利用 y = 0xff03 或者 x = 0x03XX 这样去凑是可以的, 就是输入会比较多, 手动输入的话非常麻烦.
或者炸弹放下拿起的时机刚好卡在 300ms 也可以将 本地连按两下空格差不多就是这个时间, 不知道远程是不是差不多._bomb._timer
字段设置成 0x3
看 _field
下方的内存, 能够找到有几个 0x03, 但是根据同余性质会至少会被最下方的墙给挡着, 所以想要下去就要用一点手段来修改角色坐标. 操作也只有放置炸弹了, 放置炸弹会将坐标处的内存修改为 0x2 (OBJECT_BOMB
), 但是如果修改掉了角色坐标的内存, 这个炸弹就拿不回来了, 就会造成 double free. 不过可以去 key 上放置然后拿起, 破坏掉 key 的值, 从而绕过 double free 检测. 这样一来, 把角色坐标高位给改成 0x2, 就可以到达 _field
下方了. 然后走走找到 0x3 即可.
下面是一种可行的走法:
- 向上走 2 步, 走到 (5, -2), 放置炸弹然后捡起, 破坏 key;
- 向上走 3 步, 向左走 3 步, 走到 (2, -5), 放置炸弹, 将
_player->_x
设置为 0x202; - 向下走 49 步, 向右走 2 步, 走到 (0x204, 44), 碰到 0x3.