ROHAN SEHGAL
← Back to writing

Making ctf-lite a real training environment

July 20, 2026

A capture-the-flag eval, turned into an RL training environment — including the null result I kept instead of hiding, the diagnosis, and the fix I built and measured.

Qwen3-4B · GRPO + LoRA · prime-rl · 2×H100. The honest arc matters more than any single number.

The honest headlineNaive RL training gave no statistically significant improvement (overall +0.03, p=0.71 at n=16 rollouts/task). An earlier n=3 read looked like +0.11 — small-sample noise that vanished under more rollouts. The value here is a working, validated training environment and the judgment to measure it honestly — not a win I don't have.

The fix, in one picture

Base 4B pass-rate per difficulty rung on encoding-chain. Splitting difficulty across decoupled sub-knobs turned a cliff into a staircase.

v1 — cliff
one big difficulty step → nothing to climb
0.75d1
0.12d2
0.00d3
0.00d4
0.00d5
0.00d6
v2 — staircase
decoupled sub-knobs → a gentle, climbable ramp
1.00d1
0.75d2
0.62d3
0.12d4
0.00d5
0.00d6

Bright bars sit in the learnable band (~0.2–0.8) where GRPO has a gradient. v1 has one; v2 has three.

Act 1 — the null result

Trained a 4B on the tasks, then evaluated it properly — 16 rollouts per task, base vs trained, Fisher's exact test:

scopebasetrainedΔp
overall (9 tasks)51/144 · .3555/144 · .38+.030.71
trained subset (3)35/48 · .7339/48 · .81+.080.47

Nothing reaches p<0.05. Two measurement traps produced tempting-but-false positives:

  • The harness artifact. Measuring before/after through different agent harnesses invented a gain that was pure instrumentation. Always eval through the same harness the model trained on.
  • The small-sample mirage. An n=3 eval reported +0.11; at n=16 it collapsed to noise. An RL delta from a handful of agentic rollouts is not a result.

Act 2 — the diagnosis

GRPO only learns from a task whose rollouts contain both wins and losses — that difference is the gradient. The base 4B's difficulty was bimodal:

taskbase passgradient?
git-leaked-secret0.94— always solved
xor-vault0.75thin
hash-crack0.50✓ learnable
gauntlet · multi-stage · +30.00— always fails

Effectively one task carried a usable gradient. The null result was the expected outcome, not a surprise.

Act 3 — the fix

Each task sat at one fixed point on the difficulty axis. So I made difficulty a dial: each task exposes a ladder of rungs, and prime-rl's zero-advantage filter auto-selects the rungs in the model's learnable band. The fix that produced the staircase — split each task's difficulty across decoupled sub-knobs, so every rung is a small step:

taskdifficulty sub-knobs
multi-stagechain length · per-stage crypto · final-seal key length
encoding-chainnesting depth · encoding-menu size (easy→full)
repeating-key-xorXOR key length · known-plaintext crib at easy rungs

Every rung gold-validates and every encoder chain round-trips, so a real solver can win. A curriculum is one flag: --taskset.difficulties '[1,2,3,4,5]'.

What's honest about it

  • The generator concept is validated — measured, not asserted. A fixed-difficulty task can be turned into a trainable ramp, and you can watch the learnable band populate.
  • The tasks aren't broken — they're model-matched. multi-stage gold-validates and is solvable; its floor just sits above a 4B's frontier. It's a 7B+ task pointed at a 4B, not a bug.
  • This is what a validation gate is for. I measured the difficulty response, so I know which rungs to train on and which to hold out.
  • Still to do: the training run on the smoothed curriculum. The environment is now well-posed for it; the "after" number isn't in yet.
Methodology informed by, and independently reimplemented from, Muzsai, Imolai & Lukács, Improving LLM Agents with RL on Cryptographic CTF Challenges (arXiv:2506.02048).