Payload generation & complexity testing

Real systems rarely fall over on typical input. They fall over on input crafted to hit a super-linear code path: a deeply-nested document that sends a parser quadratic, an expansion bomb that turns a kilobyte into a gigabyte, a string that drives a validator's regex into catastrophic backtracking. A load test that only replays realistic traffic will never find these.

loadr payload generates those adversarial inputs, each parameterised by a single magnitude (a depth, count, byte length or level count) so you can scale one input and watch what the response time does. Feed that scaling axis to loadr sweep --complexity and it fits the response-time-vs-size curve, tells you the algorithmic order (O(n^k)), and fails CI when the exponent crosses a bound you set. Linear scaling passes; a parser that goes quadratic on depth is a denial-of-service waiting to happen, and the gate catches it.

Responsible use. These payloads are designed to exhaust CPU and memory on the receiving end — that is the whole point. Only ever aim them at systems you own or are explicitly authorised to test. Pointed at a shared or production target they are indistinguishable from a DoS attack. Start with small magnitudes against a disposable environment.

loadr payload

loadr payload <kind>[:<magnitude>]

Writes the raw payload bytes to stdout so it pipes cleanly into curl, xargs or a file. Omit :<magnitude> to use the kind's default; exceed the kind's safety cap and it refuses rather than trying to allocate the machine to death.

# A JSON object nested 10,000 levels deep, straight to a request body.
loadr payload nested-json:10000 | curl -X POST http://localhost:3001/api/parse \
  -H 'Content-Type: application/json' --data-binary @-

# Write a 64k-deep markdown blockquote bomb to a file.
loadr payload nested-markdown-blockquote:64000 -o bomb.md
FlagMeaning
-o, --output <file>Write to a file instead of stdout (prints a one-line summary: kind, magnitude, content-type, byte count).
--listPrint the whole catalog — category, parameter, default and cap — then exit.

loadr payload --list

loadr payload --list

lists every kind grouped by category. Each kind advertises the param its magnitude controls, a default used when you omit :n, and a hard max safety cap.

The catalog

Eighteen kinds across seven categories. The param column is what the magnitude means for that kind, and stresses is the code path it targets.

nesting — deep structure → super-linear parsers

KindparamdefaultmaxContent-TypeStresses
nested-jsondepth100005000000application/jsonDeeply nested JSON object {"a":{"a":…}} — recursive-descent / stack-depth parsing.
nested-arraydepth100005000000application/jsonDeeply nested JSON array [[[…]]] — same parser stress via array nesting.
nested-markdown-blockquotedepth500005000000text/markdownOne line of N blockquote markers (>>>…) — the goldmark-class super-quadratic blowup.
nested-markdown-bracketdepth500005000000text/markdownUnmatched nested link brackets [[[…]]] — inline link/reference backtracking.
nested-xmldepth200005000000application/xmlDeeply nested XML elements <a><a>…</a></a> — stack/tree-depth parser stress.
nested-htmldepth200005000000text/htmlDeeply nested <div> tags — HTML parsers and sanitizers walking a deep tree.
nested-parensdepth500005000000text/plainBalanced nested parentheses ((((…)))) — expression/formula/filter grammars.
nested-graphqldepth2000200000application/jsonDeeply nested GraphQL selection {a{a{…}}} — query validation / depth limiting.

amplification — small in → huge out

KindparamdefaultmaxContent-TypeStresses
billion-laughslevels912application/xmlClassic XML entity-expansion bomb — ~10^levels expansion from a tiny document.
yaml-alias-bomblevels1024application/x-yamlExponential YAML anchor/alias expansion — 2^levels blowup.

volume — allocation / O(n²) stress

KindparamdefaultmaxContent-TypeStresses
json-arraycount100000050000000application/jsonA flat JSON array of N integers — allocation, GC and per-element processing.
json-object-keyscount100000020000000application/jsonA JSON object with N distinct keys — hashmap-build and key-processing stress.
long-stringbytes10000000200000000application/jsonA single JSON string of N bytes — copy/scan/validation cost in one enormous field.
csv-rowscount100000050000000text/csvA CSV with N rows — row-parsing throughput and streaming behaviour.

regex — catastrophic backtracking (ReDoS)

KindparamdefaultmaxContent-TypeStresses
redosbytes5000010000000text/plain'aaaa…!' — drives (a+)+$-style vulnerable validators into exponential backtracking.

unicode — normalization / grapheme cost

KindparamdefaultmaxContent-TypeStresses
zalgocount10000020000000text/plainA base char with N stacked combining marks — normalization / width / grapheme cost.

numeric — slow number parsing

KindparamdefaultmaxContent-TypeStresses
bignumcount10000050000000application/jsonA bare integer with N digits — bignum / arbitrary-precision parse cost.

collision — worst-case hashmaps

KindparamdefaultmaxContent-TypeStresses
hash-collisioncount655361000000application/jsonA JSON object whose N keys all collide in 31-based string hashing — O(n²) map inserts.

Payloads in a plan: ${payload:…}

Instead of piping bytes at the shell, embed a payload directly in a request body (or any templated field) with the ${payload:<kind>:<magnitude>} template. The body is generated at request time and never materialised into VU state, so even a gigabyte payload costs nothing to hold.

