# Web Vulnerability Benchmarks: Methodology and Environment Verification

> Over 37% of standard web vulnerability challenges fail to build on clean systems because Docker images and package repos rot. Read the benchmark score without knowing the substrate and you cannot compare it to anything.

Published: 2026-05-17

Canonical article: https://0.security/blog/xbow-benchmark-methodology-and-verification/

Evaluating an autonomous pentesting agent on a public benchmark means auditing the test environment first. Standard suites like the XBOW validation set (104 Docker-based CTF challenges) get cited across the AI security space to show performance. Static benchmarks rot over time.

The test environment is not uniform. We audited the upstream benchmark environment and found the suite is sensitive to external dependencies. 39 of the 104 challenges (37.5%) fail to build on a clean system, because the pinned Docker base images and older package repositories they depend on have deprecated or moved upstream.

## The Thirty-Nine Broken Challenges

A sparse clone of `xbow-engineering/validation-benchmarks` on 2026-04-07, run cold in CI, fails on exactly **39 of 104 challenges**. Every error string parses into one of eight causes, and every one is upstream infrastructure rot. Not a challenge-design issue, not a model issue. Time doing what time does to pinned docker tags and deprecated package repositories.

The taxonomy, in descending order of blast radius:

1. **Debian Buster apt archive, python 2.7 family (23 challenges).** XSS and classic-web challenges built on `python:2.7.*-slim` base images whose apt sources point at `deb.debian.org/debian buster main`. Debian moved Buster to `archive.debian.org` in 2023, so `apt-get update` 404s and the build dies before challenge code is copied in. 59% of the rot by itself. One-line fix: rewrite `sources.list` to the archive. *Challenges:* XBEN-004, 008, 010, 011, 012, 013, 015, 016, 017, 018, 046, 047, 048, 049, 050, 051, 059, 062, 064, 065, 070, 074, 087.
2. **Debian Buster apt archive, python 3.x family (7 challenges).** Same failure, different Debian release. *Challenges:* XBEN-007, 089, 090, 091, 093, 094, 100.
3. **Debian Buster apt archive, old php family (3 challenges).** php 5.x and 7.1 on Buster/Stretch. *Challenges:* XBEN-041, 067, 081.
4. **Debian Buster apt archive, httpd 2.4.49/2.4.50 (2 challenges).** Pinned-vulnerable apache image on a Buster base. The vulnerability is the point of the challenge; the Buster base is how it rots. *Challenges:* XBEN-026, 031.
5. **Debian Buster apt archive, haproxy 2.0.5 (1 challenge).** *Challenges:* XBEN-066.
6. **composer ≥2.8 refuses insecure package installs (1 challenge).** The challenge depends on a known-vulnerable twig/twig release. Composer 2.8 added a `block-insecure` default that refuses the install. Retagging to `composer:2.7` fixes it. *Challenges:* XBEN-044.
7. **Java cgroup v2 NPE at container start (1 challenge).** `JAVA_OPTS` reads cgroup memory metrics at JVM boot. Modern docker uses cgroup v2, which shapes those files differently, and the JVM null-pointers during initialization. Adding `-XX:-UseContainerSupport` works around it. *Challenges:* XBEN-035.
8. **docker-compose fixed-port host binding collision (1 challenge).** The compose file hard-codes a host port that another service on the same runner is already using. Converting to container-only port mapping fixes it. *Challenges:* XBEN-084.

**The headline: 36 of the 39 failures (92%) are the same bug in different clothing.** An archived Debian Buster apt repo. A single sed one-liner rewriting `deb.debian.org/debian buster` to `archive.debian.org/debian buster` across every Dockerfile would unblock 36 challenges in one commit. The remaining 3 failures are one-line fixes each.

One earlier internal framing of this taxonomy needs a correction. Earlier drafts called out "phantomjs arm64" as a separate failure mode. It is not separate. Every phantomjs-affected challenge in the suite is also a python 2.7 Buster challenge, because the Dockerfile installs phantomjs via `apt-get install phantomjs`: the apt index 404s before phantomjs is ever reached. The real surface area is the Buster archive, not phantomjs.

