Skip to content

Bound the nesting depth of every recursive XDR type - #197

Merged
christian-rogobete merged 1 commit into
mainfrom
hardening-xdr-depth
Nov 4, 2026
Merged

christian-rogobete merged 1 commit into
mainfrom
hardening-xdr-depth

Conversation

@christian-rogobete

@christian-rogobete christian-rogobete commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Closes a gap in the binary XDR decoder. Before this change, only XdrSorobanDelegateSignature::decode counted its nesting against the buffer's recursion cap. The twelve other XDR types that contain themselves, among them XdrSCVal through vectors and maps, XdrClaimPredicate and XdrSCSpecTypeDef, decoded without a bound. A value nested deeper than the C stack allows decoded, and releasing the decoded object graph ended the process with a segmentation fault. With Xdebug loaded, the decode ended in an Error, which a catch (Exception $e) clause does not catch. The network reads XDR to a depth of 500 levels, so such bytes come from outside the network. Examples are a transaction envelope, an authorization entry or a contract spec that an application receives for signing or display.

This change:

  • Makes the generator emit a depth guard at the entry of every decoder on a cycle of the XDR type graph, unions included, so fields, optionals, arrays and union arms share one budget. The guard is the buffer's counter of active recursive decodes, checked before the increment and released in finally, with a cap of 500 (XdrBuffer::DECODE_DEPTH_LIMIT), the network's XDR depth. The network counts every nested type and the guard counts only recursive ones, so no value the network accepts reaches the cap.
  • Keeps the delegate chain's limit of 128 signatures (XdrBuffer::RECURSION_LIMIT) and the message that names it. The delegate decoder counts its chain per buffer beside the shared budget, so a chain of 128 delegate signatures decodes and the decoder rejects a chain of 129, as before.
  • Checks the cap before the increment, so a buffer keeps its full budget after a failed decode.
  • Adds a generator test that computes the cycles of the generated decoders and checks that exactly these thirteen types carry one balanced guard each.
  • Adds decoder tests for the boundaries at 500 and 501 levels for vectors, maps, claim predicates and authorized invocations, where a map entry and its value count one level each. Further tests cover the delegate boundary at 128 and 129 and a chain of delegates and values on one budget. Two more tests check sibling breadth, which consumes no depth, and the counter after a success and after a failure on the same buffer.

The unit suite, PHPStan, the generated suite, the generator tests and the XDR validation pass, and a second regeneration changes no file.

Closes #196

Closes a gap in the binary XDR decoder. Before this change, only
`XdrSorobanDelegateSignature::decode` counted its nesting against the
buffer's recursion cap. The twelve other XDR types that contain
themselves, among them `XdrSCVal` through vectors and maps,
`XdrClaimPredicate` and `XdrSCSpecTypeDef`, decoded without a bound. A
value nested deeper than the C stack allows decoded, and releasing the
decoded object graph ended the process with a segmentation fault. With
Xdebug loaded, the decode ended in an `Error`, which a `catch (Exception
$e)` clause does not catch. The network reads XDR to a depth of 500
levels, so such bytes come from outside the network. Examples are a
transaction envelope, an authorization entry or a contract spec that an
application receives for signing or display.

This change:

- Makes the generator emit a depth guard at the entry of every decoder
  on a cycle of the XDR type graph, unions included, so fields,
  optionals, arrays and union arms share one budget. The guard is the
  buffer's counter of active recursive decodes, checked before the
  increment and released in `finally`, with a cap of 500
  (`XdrBuffer::DECODE_DEPTH_LIMIT`), the network's XDR depth. The
  network counts every nested type and the guard counts only recursive
  ones, so no value the network accepts reaches the cap.
- Keeps the delegate chain's limit of 128 signatures
  (`XdrBuffer::RECURSION_LIMIT`) and the message that names it. The
  delegate decoder counts its chain per buffer beside the shared budget,
  so a chain of 128 delegate signatures decodes and the decoder rejects
  a chain of 129, as before.
- Checks the cap before the increment, so a buffer keeps its full budget
  after a failed decode.
- Adds a generator test that computes the cycles of the generated
  decoders and checks that exactly these thirteen types carry one
  balanced guard each.
- Adds decoder tests for the boundaries at 500 and 501 levels for
  vectors, maps, claim predicates and authorized invocations, where a
  map entry and its value count one level each. Further tests cover the
  delegate boundary at 128 and 129 and a chain of delegates and values
  on one budget. Two more tests check sibling breadth, which consumes no
  depth, and the counter after a success and after a failure on the same
  buffer.
- Moves the TxRep entry of the CHANGELOG from the 1.17.0 section to
  Unreleased.

The unit suite, PHPStan, the generated suite, the generator tests and
the XDR validation pass, and a second regeneration changes no file.

Closes #196
@codecov

codecov Bot commented Nov 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.08257% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.46%. Comparing base (de21bcc) to head (f17e311).

Files with missing lines Patch % Lines
Soneso/StellarSDK/Xdr/XdrSCPQuorumSet.php 83.33% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##               main     #197   +/-   ##
=========================================
  Coverage     93.45%   93.46%           
- Complexity    21602    21603    +1     
=========================================
  Files          1006     1006           
  Lines         52362    52392   +30     
=========================================
+ Hits          48937    48967   +30     
  Misses         3425     3425           
Files with missing lines Coverage Δ
Soneso/StellarSDK/Xdr/XdrBuffer.php 99.15% <100.00%> (ø)
Soneso/StellarSDK/Xdr/XdrClaimPredicate.php 96.02% <100.00%> (+0.04%) ⬆️
Soneso/StellarSDK/Xdr/XdrSCContractInstance.php 88.63% <100.00%> (+0.26%) ⬆️
Soneso/StellarSDK/Xdr/XdrSCMapEntry.php 96.55% <100.00%> (+0.12%) ⬆️
Soneso/StellarSDK/Xdr/XdrSCSpecTypeDefBase.php 95.06% <100.00%> (+0.04%) ⬆️
Soneso/StellarSDK/Xdr/XdrSCSpecTypeMap.php 96.07% <100.00%> (+0.16%) ⬆️
Soneso/StellarSDK/Xdr/XdrSCSpecTypeOption.php 95.12% <100.00%> (+0.25%) ⬆️
Soneso/StellarSDK/Xdr/XdrSCSpecTypeResult.php 96.07% <100.00%> (+0.16%) ⬆️
Soneso/StellarSDK/Xdr/XdrSCSpecTypeTuple.php 94.11% <100.00%> (+0.24%) ⬆️
Soneso/StellarSDK/Xdr/XdrSCSpecTypeVec.php 95.12% <100.00%> (+0.25%) ⬆️
... and 4 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@christian-rogobete
christian-rogobete merged commit b257e46 into main Nov 4, 2026
13 checks passed
@christian-rogobete
christian-rogobete deleted the hardening-xdr-depth branch November 4, 2026 01:48
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.

Deeply nested XDR overflows the decoder's stack for every recursive type except delegate signatures

1 participant