Skip to content

LibBytes: bound s in dynamicStructInCalldata - #1548

Open
Popy21 wants to merge 1 commit into
Vectorized:mainfrom
Popy21:libbytes-bound-dynamic-struct-offset
Open

LibBytes: bound s in dynamicStructInCalldata#1548
Popy21 wants to merge 1 commit into
Vectorized:mainfrom
Popy21:libbytes-bound-dynamic-struct-offset

Conversation

@Popy21

@Popy21 Popy21 commented Aug 21, 2026

Copy link
Copy Markdown

Problem

dynamicStructInCalldata reads the relative offset s from calldata and computes
result.length := sub(a.length, s) without ever bounding s against a.length.

When s > a.length the subtraction underflows. The existing shr(64, s) term does not catch it — a
value slightly larger than a.length is far below 2**64. The helper then returns a slice whose
offset is past the end of a and whose length is ~2**256:

a.length = 0x20, s = 0x1000
  ->  result.offset = a.offset + 0x1000   (outside `a`)
      result.length = 115792089237316195423570985008687907853269984665640564039457584007913129635872

loadCalldata performs no bounds check of its own, so a caller following the documented pattern
reads outside the buffer. The doc comment states "Performs bounds checks".

The two sibling helpers in the same file already reject the same input:
staticStructInCalldata bounds via gt(offset, l), bytesInCalldata via
gt(add(s, result.length), l). Only dynamicStructInCalldata is missing the bound.

Change

One added term in the existing revert condition:

if or(shr(64, or(s, or(l, a.offset))), or(gt(offset, l), gt(s, a.length))) { revert(l, 0x00) }

Verification

  • New test testDynamicStructInCalldataRejectsOutOfBoundsOffset, in the style of the file.
  • The test fails without the change (next call did not revert as expected) and passes with it,
    so it is not vacuous.
  • Full test/LibBytes.t.sol suite: 24/24 passing, including the existing
    testDynamicStructInCalldata fuzz tests, so well-formed inputs are unaffected.

Note for reviewers

Found while auditing calldata-slice construction across ERC-7579 decoders. Filed as a hardening PR
rather than through a security channel: there is no in-repo caller, and the two external call sites I
found are not exploitable through this path — Uniswap/Tribunal pins the relevant offset to 0x60
before use and is not deployed, and ithacaxyz/account follows every extraction with
checkInCalldata, whose upper bound self-corrects under the underflow
((E.length - s) + (E.offset + s) == E.offset + E.length). Filing it as robustness so the helper
matches its own documentation and its siblings.

🤖 Generated with Claude Code

`dynamicStructInCalldata` reads the relative offset `s` from calldata and
computes `result.length := sub(a.length, s)` without bounding `s` against
`a.length`. When `s > a.length` the subtraction underflows and the helper
returns a slice whose offset is past the end of `a` and whose length is
~2**256. The existing `shr(64, s)` term does not catch it: a value slightly
larger than `a.length` is far below 2**64.

The two sibling helpers in the same file already reject the same input --
`staticStructInCalldata` via `gt(offset, l)` and `bytesInCalldata` via
`gt(add(s, result.length), l)`. This adds the matching bound.

Adds a regression test that fails without the change
("next call did not revert as expected") and passes with it.
Full test/LibBytes.t.sol suite: 24/24 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant