目录

2023 Seccon Final Pwn Bomberman

目录

Game + Pwn, 挺有意思的一题, 赛时找到了关键漏洞之一, 但还是差点蛮多, 没做出来. 复现一波.

题目给了源代码, 而且远程是 ssh 上去连上去交互. 代码是 cpp 实现的一个炸弹人小游戏.

代码不长, 看一遍玩一下就大概知道了. 墙不能被炸开, flag 被墙包围着, 走到 flag 才能拿 flag.

看完一个奇怪的地方是 tick() 里面对 _bomb 的处理, 是炸弹爆炸时, delete _bomb.get(), 过 1s 火焰消失后, 再去 _bomb.release():

 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
  void tick() {
    /* Player got the flag */
    if (at(_player->x(), _player->y()) == OBJECT_FLAG) {
      _state = GOT_FLAG;
      return;
    }

    /* Extinguish fire */
    if (is_exploding() && _fire->timer()++ >= 1 SEC) {
      delete _fire;
      _fire = nullptr;
      _bomb.release();
      _player->new_bomb();

      /* Remove bomb from map */
      for (i16 y = 0; y < _height; y++)
        for (i16 x = 0; x < _height; x++)
          if (at(x, y) == OBJECT_BOMB)
            at(x, y) = OBJECT_EMPTY;
    }

    /* Remove bomb instance and create fire */
    if (has_bomb() && _bomb.get()->timer()++ == 2 SEC) {
      _fire = new Fire(_bomb.get()->x(), _bomb.get()->y());
      delete _bomb.get(); // UAF
    }
  }

这和正常操作智能指针的情况相反.

注意到放置和拾取炸弹用的是 std::move(), 所以能够想到的是炸弹爆炸后, 再想办法捡起来, 就能够回到 Player 中, 造成悬垂指针.

赛时想的很天真, tick() 最后处理的炸弹, 看 main() 函数接着就是处理可能的输入, 然后 draw() 绘制界面, 那不是直接卡一个时机捡起来就完事了. 但是 draw() 中其实还含有一部分逻辑处理, 角色碰到火焰死亡的部分. 虽然捡起了炸弹, 但是火焰已经生成了, 并且角色就站在火焰上, 导致游戏结束. 然后就吐槽了一下绘制里塞逻辑的写法导致我做不出题, 后来想想其实放在 tick() 处理也是里一样的, 都不能来到下一次输入, 逃离火焰. (虽然这种写法看起来也是真的难受, 逻辑全在 tick() 处理了不好吗结构清晰)

然后看了半天也没看出来哪里还有问题就忙活其他事去了. 后来看 wp 确实关键的一步就是这个 UAF.

问题出在火焰的处理部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    /* Draw fire */
    if (_fire) {
      i16 x = _fire->x(), y = _fire->y();
      if (_fire->timer() < 0.5 SEC) {
        if (burn(x, y))
          mvaddch(y, x, '*');
      } else if (_fire->timer() < 1 SEC) {
        if (burn(x, y - 1))
          mvaddch(y - 1, x, '*');
        if (burn(x, y + 1))
          mvaddch(y + 1, x, '*');
        if (burn(x - 1, y))
          mvaddch(y, x - 1, '*');
        if (burn(x + 1, y))
          mvaddch(y, x + 1, '*');
      }
    }

burn(x, y) 是判断是否与角色碰撞的, 如果角色在 (x, y) 上, 那么就会导致游戏结束. 运行游戏尝试可以得知, 这里火焰有两个阶段, 前 0.5s 是只有一格, 后 0.5s 会呈 “十” 字扩散到四周一格. 不过 仔细观察 的话能够看到, 这里用的是 else if, 也就是说其实 0.5s - 1s 的时候, 中间这格并不会燃烧. 可是是这和游戏表现的不一样啊? 往上面看一点, 答案就出来了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
    /* Draw stage */
    for (i16 y = 0; y < _height; y++) {
      for (i16 x = 0; x < _width; x++) {
        switch (at(x, y)) {
        case OBJECT_WALL:
          mvaddch(y, x, '#');
          break;
        case OBJECT_BLOCK:
          mvaddch(y, x, '@');
          break;
        case OBJECT_BOMB:
          mvaddch(y, x, is_exploding() ? '*' : 'B');
          break;
        case OBJECT_FLAG:
          mvaddch(y, x, 'F');
          break;
        default:
          mvaddch(y, x, ' ');
        }
      }
    }

如果当前位置是炸弹并且在爆炸状态, 那么这里直接就绘制了一个火焰, 所以即使后面没有绘制中心点, 依旧能够看到完整的 “十” 字.

真搞啊.

更蠢的是我 “仔细” 审过这部分代码, 然后自己在脑袋里把 else 给删掉了, 因为我觉得这样写很合理, 他一定也是这样写的 :(

所以只要卡一个时机, 在 0.5s 之后, 下一次判断之前, 移动角色进入中心, 并捡起炸弹, 就能够在 Player 里获得一个含有悬垂指针的 _bomb.

然后思考可以利用这个炸弹干什么. 我们的操作十分有限, 只有移动和放置 / 拾取.

移动会同步炸弹和玩家的坐标, 将 bomb._x, bomb._y 改变:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  void move(enum DIRECTION dir) {
    /* Move player */
    if (dir == LEFT && !is_solid(_player->x() - 1, _player->y()))
      _player->x()--;
    else if (dir == RIGHT && !is_solid(_player->x() + 1, _player->y()))
      _player->x()++;
    else if (dir == UP && !is_solid(_player->x(), _player->y() - 1))
      _player->y()--;
    else if (dir == DOWN && !is_solid(_player->x(), _player->y() + 1))
      _player->y()++;

    if (_player->bomb()) {
      /* Sync bomb position with player position */
      _player->bomb().get()->x() = _player->x();
      _player->bomb().get()->y() = _player->y();
    }
  }

而放置会将 bomb._timer 置零.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  void put_bomb() {
    if (!_player->has_bomb())
      return;

    /* Move bomb from player to stage */
    _bomb = std::move(_player->bomb());
    _bomb.get()->timer() = 0;

    /* Mark bomb on field */
    at(_bomb.get()->x(), _bomb.get()->y()) = OBJECT_BOMB;
  }

这两个操作都能改变 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 即可.

下面是一种可行的走法:

  1. 向上走 2 步, 走到 (5, -2), 放置炸弹然后捡起, 破坏 key;
  2. 向上走 3 步, 向左走 3 步, 走到 (2, -5), 放置炸弹, 将 _player->_x 设置为 0x202;
  3. 向下走 49 步, 向右走 2 步, 走到 (0x204, 44), 碰到 0x3.