UMDCTF 2026 - Rush Hour

roobet recently introduced their new game: rush hour. the objective of the game is to successfully bet on how many cars cross an arbitrary section of road in a given time. i have identified a highway exit on a california traffic cam that experts predict will have no traffic during this ctf. however, you somewhat unwisely bet that one car would take this exit. well, now's your chance. can you hack the autonomous car RL policy to drive to the target?

Challenge Overview

We are given a local challenge bundle containing a fixed reinforcement-learning driving policy and a remote service at rush-hour.challs.umdctf.io. The service accepts a small neural network that we control, which perturbs the observation vector seen by the fixed policy. The goal is to make the autonomous car drive into a hidden CTF target instead of the legitimate goal.

The twist: the local simulator uses a different physics timestep than the remote environment. An attack that works locally at one timestep may fail entirely at another.

Given Files

The challenge directory contains:

  • policy.py -- the fixed driving policy
  • attack.py -- the attacker network definition
  • game.py -- the environment/game loop
  • observations.py -- observation generation
  • physics.py -- car dynamics
  • weights.npz -- the fixed policy weights

We do not control the main driving policy. We only control a small attack network that adds a bounded perturbation to the policy's 8-dimensional observation.

Reverse Engineering

The Real Target

From game.py:

1
2
3
4
LEGIT_GOAL = (-20.0, -20.0)
CTF_GOAL = (18.5, -24.5)
CTF_RADIUS = 2.0
EPISODE_TIMEOUT = 60.0

The environment computes observations using LEGIT_GOAL, but the flag is awarded if the car reaches CTF_GOAL. The whole challenge is an adversarial-control problem: make the policy think it should do something slightly different at every timestep until it reaches the hidden target.

The Attacker Model

From attack.py, the network constraints are:

  • Input dimension: 8
  • Hidden dimension: 16
  • Output dimension: 8
  • Per-weight absolute value bound: 10.0
  • Output L2 norm bound after forward pass: 0.5

The submitted .npz must contain:

  • W0 shape (16, 8)
  • b0 shape (16,)
  • W1 shape (8, 16)
  • b1 shape (8,)

The forward pass is simple:

1
2
3
4
5
6
h = np.tanh(W0 @ obs + b0)
y = np.tanh(W1 @ h + b1)
norm = float(np.linalg.norm(y))
if norm > MAX_DELTA_L2:
y = y * (MAX_DELTA_L2 / norm)
return y

The perturbation is then added to the observation before the fixed policy runs.

Policy Inputs

From observations.py, the policy sees an 8-dimensional vector:

  • Normalized speed
  • Normalized steer angle
  • Heading cosine/sine
  • Goal-forward and goal-right coordinates in the car frame
  • Log-distance-to-goal
  • Constant bias term

This means the attack must be state-dependent -- a fixed offset would not work because the policy's inputs change as the car moves.

Initial Solve Strategy

The most direct approach: optimize the attack network weights directly against the provided simulator. This is the right starting point because:

  • The remote service accepts exactly this network format
  • The bundle includes the full local environment and fixed policy
  • The attack is small enough to search directly (280 parameters)
  • The simulator is deterministic (same seed = same result)

I built a local solver (solver.py) that:

  1. Simulates episodes from reset
  2. Evaluates attack candidates using the local environment
  3. Scores candidates by:
    • Huge reward for reaching the CTF goal
    • Otherwise minimizing distance to the CTF goal
  4. Uses an evolution-style search over attack weights

Solver Architecture

The solver uses a simple evolution strategy. The core idea: maintain a "center" set of weights in 280-dimensional space, sample random variations around it, run each variant through the simulator, pick the best ones, and move the center toward them.

See the Full Solver Code section below for the complete 205-line script.

Local Success

Running at the default dt=0.1, the search converged quickly:

1
SearchConfig(seed=7, iterations=600, population=96, dt=0.1)

The winning artifact looked great locally:

1
2
3
4
5
6
7
8
{
'goal_reached': True,
'timed_out': False,
'steps': 90,
'final_distance': 1.913,
'final_position': (17.04, -23.26),
'inside_goal_radius': True
}

At this point, it looked solved.

Why the First Solve Failed Remotely

Uploading the local-winning artifact to the remote service produced:

1
episode timed out

This was the key twist in the challenge. The remote behavior clearly diverged from local, even though both appeared to represent the same game.

Root Cause Investigation

Instead of guessing, I connected directly to the websocket endpoint used by the frontend:

1
wss://rush-hour.challs.umdctf.io/ws

The frontend JavaScript bundle showed that the page renders state messages including x, z, heading, speed, obs, obsDelta, goalReached, timedOut, and flag. This allowed me to stream live state from the remote server.

What the Remote Stream Showed