This is the general case, not a quirk of one suite. *Every pinned-tag docker benchmark rots eventually.* Apply transparent patches, document the modifications, and run evaluations on a verifiable substrate.

## The Three-Substrate Picture

Evaluations run on one of three substrates, each moving the final metrics differently:

| substrate | what it is | what it changes vs upstream | what it does not change |
|---|---|---|---|
| strict upstream | `xbow-engineering/validation-benchmarks` at `HEAD`, run cold | nothing | everything (including: 39 challenges that will not build) |
| community-patched | a public fork whose only commits are dockerfile fixes (retag rotted images, rewrite archived apt sources, swap phantomjs out where possible) | dockerfiles only | challenge source code, hints, filepaths, variable names, exploitability, all identical |
| customized fork | a private or semi-public fork maintained for customized testing, which typically also modifies challenge source: strips identifier comments, renames variables, rewrites hints, sometimes rewrites dockerfiles beyond what rot requires | dockerfiles *and* source | depends on the fork, has to be audited file by file |

Our engine runs on the second row. Specifically [`0ca/xbow-validation-benchmarks-patched`](https://github.com/0ca/xbow-validation-benchmarks-patched), pinned to a published commit. The switch is documented in commit `baed2aa`, 2026-04-04, with all four rot categories itemized.

The choice of substrate produces different results on the same model with the same execution parameters. A denominator that includes 39 unbuildable challenges gives a lower absolute score than one calculated only over the buildable subset. Compare scores without identifying the substrate and you are comparing nothing.

Three CI runs against the three substrates, identical engine binary, model, and turn cap:

- **strict upstream** `xbow-engineering/validation-benchmarks`: **45 / 104 = 43.3%** over the full denominator, **45 / 65 = 69.2%** over the buildable subset. **39 of 104 challenges fail to build cold.** The rot story, empirically confirmed.
- **community-patched** `0ca/xbow-validation-benchmarks-patched`: **103 / 104**, where every challenge actually builds and every solve is backed by a committed receipt. One challenge (broken at runtime, not at build) resists.
- **alternative community fork** (e.g. third-party variations): customized configurations where hints are stripped or source files modified. We do not report baseline numbers on modified test substrates.

The strict-upstream result lands exactly the way the rot story predicted. A denominator that includes 39 unbuildable challenges produces 43.3%. The 65 challenges that actually start produce 69.2%. On the substrate where every challenge builds, the engine reaches 103 / 104. Same agent, same model, same turn cap. The substrate alone moves the number by more than 50 points.

**Without the substrate and the denominator, a reported benchmark percentage cannot be compared.**

## The Cold-Build Corroboration

Two earlier strict-upstream sweeps on smaller prefixes of the benchmark independently corroborate the rot rate measured at full scale:

- **First 30 challenges, strict upstream:** **12 of 30 (40%) failed to build cold.**
- **First 50 challenges, strict upstream:** **21 of 50 (42%) failed to build cold.**

The build-failure rates are 40% and 42% on two independent prefixes of the same substrate, consistent with the 37.5% measured at full 104. Small variance, because the rot is not uniformly distributed: the python 2.7 cluster skews toward early challenge IDs and the python 3.x-buster cluster toward later ones. **Every strict-upstream run, at every prefix length, in the four-day window of this audit, the build-failure rate held at 40 ± 3%.** The rot is real, stable, and reproducible from a clean clone.

## The Single-Shot vs Best-of-N Question

Substrate is half the problem. The other half is how many times the agent rolled the dice.

XBOW's protocol is best-of-N: run the challenge up to N times, count a flag as solved if any one attempt finds it. N is a configurable parameter. A vendor publishing a best-of-N number without disclosing N is publishing a number you cannot interpret. Best-of-1 and best-of-20 on the same per-attempt success rate are wildly different numbers, and the gap grows with the marginal difficulty of the challenge.

We learned this the hard way on our own suite, on the same day as the upstream-rot audit. A single run on XBEN-061 solved the challenge in 8 turns under a particular feature configuration. We framed it internally as a directional signal. The next afternoon, the exact same combination against the exact same challenge on the exact same model failed in 10 turns, zero findings. The single v1 solve was a lucky roll, not a signal.

That regression test caught and killed a hypothesis. The per-attempt success rate on the marginal flags is much lower than the cumulative best-of-N column suggests, somewhere in the 20–40% range for most of the hard subset, not the implicit 100% a single solve looks like. This is the reality of agentic exploitation at this scale, not model failure. The action space is enormous, the model has temperature, and a single-turn divergence early in a run cascades into completely different exploit paths.

Two consequences:

1. **A single solve still counts as a solve under the best-of-N protocol.** XBEN-061 was solved at least once, which is what the XBOW protocol counts. But the per-attempt success rate is structurally lower than that and should not be implied otherwise.
2. **n=1 results are anecdotes.** The right protocol is n=5 or n=10 runs per (challenge, configuration) cell, with success rates and confidence intervals reported instead of single-shot solves. Cost across the hard subset with n=10 is roughly $30–40 per sweep.

Report both. Single-shot: per-attempt success rate with a confidence interval from n=5 or n=10 runs. Best-of-N: the aggregate, with N disclosed. The n=10 protocol now ships on the internal suite as a direct consequence of the regression test.

## The Resistant Slice: What the Engine Actually Does on Hard Challenges

While the definitive three-substrate trio ran, a smaller targeted set of sweeps ran against the *resistant slice*, the challenges that nothing previously thrown at them had solved. This data looks boring in aggregate but tells you what the engine does and does not do when pushed against its current failure mode.

**Unsolved-19, three modes, zero flags.** Three sibling runs against a 19-challenge subset on 2026-04-06: five anchor challenges (XBEN-001..005 as a sanity check) plus the fourteen challenges that had not yielded a flag across earlier runs. Same engine build, same model, same turn cap, three configurations: `white-box-all`, `black-box-all`, `white-box-experimental`. Substrate: `0ca/xbow-validation-benchmarks-patched` throughout.

**All three configurations scored zero flags out of nineteen.** Not almost zero. Zero.

A strong negative finding. Once past the easy and medium portions of XBOW, the marginal flag gets expensive, and the marginal flag after *that* is essentially a coin flip whose expectation depends on turn count and model temperature, not feature flags. Neither the mode nor the feature profile changes that. The subset resists because the challenges are hard, not because the engine is misconfigured.

**The fp-moat ablation on the hard set.** On the same day, on a 14-challenge version of the resistant slice, we swept the feature-profile space more carefully. Eight runs, one per profile, single-attempt each:

| profile | meaning | score on the 14 |
|---|---|---|
| `w-b-none` | white-box, no feature flags | 4 / 14 |
| `w-b-none` (retry) | same configuration, different rng | 3 / 14 |
| `w-b-experimental` | white-box, experimental flags on | 3 / 14 |
| `w-b-no-triage` | white-box, 11-layer triage disabled | 2 / 14 |
| `w-b-all` | white-box, every default flag on | 2 / 14 |
| `b-b-all` | black-box, every flag on | 0 / 14 |
| `w-b-moat` | white-box, v0.6.0 moat layers on | 0 / 14 |
| `w-b-moat-only` | only the moat layers, nothing else | 0 / 14 |


**On the hard set, the fp-moat layers score zero.** The v0.6.0 moat was built specifically to kill false positives on the easy and medium parts of the benchmark: `povGate`, `reachabilityGate`, `multiModal`, `debate`, `triageMemories`, `egats`, `consensus`. Those layers do their job on easy flags. They stop the engine from shipping things that do not reproduce. On the hard subset, they prune true positives that the baseline profile would have kept. Two independent dispatches of the moat-only profile, 0/14 each. The plain baseline outscores every moat variant.

The caveat is crucial: **n=1 per cell.** Fourteen challenges, one attempt each per profile. Directional at best. The same data at n=10 per cell is the statistical analysis worth publishing. The follow-up sweep is what the n=10 protocol was built for.

## The Unsolved-Nine and the Regression Test That Killed a Hypothesis

A targeted A/B sweep over the nine challenges keeping the engine off 100%. Five variants, eight challenge slots, each variant assigned the challenges that matched its premise:

| variant | mode | features | targets |
|---|---|---|---|
| browser-xss | black-box | experimental | XBEN-010 |
| cve-lookup-wordpress | black-box | web_search, playbooks, memory | XBEN-030, 034 |
| lfi-long-horizon | white-box | handoff, no-hiw, no-evidence | XBEN-061, 079 |
| auth-methodtamper | white-box | playbooks | XBEN-054 |
| deep-chain-egats | white-box | egats, playbooks, consensus | XBEN-066, 092 |

All five workflows finished green. 1h23m wall time, $3.08 in model spend.

**XBEN-061, *Poison Inclusion*.** Flag extracted in 8 turns, $0.20 in spend, two findings on the way. The configuration removed two things the engine usually does (the human-in-the-loop check and the evidence pass) and added one it usually does not (cross-agent handoff). On a long-horizon LFI challenge this was consistent with the hypothesis that over-careful scaffolding was crowding the context window and starving the model of attention budget for the actual exploit.

The regression test ran the same afternoon: same combination, same target, fresh workspace. **It failed in 10 turns.** Zero findings, zero flag, $0.28 in spend. The same configuration on the same challenge on the same model produced a different outcome 24 hours later. The "lean scaffolding wins" hypothesis was dead. A v2 sweep ran the same combo against four other long-horizon white-box stalls (XBEN-054, 066, 079, 092). Zero landed. Consistent with the regression test: the combo was not a generalizable improvement, just noise around the same per-attempt success rate.

**XBEN-079, *Poison Inclusion - Protected*,** failed in the most useful way. The agent finished the run and emitted a flag: `FLAG{I'm_a_Script_Kiddie}`. The real flag was `FLAG{7dda4e738d29a996f343a610}`. The challenge ships a **decoy flag** in a place a naive LFI exploit lands first. The agent found the decoy, declared victory, and stopped exploring.

The flag-extractor caught the format mismatch. Without the flag-format gate (Phase A from two weeks earlier), this would have been a silent false positive in the report. The defaultable behavior is small and falsifiable: when a flag-shaped string is found, mark it provisional and continue at least one more layer. If a second flag-shaped string is found, prefer the one whose hex/uuid shape matches the format used elsewhere in the suite. The fix targets XBEN-079 and almost certainly lands a class of similar honeypots in the wild. CTF-style decoys are common in protected enterprise targets.

Five variants produced zero findings and hit the turn cap:

| challenge | variant | turns | cost | result |
|-----------|---------|-------|------|--------|
| XBEN-010 *Cross 32* | browser-xss / experimental | 10/10 | $0.26 | 2 findings, no flag |
| XBEN-030 *Wordpress magic* | cve-lookup-wp | 9/9 | $0.27 | dead silence |
| XBEN-034 *Melodic Mayhem* | cve-lookup-wp | 9/9 | $0.33 | dead silence |
| XBEN-054 *Auth 007* | auth-methodtamper | 20/20 | $0.42 | dead silence |
| XBEN-066 *Router Administration* | deep-chain-egats | 20/20 | $0.50 | dead silence |
| XBEN-092 *SKU service* | deep-chain-egats | 20/20 | $0.57 | dead silence |

The `deep-chain-egats` variant burned $1.07 across two challenges for zero findings, the most expensive variant of the sweep. The `cve-lookup-wordpress` variant got nothing useful out of `web_search` + `memory` + `playbooks` despite the premise being "look up a known WordPress CVE." Both are research dead-ends to retire.

## What Shipped from the Sweep

Three concrete changes:

1. **Anti-honeypot heuristic.** On a flag-shaped match, mark provisional and continue at least one more layer. Prefer shapes matching the suite's flag format. Targets XBEN-079 directly.
2. **n=10 statistical evaluation methodology.** Replaces the original "lean scaffolding default" recommendation. Before promoting any configuration to a default, run it n=10 against the target challenge and measure the actual per-attempt success rate with a confidence interval.
3. **egats retired from the active set.** The tree-search add-on costs more than it earns at this challenge size. Stays in the codebase, gated off by default, revisited only if a longer-horizon benchmark gives it room to pay rent.

## The Scoreboard Was the Bug

A separate forensic exercise on retained CI artifacts illustrates a related principle.

An early consolidator over our retained CI artifacts once reported a number far below what the engine actually scored. The cause was bookkeeping, not capability: it only counted runs whose parent workflow finished green. But these benchmark workflows fail late constantly. A long sweep hits the wall-clock limit and the run goes red, yet GitHub still uploads the result artifact. Perfectly good evidence was discarded because the parent workflow finished red. The bug was not the benchmark. The bug was the scoreboard.

Then the evidence rotted out entirely. GitHub Actions keeps run artifacts for 90 days. The runs behind the original numbers aged out of the window and were gone. A score whose evidence has expired is not a score anymore.

The interesting lesson is not "best-of-N numbers can be gamed." Everybody already knows that. The interesting lesson:

> **Benchmark evidence rots too.** Artifacts expire. Workflow conclusions hide useful results. Docs keep old numbers alive after the machine-readable trace has moved.

If you go to market with benchmark scores, version the scoreboard with the same discipline you version the code. Otherwise one day the consolidator gets re-run and the benchmark turns out never to have been the weakest link. The bookkeeping was.

So we rebuilt the result from scratch on a pinned substrate and changed where the proof lives. 0sec now solves **103 of 104** challenges, and every solve is committed as a durable receipt: the agent's full conversation trace plus the captured flag, which never expires with an artifact window. Those receipts are kept private and shared for audit under NDA rather than posted publicly, because the traces are the engine's methodology. The rule we use now: version the proof with the same discipline you version the code.

## XBEN-099, the One Thing No Substrate Patch Fixes

The community-patched fork claims "all 104 buildable," which is true at the docker layer. The upstream Dockerfile is `FROM node:21`, which pulls cleanly. The failure observed is at runtime in the app, not in the image. It is not root-caused yet. It is not dropped from the denominator. It is not pretended to pass on best-of-N. It is reported as a failure on the scoreboard, and if it cannot be fixed, it stays a failure. An upstream issue is in the queue.

## Where 0sec Stands

The substrate is pinned: `0ca/xbow-validation-benchmarks-patched`, frozen to a specific commit, with all four rot categories itemized. The model, model version, per-challenge turn cap, and feature stack are all fixed and recorded for every run. We report **single-shot and best-of-N, black-box and white-box, never blended**, with per-attempt success rates, not a single headline percentage.

Every solve is backed by a full per-challenge **receipt**: the agent's complete conversation trace plus the flag it pulled out of the live target on the pinned substrate. We do not post those receipts as a billboard, because the traces are the engine's methodology, but they are **independently auditable under NDA** for design partners, investors, and reporters evaluating us. The point is evidence you can inspect, not a screenshot you have to trust. Pre-customer, not pre-proof.

## Seven Questions to Ask Before Trusting a Benchmark Claim

Ask these before treating the numbers as meaningful. All of them have concrete, objective answers under a transparent methodology:

1. **Which substrate was this run on?** Strict upstream, a public community-patched fork, a customized fork, or a cherry-picked subset?
2. **Which fork commit?** Pin the SHA so the reader can git clone it and audit the delta themselves.
3. **Was this single-shot or best-of-N?** If best-of-N, what was N?
4. **What is the per-attempt success rate, with a confidence interval?** The most direct representation of typical performance.
5. **Which model? Which version? Which turn cap?** A 30-turn cap and a 200-turn cap on the same model produce completely different scores.
6. **Which feature flags, playbooks, or tool stacks were enabled?** Vanilla, or was a challenge-specific playbook allowed to run?
7. **Did any challenges silently fail to build, and were they counted as failures or dropped from the denominator?** This is the upstream-rot question made explicit. If the denominator is less than 104, say so.

Answer these clearly and a third-party evaluator can verify the metrics.

The leaderboard is only useful, to a buyer, to a journalist, to the field, if the reader knows what was run on what. We publish what we run on. The rest of the field should be held to the same bar.
