From cbf4e45730d74fb75be67004fd5dcb8ab5a35262 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 23 Jun 2026 17:06:58 -0700 Subject: [PATCH 1/2] oorpb: fuzz OOR submit/finalize proto parsers for no-panic The server's OOR RPC handlers call ParseSubmitPackageRequest and ParseFinalizePackageRequest on fully client-controlled proto bytes, so a panic, slice OOB, or integer overflow in these decode paths is a remote server-crash vector: one hostile client could take down the operator. Add Go native fuzz targets that assert only the no-panic invariant (the parsers may return errors freely). Each parser gets a field-level target that builds the proto in-harness from fuzzer-controlled bytes (ark PSBT, checkpoint PSBT, descriptor outpoint/policy/spend-path, recipient script/value) and a wire-bytes target that runs proto.Unmarshal then the parser to reach shapes the field fuzzer cannot construct. Seeds cover a representative valid value, empty input, and 31/33-byte session-hash edge cases that straddle the fixed-length copy gates. The response parsers are fuzzed too: a compromised operator must not be able to panic the client. No crashers surfaced across all targets. --- rpc/oorpb/payloads_fuzz_test.go | 247 ++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 rpc/oorpb/payloads_fuzz_test.go diff --git a/rpc/oorpb/payloads_fuzz_test.go b/rpc/oorpb/payloads_fuzz_test.go new file mode 100644 index 000000000..894169806 --- /dev/null +++ b/rpc/oorpb/payloads_fuzz_test.go @@ -0,0 +1,247 @@ +package oorpb + +import ( + "testing" + + "google.golang.org/protobuf/proto" +) + +// The parsers in this file (ParseSubmitPackageRequest and +// ParseFinalizePackageRequest) run on the SERVER's OOR RPC handlers +// against fully client-controlled proto bytes. A panic, OOM, slice +// out-of-bounds, or integer overflow anywhere in these decode paths is a +// remote server-crash vector: a single hostile client could take down +// the operator. These fuzz targets assert ONLY the no-panic invariant +// (the parsers are free to return errors); they are deliberately not +// round-trip checks. Seeds cover a representative valid value, empty +// input, and off-length edge cases (31/33-byte session hashes) that +// historically trip fixed-length copies. + +// validSessionID is a representative 32-byte session hash used to seed the +// fuzzers with a value that passes the length gate so the fuzzer explores +// past it. +var validSessionID = []byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, +} + +// FuzzParseSubmitPackageRequestFields builds a SubmitPackageRequest in +// the harness from fuzzer-controlled bytes for every byte-typed field the +// parser touches (ark PSBT, one checkpoint PSBT entry, one descriptor's +// outpoint txid + policy/spend-path bytes, one recipient's pk_script and +// value). This drives the decode helpers directly without the proto wire +// layer in the way, so the fuzzer can reach the inner length checks +// (decodeOutPoint's 32-byte txid gate, psbtutil.Parse) quickly. +func FuzzParseSubmitPackageRequestFields(f *testing.F) { + // Seed: a plausible-shaped request. The PSBTs will fail to parse, + // but the goal is no-panic, not success. + f.Add( + []byte("psbt"), []byte("checkpoint"), validSessionID, + []byte("policy"), []byte("spendpath"), uint32(0), + []byte{0x51}, int64(1000), []byte("vtxopolicy"), + ) + // Seed: all-empty fields (proto defaults). + f.Add( + []byte{}, []byte{}, []byte{}, []byte{}, []byte{}, + uint32(0), []byte{}, int64(0), []byte{}, + ) + // Seed: off-length txid (31 bytes) to exercise the length gate in + // decodeOutPoint, plus a negative recipient value. + f.Add( + []byte{}, []byte{}, validSessionID[:31], []byte{}, + []byte{}, uint32(0xffffffff), []byte{}, int64(-1), []byte{}, + ) + // Seed: off-length txid (33 bytes). + f.Add( + []byte{}, []byte{}, append(append([]byte{}, validSessionID...), + 0xaa), []byte{}, []byte{}, uint32(1), []byte{}, + int64(1<<62), []byte{}, + ) + + f.Fuzz(func(t *testing.T, arkPSBT, checkpointPSBT, descTxid, + descPolicy, descSpendPath []byte, descVout uint32, + recipientScript []byte, recipientValue int64, + recipientPolicy []byte) { + + req := &SubmitPackageRequest{ + ArkPsbt: arkPSBT, + CheckpointPsbts: [][]byte{checkpointPSBT}, + SigningDescriptors: []*OORSigningDescriptor{ + { + Outpoint: &OOROutPoint{ + Txid: descTxid, + Vout: descVout, + }, + VtxoPolicyTemplate: descPolicy, + SpendPath: descSpendPath, + OwnerLeafPolicy: descPolicy, + }, + // A nil descriptor entry must be rejected, not + // dereferenced. + nil, + }, + RecipientOutputs: []*OORRecipientOutput{ + { + PkScript: recipientScript, + ValueSat: recipientValue, + VtxoPolicyTemplate: recipientPolicy, + }, + nil, + }, + } + + // Only assertion: the parser must not panic. Returning an + // error is the expected behavior for hostile input. + _, _, _, _, _ = ParseSubmitPackageRequest(req) + }) +} + +// FuzzParseSubmitPackageRequestWire feeds raw bytes straight into +// proto.Unmarshal and then into the parser. This catches panics that only +// manifest from wire shapes the in-harness field fuzzer cannot construct +// (e.g. many repeated descriptors/recipients, deeply nested unknown +// fields). The seed is a marshaled valid-shaped request. +func FuzzParseSubmitPackageRequestWire(f *testing.F) { + seed := &SubmitPackageRequest{ + ArkPsbt: []byte("psbt"), + CheckpointPsbts: [][]byte{[]byte("a"), []byte("b")}, + SigningDescriptors: []*OORSigningDescriptor{ + { + Outpoint: &OOROutPoint{ + Txid: validSessionID, + Vout: 0, + }, + VtxoPolicyTemplate: []byte("policy"), + SpendPath: []byte("spend"), + OwnerLeafPolicy: []byte("owner"), + }, + }, + RecipientOutputs: []*OORRecipientOutput{ + { + PkScript: []byte{0x51}, + ValueSat: 1000, + }, + }, + } + if raw, err := proto.Marshal(seed); err == nil { + f.Add(raw) + } + f.Add([]byte{}) + + f.Fuzz(func(t *testing.T, data []byte) { + var req SubmitPackageRequest + if err := proto.Unmarshal(data, &req); err != nil { + return + } + + _, _, _, _, _ = ParseSubmitPackageRequest(&req) + }) +} + +// FuzzParseFinalizePackageRequestFields builds a FinalizePackageRequest +// from a fuzzer-controlled session id and one checkpoint PSBT entry. The +// session id is the prime target: decodeSessionID does a fixed 32-byte +// copy gated by a length check, and an off-by-one in that gate would be a +// slice OOB. +func FuzzParseFinalizePackageRequestFields(f *testing.F) { + f.Add(validSessionID, []byte("checkpoint")) + f.Add([]byte{}, []byte{}) + // 31-byte and 33-byte session ids straddle the length gate. + f.Add(validSessionID[:31], []byte{}) + f.Add(append(append([]byte{}, validSessionID...), 0xaa), []byte{}) + + f.Fuzz(func(t *testing.T, sessionID, checkpointPSBT []byte) { + req := &FinalizePackageRequest{ + SessionId: sessionID, + FinalCheckpointPsbts: [][]byte{checkpointPSBT}, + } + + _, _, _ = ParseFinalizePackageRequest(req) + }) +} + +// FuzzParseFinalizePackageRequestWire feeds raw bytes through +// proto.Unmarshal, then the parser, to probe wire shapes (e.g. a large +// repeated final_checkpoint_psbts) beyond the field fuzzer's reach. +func FuzzParseFinalizePackageRequestWire(f *testing.F) { + seed := &FinalizePackageRequest{ + SessionId: validSessionID, + FinalCheckpointPsbts: [][]byte{[]byte("a")}, + } + if raw, err := proto.Marshal(seed); err == nil { + f.Add(raw) + } + f.Add([]byte{}) + + f.Fuzz(func(t *testing.T, data []byte) { + var req FinalizePackageRequest + if err := proto.Unmarshal(data, &req); err != nil { + return + } + + _, _, _ = ParseFinalizePackageRequest(&req) + }) +} + +// FuzzParseSubmitPackageResponse exercises the response parser. Although +// the response is operator-authored on the happy path, the same decode +// helpers (decodeSessionID, psbtutil.Parse, decodePSBTSlice) run on it, +// and a compromised or buggy operator must not be able to panic the +// client. Wire-bytes style covers both the success and rejection oneof +// branches. +func FuzzParseSubmitPackageResponse(f *testing.F) { + success := &SubmitPackageResponse{ + Result: &SubmitPackageResponse_Success{ + Success: &SubmitPackageSuccess{ + SessionId: validSessionID, + CoSignedArkPsbt: []byte("psbt"), + CoSignedCheckpointPsbts: [][]byte{[]byte("a")}, + }, + }, + } + if raw, err := proto.Marshal(success); err == nil { + f.Add(raw) + } + rejection := &SubmitPackageResponse{ + Result: &SubmitPackageResponse_Rejection{ + Rejection: &SubmitPackageRejection{ + SessionId: validSessionID, + Reason: "nope", + }, + }, + } + if raw, err := proto.Marshal(rejection); err == nil { + f.Add(raw) + } + f.Add([]byte{}) + + f.Fuzz(func(t *testing.T, data []byte) { + var resp SubmitPackageResponse + if err := proto.Unmarshal(data, &resp); err != nil { + return + } + + _, _, _, _ = ParseSubmitPackageResponse(&resp) + }) +} + +// FuzzParseFinalizePackageResponse exercises decodeSessionID on the +// finalize response's session_id via the wire layer. +func FuzzParseFinalizePackageResponse(f *testing.F) { + seed := &FinalizePackageResponse{SessionId: validSessionID} + if raw, err := proto.Marshal(seed); err == nil { + f.Add(raw) + } + f.Add([]byte{}) + + f.Fuzz(func(t *testing.T, data []byte) { + var resp FinalizePackageResponse + if err := proto.Unmarshal(data, &resp); err != nil { + return + } + + _, _ = ParseFinalizePackageResponse(&resp) + }) +} From e0436b6a1042679ad7301b31058271f7ec96ba9c Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 23 Jun 2026 17:07:06 -0700 Subject: [PATCH 2/2] roundpb: fuzz round proto converters for no-panic The round RPC converters decode proto messages that originate from a remote peer, so a panic, OOM, slice OOB, or integer overflow in any decode helper is a process-crash vector. Add Go native fuzz targets asserting only the no-panic invariant for OutpointFromProto, OutpointsFromProto, TxOutFromProto, PSBTFromBytes, MsgTxFromBytes, SchnorrSigFromBytes, TxIDFromHex, OutpointFromMapKey, and TreeFromProto (both a field-level and a wire-bytes target each where useful). TreeFromProto gets extra attention because it reconstructs the recursive VTXO tree from a flattened node array. The wire target pins an explicit WithMaxTreeNodes bound and seeds a many-node corpus so the fuzzer can grow node counts toward and past the cap, proving the bound is enforced rather than ignored and that hostile child-index maps cannot drive OOB or unbounded work. Length-gate seeds straddle 31/33-byte hashes and 63/64/65-byte signatures. No crashers surfaced across all targets. --- rpc/roundpb/convert_fuzz_test.go | 279 +++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 rpc/roundpb/convert_fuzz_test.go diff --git a/rpc/roundpb/convert_fuzz_test.go b/rpc/roundpb/convert_fuzz_test.go new file mode 100644 index 000000000..91429fa53 --- /dev/null +++ b/rpc/roundpb/convert_fuzz_test.go @@ -0,0 +1,279 @@ +package roundpb + +import ( + "encoding/hex" + "testing" + + "google.golang.org/protobuf/proto" +) + +// The converters in this file decode proto messages that, on the round +// RPC path, originate from a remote peer. A panic, OOM, slice +// out-of-bounds, or integer overflow in any of these decode helpers is a +// crash vector for the process that runs them. These fuzz targets assert +// ONLY the no-panic invariant; the converters are free to return errors +// on hostile input. The recursive-looking surface (TreeFromProto) gets +// extra attention: it must hold its node-count bound and refuse to blow +// the stack regardless of how the flattened node array is shaped. + +// fuzzHash32 is a representative 32-byte hash used to seed length gates +// with a value that passes so the fuzzer can explore beyond them. +var fuzzHash32 = []byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, +} + +// FuzzOutpointFromProto fuzzes the tx-hash bytes and output index. The +// 32-byte length gate guards a fixed-size copy, so off-length inputs +// (notably 31/33 bytes) are the interesting cases. +func FuzzOutpointFromProto(f *testing.F) { + f.Add(fuzzHash32, uint32(0)) + f.Add([]byte{}, uint32(0)) + f.Add(fuzzHash32[:31], uint32(0xffffffff)) + f.Add(append(append([]byte{}, fuzzHash32...), 0xaa), uint32(1)) + + f.Fuzz(func(t *testing.T, txHash []byte, idx uint32) { + op := &Outpoint{TxHash: txHash, OutputIndex: idx} + + // OutpointsFromProto exercises the slice wrapper, including a + // nil entry which must be rejected rather than dereferenced. + _, _ = OutpointsFromProto([]*Outpoint{op, nil}) + _, _ = OutpointFromProto(op) + }) +} + +// FuzzTxOutFromProto fuzzes the output value (probing the negative-value +// rejection) and the pk_script bytes. +func FuzzTxOutFromProto(f *testing.F) { + f.Add(int64(1000), []byte{0x51}) + f.Add(int64(0), []byte{}) + f.Add(int64(-1), []byte{}) + f.Add(int64(-1<<62), []byte{0x00}) + + f.Fuzz(func(t *testing.T, value int64, pkScript []byte) { + out := &TxOut{Value: value, PkScript: pkScript} + + _, _ = TxOutFromProto(out) + }) +} + +// FuzzPSBTFromBytes feeds raw bytes to the PSBT deserializer. btcd's PSBT +// reader owns the parse, but this confirms our bytesReader wrapper plus +// the empty/nil handling never panic on truncated or garbage input. +func FuzzPSBTFromBytes(f *testing.F) { + f.Add([]byte("psbt\xff")) + f.Add([]byte{}) + f.Add([]byte{0x70, 0x73, 0x62, 0x74}) + + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = PSBTFromBytes(data) + }) +} + +// FuzzMsgTxFromBytes feeds raw bytes to the wire-tx deserializer. The +// witness/varint length prefixes in the wire format are a classic +// allocation-amplification surface; this confirms decoding never panics. +func FuzzMsgTxFromBytes(f *testing.F) { + f.Add([]byte{0x01, 0x00, 0x00, 0x00, 0x00, 0x00}) + f.Add([]byte{}) + // A version followed by a huge input-count varint, the kind of + // shape that drives oversized make() in naive decoders. + f.Add([]byte{0x01, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff}) + + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = MsgTxFromBytes(data) + }) +} + +// FuzzSchnorrSigFromBytes fuzzes signature bytes. The 64-byte parse is +// owned by btcd's schnorr package; seeds straddle the 63/65 boundary to +// confirm our nil/length handling never panics around it. +func FuzzSchnorrSigFromBytes(f *testing.F) { + f.Add(make([]byte, 64)) + f.Add([]byte{}) + f.Add(make([]byte, 63)) + f.Add(make([]byte, 65)) + + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = SchnorrSigFromBytes(data) + }) +} + +// FuzzTxIDFromHex fuzzes the hex string parsed into a fixed-size tree +// TxID. The hex decode plus the 32-byte length gate guard a fixed copy. +func FuzzTxIDFromHex(f *testing.F) { + f.Add(hex.EncodeToString(fuzzHash32)) + f.Add("") + f.Add("zz") + f.Add(hex.EncodeToString(fuzzHash32[:31])) + f.Add(hex.EncodeToString(append(append([]byte{}, fuzzHash32...), 0xaa))) + + f.Fuzz(func(t *testing.T, s string) { + _, _ = TxIDFromHex(s) + }) +} + +// FuzzOutpointFromMapKey fuzzes the "hash:index" string key parser, which +// splits on ":" and parses both halves. Malformed separators and oversize +// indices are the interesting cases. +func FuzzOutpointFromMapKey(f *testing.F) { + f.Add(hex.EncodeToString(fuzzHash32) + ":0") + f.Add("") + f.Add(":") + f.Add("nothex:0") + f.Add(hex.EncodeToString(fuzzHash32) + ":99999999999999999999") + + f.Fuzz(func(t *testing.T, key string) { + _, _ = OutpointFromMapKey(key) + }) +} + +// FuzzTreeFromProtoFields builds a VTXOTree from fuzzer-controlled scalar +// fields for a small fixed node set, including a self-referencing / +// forward / out-of-range child index and a co-signer key. This drives the +// child-index validation, the per-node decode (treeNodeFromProto), and +// ComputeFinalKey on attacker-shaped co-signers, all on the no-panic +// invariant. +func FuzzTreeFromProtoFields(f *testing.F) { + // Seed: a two-node, structurally valid-ish tree with a forward + // child reference. + f.Add( + fuzzHash32, int64(1000), []byte{0x51}, + uint32(0), uint32(1), make([]byte, 33), make([]byte, 64), + fuzzHash32, + ) + // Seed: child index points back at the parent (cycle), zero node + // amount, empty co-signer/sig. + f.Add( + fuzzHash32, int64(0), []byte{}, + uint32(0), uint32(0), []byte{}, []byte{}, []byte{}, + ) + // Seed: huge child output index and child node index to probe the + // out-of-range guards, plus a negative amount. + f.Add( + fuzzHash32[:31], int64(-1), []byte{}, + uint32(0xffffffff), uint32(0xffffffff), make([]byte, 33), + make([]byte, 64), fuzzHash32, + ) + + f.Fuzz(func(t *testing.T, txHash []byte, amount int64, + pkScript []byte, childOut, childIdx uint32, coSigner, + sig, sweepRoot []byte) { + + pt := &VTXOTree{ + Nodes: []*TreeNode{ + { + Input: &Outpoint{ + TxHash: txHash, + OutputIndex: 0, + }, + Outputs: []*TxOut{ + { + Value: amount, + PkScript: pkScript, + }, + }, + CoSigners: [][]byte{coSigner}, + Children: map[uint32]uint32{ + childOut: childIdx, + }, + Amount: amount, + Signature: sig, + }, + { + Input: &Outpoint{ + TxHash: txHash, + OutputIndex: 1, + }, + Outputs: []*TxOut{ + { + Value: amount, + PkScript: pkScript, + }, + }, + Children: map[uint32]uint32{}, + Amount: amount, + }, + }, + BatchOutpoint: &Outpoint{ + TxHash: txHash, + OutputIndex: 0, + }, + BatchOutput: &TxOut{ + Value: amount, + PkScript: pkScript, + }, + SweepTapscriptRoot: sweepRoot, + } + + // Only assertion: no panic. The default node-count bound + // applies; the converter may return any error. + _, _ = TreeFromProto(pt) + }) +} + +// FuzzTreeFromProtoWire feeds raw bytes through proto.Unmarshal into a +// VTXOTree, then into TreeFromProto. This is the strongest probe of the +// node-count bound and the child-index validation: the wire layer can +// materialize many nodes and arbitrary children maps that the field +// fuzzer cannot. A representative seed plus a many-node seed (well under +// the bound) exercise both the accept and reject paths. +func FuzzTreeFromProtoWire(f *testing.F) { + seed := &VTXOTree{ + Nodes: []*TreeNode{ + { + Input: &Outpoint{ + TxHash: fuzzHash32, + OutputIndex: 0, + }, + Outputs: []*TxOut{ + {Value: 1000, PkScript: []byte{0x51}}, + }, + Children: map[uint32]uint32{}, + Amount: 1000, + }, + }, + BatchOutpoint: &Outpoint{TxHash: fuzzHash32, OutputIndex: 0}, + BatchOutput: &TxOut{Value: 1000, PkScript: []byte{0x51}}, + } + if raw, err := proto.Marshal(seed); err == nil { + f.Add(raw) + } + + // A larger many-node seed (still under DefaultMaxTreeNodes) so the + // fuzzer has a corpus base from which to grow node counts toward + // and past the bound, probing for OOM/stack growth. + big := &VTXOTree{ + BatchOutpoint: &Outpoint{TxHash: fuzzHash32, OutputIndex: 0}, + BatchOutput: &TxOut{Value: 1, PkScript: []byte{0x51}}, + } + for i := 0; i < 256; i++ { + big.Nodes = append(big.Nodes, &TreeNode{ + Input: &Outpoint{ + TxHash: fuzzHash32, + OutputIndex: uint32(i), + }, + Outputs: []*TxOut{{Value: 1, PkScript: []byte{0x51}}}, + Children: map[uint32]uint32{}, + Amount: 1, + }) + } + if raw, err := proto.Marshal(big); err == nil { + f.Add(raw) + } + f.Add([]byte{}) + + f.Fuzz(func(t *testing.T, data []byte) { + var pt VTXOTree + if err := proto.Unmarshal(data, &pt); err != nil { + return + } + + // Pin a small explicit bound so the fuzzer's own large + // inputs cannot OOM the test process, while still proving the + // bound is enforced rather than ignored. + _, _ = TreeFromProto(&pt, WithMaxTreeNodes(4096)) + }) +}