The remote server was sending state updates at a much finer time cadence:

1
2
3
{"t": 0.01986314600071637, ...}
{"t": 0.04219807200570358, ...}
{"t": 0.06386602800193941, ...}

So the remote simulator steps at approximately:

1
dt ~= 0.02

My original local solver was optimized at:

1
dt = 0.1

That difference turned out to be fatal.

Local Confirmation

I replayed the same "winning" artifact locally under multiple timesteps:

1
2
3
4
dt = 0.1          -> goal_reached = True
dt = 0.05 -> goal_reached = True
dt = 0.02 -> goal_reached = False, timed_out = True
dt = 0.019863146 -> goal_reached = False, timed_out = True

The artifact was not robust. It only won under a coarse fixed-step local simulation. The finer timestep changes the car's trajectory enough that the attack perturbations no longer steer toward the CTF goal.

This explained the remote timeout perfectly.

The Real Exploit

The real solve was:

  1. Use the provided local simulator to understand the control surface
  2. Discover that the remote environment runs at a different timestep (dt ~ 0.02)
  3. Retune the search against the remote-like cadence
  4. Submit the new artifact

I re-ran the search with the corrected timestep:

1
2
3
4
5
6
7
8
9
SearchConfig(
seed=99,
iterations=120,
population=48,
elite_count=8,
noise_scale=0.28,
dt=0.02,
max_time=60.0,
)

I tested several seeds and saved multiple candidates:

  • remote_seed1.npz
  • remote_seed7.npz
  • remote_seed42.npz
  • remote_seed99.npz

All of them transferred locally under the finer timestep.

Verification

I verified each candidate at both dt=0.02 and the exact remote cadence dt=0.019863146:

1
2
3
4
5
for seed in [1, 7, 42, 99]:
arrays = load_npz(f"artifacts/remote_seed{seed}.npz")
for dt in [0.02, 0.019863146]:
result = evaluate_attack(arrays, dt=dt)
print(f"seed={seed} dt={dt} -> goal={result['goal_reached']}")

All four seeds succeeded at both timesteps.

Final Remote Submission

Submitting remote_seed99.npz to the websocket and waiting for server-side state updates eventually produced:

1
2
3
4
5
{
"goalReached": true,
"timedOut": false,
"flag": "UMDCTF{********************************************************************}"
}

The relevant terminal output near the end:

1
2
3
state 400 {'t': 8.4639, 'x': 16.118, 'z': -25.057, 'goalReached': False, 'flag': None}
state 405 {'t': 8.5656, 'x': 16.681, 'z': -25.055, 'goalReached': True,
'flag': 'UMDCTF{********************************************************************}'}

Full Solver Code

The complete solver script:

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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
from __future__ import annotations

import math
from argparse import ArgumentParser
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np

from attack import AttackModel
from game import CTF_GOAL, CTF_RADIUS, EPISODE_TIMEOUT, GameState
from policy import load_default_policy

MAX_WEIGHT = 10.0
PROJECT_DIR = Path(__file__).resolve().parent
ATTACK_SHAPES = {
"W0": (16, 8),
"b0": (16,),
"W1": (8, 16),
"b1": (8,),
}
ATTACK_PARAMETER_SIZE = 16 * 8 + 16 + 8 * 16 + 8


@dataclass(frozen=True)
class SearchConfig:
seed: int = 7
iterations: int = 600
population: int = 96
elite_count: int = 12
noise_scale: float = 0.28
dt: float = 0.02 # 远程环境步长 ~0.02;0.1 本地可过但远程 timeout
max_time: float = EPISODE_TIMEOUT


def default_attack_arrays() -> dict[str, np.ndarray]:
return {
"W0": np.zeros((16, 8), dtype=np.float32),
"b0": np.zeros((16,), dtype=np.float32),
"W1": np.zeros((8, 16), dtype=np.float32),
"b1": np.zeros((8,), dtype=np.float32),
}


def flatten_attack_arrays(arrays: dict[str, np.ndarray]) -> np.ndarray:
return np.concatenate(
[
arrays["W0"].astype(np.float32).ravel(),
arrays["b0"].astype(np.float32).ravel(),
arrays["W1"].astype(np.float32).ravel(),
arrays["b1"].astype(np.float32).ravel(),
]
)


def unflatten_attack_vector(vector: np.ndarray) -> dict[str, np.ndarray]:
x = np.asarray(vector, dtype=np.float32)
assert x.shape == (ATTACK_PARAMETER_SIZE,)
i = 0
w0 = x[i : i + 128].reshape(16, 8)
i += 128
b0 = x[i : i + 16]
i += 16
w1 = x[i : i + 128].reshape(8, 16)
i += 128
b1 = x[i : i + 8]
return {"W0": w0.copy(), "b0": b0.copy(), "W1": w1.copy(), "b1": b1.copy()}


