目录

2024 Googlectf Hx8 Teaser

这题当时做的时候思路完全错误, 看了一天都没做出来. 之后想去 discord 看看做法, 结果进不去他频道, 然后就搁置了. 某天心血来潮搜了一下, 有 WP 了. 复现.

游戏之前 2023 googlectf 的时候被 crazyman 喊着看过, 一个 2D 类似平台的游戏, 客户端直接发按键序列给服务器, 完全没有数据包的操作空间, 然后就歇菜了 (老毛子搓了巨强的外挂, 非常牛逼). 24 年的题改了改, 地图上有两个 flag (对应两个题) 要拿到.

首先找一下 flag 的坐标. 搜一下能看到加载地图的时候:

map_loading/tilemap.py

        elif "flag" in o.name:
            logging.debug(o)
            logging.debug("parsing new flag object")
            self.objs.append(flag.Flag(coords, o.name))

这里就有坐标信息, 和 map 一起打印出来:

python

            logging.info(f'{o.name}: {coords} @ {self.map_file}')

text

22:44:03.793 INFO     [tilemap.py:374 parse_object] flag_2: OrderedPair(x=4752.0, y=416.0) @ resources/levels/rusty/rusty_level.tmx
22:44:04.666 INFO     [tilemap.py:374 parse_object] flag_1: OrderedPair(x=1120.0, y=2272.0) @ resources/levels/water/water_level.tmx

分别在 rusty 和 water 地图上.

然后能在 ludicer.py 中找到 switch_maps 函数, 打印一下名称, 开局进去就知道是哪个图了. 或者找坐标也行? 反正怎么方便怎么来, 不是重点. 跑一下知道左下角是 rusty, 右上角是 water.

看一下代码可以发现视野和窗口大小有关, 拉大窗口能够找到 flag 在哪, 或者画一条线. 找到 ludicer_gui.py 里的绘制逻辑, 在 arcade.finish_render() 前加入如下代码:

ludicer_gui.py

    def on_draw(self):
        # ...
        for o in self.game.objects:
            if o.name and 'flag' in o.name:
                player_screen_x, player_screen_y = self.world_to_screen(self.game.player.x, self.game.player.y)
                flag_screen_x, flag_screen_y = self.world_to_screen(o.x, o.y)
                arcade.draw_line(player_screen_x, player_screen_y, flag_screen_x, flag_screen_y, arcade.color.RED, 2)

        arcade.finish_render()

    def world_to_screen(self, x, y):
        screen_x = x - self.camera.position[0]
        screen_y = y - self.camera.position[1]
        return screen_x, screen_y

效果是这样的:

拉大窗口并画线寻找 flag
拉大窗口并画线寻找 flag

上图就是这个关卡的 flag. flag 在两个移动的平台之间, 当时做的时候除了找如何瞬移之类的就没其他想法了. (现在重新做也没找到, 还是太菜了)

平台上方有一把枪, 枪能射出子弹. 既然给了道具说明就是有用的, 顺着去找. (经典马后炮)

在战斗系统 engine/combat.py 中能够找到发射子弹的逻辑:

engine/combat.py

    def tick(self, pressed_keys, newly_pressed_keys, tick):
        # ...
        if len(self.active_weapons + self.game.player.weapons) > 0:
            self._update_active_weapons(pressed_keys, newly_pressed_keys, tick)
        # ...

    def _update_active_weapons(self, pressed_keys, newly_pressed_keys, tics):
        for i in self.game.player.weapons:
            i.game = self.game
            rval = i.tick(pressed_keys, newly_pressed_keys, tics, self.game.player)
            if type(rval).__name__ == "Projectile":
                logging.debug("New player-shot projectile")
                self.active_projectiles.append(rval)
                self.game.physics_engine.moving_objects.append(rval)
        # ...

敌人发射的子弹也一样, 这里省略. 武器的 tick 逻辑在 components/weapon_system/base.py 中, 根据按键返回一个子弹.

components/weapon_system/base.py

    def tick(self, pressed_keys, newly_pressed_keys, tics, player, origin="player"):
        # ...
        if not self.ai_controlled:
            if not self.equipped:
                return None
            self.move_to_player()
            if not self.player.dead:
                if arcade.key.SPACE in newly_pressed_keys:
                    return self.fire(tics, self.player.face_towards, origin)
        # ...

枪的 filecomponents/weapons/gun.py 中:

components/weapons/gun.py

    def fire(self, tics, direction, origin):
        if self.cool_down_timer == 0:
            self.cool_down_timer = self.COOL_DOWN_DELAY
            speed_x = 10
            if direction == "W":
                speed_x = -speed_x
            logging.info(f"Firing gun from coordinates {self.x, self.y} at tick "
                         f"{tics} in direction {direction}")
            return projectile.Projectile(coords=hitbox.Point(self.x, self.y), speed_x=
            speed_x, speed_y=0, origin=origin, damage_algo=self.damage_algo,
                                         damage_type=self.damage_type)

