Skip to content

fix(ssz): reject zero first offset in variable-size list decoder#1177

Merged
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:fix/ssz-list-zero-first-offset
Jul 4, 2026
Merged

fix(ssz): reject zero first offset in variable-size list decoder#1177
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:fix/ssz-list-zero-first-offset

Conversation

@tcoratger

Copy link
Copy Markdown
Collaborator

What

The SSZList variable-size decoder now rejects a first offset below one offset word (four bytes), which closes the first_offset == 0 gap reported in #1175.

The bug

The decoder validated that the first offset was in range (first_offset > scope) and aligned (first_offset % 4 != 0), but never that it was at least one offset word wide. A first offset of zero slipped through both checks: zero is a multiple of four, and it is below any positive scope.

That value is contradictory:

num_elements   = first_offset // 4      = 0            "the list is empty"
boundary list  = [first_offset, scope]  = [0, scope]   one span (0, scope) -> one element

The count says zero while the boundary says one. The decoder then tries to parse an element spanning the whole scope while believing the count is zero. Today the input does fail — but only by accident, through a per-element short read deeper in the stack, not because the malformed offset was rejected.

For a wire format that must decode identically byte-for-byte across clients, this must fail closed by construction. consensus-specs lists a first offset with a mismatching minimum element size as something a decoder MUST harden against.

The fix

Fold a lower-bound clause into the existing offset check so a first offset below one offset word — zero included — is rejected before the boundary list is built:

if (
    first_offset < BYTES_PER_LENGTH_OFFSET
    or first_offset > scope
    or first_offset % BYTES_PER_LENGTH_OFFSET != 0
):
    raise SSZSerializationError(f"{cls.__name__}: invalid offset {first_offset}")

This mirrors the vector decoder in the same module, which already requires its first offset to equal the fixed-part width, and the container decoder, which requires its first offset to equal the fixed-part end.

Test

Added a regression test that decodes the exact payload from the issue (00000000aabbccdd) and asserts the complete error message with string equality, so the message cannot drift unnoticed.

Credit

Found via the Lean 4 formalization of this spec (NyxFoundation/formal-leanSpec), examining the decode direction of SSZ round-tripping.

Closes #1175

🤖 Generated with Claude Code

The variable-size list decoder validated that the first offset was in
range and aligned, but never that it was at least one offset word wide.
A first offset of zero slipped through: zero is a multiple of the offset
width and below any positive scope.

That value is contradictory. The element count computes as zero (an empty
list) while the boundary list becomes [0, scope], which describes one
element spanning the whole scope. The decoder only failed closed by
accident, through a per-element short read deeper in the stack. For a
wire format that must decode identically byte-for-byte across clients,
the malformed offset must be rejected up front.

Fold a lower-bound clause into the existing offset check so a first
offset below one offset word, zero included, is rejected before the
boundary list is built. This mirrors the vector decoder, which already
requires its first offset to equal the fixed-part width.

Closes leanEthereum#1175

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tcoratger tcoratger merged commit aaac891 into leanEthereum:main Jul 4, 2026
14 checks passed
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.

SSZ variable-length list decoder accepts first_offset == 0

1 participant