Skip to Content
shapekit

Differential fuzzing

shapekit fuzz feeds generated and mutated XDR values through two decoders and reports every input on which they disagree:

  • rust: stellar-xdr 30.0.0, linked into shapekit and called in process the way core calls it, T::from_xdr(bytes, limits) then to_xdr(limits).
  • go: go-stellar-sdk at the current version of conformance/versions.toml (v0.8.0), through its conformance testee and the decode_encode op of the testee protocol.

The nightly workflow runs it for 25 minutes. It goes beyond what the RFP asks for.

shapekit fuzz --seconds 1200 --seed 20261112 --jobs 4
shapekit fuzz --replay findings/fuzz          # run every stored reproducer again

Why not cargo-fuzz

cargo-fuzz 0.13.2 with libfuzzer-sys 0.4.13 needs a nightly compiler (-Zsanitizer). rustup on the build host can install one, and cargo +nightly fuzz would not disturb the 1.99.1 pin in rust-toolchain.toml, because the +toolchain override applies to one invocation. It was still not used:

  • libFuzzer's feedback is coverage of the process it runs in. Half of every comparison happens in the go testee, a separate process, so coverage would steer towards new paths in stellar-xdr only and say nothing about go.
  • Every input costs a pipe round trip to go either way, so the in-process speed libFuzzer is built for is not available. The measured rate below is pipe-bound.
  • A nightly compiler in CI is an unpinned toolchain beside a pinned one, and a second full build of the dependency tree with sanitizer flags, for no gain in what is compared.

So the fuzzer is an ordinary subcommand on the pinned toolchain. Values come from stellar-xdr's own arbitrary feature (arbitrary 1.5.0) over bytes from a seeded generator, and from xdr-aware mutation of corpus entries. Every class records the seed, worker index and iteration of its input. A whole run is reproducible from --seed for any --jobs when it is given --max-inputs and finishes before --seconds runs out: each worker then has a fixed share of the inputs and classes are merged in a fixed order. report.json says reproducible: true in that case; timing goes to timing.json so it does not affect the report.

Inputs

Types: every type both testees decode. The go testee's type table is generated from the corpus manifest, so this is the manifest's xdr types: TransactionEnvelope, LedgerCloseMeta, TransactionResultMeta, TransactionResultMetaV1, LedgerEntry, ContractEvent, ScVal, SorobanAuthorizationEntry, ScSpecFunctionV0, Asset, SignerKey. --types narrows it.

Seeds: every corpus kind = xdr entry of those types up to --max-seed-bytes (64 KiB, which keeps all but a few large ledgers), plus the contract arguments and auth entries inside envelopes and the key and value of contract data entries, so the small types get real seeds.

Each iteration picks a type, then one of:

originwhat
seed-mutationa corpus seed with one to four mutations
arbitraryT::arbitrary over 1 to 4096 generated bytes, encoded without limits
arbitrary-mutationthe same, then mutated
pool-mutationan input both sides accepted earlier in the run, mutated again

The mutations work on 4-byte words because XDR lengths, counts and discriminants are big-endian words: set a word to a boundary value (0, 1, 2, 0x7fffffff, 0x80000000, 0xffffffff and others), set a length word near the number of bytes after it, nudge a word by a small delta, delete or repeat a run of words, insert zero or random words, truncate, splice words from another input of the same type, flip a bit, append bytes.

Limits

Both sides get the corpus decode limits from crates/shapekit-manifest/src/depth.rs: depth 1000 (xdr.envelope.depth), 1012 for LedgerCloseMeta, and the input length as the length limit. The go testee honours both (decode_limits: "honoured").

What counts as a disagreement

class kindmeaning
rust-accepts-go-rejectsrust decodes the whole input, go refuses it
go-accepts-rust-rejectsthe other way round
reencode-differsboth decode, the re-encoded bytes differ
rust-panic, go-panica decoder crashed instead of refusing
go-failurethe go testee timed out, died or broke the protocol

Both refusing, for whatever reasons, is agreement. Both accepting with the same bytes is agreement even when those bytes differ from the input.

Disagreements are grouped into classes by a signature: the type, the kind, rust's error variant without its payload, and go's innermost decoding <Type>: ... message with numbers and quoted values blanked. go wraps its error once per enclosing type, so without the trim one refusal reached through two paths would be two classes. The class id is fuzz.<type>-<kind>-<first 8 hex of sha256(signature)>.

The depth-accounting filter

