2024 Googlectf Hx8 Teaser
这题当时做的时候思路完全错误, 看了一天都没做出来. 之后想去 discord 看看做法, 结果进不去他频道, 然后就搁置了. 某天心血来潮搜了一下, 有 WP 了. 复现.
游戏之前 2023 googlectf 的时候被 crazyman 喊着看过, 一个 2D 类似平台的游戏, 客户端直接发按键序列给服务器, 完全没有数据包的操作空间, 然后就歇菜了 (老毛子搓了巨强的外挂, 非常牛逼). 24 年的题改了改, 地图上有两个 flag (对应两个题) 要拿到.
准备工作
首先找一下 flag 的坐标. 搜一下能看到加载地图的时候:
elif "flag" in o.name:
logging.debug(o)
logging.debug("parsing new flag object")
self.objs.append(flag.Flag(coords, o.name))
这里就有坐标信息, 和 map 一起打印出来:
logging.info(f'{o.name}: {coords} @ {self.map_file}')
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()
前加入如下代码:
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
效果是这样的:

water
上图就是这个关卡的 flag. flag 在两个移动的平台之间, 当时做的时候除了找如何瞬移之类的就没其他想法了. (现在重新做也没找到, 还是太菜了)
平台上方有一把枪, 枪能射出子弹. 既然给了道具说明就是有用的, 顺着去找. (经典马后炮)
在战斗系统 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
中, 根据按键返回一个子弹.
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)
# ...
枪的 file
在 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
是一个长度有限的双端队列:
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
也没有把玩家加入进来.
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. 麻了, 放弃了.
rusty
用同样的方法先找到 flag:

可以看到, 给的道具是毒药. 测试发现毒药能够扣自己一滴血. 有两个摄像头怪物, 这个是当摄像头看着玩家时, 将玩家按键反向. 逻辑在 player.py
中, 有 inverted_controls
变量, 来确定是否需要反向.
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)
# ...
然而写了一下没绕过, 按压着的按键, 新按压的按键, 释放的按键都要处理, 有些困难, 于是用了之前写的按住回车一帧一帧跑.
具体只要在按下和释放的过程中加入如下处理:
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 的时候判断一下是否应该行动:
def tick(self):
if self.freeze:
if not self.go:
return
self.go = False
# ...
这样就可以按住回车停止发送帧, 并且按下其他按键动一下, 用来微操.
再写一个录制和播放记录. 在 send_game_info
的时候记录按键:
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 的时候重放:
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 这边. 显示一下武器的攻击:
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:
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)
:
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
做了溢出检查:
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:
def check_death(self):
if self.health == 0:
self.dead = True
很明显, 就需要想办法用药让自己的血量从 0 变成 -1. 这样被子弹打一下就不会死了. 测试一下只要能抗住一下伤害, 就能够拿到 flag. 这就要求在同一帧中 (不同帧应该也可以, 只要相对顺序不变应该就行), 先被子弹打, 扣血到 0, 然后再使用毒药, 让血量变成 -1, 最后检查生命值. game 的 tick 顺序是先 tick 战斗系统, 包括子弹和武器 (毒药), 然后再 tick player, 检查血量:
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 是先处理子弹, 再处理使用武器:
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)
而且在处理玩家被子弹击中的逻辑中, 只扣了血量, 没有检测死亡 (相反敌人检测了死亡哈哈哈):
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 添加代码如下:
for p in self.combat_system.active_projectiles:
c, _ = self.player.collides(p)
if c:
self.raw_pressed_keys.add(arcade.key.SPACE)