- request:
    method: POST
    url: /api/parse
    headers: { Content-Type: application/json }
    body: '${payload:nested-json:20000}'

The magnitude may be a literal, or a $ENVVAR reference that reads an environment variable at request time:

body: '${payload:nested-markdown-blockquote:$LOADR_SWEEP_DEPTH}'

That $ENVVAR form is the hook for scaling. loadr sweep --var depth=… exports each value as LOADR_SWEEP_DEPTH (the LOADR_SWEEP_<NAME> convention — see Parameter sweeps), so the same plan runs at every depth on the axis with no edits. Only the magnitude after the last : is expanded; the kind name is left untouched. An unset variable resolves to empty and the payload spec then fails to parse — the honest signal that your sweep axis wasn't exported.

Fitting complexity: loadr sweep --complexity

loadr sweep gained two flags for turning a size sweep into a complexity verdict:

FlagMeaning
--complexity <AXIS>Treat this swept axis as an input size and fit the exponent k in response-time ≈ size^k. The axis values must be numeric.
--max-exponent <K>Fail (exit 99) if the fitted exponent exceeds K — e.g. 1.2 flags worse-than-quasilinear scaling. Implies --complexity.

The fit is a log-log least-squares regression of p95 http_req_duration against the size axis, done per group of combos that share every other axis. The resulting k is labelled:

Fitted kVerdict
< 0.5flat / sub-linear
< 1.2≈ linear
< 1.6super-linear
< 2.4≈ quadratic ⚠ DoS risk
≥ 2.4super-quadratic ⚠⚠ DoS

Worked example

examples/49-payload-complexity.yaml scales a nested-markdown blockquote bomb against a markup-rendering endpoint. The body's depth is the swept depth axis, read at request time from LOADR_SWEEP_DEPTH:

name: payload-complexity-probe
description: scale a nested-markdown payload and fit the target's complexity exponent

defaults:
  http:
    base_url: http://localhost:3001

scenarios:
  render:
    executor: per-vu-iterations
    vus: 1
    iterations: 4
    flow:
      - request:
          name: render
          method: POST
          url: /api/v1/markup
          headers: { Content-Type: application/json }
          # ${payload:<kind>:$ENVVAR} — depth comes from the swept axis.
          body: '{"Text": "${payload:nested-markdown-blockquote:$LOADR_SWEEP_DEPTH}", "Mode": "markdown", "Context": "x/y"}'
          checks: [ { type: status, equals: 200 } ]

Sweep the depth axis and gate the exponent at 1.2:

loadr sweep examples/49-payload-complexity.yaml \
  --var depth=4000,8000,16000,32000,64000 \
  --complexity depth --max-exponent 1.2

Against a parser that walks the blockquote nesting quadratically, the run ends like this:

→ sweeping 5 combination(s) of depth
→ [1/5] depth=4000
  ✓ exit 0 — loadr-sweep/sweep-depth-4000.json
→ [2/5] depth=8000
  ✓ exit 0 — loadr-sweep/sweep-depth-8000.json
...

  combo        p50      p95       p99       error rate  rps
  -----------  -------  --------  --------  ----------  -------
  depth=4000   41.20ms  52.90ms   61.00ms   0.00%       18.9/s
  depth=8000   150.4ms  178.2ms   190.1ms   0.00%       5.4/s
  depth=16000  590.7ms  651.0ms   690.4ms   0.00%       1.5/s
  depth=32000  2.35s    2.61s     2.74s     0.00%       0.4/s
  depth=64000  9.41s    10.30s    10.80s    0.00%       0.1/s

complexity (response time vs depth)
  O(n^1.99)  ≈ quadratic ⚠ DoS risk
    4.0k→52.90ms  8.0k→178.20ms  16.0k→651.00ms  32.0k→2.61s  64.0k→10.30s
✗ fitted exponent O(n^1.99) exceeds the --max-exponent 1.20 bound

Every 2× in depth roughly 4×s the latency — quadratic — so the fit lands near k ≈ 2, past the 1.2 bound, and loadr sweep exits 99. Wired into CI, that turns "our markdown renderer is O(n²) on nesting depth" from a production incident into a failed check on the PR that introduced it.

A healthy, linear-scaling endpoint reports the opposite:

complexity (response time vs depth)
  O(n^1.03)  ≈ linear
    4.0k→41.10ms  8.0k→82.40ms  16.0k→165.20ms  32.0k→331.00ms  64.0k→660.10ms
✓ O(n^1.03) within the --max-exponent 1.20 bound

In CI

Complexity probes are cheap — a single VU walking a handful of magnitudes — so unlike a full load sweep they belong on every PR:

      - name: Complexity gate
        run: |
          loadr sweep examples/49-payload-complexity.yaml \
            --var depth=4000,8000,16000,32000,64000 \
            --complexity depth --max-exponent 1.2

The step fails on exit 99, blocking a merge that makes a parser scale worse than quasilinear. Point the same pattern at a nested-json, redos or hash-collision body to guard whichever code path you care about — and see the Exit codes reference for wiring the gate into a pipeline.