go-stellar-sdk spends MaxDepth once per generated DecodeFrom call; rs-stellar-xdr spends its depth once per read_xdr of every type, including the wrappers and boxes go does not count. At the same number the two refuse at different nesting: go reads about twice as deep. This is already known and recorded (conformance/known-failures/go.txt, the depth entries, and their drafts in findings/issues/), so it is filtered rather than reported again.

A disagreement is filtered when, and only when, lifting the refusing side's depth limit, and nothing else, makes that side accept with exactly the bytes the other side produced:

  • depth-accounting-go-deeper: rust refused with DepthLimitExceeded, go accepted, and rust at depth u32::MAX with the same length limit accepts and re-encodes to go's bytes.
  • depth-accounting-go-shallower: go refused with maximum decoding depth reached, rust accepted, and go at depth u32::MAX accepts and re-encodes to rust's bytes.

Anything else about the input, including a depth refusal combined with a second difference, stays a disagreement. The report counts filtered inputs per type.

Reproducers

At the end of the run every class is shrunk while it stays in the same class: delta debugging over aligned word runs, then trailing bytes, then each remaining word set to 0 or 1. Each class gets a directory in the corpus vector layout:

  • entry.json: a manifest entry record (kind: xdr, synthetic: true, family fuzz), checked against the manifest's own record type before it is written.
  • any.xdr: the minimised bytes.
  • class.json: the signature, both verdicts, the run seed and where the class was first seen.

Every class goes to <out>/reproducers/; classes not in findings/fuzz/known-classes.txt are also written under findings/fuzz/, which is where triaged reproducers are kept.

The expected verdict in entry.json follows core where core fixes it:

rust on the reproducertypeexpected
accepts, re-encodes other bytesanyimplementation-defined, not-emittable: not canonical, no conforming encoder writes it
accepts and round-tripsTransactionEnvelopemust-accept: core's decode is stellar-xdr at depth 1000
accepts and round-tripsotherimplementation-defined: core produces these types and never decodes untrusted bytes of them
rejectsTransactionEnvelopemust-reject, invalid-by-core: from protocol 28 core refuses what its rust parser cannot read (can_parse_transaction)
rejectsotherimplementation-defined

core decodes an envelope with its own decoder, so stellar-xdr's verdict on bytes it does not round-trip is not core's verdict, and such inputs are left implementation-defined.

A reproducer is a finding, not a corpus entry. When a class is real and new, add a synthetic entry for it to crates/shapekit-gen/src/synth_xdr.rs (where the build recomputes the expectation with stellar-xdr), draft the issue under findings/issues/, and list the class in findings/fuzz/known-classes.txt with the entry and draft.

Known classes and the exit status

findings/fuzz/known-classes.txt lists triaged classes, one <class-id> <issue or reason> per line. A run exits non-zero when it sees a class that is not listed; listed classes are counted and reported. --replay exits non-zero when a stored reproducer no longer lands in its class, which is how a fix upstream shows up.

Output

<out>/report.json and <out>/FUZZ.md (posted to the nightly job summary): inputs and rate, per type the number of seeds, inputs, both-accept, both-reject, filtered and disagreeing inputs, the inputs per origin, and every class with its count, reproducer and both verdicts.

First run (2026-11-12)

Seed 20261112, 4 workers, 1200 s on the 10-core build host: 87,738,857 inputs, about 73,000 per second (39.5 M seed mutations, 21.9 M pool mutations, 17.5 M arbitrary values, 8.8 M mutated arbitrary values). 8,439 inputs were filtered as depth accounting (8,328 TransactionEnvelope, 111 ScVal, all depth-accounting-go-deeper).

Every other disagreement, 113,063 inputs, was one root cause in eight classes, one per type that carries a bool: stellar-xdr reads a bool word other than 0 or 1 (as false), go-stellar-sdk refuses it. Core's own decoder, xdrpp, reads the same word as true and re-encodes it as 1, and core hashes and checks signatures against that re-encoding, never the submitted bytes. The minimal reproducer is the ScVal 00000000 00000002. The corpus already holds it as soroban.scval-shapes.synth-bool-value-2, and in an envelope as soroban.invoke-basics.synth-invoke-arg-bool-2; the issue drafts are findings/issues/fuzz-rust-bool-non-canonical.md and findings/issues/stellar-core-bool-non-canonical.md. No input made either side panic, no go request failed, and no input was accepted by both sides with different re-encodings. ScSpecFunctionV0, Asset and SignerKey, which carry no bool, had no disagreement at all.

This page is generated from docs/differential-fuzzing.md in the repository.

On this page