注意到和移动平台有关的东西: self.game.physics_engine.moving_objects.append(rval). 子弹创建出来之后会放入这里. 而这个 moving_objects 是一个长度有限的双端队列:

engine/physics.py

        self.moving_objects = deque(maxlen=9)
        self.moving_objects += [o for o in self.objects if o.enable_moving_physics]
        self.moving_objects += [self.player]

不过很可惜, 移动平台不在这里. 移动平台是特殊处理的. 但是, 玩家在这个队列中. 测试一下刚进入地图是没有 enable_moving_physics 的物体的. 玩家在这个队列的开头. 如果能够发射 9 个子弹 (包括敌人发射的), 那么就可以把玩家挤出队列. 而移动平台的碰撞检测是对 moving_objects 进行移动, 没有单独考虑玩家. 并且, 获取碰撞列表 _get_collisions_list 也没有把玩家加入进来.

engine/physics.py

    def _detect_collision(self):
        for o in self.moving_objects:
            self._align_edges(o)
        # ...

    def _align_edges(self, o):
        collisions_x, collisions_y, non_blocking = self._get_collisions_list(o)
        # ...
        for o2, mpv in collisions_y:
            self._align_y_edge(o, o2, mpv[1])

    def _align_y_edge(player, o1, mpv):
        # ...
            delta_e = 0
            if o1.nametype == "MovingPlatform":
                delta_e = abs(o1.y_speed * 3)
                logging.info(f"Adding a delta of {delta_e} to offset moving platform")
            min_y_o2 = o1.get_lowest_point() - delta_e
            player.place_at(player.x, min_y_o2 - player.get_height() // 2)

如果 player 不在 moving_objects 中, 就能够无视移动平台的碰撞.

卡不到 timing. 麻了, 放弃了.

用同样的方法先找到 flag:

rusty 地图的 flag (两块屏幕拼起来刚刚好)
rusty 地图的 flag (两块屏幕拼起来刚刚好)

可以看到, 给的道具是毒药. 测试发现毒药能够扣自己一滴血. 有两个摄像头怪物, 这个是当摄像头看着玩家时, 将玩家按键反向. 逻辑在 player.py 中, 有 inverted_controls 变量, 来确定是否需要反向.

components/player.py

    def update_movement(self, pressed_keys, reset_speed=True):
        self.x_speed = 0
        if reset_speed:
            self.reset_speed()

        if self.dead:  # Can't move
            return

        self.running = False
        if self.can_control_movement:
            sprinting = arcade.key.LSHIFT in pressed_keys
            if (arcade.key.D in pressed_keys) and (arcade.key.A not in pressed_keys):
                computed_direction = self.DIR_E if not self.inverted_controls else self.DIR_W
                self.change_direction(computed_direction, sprinting)

            if (arcade.key.A in pressed_keys) and (arcade.key.D not in pressed_keys):
                computed_direction = self.DIR_W if not self.inverted_controls else self.DIR_E
                self.change_direction(computed_direction, sprinting)

            if (arcade.key.W in pressed_keys) and (arcade.key.S not in pressed_keys):
                computed_direction = self.DIR_N if not self.inverted_controls else self.DIR_S
                self.change_direction(computed_direction, sprinting)

            if (arcade.key.S in pressed_keys) and (arcade.key.W not in pressed_keys):
                computed_direction = self.DIR_S if not self.inverted_controls else self.DIR_N
                self.change_direction(computed_direction, sprinting)
        # ...

然而写了一下没绕过, 按压着的按键, 新按压的按键, 释放的按键都要处理, 有些困难, 于是用了之前写的按住回车一帧一帧跑.

具体只要在按下和释放的过程中加入如下处理:

ludicer_gui.py

    def on_key_press(self, symbol: int, _modifiers: int):
        if self.game is None:
            return
        if symbol == arcade.key.M:
            logging.info("Showing menu")
            self.show_menu()
            return
        if symbol == arcade.key.ENTER:
            self.game.freeze = True
        elif self.game.go == False:
            self.game.go = True
        self.game.raw_pressed_keys.add(symbol)

    def on_key_release(self, symbol: int, _modifiers: int):
        if self.game is None:
            return
        if symbol == arcade.key.ENTER:
            self.game.freeze = False
        if symbol in self.game.raw_pressed_keys:
            self.game.raw_pressed_keys.remove(symbol)

然后 tick 的时候判断一下是否应该行动:

ludicer.py

    def tick(self):
        if self.freeze:
            if not self.go:
                return
            self.go = False
        # ...

这样就可以按住回车停止发送帧, 并且按下其他按键动一下, 用来微操.

再写一个录制和播放记录. 在 send_game_info 的时候记录按键:

ludicer.py

    def send_game_info(self):
        with open('rusty_key.log', 'a') as f:
            f.write("".join(f"{hex(k)} " for k in self.raw_pressed_keys) + "\n")
    # ...

记录完了以后读出来并且在 tick 的时候重放:

ludicer.py

    def setup(self):
        self.keys_per_tick = []
        with open('rusty_key.log.bak', 'r') as f:
            for line in f.readlines():
                keys = set()
                for key in line.split():
                    keys.add(int(key, 16))
                self.keys_per_tick.append(keys)
        # ...

    def tick(self):
        if self.freeze:
            if not self.go:
                return
            self.go = False

        if self.tics < len(self.keys_per_tick):
            self.raw_pressed_keys = self.keys_per_tick[self.tics]
        # ...

操作一次, 就能够来到大炮守着的 flag 这边. 显示一下武器的攻击:

ludicer_gui.py

        for o in self.game.combat_system.active_weapons:
            weapon_screen_x, weapon_screen_y = self.world_to_screen(o.x, o.y)
            arcade.draw_text(f'{o.cool_down_timer}', weapon_screen_x - 8, weapon_screen_y + 20, arcade.color.RED)
            try:
                arcade.draw_text(f'{o.damage}', weapon_screen_x - 8, weapon_screen_y + 40, arcade.color.RED)
            except:
                pass

发现这个大炮伤害足足有 1000, 很显然不能直接过. 看给的道具, 毒药的效果是直接将玩家的血量 -1:

components/weapon_systems/poison.py

    def fire(self, _tics, _direction, _origin):
        if self.cool_down_timer == 0:
            self.cool_down_timer = self.COOL_DOWN_DELAY
            # *Glug glug* Mmm, refreshing!
            self.game.player.health -= 1
            self.game.player.sprite.set_flashing(True)

而被子弹打的逻辑在 engine/combat.py 里是 self.game.player.decrease_health(dmg):

engine/combat.py

    def _check_enemy_projectile(self, p):
        c, _ = self.game.player.collides(p)
        if c:
            dmg = self._check_damage(p)
            self.game.player.decrease_health(dmg)
            if not self.game.player.dead:
                self.game.player.sprite.set_flashing(True)

decrease_health 做了溢出检查:

engine/generics.py

    def decrease_health(self, points):
        logging.debug(f"decreasing {self} health")
        points = int(max(1, round(points)))
        self.health = max(0, min(100, self.health - points))

check_health 是检查是否为 0:

engine/generics.py

    def check_death(self):
        if self.health == 0:
            self.dead = True

很明显, 就需要想办法用药让自己的血量从 0 变成 -1. 这样被子弹打一下就不会死了. 测试一下只要能抗住一下伤害, 就能够拿到 flag. 这就要求在同一帧中 (不同帧应该也可以, 只要相对顺序不变应该就行), 先被子弹打, 扣血到 0, 然后再使用毒药, 让血量变成 -1, 最后检查生命值. game 的 tick 顺序是先 tick 战斗系统, 包括子弹和武器 (毒药), 然后再 tick player, 检查血量:

ludicer.py

        self.combat_system.tick(self.pressed_keys, self.newly_pressed_keys, self.tics)
        # ...
        if self.player is not None:
            self.player.tick(self.pressed_keys,
                             self.newly_pressed_keys,
                             reset_speed=(self.current_mode == GameMode.MODE_SCROLLER))

combat_system 的 tick 是先处理子弹, 再处理使用武器:

engine/combat.py

    def tick(self, pressed_keys, newly_pressed_keys, tick):
        self._maybe_drop_weapon(newly_pressed_keys)
        if len(self.active_projectiles) > 0:
            self._update_active_projectiles()
        if len(self.active_weapons + self.game.player.weapons) > 0:
            self._update_active_weapons(pressed_keys, newly_pressed_keys, tick)
        self._check_player_collisions(newly_pressed_keys)

而且在处理玩家被子弹击中的逻辑中, 只扣了血量, 没有检测死亡 (相反敌人检测了死亡哈哈哈):

engine/combat.py

    def _check_enemy_projectile(self, p):
        c, _ = self.game.player.collides(p)
        if c:
            dmg = self._check_damage(p)
            self.game.player.decrease_health(dmg)
            if not self.game.player.dead:
                self.game.player.sprite.set_flashing(True)

所以只要在被击中的这一帧使用毒药即可. 在 game tick 添加代码如下:

python

        for p in self.combat_system.active_projectiles:
            c, _ = self.player.collides(p)
            if c:
                self.raw_pressed_keys.add(arcade.key.SPACE)
rusty flag! 生命值是 -1
rusty flag! 生命值是 -1