Local robustness probe (just vuln-check)¶
just vuln-check [base_url="http://127.0.0.1:8000"] [h2_url="https://127.0.0.1:8443"] [lane="all"]
(recipe arguments are positional, so just vuln-check http://127.0.0.1:8000
https://127.0.0.1:8443 h2 runs one lane; tools/security/probe.py --lane h2
does the same) runs tools/security/probe.py against already-running local servers —
normally the minimal fixture app started with
just vuln-target-up — and prints one results table per lane (check id,
severity, verdict, detail). The same run writes a markdown report with both
tables to bench/results/security/<UTC-timestamp>.md (created on demand).
Exit code: 0 all PASS, 1 any FAIL/TIMEOUT, 2 the safety gate refused a
target. This harness is deliberately not part of the normal pytest run
(pytest.ini keeps tools/ out of collection); the checks need live servers.
Lanes¶
| Lane | Default target | Transport | Checks |
|---|---|---|---|
h1 |
http://127.0.0.1:8000 |
HTTP/1.1 over cleartext | BASELINE-*, H1-ROBUST-*, SMUGGLE-*, CHUNK-*, TRAILER-*, RANGE-*, EXPECT-*, HOST-*, STATE-*, STATIC-*, SYMLINK-*, WS-*, HDR-* |
h2 |
https://127.0.0.1:8443 |
HTTP/2 over TLS (ALPN h2) |
H2-BASE-*, H2-ROBUST-*, TLS-* |
--lane h1|h2|all selects the lane(s); --run-timeout bounds the whole run
across all lanes.
Safety gates (enforced in code, not just here)¶
- Loopback allow-list.
parse_target()refuses any host that is not exactly127.0.0.1,::1, orlocalhost(case-insensitive), and refuses schemes other thanhttp/httpsand URLs with userinfo — before any socket is opened. A host name is then resolved exactly once; every resolved address must itself be loopback, and the probes dial the literal resolved address (the name stays only in request authorities and TLS SNI). The gate covers both lane URLs on every run, even when--laneselects one. A refusal exits with code 2. - TLS verification is never disabled. The h2 lane verifies the server
certificate against
--tls-ca(default: the fixture's published certificate in the per-user private runtime directory thattools/security/paths.pynames — created 0700 with an owner check, so no other local user can substitute the trust anchor). An https target without a verifiable CA file is refused (exit 2) rather than probed unverified. - Timeouts. Every check is bounded by
--check-timeout(default 5 s) and the whole run by--run-timeout(default 120 s). Each check gets one absolute asyncio deadline; every session of that check — multi-variant checks included — spends only the time remaining until it, and every scenario closes with an RST abort so a peer that stops reading cannot trap teardown; a check that hits its deadline records a TIMEOUT verdict and is cancelled. The run cannot hang. The run budget stops later checks from starting; an in-flight check may overshoot it by one connect/teardown slack. - WebSocket exchanges are raw TCP against the same gated target.
WS-001 writes its upgrade request to the h1 lane's host:port directly (the
scenario vocabulary parses HTTP responses, not post-upgrade frames) —
Probe.ws_attemptuses the sameparse_target-gatedTarget, one connection budget slot, and the same session deadline. The/wspath is a route on the fixture, not a new URL form; no other host or port is ever contacted. - Connection caps. At most 4 concurrent and 96 total connections per run
(
ConnectionBudget, one instance shared by every lane of the run), TLS handshake attempts included; every connection is closed in afinallyblock. - Post-check canary (G2-2). After every check the runner sends that
lane's canary (
GET /expecting 200"ok", over HTTP/1.1 or h2) and records its outcome and latency on the check's row. A canary failure marks the preceding checkFAILat High — the run cannot claim a pass over a dead server. Canaries spend their own capped budget (2 concurrent / 512 per run), never the checks' slots.just vuln-canary-gateproves the mapping: a stub that dies mid-run turns every later row into a canary failure. Canary latency includes the exchange plus at most a 0.5 s drain, so degradation is judged as a multiple of a baseline measured the same way. - No real DoS. H1-ROBUST-011 is slow-send lite: 2 connections, one
bounded hold (at most 12 s, long tier only), then abort. The flood-shaped h2 checks are
lite by construction: H2-ROBUST-006 resets exactly 20 streams one at a
time (never more than one open, so the advertised
MAX_CONCURRENT_STREAMSis never exceeded), H2-ROBUST-007 sends 30 CONTINUATION frames on one stream, and H2-ROBUST-008's HPACK bomb decodes to at most 4 MiB from ~4 KiB of encoded block (the cap is asserted in code and in the unit tests). RANGE-001 sends at most 15 ranges per request. Nothing in the harness floods, loops, or holds more than the per-check deadline.
Tiers (G2-5)¶
--tier quick (default) is the PR gate: bounded waits only, both lanes in
about 25 s. --tier long additionally runs the long-only checks, which wait
out documented default defence timeouts in real time (H1-ROBUST-011 waits
the 10 s default header timeout plus margin). The report records each tier's
check list and per-check seconds; checks outside the selected tier are listed
in the header. Silence is never a verdict's evidence (G2-1): a verdict of
PASS always names an observed answer or close — just vuln-stub-gate proves
it by running every check against a do-nothing peer and requiring 0 PASS.
Lanes (G4-1/G4-2)¶
The harness runs each check family over every transport where its semantics are meaningful:
| Lane | Transport | Checks | Baseline (negotiation) |
|---|---|---|---|
h1 |
cleartext HTTP/1.1 | the h1 family | LANE-001: answered as HTTP/1.1 |
https1 |
TLS, ALPN http/1.1 |
the h1 family | LANE-001: exactly ALPN http/1.1 |
h2 |
TLS, ALPN h2 |
the h2 family | LANE-002: exactly ALPN h2 |
h2c |
HTTP/2 prior knowledge, cleartext | the h2 family | LANE-002: preface accepted (SETTINGS) |
--base-url serves h1 and h2c; --h2-url serves https1 and h2.
A lane's baseline must negotiate exactly its protocol — any other
protocol succeeding is FAIL. A lane whose URL scheme contradicts its
transport records SKIP (h2c needs a cleartext --base-url; https1
needs a TLS --h2-url).
Exclusions with reasons (G4-2):
| Check | Not on | Reason |
|---|---|---|
| TLS-001 | h2c |
endpoint-level TLS check; h2c has no TLS. The https1 lane shares the same TLS listener as h2, so the endpoint check stays on h2 alone. |
| H1-ROBUST-005 | https1 |
the check ends its truncated body with a TCP half-close (FIN); TLS has no half-close for the probe to express, so the server's defence cannot be observed. Covered on h1. |
| CHUNK-002 | https1 |
half-close is needed to expose an incomplete chunk body. Covered on h1. |
| STATE-001 | https1 |
its truncated-body case uses the same half-close step. Covered on h1. |
| H1-ROBUST-011 | quick tier | documented default defence waits; run in the long tier (G2-5). |
gRPC rides on the h2 family's transports (h2 and h2c); its baselines are the gRPC lanes of M5-4's fixture work — see the unsupported list until those land.
Catalogue run (G1)¶
just vuln-catalogue runs the existing blackbull.fault_injection
catalogue against the fixture unchanged — the 14 h1_client scenarios
(absent_host, two_content_lengths,
content_length_and_transfer_encoding, duplicate_transfer_encoding,
space_before_header_colon, bare_lf_terminators, obs_fold_header,
negative_content_length, chunk_size_not_hex, nul_in_header_value,
oversized_method_token, trickled_head, head_never_ends,
body_shorter_than_declared) and the 11 h2_client scenarios
(preface_never_arrives, preface_trickled, rapid_reset_burst, ping_flood,
settings_flood, empty_continuation_flood, header_block_never_finished,
data_frame_lies_about_length, unknown_frame_type,
settings_ack_with_payload, abort_mid_header_block). The G1-2 oracle
judges every outcome mechanically: answer-or-close passes, silence
fails — a response of any status is an answer, the peer closing or a
transport-level error it caused is a close, and a wait that expires with
neither is a FAIL; a harness fault is a runner error, never a close. The report
lands in bench/results/security/catalogue.txt; a unit test pins the
14/11 inventory to the module callables.
Detection power by mutation (G3-4)¶
just vuln-mutation measures whether the harness notices a missing
defense. tools/security/mutation_run.py neutralizes one BlackBull
defense at a time in a throwaway copy of the package (the fixture serves
from the mutated tree — it must contain the blackbull/ it serves, since
the fixture's own bootstrap pins its package root), reruns the quick
tier with the probe unchanged, and reports which clean PASS rows flip to
FAIL/TIMEOUT. A mutation with at least one flip is DETECTED. The
current mutations: the CL+TE smuggling rejection, the malformed
Content-Length rejection, and the TE-list rejection (all in
protocol/http1.py's request framing), plus the G5-3 recall entry
bla-525-range-revert — it reverts the filed BLA-525 fix in
middleware/static.py (a satisfiable range ending at or beyond EOF
clipping to 206 becomes a 416 again) and the harness must catch the
regression. A filed fix with no revertable commit and no detecting row
(BLA-345, gRPC unary message bounds) is a recall gap, recorded on
BLA-526. Evidence on BLA-526 lists the
detecting rows; a flip only by an indirect row is recorded as such, and
a mutation no row catches is a harness gap — the point of the measure.
Aggregated results (JUnit XML)¶
Every judgement-producing tool merges its rows into ONE results file,
bench/results/security/results.xml (JUnit XML): the probe writes one
suite per lane (probe.h1, probe.https1, probe.h2, probe.h2c),
the catalogue run writes catalogue, the mutation run writes mutation.
Merging replaces whole suites by name; suites of other tools are kept.
Every tool takes --junit PATH (empty disables).
| Verdict | JUnit representation |
|---|---|
PASS |
<testcase> |
FIXED |
<testcase> with the fixed issue in <system-out> |
FAIL / TIMEOUT |
<failure message=... type=verdict> |
SKIP |
<skipped/> |
KNOWN |
<skipped message="known: BLA-…"> — visible, but does not fail the consuming CI |
Defense-site reachability (G3-3)¶
just vuln-reachability tier=quick workers=1 measures how much of
BlackBull's defense surface the probe exercises. tools/security/
reachability.py extracts the defense sites from blackbull/ via AST
(the E1 method): every raise, every defense call (log_cap_hit,
rst_stream, goaway, _connection_error), and every reference to a
rejection status or an HTTP/2 error code — excluding startup config
validation (config.py, env.py) and non-surface
packages (client/, fault_injection/, mqtt/, testing/).
BB_WORKERS=1 serves in-process so one interpreter holds the request
path (the report proves it: server/worker.py executes nothing);
SIGINT reaches the whole process group so each interpreter writes its
own .coverage.* file before coverage combine. The report — per-kind
reachability, a per-module table (unreached first), lines/branches —
lands in bench/results/security/reachability.txt.
Current floor (quick tier): 596 sites, 4 reached (0.7%); 3 777/19 950 lines; 255/6 758 branches. A floor and a map, not a score: the quick tier's oracles accept "400 or close", so the serving modules' rejection branches stay untripped. New scenarios should aim where the per-module table points; tightening an "or close" oracle to require the refusal is what watches an unreached site. An unreached site is never a defect by itself.
Fuzzing (G6)¶
The fuzz surface reuses the existing Atheris harnesses unchanged (M5-6: new-input harnesses are out of scope):
| Recipe | Asset | Bound |
|---|---|---|
just fuzz-seed |
tests/conformance/http1/fuzz/make_seeds.py, tests/conformance/http2/fuzz/make_seeds.py |
regenerates opcode-tagged seeds under each corpus/ |
just fuzz-http1 t=60 |
tests/conformance/http1/fuzz/fuzz_http1.py |
$t seconds of fuzzing inside an outer timeout; crash artifacts to /tmp/bla526-fuzz-http1/ |
just fuzz-http2 t=60 |
tests/conformance/http2/fuzz/fuzz_http2.py |
same, artifacts to /tmp/bla526-fuzz-http2/ |
just fuzz-all t=60 |
both | recipe-level bound |
The harnesses self-host BlackBull per input; the committed corpus/
directories are the seeds and user-corpus/ holds replayable
differential findings. Differential mode against nginx is available
through the harnesses' own BB_FUZZ_NGINX_HOST/_PORT env vars and is
not part of the recipe. atheris is an environment dependency
(uv pip install atheris); the recipes exit 3 with the hint when it is
missing.
tools/security/fuzz_run.py is the recipes' only adapter: it forces the
fork multiprocessing start method (Python 3.14 defaults to
forkserver, under which the harnesses' lambda Process target does not
pickle), drops into the harness directory, and runs the file as
__main__ — the harnesses themselves are unmodified in structure. Two
compatibility repairs were needed on top: fuzz_http1.py now sets
app.port = server.port after binding (the attribute the single-server
helpers dial), and both harnesses treat a cleanup-time RuntimeError
("Event loop is closed") as an expected outcome so a bound-adjacent
teardown cannot kill a run. Neither is a product finding — no
blackbull/ frame appears in either traceback. No harness exists for
the gRPC LPM decoder or the WebSocket response-side frame parser —
listed as unsupported on BLA-526.
Process observation (G2-3)¶
With --server-pid PID the probe samples the server's /proc before and
after every check — fd count, sockets owned per state (matched by inode
against /proc/PID/net/tcp{,6}), VmRSS, VmHWM, threads and CPU ticks — and
waits --observe-settle seconds before the second sample. The judgement is
the residual: fd, ESTABLISHED and CLOSE_WAIT must return to the pre-check
level after the settle wait; strictly more of any of them marks the row
FAIL (High) — a connection the server did not release is a leak. Memory and
CPU are recorded in the report's Proc column, never as thresholds.
TIME_WAIT never appears: the kernel holds those, not the process. A server
process that vanishes mid-check marks the row FAIL (High) as well.
just vuln-proc-gate is the verification: a stub that holds every accepted
socket must show residuals on every row (4/4 rows marked), while the
fixture shows zero residual across the quick tier.
Container tier (G2-4)¶
just vuln-container runs the same quick tier against a server confined by
--memory (default 64m) and --pids-limit (default 256): the fixture runs
in a container and the probe runs in a sibling container sharing the
server's network and PID namespaces (--network container:… --pid
container:…), with no published ports — so /proc observation (G2-3) works
unchanged. The image is bb-vuln, built from
tools/security/container.Dockerfile (base bb-multiport): it copies this
worktree's blackbull/ sources over the base's pinned installation, so the
container tier probes the code under review.
Recorded per run (bench/results/security/container-*.{json,txt}):
memory.events oom_kill, pids.events max, memory.peak, pids.peak
(read from the container's cgroup), and docker inspect's OOMKilled and
ExitCode (the fallback when the server was killed). The run is a pass only
when all of these are zero/absent, the probe reports no FAIL, the container
verdicts are identical to a fresh native run (38 rows), and no labelled
container is left behind. Any breach — oom_kill, pids max, OOMKilled, a
server exit, or verdict drift — makes it exit non-zero.
just vuln-container-oom is the detector verification: with --memory 12m
the kernel OOM-kills the fixture and the tool must detect it (verified:
OOMKilled=True recorded via the docker-inspect fallback). Containers are
self-expiring (timeout 600 inside the server command) and carry
bb-vuln/bb-vuln.run labels; every failure path removes them.
Configuration matrix (G7)¶
just vuln-matrix runs the quick tier across the configuration matrix —
uvloop on/off x 1/2 workers (G7-1) — and requires identical verdicts in
every configuration; it then runs an operational configuration with
explicit caps (BB_MAX_CONNECTIONS=8, BB_REQUEST_TIMEOUT=30) and records
the result (G7-2). Per-config tables and verdict lists land in
bench/results/security/matrix-*.
Verdicts and severity¶
PASS — the check's mechanical oracle held. FAIL — the oracle was
violated. TIMEOUT — no answer within the bound (counts as failure for the
exit code). SKIP — the check could not be exercised by this client (for
example its TLS stack refuses to offer the version under test); it is
recorded, never reported as PASS, and does not fail the run.
KNOWN and FIXED (G5) link filed defects. A known_issue failure
is KNOWN only when its detail matches the known_signature fragments
(the filed defect's own failure shape); the issue number rides in the
row and does not fail the run. A failure that does not match stays
FAIL, marked new failure, not the BLA-… shape. A fixed_issue
pass is FIXED (the fix holds, the row guards the regression); a
fixed_issue failure stays FAIL, prefixed REGRESSION, fixed in
BLA-…. H1-ROBUST-001 carries known_issue='BLA-527' with signature
('answered 405', 'answered 501'); its oracle is untouched.
The severity column is the rank a failure of that check carries, per severity criteria; a hang or crash escalates the H1-ROBUST/H2-ROBUST/CHUNK checks to High, as that document's defaults table prescribes. A canary failure (G2-2) or a residual (G2-3) marks the row FAIL at High, whatever the check's own severity: the row would claim something about a server that no longer answered, or that held the check's connection open.
Checks and oracles — h1 lane¶
| Check | Oracle (mechanical) |
|---|---|
| LANE-001 | negotiation baseline: the h1-family lane speaks HTTP/1.1; on https1 the handshake must select exactly ALPN http/1.1 (another protocol succeeding = FAIL) |
| RANGE-002 | a Range whose end is at or beyond EOF answers 206 clipped to the last byte (Content-Range: bytes 0-<size-1>/<size>); the last byte alone is 206; 416 only when the start is at or past EOF (RFC 9110 §14.1.2, BLA-525's clip contract — the G5-3 recall target) |
| ROUTES-001 | /docs and /openapi.json → 404 exactly (no auto-generated API surface) |
| ROUTES-002 | GET /stream/1000 → 200 and the body is exactly 1000 bytes |
| ROUTES-003 | POST /items with valid JSON → 200 echoing the parsed dataclass; invalid JSON → 4xx (never 5xx); POST /form with a valid form → 200 |
| ROUTES-004 | GET /raise → 500 and the body carries no traceback or exception text (CWE-209) |
| ROUTES-005 | middleware contracts: gzip on /big with exact decoded bytes, CORS preflight carries Access-Control-Allow-Origin, cache headers present, TrustedProxy rewrites the client IP from X-Forwarded-For, /pcstatic/hello.txt serves the .gz sibling with Content-Encoding: gzip |
| BASELINE-001/003 | GET / → 200 and body exactly ok (003 runs after all abuse) |
| BASELINE-002 | GET /json → 200 and body is exactly {"ok": true} JSON |
| H1-ROBUST-001 | request line FOO / HTTP/1.1 → 4xx or 501/505 or connection close; 2xx/3xx and other 5xx = FAIL |
| H1-ROBUST-002 | 100 KiB header value → 4xx (431/400) or close; 5xx = FAIL |
| H1-ROBUST-003 | garbage request-line bytes → 400 or close |
| H1-ROBUST-004 | CRLF in a header value (a literal value\r\nX-Injected: 1, and a bare-CR variant) → reject/close, or the value is kept opaque/sanitized with no injected response header and no CR/LF in the echo (RFC 9110 §5.5); a bare CR that splits into a second field line = FAIL |
| H1-ROBUST-005 | Content-Length: 100 with 5 body bytes then FIN → 4xx/408 or close |
| H1-ROBUST-006 | field name with whitespace (Foo Bar: x, Foo : x, Foo\tBar: x) → 400 or close (RFC 9112 §5.1) |
| H1-ROBUST-007 | obs-fold continuation (Foo: a + b; injection variant + X-Smuggle: 1) → 400 or close, or safe coalescing: exactly one foo field whose value is a b/a\tb with no CR/LF kept and no separate injected field (RFC 9112 §5.2). The fixture refuses obs-fold with 400; the coalescing branch exists for peers that accept it. |
| H1-ROBUST-008 | HTTP/1.1 request without Host → 400 or close (RFC 9112 §3.2) |
| H1-ROBUST-009 | absolute-form request line (same-authority and foreign-authority variants) → 200 with body ok, or 400, or close; anything else = FAIL. Documented actual behavior: the fixture accepts absolute-form and routes by the target path for both variants — a 200 ok — without comparing the absolute authority against Host. |
| H1-ROBUST-010 | NUL byte and overlong UTF-8 in the path (raw \x00, NUL-truncation bait /\x00/static/hello.txt, raw \xc0\xaf, /%c0%af) → 400/404 or close. A 200 is always FAIL: the target carried bytes the router must never see, whatever the body. |
| SMUGGLE-001 | Content-Length and Transfer-Encoding: chunked together → 400 or close (RFC 9112 §6.3); any other status = FAIL |
| SMUGGLE-002 | Content-Length plus obfuscated/duplicated TE (chunked, identity; xchunked; Transfer-Encoding: chunked + Transfer-Encoding: x; TE listed first) → 400 or close only (RFC 9112 §6.1/§6.3), then the STATE-001 follow-up |
| SMUGGLE-003 | duplicate Content-Length with different values → 400 or close (RFC 9112 §6.3; catalogued two_content_lengths), then the STATE-001 follow-up |
| CHUNK-001 | chunk extension (1;x=y, 4;foo="a b") + valid chunked body → 200 with the body echoed exactly, or 400, or close; then the STATE-001 follow-up |
| CHUNK-002 | invalid size syntax (-1, 0x10) or a valid large size (FFFFFFFFFFFFFFFF) with incomplete data, then FIN → 400 or close (RFC 9112 §§7.1, 8). For the large-size case, the pipelined GET is chunk data, not a new request. |
| STATE-001 | standalone battery: after each abusive exchange (chunk extension, obs-fold, garbage line, TE obfuscation, truncated body) a GET / pipelined on the same connection must yield exactly one clean 200 ok, or the connection must be closed; a garbled/mixed response, a second response, or GET / ignored on a live connection = FAIL. The same oracle runs inside every SMUGGLE-*/CHUNK-* variant. |
| H1-ROBUST-011 | slow-send lite (long tier only): 2 connections each send a partial request line (GET / HT) and hold without completing the request, waiting out the documented default header-idle defence (10 s) plus margin in real time. Acceptable outcomes are a refusal (4xx/5xx) or close; silence is never a pass (G2-1 — no answer and no close within the wait = FAIL), and a 2xx/3xx answer to a partial line = FAIL. Server survival after the hold is BASELINE-003's row. |
| STATIC-001 | GET /static/../fixture_app.py and /static/%2e%2e/fixture_app.py → 400/403/404; any 200 = FAIL |
| TRAILER-001 | chunked request with trailer fields: forbidden ones (Content-Length, Transfer-Encoding, Host, RFC 9110 §6.5.1) → 400 or close, then the STATE-001 follow-up; a permitted custom trailer (X-Smuggle: 1) is ignored safely — 200 with the body echoed exactly or 400, and the pipelined GET /echo-headers echo must not contain the trailer name and must keep no CR/LF (RFC 9112 §7.1.2; CVE-2023-46589 / CVE-2025-53643 / CVE-2026-22815 class) |
| RANGE-001 | Range abuse on /static/hello.txt (≤ 15 ranges: bytes=0-0,-1,1-99999999999, inverted 10-5, suffix -0, junk --3, 15 single-byte ranges) → 200/206/416 only, nothing else: 200 must carry the whole file (an ignored Range is legal), a single-range 206 must match its Content-Range exactly (start ≤ end < size, body length = end−start+1), multipart/byteranges 206 is accepted as-is, 416 is accepted (its Content-Range, when present, must use bytes */size); a close without a response, a 5xx, or a deadline overrun is FAIL/TIMEOUT (CVE-2011-3192 class) |
| EXPECT-001 | Expect: 100-continue with the body sent immediately, and a bogus Expect: 100-continue, x, each pipelined with GET /: informational responses must be 100 at most (or absent — RFC 9112 §10.1.1 lets a server omit it once the body arrived); the request earns 200 with the body echoed exactly, 417, or 400; the pipelined GET / must then be exactly one clean 200 ok or the connection must close — a leftover body read as the next request is the CVE-2026-103399 / CVE-2024-24791 class and FAILs |
| HOST-001 | duplicate Host (identical and conflicting) and empty Host: → 400 or close (RFC 9112 §3.2: "more than one Host header field or … an invalid field value"), then the STATE-001 follow-up (host-confusion class: CVE-2026-71554, CVE-2026-34525) |
| SYMLINK-001 | GET /static/escape-link.txt — a symlink under the static root whose target is outside it — → 400/403/404 or close; any 200 = FAIL (CWE-59, CVE-2024-23334 / CVE-2024-42367 class). Positive control hello-link.txt → hello.txt (in-root): 200 with that file, or a safe refusal — both designs acceptable |
| WS-001 | raw WebSocket upgrade on /ws: a bad Sec-WebSocket-Key and Sec-WebSocket-Version: 12 → 400/426 or close (a 101 = FAIL); a flooded handshake (100-entry Sec-WebSocket-Protocol list + 200 extra headers, CVE-2024-37890 class) → 101/4xx or close, never a hang; and after a valid 101 (its Sec-WebSocket-Accept must be present) an unmasked TEXT frame → close with code 1002 or a bare connection close (RFC 6455 §5.1/§5.3) — an echo, a masked server frame, any other close code, or a connection left open = FAIL |
| HDR-001 | the server: response header of GET /, if present, must not match: a POSIX path under /home, /users, /usr, /var, /etc, /opt, /tmp, /root, /srv, /app, /workspace; a Windows drive path; site-packages/dist-packages; or python, cpython, py/<digit> |
Checks and oracles — h2 lane¶
| Check | Oracle (mechanical) |
|---|---|
| GRPC-BASE-001 | unary gRPC (/probe.v1.Echo/Echo) over the lane's h2 transport: 200, exactly one length-prefixed echo message, grpc-status: 0 in the trailers (G4-3) |
| GRPC-BASE-002 | server-streaming gRPC (/probe.v1.Echo/Count): 200, every message delivered (1,2,3), grpc-status: 0 in the trailers (G4-3) |
| LANE-002 | negotiation baseline: the h2-family lane speaks HTTP/2 — on h2 exactly ALPN h2; on h2c the prior-knowledge preface is accepted (SETTINGS received) |
| H2-BASE-001 | GET / over HTTP/2 (TLS + ALPN h2) → 200 and body exactly ok |
| H2-ROBUST-001 | HEADERS with a missing pseudo-header, or pseudo-headers after a regular field (RFC 9113 §8.1/§8.3) → RST_STREAM/GOAWAY carrying PROTOCOL_ERROR, or close; a dispatched request or any other error code = FAIL |
| H2-ROBUST-002 | DATA on an idle stream, or DATA on stream 0 (RFC 9113 §6.1) → connection error: GOAWAY carrying PROTOCOL_ERROR, or close. A stream error (RST_STREAM) is FAIL — the RFC requires a connection error. |
| H2-ROBUST-003 | unknown frame type 0xfa with the reserved bit clear (RFC 9113 §4.1) → ignored: a GET / sent afterwards on the same connection must still answer 200 ok |
| H2-ROBUST-004 | 128 KiB header list, fragmented into HEADERS + CONTINUATION frames of ≤16 KiB (so the list, not the frame size, is what overflows) → 431, or RST_STREAM REFUSED_STREAM, or GOAWAY ENHANCE_YOUR_CALM/REFUSED_STREAM, or close; no crash |
| H2-ROBUST-005 | PRIORITY frame with stream dependency = own stream (RFC 9113 §5.3.1) → PROTOCOL_ERROR (stream or connection error), or close |
| H2-ROBUST-006 | bounded Rapid Reset (CVE-2023-44487 class): exactly 20 streams, each HEADERS + an immediate RST_STREAM CANCEL, opened one at a time so the advertised MAX_CONCURRENT_STREAMS is never exceeded (it is reported from the server's SETTINGS); the burst is legal traffic, so only NO_ERROR/CANCEL (graceful) or REFUSED_STREAM/ENHANCE_YOUR_CALM (flood defense) error frames are acceptable — PROTOCOL_ERROR/INTERNAL_ERROR over legal frames = FAIL — and a fresh request on the same connection (or, if the burst connection was closed, on a fresh one) must still answer 200 ok |
| H2-ROBUST-007 | one legal header block cut into 30 CONTINUATION frames then END_HEADERS (CVE-2023-45288 / CVE-2024-27316 / CVE-2024-27983 class): the request completes (2xx/4xx) or is refused (431, or GOAWAY/RST_STREAM carrying ENHANCE_YOUR_CALM/REFUSED_STREAM/PROTOCOL_ERROR/NO_ERROR — a header-block limit may signal PROTOCOL_ERROR per RFC 9113 §4.3) or the connection closes; a crash, a wrong error code, or a deadline overrun = FAIL/TIMEOUT |
| H2-ROBUST-008 | HPACK bomb lite (CVE-2016-6581 / CVE-2022-41723 class): a ~3 KiB seed block fills the dynamic table with one 4 KiB entry, then a ~4 KiB block of 1000 indexed references decodes to ≤ 4 MiB (the cap is asserted in code) on stream 3 of the same connection: bounded completion (2xx/4xx) or refusal (431, GOAWAY/RST_STREAM with ENHANCE_YOUR_CALM/REFUSED_STREAM/PROTOCOL_ERROR/COMPRESSION_ERROR/NO_ERROR — a decoder expansion cap may trip COMPRESSION_ERROR) or close; a crash or a wrong error code = FAIL (the observed refusal is recorded verbatim in the detail) |
| H2-BASE-002 | after all h2 abuse a fresh HTTP/2 request → 200 ok (server survived) |
| TLS-001 | TLS 1.0 and 1.1 handshake attempts must fail; TLS 1.2 and 1.3 must succeed negotiating ALPN h2 at that version. A client stack that refuses to offer TLS 1.0/1.1 outright (NO_PROTOCOLS_AVAILABLE) is reported as not exercised — the check then records SKIP, never PASS, because no ClientHello reached the server (PR #479 review M2). The fixture additionally pins minimum_version = TLSv1_2 server-side. A server-side refusal oracle would need a hand-rolled ClientHello (open question, below). |
Implementation note¶
The wire-level checks are driven by blackbull.fault_injection's scenario
machinery against the external running servers — one executor style for both
lanes, and no in-process coupling:
- h1 lane:
scenario_h1steps (SendRawBytes,HalfClose,Sleep,ReadResponse) executed byHTTP1Client.execute_scenario. That machinery demonstrably drives servers other than BlackBull (tests/unit/test_fault_h1_client_third_party.pyruns the same executor against CPython'shttp.server).SMUGGLE-001reuses the catalogued scenariocontent_length_and_transfer_encodingandSMUGGLE-003the cataloguedtwo_content_lengthsverbatim, with their read steps retimed to--check-timeout. The baseline, static, and header checks use the same executor with plain well-formed raw requests so oneasynciodeadline bounds every check identically. - h2 lane:
scenario_h2_clientsteps (SendPreface,SendFrame,SendRawBytes,ReadResponse) executed byHTTP2Client.execute_scenarioinscenario_mode=True, so the scenario owns the wire from byte zero and no receive loop races its reads.SendFrame's raw frame type is exactly what H2-ROBUST-003 needs (an unregistered type);SendHeaders/SendFramewire()build HEADERS frames for the ordered-field-block faults (H2-ROBUST-001) and the CONTINUATION fragmentation of H2-ROBUST-004, withhpack— already a runtime dependency — encoding fields in the given order so pseudo-header order violations are expressible. The cataloguedh2_clientcases informed the shapes but several are 30-second floods, deliberately out of scope here; the probe's h2 checks are one-shot frames. - TLS-001 uses the standard-library
sslmodule for bounded handshake attempts (the task's sanctioned "openssl s_client or python ssl").
Referenced vulnerability sources¶
Researched for M4 from the NVD CVE API (keyword and cveId lookups) and
GitHub's global advisories (gh api /advisories, ?ecosystem=pip&affects=…);
each entry below was fetched and read, and the vector is quoted from what the
database actually returned. NVD URLs are https://nvd.nist.gov/vuln/detail/<id>,
advisory URLs https://github.com/advisories/<GHSA-id>.
| Source | Vector (one line) | Mapping |
|---|---|---|
| CVE-2024-23334 (GHSA-5h86-8mv2-jq9f) | aiohttp follow_symlinks=True static routes resolve symlinks with no root-boundary check → arbitrary file read |
SYMLINK-001, STATIC-001 |
| CVE-2024-42367 (GHSA-jwhx-xcg6-8xhj) | aiohttp: compressed files presented as symlinks also escape the static root | SYMLINK-001 |
| CVE-2023-37276 (GHSA-45c4-8wx5-qw6w) | aiohttp/llhttp: crafted request misparses a header value → request smuggling | SMUGGLE-002 |
| CVE-2024-52304 (GHSA-8495-4g3g-x7pr) | aiohttp pure-Python parser reads newlines in chunk extensions wrong → request smuggling | CHUNK-001 |
| CVE-2025-53643 (GHSA-9548-qrrj-x5pj) | aiohttp parses the chunked trailer section wrong → request/response smuggling | TRAILER-001 |
| CVE-2026-22815 (GHSA-w2fm-2cpv-w7v5) | aiohttp accepts unlimited trailer headers → unbounded memory use | TRAILER-001 |
| CVE-2023-46589 | Tomcat: an oversize trailer field splits one request into two → smuggling behind a reverse proxy | TRAILER-001 |
| CVE-2023-45648 | Tomcat: specially crafted invalid trailer header treated as multiple requests → smuggling | TRAILER-001 |
| CVE-2025-12642 | lighttpd 1.4.80 merges trailer fields into headers after parsing → header smuggling | TRAILER-001 |
| CVE-2025-59822 | http4s: improper trailer-section handling → request smuggling / cache poisoning | TRAILER-001 |
| CVE-2023-44487 | HTTP/2 Rapid Reset: streams cancelled faster than work can be shed → server resource exhaustion | H2-ROBUST-006 |
| CVE-2023-45288 | HTTP/2 CONTINUATION flood (Nowotarski): excessive CONTINUATION frames keep the endpoint parsing headers | H2-ROBUST-007 |
| CVE-2024-27316 | nghttp2 buffers over-limit CONTINUATION headers while building the 413 → memory exhaustion | H2-ROBUST-007 |
| CVE-2024-27983 | Node.js HTTP/2: CONTINUATION headers + abrupt close → race/memory crash | H2-ROBUST-007 |
| CVE-2016-6581 (GHSA-ffq8-576r-v26g) | HPACK bomb: a tiny block expands via the dynamic table (Python hpack 1.0.0–2.2.0) |
H2-ROBUST-008 |
| CVE-2022-41723 | Go: malicious HTTP/2 stream → excessive HPACK-decoder CPU from small requests | H2-ROBUST-008 |
| CVE-2011-3192 | Apache: overlapping byte ranges in Range → memory/CPU exhaustion |
RANGE-001 |
| CVE-2005-2728 | Apache byte-range filter: huge Range field → memory consumption |
RANGE-001 |
| CVE-2020-10705 | Undertow: Expect: 100-continue requests can exhaust memory → DoS |
EXPECT-001 |
| CVE-2024-24791 | Go net/http: a final (non-1xx) answer to Expect: 100-continue leaves the connection invalid → desync at the next request |
EXPECT-001 |
| CVE-2026-103399 | libsoup: early final response before the body is read; leftover body bytes are parsed as the next request | EXPECT-001 |
| CVE-2019-20372 | nginx error_page misconfiguration → request smuggling |
SMUGGLE-002 |
| CVE-2022-41721 | Go MaxBytesHandler: unconsumed body bytes read as HTTP/2 frames → request tunneling |
SMUGGLE-001 (class) |
| CVE-2024-1135 (GHSA-w3h3-4rj7-4ph4) | gunicorn request smuggling → endpoint restriction bypass | SMUGGLE-001 |
| CVE-2024-6827 (GHSA-hc5x-x2vx-497g) | gunicorn HTTP request/response smuggling | SMUGGLE-002 |
| CVE-2018-1000164 | gunicorn: CRLF sequences in HTTP headers not neutralized | H1-ROBUST-004 |
| CVE-2020-7695 | uvicorn HTTP response splitting | H1-ROBUST-004 |
| CVE-2024-37890 | ws (Node.js): a handshake with more headers than maxHeadersCount crashes the server |
WS-001 |
| CVE-2026-69243 (GHSA-mfx4-hv73-q22v) | aiohttp: HTTP request smuggling via the WebSocket upgrade | WS-001 |
| CVE-2026-54274 (GHSA-xcgm-r5h9-7989) | aiohttp: incomplete WebSocket frame payloads bypass memory limits | WS-001 (class) |
| CVE-2018-1000518 (GHSA-6g87-ff9q-v847) | websockets: memory-exhaustion DoS during handshake/parsing | WS-001 (class) |
| CVE-2026-71554 (GHSA-6hr6-w5qg-qmwg) | Python h2: duplicate Host header can facilitate request smuggling |
HOST-001 |
| CVE-2026-34525 (GHSA-c427-h43c-vf67) | aiohttp accepts duplicate Host headers (host confusion) |
HOST-001 |
| CVE-2025-57804 (GHSA-847f-9342-265h) | Python h2: illegal characters in headers → request smuggling |
H2-ROBUST-001 (class) |
| CVE-2009-3555 | TLS renegotiation: unauthenticated request prefix injection into HTTPS sessions | TLS-001 (class) |
| CVE-2024-24762 | python-multipart (uvicorn's form parser): Content-Type regex → ReDoS stall |
consulted — not judged (timing oracle) |
| CVE-2024-7592 | CPython http.cookies: quadratic cookie parsing → CPU exhaustion |
consulted — not judged (timing oracle) |
One requested lookup did not match its assumed class: CVE-2024-27351 is
Django's Truncator.words regex DoS (per NVD), not an aiohttp smuggling
case; the aiohttp smuggling trail is CVE-2023-37276, CVE-2024-52304 and the
2025/2026 trailer advisories above. Hypercorn returned zero pip
advisories (gh api /advisories?ecosystem=pip&affects=hypercorn).
Coverage mapping (M4 research → checks)¶
| Vector class | Source (CVE/advisory) | Coverage | Check |
|---|---|---|---|
| CL.TE / CL-vs-TE smuggling | CVE-2023-37276, CVE-2024-1135, CVE-2022-41721 | COVERED | SMUGGLE-001/002, STATE-001 |
| duplicate/ambiguous Content-Length | GHSA-xx9p-xxvh-7g8j class | COVERED | SMUGGLE-003 |
| TE obfuscation | CVE-2019-20372, CVE-2024-6827 | COVERED | SMUGGLE-002 |
| chunk extensions and sizes | CVE-2024-52304 | COVERED | CHUNK-001/002 |
| chunked trailer section | CVE-2023-46589, CVE-2023-45648, CVE-2025-53643, CVE-2025-12642, CVE-2025-59822, CVE-2026-22815 | COVERED (M4) | TRAILER-001 |
| CRLF / obs-fold injection | CVE-2018-1000164, CVE-2020-7695 | COVERED | H1-ROBUST-004/006/007 |
| path traversal | CVE-2024-23334 (traversal form) | COVERED | STATIC-001 |
| symlink escape from static root | CVE-2024-23334, CVE-2024-42367 | COVERED (M4) | SYMLINK-001 |
| Range abuse | CVE-2011-3192, CVE-2005-2728 | COVERED (M4) | RANGE-001 |
| Expect: 100-continue | CVE-2020-10705, CVE-2024-24791, CVE-2026-103399 | COVERED (M4) | EXPECT-001 |
| duplicate/conflicting Host | CVE-2026-71554, CVE-2026-34525 | COVERED (M4) | HOST-001 |
| Rapid Reset | CVE-2023-44487 | COVERED (M4, bounded) | H2-ROBUST-006 |
| CONTINUATION flood | CVE-2023-45288, CVE-2024-27316, CVE-2024-27983 | COVERED (M4, bounded) | H2-ROBUST-007 |
| HPACK bomb | CVE-2016-6581, CVE-2022-41723 | COVERED (M4, bounded) | H2-ROBUST-008 |
| header-list floods | CVE-2023-36478 class | COVERED | H2-ROBUST-004, H1-ROBUST-002 |
| pseudo-header / frame-order faults | RFC 9113, CVE-2025-57804 class | COVERED | H2-ROBUST-001..005 |
| WebSocket handshake validation, masking | CVE-2024-37890, CVE-2026-69243, RFC 6455 §5.1/§5.3 | COVERED (M4) | WS-001 |
| TLS version floor / ALPN | CVE-2009-3555 (class) | PARTIAL | TLS-001 (floor only; no renegotiation probe — see open questions) |
| slow-send / slowloris | class | PARTIAL | H1-ROBUST-011 (lite; waits the default 10 s header timeout in long tier) |
| h1→h2 request tunneling | CVE-2022-41721 | PARTIAL | framing checks cover the h1 side; no MaxBytesHandler analog in the fixture surface |
| WS frame memory limits / compression | CVE-2026-54274, GHSA-mq44-7p77-q5h7 | GAP (skipped) | needs fragment/deflate state juggling and a memory oracle — not one-shot mechanical |
| regex DoS (Content-Type, cookies) | CVE-2024-24762, CVE-2024-7592 | GAP (skipped) | judged by timing, not a mechanical accept set |
| unbounded pipelining / body-size limits | CVE-2026-54273 class | GAP (skipped) | capacity behavior, not attack semantics — a load test |
| decompression bombs | CVE-2025-69223 class | GAP (skipped) | the fixture never decompresses request bodies — out of surface |
Open questions (documented, not checked)¶
- Server-side TLS 1.0/1.1 refusal. The python.org/OpenSSL build refuses
to offer TLS 1.0/1.1 (
NO_PROTOCOLS_AVAILABLE), so TLS-001's sub-1.2 attempts cannot reach the server. A raw ClientHello probe with modern extensions would close this gap; attempts to hand-roll one showed bare closes for even valid hellos, so it is parked as an open question. - Embedded colon in a field name. HTTP/1.1 splits the field name at the
first colon, so a "colon in the name" is unobservable on the wire —
Foo:Bar: xis a legalFoofield with valueBar: x. H1-ROBUST-006 therefore tests the crisp cases (whitespace in the name).
Recording findings¶
- Run results: comment on YouTrack issue BLA-526 (
just yt-comment). - A confirmed defect: its own issue, with repro steps and the severity rank from severity criteria.
- Fixes: land as PRs plus a fast deterministic regression test added to the
normal
pytestsuite.
Next¶
- Minimal fixture app — the probe target definition.
- Severity criteria — how findings are ranked.