Skip to content

fix(model): bound subtree-list allocation on untrusted block parse + fuzz#1005

Merged
liam merged 1 commit into
bsv-blockchain:mainfrom
liam:liam/fuzz-block-deser
Jun 2, 2026
Merged

fix(model): bound subtree-list allocation on untrusted block parse + fuzz#1005
liam merged 1 commit into
bsv-blockchain:mainfrom
liam:liam/fuzz-block-deser

Conversation

@liam

@liam liam commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Second in the deserializer-fuzzing series (after #1004). Targets the block parser and fixes the unbounded-allocation bug it surfaces.

Bug

readBlockFromReader pre-sized the subtree slice directly from the block's subtree-count varint:

block.Subtrees = make([]*chainhash.Hash, 0, block.subtreeLength)

A block from an untrusted peer claiming ~33M subtrees (or more, up to a varint's range) forces a multi-hundred-MB-to-GB allocation before the first missing hash fails the read — a cheap OOM that takes down the core sidecar via the ServiceManager errgroup. NewBlockFromBytes' recover() converts ordinary panics to errors but does not save a genuine OOM. Notably the CoinbaseBUMP read just below was already bounded (1 MB); the subtree list was the gap.

Fix

Cap the speculative capacity at min(subtreeLength, 1024). The loop already appends and validates each hash, so a bogus count now errors on the first missing hash instead of pre-allocating. Valid blocks parse unchanged.

Tests

  • FuzzNewBlockFromBytes — public entry; guards the OOM/allocation class that its recover() cannot catch. Seeded from model/testdata/*.block.
  • FuzzReadBlockFromReader — the inner body parser fuzzed without the recover() wrapper, so a panic in the varint / subtree-list / coinbase / bump parsing surfaces as a fuzz failure instead of being silently converted to an error in production.
  • TestBlock_HostileSubtreeLengthIsBounded — deterministic regression: a tiny body claiming a huge subtree count allocates < 4 MB (was ~256 MB) before erroring. Fails on the old code, passes on the fix.

Verification

  • Regression fails before the fix (268 MB observed), passes after.
  • FuzzReadBlockFromReader — ~525K execs, no crashers (no recover-masked panics surfaced; the inner reader also exercises go-bt/go-wire and stayed clean).
  • FuzzNewBlockFromBytes — ~860K execs, no crashers.
  • go test -race (block tests + fuzz corpus replay), go vet, gofmt, gci — clean.

Last planned target (separate PR): pkg/fileformat header parsing.

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review: No issues found

This PR correctly addresses a security vulnerability (unbounded allocation from untrusted input) with an appropriate fix and comprehensive testing:

Core Fix (model/Block.go:286)
The change effectively caps speculative allocation at min(block.subtreeLength, 1024) while preserving correctness. The loop still validates the full subtreeLength using append, so valid blocks parse identically and malicious blocks fail cleanly on the first missing hash.

Test Coverage (model/block_fuzz_test.go)
Three complementary tests provide defense in depth:

  • FuzzNewBlockFromBytes guards OOM/allocation bugs that recover() cannot catch
  • FuzzReadBlockFromReader surfaces masked panics in the inner parser without the recover() wrapper
  • TestBlock_HostileSubtreeLengthIsBounded is a deterministic regression using memory measurement

The fix aligns with the existing maxCoinbaseBUMPSize pattern at line 329 and follows AGENTS.md security rules.

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-1005 (4962f14)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 144
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.801µ 1.849µ ~ 0.800
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 61.82n 61.66n ~ 0.400
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 61.72n 61.68n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 61.75n 61.79n ~ 0.600
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 31.31n 31.04n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 55.18n 53.52n ~ 0.200
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 117.9n 118.8n ~ 0.400
MiningCandidate_Stringify_Short-4 259.8n 260.4n ~ 0.300
MiningCandidate_Stringify_Long-4 1.932µ 1.893µ ~ 0.100
MiningSolution_Stringify-4 967.4n 968.0n ~ 1.000
BlockInfo_MarshalJSON-4 1.811µ 1.835µ ~ 0.200
NewFromBytes-4 125.1n 123.6n ~ 0.400
AddTxBatchColumnar_Validation-4 2.497µ 2.365µ ~ 0.100
OffsetValidationLoop-4 545.1n 721.6n ~ 0.100
Mine_EasyDifficulty-4 60.36µ 60.36µ ~ 0.700
Mine_WithAddress-4 7.020µ 6.980µ ~ 1.000
DirectSubtreeAdd/4_per_subtree-4 60.18n 62.43n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 29.94n 31.43n ~ 0.100
DirectSubtreeAdd/256_per_subtree-4 28.97n 30.22n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 27.93n 28.97n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 27.63n 28.58n ~ 0.100
SubtreeProcessorAdd/4_per_subtree-4 288.4n 286.1n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 280.3n 284.6n ~ 0.100
SubtreeProcessorAdd/256_per_subtree-4 284.2n 282.3n ~ 0.200
SubtreeProcessorAdd/1024_per_subtree-4 272.7n 273.8n ~ 0.300
SubtreeProcessorAdd/2048_per_subtree-4 272.1n 273.8n ~ 0.200
SubtreeProcessorRotate/4_per_subtree-4 282.7n 279.5n ~ 0.200
SubtreeProcessorRotate/64_per_subtree-4 276.4n 280.8n ~ 0.100
SubtreeProcessorRotate/256_per_subtree-4 279.1n 285.7n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 275.9n 283.0n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 54.81n 54.95n ~ 1.000
SubtreeNodeAddOnly/64_per_subtree-4 34.37n 34.36n ~ 0.400
SubtreeNodeAddOnly/256_per_subtree-4 33.30n 33.29n ~ 1.000
SubtreeNodeAddOnly/1024_per_subtree-4 32.55n 32.56n ~ 0.800
SubtreeCreationOnly/4_per_subtree-4 115.1n 115.7n ~ 0.700
SubtreeCreationOnly/64_per_subtree-4 406.7n 403.4n ~ 1.000
SubtreeCreationOnly/256_per_subtree-4 1.380µ 1.359µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 4.401µ 4.471µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 8.270µ 8.250µ ~ 1.000
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 275.6n 279.1n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 272.9n 276.2n ~ 0.200
ParallelGetAndSetIfNotExists/1k_nodes-4 2.224m 2.212m ~ 1.000
ParallelGetAndSetIfNotExists/10k_nodes-4 5.554m 5.588m ~ 0.700
ParallelGetAndSetIfNotExists/50k_nodes-4 7.703m 7.716m ~ 1.000
ParallelGetAndSetIfNotExists/100k_nodes-4 11.20m 10.46m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 1.963m 1.946m ~ 0.100
SequentialGetAndSetIfNotExists/10k_nodes-4 4.512m 5.111m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 13.24m 13.80m ~ 0.400
SequentialGetAndSetIfNotExists/100k_nodes-4 23.66m 23.15m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.274m 2.283m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.477m 8.598m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.98m 13.91m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.996m 2.015m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.141m 8.816m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 40.80m 49.02m ~ 0.100
DiskTxMap_SetIfNotExists-4 3.475µ 3.429µ ~ 0.700
DiskTxMap_SetIfNotExists_Parallel-4 3.241µ 3.292µ ~ 0.100
DiskTxMap_ExistenceOnly-4 306.4n 307.9n ~ 1.000
Queue-4 192.7n 190.8n ~ 0.100
AtomicPointer-4 4.535n 4.678n ~ 0.700
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 889.0µ 843.1µ ~ 0.200
ReorgOptimizations/DedupFilterPipeline/New/10K-4 807.5µ 786.8µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 114.6µ 103.8µ ~ 0.400
ReorgOptimizations/AllMarkFalse/New/10K-4 61.94µ 62.03µ ~ 0.400
ReorgOptimizations/HashSlicePool/Old/10K-4 59.99µ 67.60µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/10K-4 11.42µ 11.65µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/10K-4 4.593µ 5.049µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.558µ 1.719µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 10.077m 9.419m ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/100K-4 10.052m 9.584m ~ 0.200
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.077m 1.090m ~ 0.400
ReorgOptimizations/AllMarkFalse/New/100K-4 693.4µ 682.8µ ~ 0.200
ReorgOptimizations/HashSlicePool/Old/100K-4 549.5µ 548.5µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/100K-4 306.2µ 303.6µ ~ 0.400
ReorgOptimizations/NodeFlags/Old/100K-4 49.90µ 50.47µ ~ 0.200
ReorgOptimizations/NodeFlags/New/100K-4 17.19µ 17.63µ ~ 0.200
TxMapSetIfNotExists-4 51.76n 52.26n ~ 0.400
TxMapSetIfNotExistsDuplicate-4 40.42n 39.88n ~ 0.100
ChannelSendReceive-4 579.3n 612.3n ~ 0.100
BlockAssembler_AddTx-4 0.01683n 0.01581n ~ 0.700
AddNode-4 7.639 7.762 ~ 0.400
AddNodeWithMap-4 7.280 7.259 ~ 0.400
CalcBlockWork-4 476.1n 514.8n ~ 0.200
CalculateWork-4 656.1n 653.2n ~ 0.400
BuildBlockLocatorString_Helpers/Size_10-4 1.624µ 1.684µ ~ 0.400
BuildBlockLocatorString_Helpers/Size_100-4 13.01µ 12.87µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 127.2µ 127.1µ ~ 0.700
CatchupWithHeaderCache-4 104.5m 104.5m ~ 1.000
_prepareTxsPerLevel-4 408.8m 411.4m ~ 1.000
_prepareTxsPerLevelOrdered-4 3.420m 3.392m ~ 1.000
_prepareTxsPerLevel_Comparison/Original-4 413.4m 410.2m ~ 0.400
_prepareTxsPerLevel_Comparison/Optimized-4 3.501m 3.461m ~ 0.200
SubtreeSizes/10k_tx_4_per_subtree-4 1.414m 1.403m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 331.8µ 325.8µ ~ 0.700
SubtreeSizes/10k_tx_64_per_subtree-4 77.87µ 78.25µ ~ 1.000
SubtreeSizes/10k_tx_256_per_subtree-4 19.55µ 19.37µ ~ 0.100
SubtreeSizes/10k_tx_512_per_subtree-4 9.773µ 9.602µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 4.839µ 4.760µ ~ 0.300
SubtreeSizes/10k_tx_2k_per_subtree-4 2.417µ 2.381µ ~ 0.400
BlockSizeScaling/10k_tx_64_per_subtree-4 76.61µ 77.00µ ~ 1.000
BlockSizeScaling/10k_tx_256_per_subtree-4 19.45µ 19.21µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.793µ 4.770µ ~ 0.400
BlockSizeScaling/50k_tx_64_per_subtree-4 405.0µ 396.7µ ~ 0.100
BlockSizeScaling/50k_tx_256_per_subtree-4 97.69µ 96.88µ ~ 0.100
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.93µ 23.86µ ~ 0.800
SubtreeAllocations/small_subtrees_exists_check-4 162.1µ 161.1µ ~ 0.400
SubtreeAllocations/small_subtrees_data_fetch-4 172.4µ 171.7µ ~ 1.000
SubtreeAllocations/small_subtrees_full_validation-4 337.5µ 335.1µ ~ 0.200
SubtreeAllocations/medium_subtrees_exists_check-4 9.630µ 9.596µ ~ 0.400
SubtreeAllocations/medium_subtrees_data_fetch-4 10.163µ 9.961µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 19.80µ 19.63µ ~ 0.100
SubtreeAllocations/large_subtrees_exists_check-4 2.312µ 2.270µ ~ 0.100
SubtreeAllocations/large_subtrees_data_fetch-4 2.485µ 2.427µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 4.923µ 4.882µ ~ 0.200
_BufferPoolAllocation/16KB-4 2.197µ 2.282µ ~ 0.400
_BufferPoolAllocation/32KB-4 5.667µ 5.046µ ~ 0.200
_BufferPoolAllocation/64KB-4 11.133µ 9.371µ ~ 0.100
_BufferPoolAllocation/128KB-4 21.27µ 19.91µ ~ 0.200
_BufferPoolAllocation/512KB-4 79.07µ 80.05µ ~ 0.700
_BufferPoolConcurrent/32KB-4 12.82µ 11.51µ ~ 0.100
_BufferPoolConcurrent/64KB-4 19.16µ 17.72µ ~ 0.100
_BufferPoolConcurrent/512KB-4 87.30µ 84.47µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 322.1µ 366.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 323.9µ 326.2µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/64KB-4 315.5µ 333.2µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 331.2µ 331.6µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 334.6µ 326.1µ ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/16KB-4 23.03m 22.28m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/32KB-4 22.62m 22.06m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 21.80m 22.23m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 21.52m 21.97m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 21.78m 22.14m ~ 0.200
_PooledVsNonPooled/Pooled-4 373.2n 361.8n ~ 0.200
_PooledVsNonPooled/NonPooled-4 5.136µ 5.146µ ~ 1.000
_MemoryFootprint/Current_512KB_32concurrent-4 5.529µ 4.957µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 6.808µ 6.400µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 6.358µ 6.222µ ~ 0.400
StoreBlock_Sequential/BelowCSVHeight-4 334.6µ 338.5µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 342.5µ 336.8µ ~ 0.700
GetUtxoHashes-4 259.1n 256.1n ~ 0.300
GetUtxoHashes_ManyOutputs-4 43.58µ 43.40µ ~ 0.700
_NewMetaDataFromBytes-4 229.0n 228.9n ~ 1.000
_Bytes-4 399.1n 400.1n ~ 0.700
_MetaBytes-4 138.4n 137.6n ~ 0.100

Threshold: >10% with p < 0.05 | Generated: 2026-06-01 15:37 UTC

…fuzz

readBlockFromReader pre-sized the subtree slice straight from the block's
subtree-count varint:

  block.Subtrees = make([]*chainhash.Hash, 0, block.subtreeLength)

A block from an untrusted peer claiming ~33M subtrees (or more) forces a
multi-hundred-MB-to-GB allocation before the first missing hash fails the
read — a cheap OOM that takes down the core sidecar via the ServiceManager
errgroup. NewBlockFromBytes' recover() does not save an OOM. The
CoinbaseBUMP read just below was already bounded (1 MB); the subtree list
was not.

Cap the speculative capacity (min(subtreeLength, 1024)); the loop already
appends, so a bogus count now errors on the first missing hash instead of
pre-allocating. Valid blocks parse unchanged.

Add fuzz coverage for the block deserializer:
  - FuzzNewBlockFromBytes (public entry; guards the OOM/alloc class that
    its recover() cannot catch),
  - FuzzReadBlockFromReader (inner body parser, no recover wrapper, so
    masked panics in varint/subtree/coinbase/bump parsing surface), and
  - TestBlock_HostileSubtreeLengthIsBounded, a deterministic regression
    asserting the hostile body allocates < 4 MB (was ~256 MB) before
    erroring.

Both fuzz targets ran clean (~525K and ~860K execs, no crashers).
Second in the deserializer-fuzzing series after the utxopersister fix.
@liam liam force-pushed the liam/fuzz-block-deser branch from 15e237e to 97b8eea Compare June 1, 2026 15:23
@liam

liam commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on the implicit init — you're right that it's safe because readBlockFromReader never touches block.Header. I've added a comment at the call site noting the bare &Block{} is intentional (the public NewBlockFromBytes/NewBlockFromReader set the header before calling in), so the isolation isn't mistaken for an oversight.

@sonarqubecloud

sonarqubecloud Bot commented Jun 1, 2026

Copy link
Copy Markdown

@liam liam requested review from ordishs and oskarszoon June 1, 2026 16:06

@ordishs ordishs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. Minimal, correct fix for a real unbounded-allocation OOM vector on the untrusted block-parse path.

  • min(subtreeLength, 1024) cap is safe — the append loop validates each hash and errors on the first missing one; valid blocks parse identically.
  • Confirmed this was the only allocation sized from an untrusted count in readBlockFromReader (CoinbaseBUMP was already bounded at 1MB).
  • Verified locally: regression test passes (<4MB alloc), FuzzReadBlockFromReader ran clean (~190K execs, no crashers), seed fixtures load.

Optional, non-blocking: line 307 passes the block.Subtrees slice to a %d verb (should be len(...)) — pre-existing and now effectively unreachable, but a one-line cleanup while you're here.

@liam liam requested a review from sugh01 June 2, 2026 08:48

@oskarszoon oskarszoon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Correct, minimal fix for a real OOM vector — pre-sizing Subtrees from an untrusted varint. Capping the capacity hint is right; the append loop and post-loop length check preserve parse semantics, so valid blocks are unaffected. Fuzz + deterministic regression are well-targeted.

No blocking issues. One non-blocking nit: the allocDelta comment (block_fuzz_test.go:107) says the reading "is not racy" because it's "single-goroutine" — but TotalAlloc is process-wide. The test is fine (monotonic counter + ~512× margin), but the justification is misleading and could trip up someone who later tightens the threshold. Worth a one-line comment fix.

Optional: a deterministic regression through the public NewBlockFromBytes (not just readBlockFromReader) would close the OOM story at the API boundary — fuzz already covers it.

@liam liam merged commit 4ae4c7a into bsv-blockchain:main Jun 2, 2026
46 of 47 checks passed
freemans13 added a commit to freemans13/teranode that referenced this pull request Jun 2, 2026
…tegration

Merge stu/blockvalidation-inmemory-checkold (ca7ef31) — off-chain prefetch
for checkOldBlockIDs plus the upstream/main it carried (bsv-blockchain#1005, bsv-blockchain#993, bsv-blockchain#1022).

Conflict resolutions:
- services/legacy/netsync/manager.go: kept integration's gap-watchdog
  self-heal (requestBlocksFromTip / maybeResyncOnMissingParent /
  missingParentResyncInterval); the incoming side had no content there.
- stores/utxo/aerospike/batch_create.go: pass nil for the new GetBinsToStore
  arena param (main added *bt.Arena; integration-only BatchCreate caller
  predates it). nil = non-arena route, output-equivalent per create_arena_test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants