Context¶
The dmint V1 classifier gap (see
docs/solutions/logic-errors/dmint-v1-classifier-gap.md)
surfaced because pyrxd’s parser tests round-tripped a synthetic builder
through the parser they were testing. That bug was spec mismatch, not
memory corruption / hang / crash on adversarial input. The next class
of parser bug — what fuzzing actually finds — is still latent in pyrxd’s
attacker-facing parser surface:
Transaction.from_hex(bytes_from_electrumx_server)— adversarial varintsDmintState.from_script(bytes_from_some_output)— adversarial state pushesdecode_payload(cbor_bytes_from_attacker_reveal_tx)— adversarial CBORextract_reveal_metadata(scriptsig_bytes)— adversarial scriptSig push-dataGlyphRef.from_bytes/GlyphRef.from_contract_hex— short-input edge cases
Every one of these takes attacker-controllable bytes and walks them. They are exactly where fuzzing pays off.
What was decided¶
A graduated approach rather than jumping straight to OSS-Fuzz. Three stages, each independently valuable, increasing in setup cost:
Stage 1 — hypothesis property tests (next session)¶
hypothesis is already in the dev dependencies (hypothesis = "^6.98.0" in
pyproject.toml). Property-based tests via @hypothesis.given(binary())
provide ~80% of the fuzzing value with ~zero new infrastructure. They run
inside the existing pytest invocation, surface failures as normal test
output, and seed-shrink any failing input down to the minimum reproducer.
Targets and properties to assert:
Function |
Property |
|---|---|
|
Never crashes; either returns |
|
Never raises anything but |
|
Same — only |
|
Same. |
|
Never raises anything but |
|
Never raises anything but |
|
Never raises anything but |
|
Never raises (returns |
|
Never raises; always returns |
Estimated cost: ~50 LOC across tests/property/test_parser_robustness.py,
~1-2 hours of work, runs in the existing pytest invocation.
Estimated value: if any of these properties fail, that’s a real crash-on-adversarial-input bug. Hypothesis will minimise the failing input to the smallest possible reproducer, making the fix and regression test trivial.
Stage 2 — atheris harnesses (when there’s a free afternoon)¶
atheris is Google’s libFuzzer-based fuzzer for Python. It instruments
imports for coverage feedback and runs at ~10k executions/sec on a single
core. Compared to hypothesis:
Coverage-guided (mutates inputs that reached new branches)
Higher throughput (orders of magnitude more inputs per second)
Persists a corpus of “interesting” inputs across runs
Catches subtle bugs hypothesis’s random generation misses
Targets: the same parsers as Stage 1, but as standalone fuzz harnesses
under fuzz/. Each harness is ~10-30 lines:
# fuzz/fuzz_transaction_parse.py
import sys
import struct
import atheris
with atheris.instrument_imports():
from pyrxd.transaction.transaction import Transaction
def TestOneInput(data: bytes) -> None:
try:
tx = Transaction.from_hex(data)
if tx is not None:
tx.serialize()
except (ValueError, IndexError, struct.error):
pass # documented failure modes
# ANY OTHER exception is a bug.
atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()
Run locally for hours/days; whenever a crash is found, atheris dumps the
offending bytes. Add the bytes to tests/property/corpus/ as a regression
seed and fix the bug.
Estimated cost: ~1 day initial setup (4-5 harnesses, dev-dep on
atheris, optional seed corpus from real-mainnet fixtures).
Stage 3 — OSS-Fuzz integration (when pyrxd hits a v1.0 / security milestone)¶
OSS-Fuzz is Google’s free continuous
fuzzing service. After Stage 2 harnesses exist, submitting them to OSS-Fuzz
takes ~1 day (3 files: build.sh, Dockerfile, project.yaml in the
OSS-Fuzz monorepo). After that:
Google runs the harnesses on their cluster, their CPUs, their bill
90-day responsible-disclosure window on findings
Sanitizer-instrumented build catches memory errors and undefined behaviour
Auto-bisects to the introducing commit
Public coverage stats
Why not skip straight to Stage 3: common mistake. Going to OSS-Fuzz before doing the cheap hypothesis wins floods you with shallow bugs that were obvious in retrospect. Stage 1 squashes those in a few hours; OSS-Fuzz then finds the deep bugs without noise.
What was rejected¶
Going straight to OSS-Fuzz. See above.
One mega-harness covering all parsers. Hypothesis’s
@hypothesis.given()per-test-function model is the right granularity. One harness per parser keeps failures localized.Custom mutators. Both hypothesis and atheris generate inputs well enough for our parsers. Adding format-aware mutators is premature optimisation.
Fuzzing the CBOR codec itself.
cbor2is upstream and already fuzzed. Our exposure is the post-decode validation indecode_payload— that’s what we test.
Open questions for Stage 1 implementation¶
Where do the property tests live? Recommend
tests/property/to match the existingtests/security/(special-cased coverage gate) andtests/cli/(sub-package layout) conventions.What’s the failure budget? Each property test should run for at least ~200 generated inputs by default; raise via
@settings(max_examples=...)for the slow ones. Keep total runtime under 30s so the existing test suite doesn’t balloon.Should we ship the seed corpus? Inline-as-bytes in the test file is the project norm (see
_RBG_DMINT_V1_HEXintests/test_glyph.py::TestV1DmintParser). Atests/property/corpus/directory would only matter for atheris later.
Pointers for whoever picks this up¶
Already in dev deps:
hypothesis = "^6.98.0"(pyproject.tomlline ~88)Existing hypothesis usage in pyrxd: search
tests/for@hypothesis.givento see the project’s idiom.Useful strategies for parser fuzzing:
binary(min_size=0, max_size=10_000)— general byte fuzzingbinary(min_size=0, max_size=100)— short-input edge casestext(alphabet="0123456789abcdef")— hex-string parserstuples(...)to combine valid prefixes with random suffixes
The corrective rule from
docs/solutions/logic-errors/dmint-v1-classifier-gap.mdapplies: synthetic round-trips aren’t enough — the property test must also be exercised on real-mainnet bytes (use existing fixtures as hypothesis seeds via the@example()decorator).