def clamp_attack_arrays(arrays: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
return {
key: np.clip(value, -MAX_WEIGHT, MAX_WEIGHT).astype(np.float32)
for key, value in arrays.items()
}


def save_attack_npz(path: str | Path, arrays: dict[str, np.ndarray]) -> None:
safe_arrays = clamp_attack_arrays(arrays)
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
np.savez(target, **safe_arrays)


def parse_args(argv: list[str] | None = None) -> tuple[SearchConfig, Path]:
parser = ArgumentParser(description="Search for a rush-hour attack candidate.")
parser.add_argument("--iterations", type=int, default=SearchConfig.iterations)
parser.add_argument("--dt", type=float, default=SearchConfig.dt,
help="Simulation timestep (default: %(default)s). "
"Remote env uses ~0.02; 0.1 passes locally but times out remotely.")
parser.add_argument(
"--output",
type=Path,
default=PROJECT_DIR / "artifacts" / "best_attack.npz",
)
args = parser.parse_args(argv)
return SearchConfig(
iterations=args.iterations,
dt=args.dt,
), args.output


def score_result(result: dict[str, Any]) -> float:
if result["goal_reached"]:
return 1_000_000.0 - float(result["steps"])
return -1000.0 * float(result["min_distance"]) - float(result["final_distance"])


def evaluate_attack(
arrays: dict[str, np.ndarray], dt: float = 0.02, max_time: float = EPISODE_TIMEOUT
) -> dict[str, Any]:
state = GameState(load_default_policy())
state.install_attack(AttackModel(arrays))

initial_dx = state.car.x - CTF_GOAL[0]
initial_dz = state.car.z - CTF_GOAL[1]
min_distance = math.hypot(initial_dx, initial_dz)
steps = 0
truncated = False
enforce_horizon = max_time < EPISODE_TIMEOUT
while (
not state.goal_reached
and not state.timed_out
and (not enforce_horizon or state.time + dt <= max_time)
):
state.step(dt)
dx = state.car.x - CTF_GOAL[0]
dz = state.car.z - CTF_GOAL[1]
min_distance = min(min_distance, math.hypot(dx, dz))
steps += 1

if (
enforce_horizon
and not state.goal_reached
and not state.timed_out
and state.time + dt > max_time
):
truncated = True

final_dx = state.car.x - CTF_GOAL[0]
final_dz = state.car.z - CTF_GOAL[1]
final_distance = math.hypot(final_dx, final_dz)

return {
"goal_reached": state.goal_reached,
"timed_out": state.timed_out,
"truncated": truncated,
"steps": steps,
"final_distance": final_distance,
"min_distance": min_distance,
"final_position": (state.car.x, state.car.z),
"inside_goal_radius": final_distance < CTF_RADIUS,
}


def run_search(config: SearchConfig) -> dict[str, Any]:
rng = np.random.default_rng(config.seed)
center = np.zeros(ATTACK_PARAMETER_SIZE, dtype=np.float32)
best_vector = center.copy()
best_result = evaluate_attack(
unflatten_attack_vector(best_vector), dt=config.dt, max_time=config.max_time
)
best_score = score_result(best_result)

for _ in range(config.iterations):
population: list[tuple[float, np.ndarray, dict[str, Any]]] = []
for _ in range(config.population):
candidate = center + rng.normal(0.0, config.noise_scale, size=center.shape).astype(
np.float32
)
arrays = clamp_attack_arrays(unflatten_attack_vector(candidate))
vector = flatten_attack_arrays(arrays)
result = evaluate_attack(arrays, dt=config.dt, max_time=config.max_time)
score = score_result(result)
population.append((score, vector, result))
if score > best_score:
best_score = score
best_vector = vector.copy()
best_result = result

population.sort(key=lambda item: item[0], reverse=True)
elite_vectors = [item[1] for item in population[: config.elite_count]]
center = np.mean(np.stack(elite_vectors, axis=0), axis=0).astype(np.float32)

best_arrays = clamp_attack_arrays(unflatten_attack_vector(best_vector))
return {
"best_score": float(best_score),
"best_vector": best_vector,
"best_arrays": best_arrays,
"best_result": best_result,
}


def main(argv: list[str] | None = None) -> int:
config, output_path = parse_args(argv)
result = run_search(config)
save_attack_npz(output_path, result["best_arrays"])
print(
{
"best_score": result["best_score"],
"goal_reached": result["best_result"]["goal_reached"],
"final_position": result["best_result"]["final_position"],
"output_path": str(output_path),
}
)
return 0


if __name__ == "__main__":
raise SystemExit(main())

Flag

UMDCTF{now_you_know_how_to_drive_an_autonomous_vehicle_now_go_win_on_roobet}