Skip to content

🐞 Fix LibBytes.split null path and dynamicStructInCalldata bounds check - #1562

Open
rakhmadaa-gif wants to merge 1 commit into
Vectorized:mainfrom
rakhmadaa-gif:fix/libbytes-split-null-path-and-calldata-bounds
Open

🐞 Fix LibBytes.split null path and dynamicStructInCalldata bounds check#1562
rakhmadaa-gif wants to merge 1 commit into
Vectorized:mainfrom
rakhmadaa-gif:fix/libbytes-split-null-path-and-calldata-bounds

Conversation

@rakhmadaa-gif

Copy link
Copy Markdown

Summary

Two edge-case bugs in LibBytes.sol, each with a deterministic failing test before the fix:

1. split reads the array length from scratch space on the null path

When needle.length > subject.length (e.g. split("", ",")), indicesOf skips its entire body and returns the default null pointer (0). split then computes:

let indicesEnd := add(indexPtr, shl(5, add(mload(indices), 1)))

i.e. it loads the array length from address 0x00 (scratch space), whose content is not guaranteed to be zero between calls. With dirty scratch the null path corrupts memory (the FMP slot at 0x40 can get clobbered with subject.length before the loop allocates elements) — deterministic repro: two consecutive split calls with a longer delimiter OOG-revert on the second call.

The clean-scratch path also synthesizes the returned bytes[] header in scratch 0x000x40 rather than in allocated memory, so any later scratch use corrupts the returned array.

Fix: indicesOf now allocates a proper empty array on the needle.length > subject.length path (including the one spare word so the array stays recyclable for split, per the existing allocation comment). With this, split operates on real allocated memory on every path.

2. dynamicStructInCalldata is missing the s <= a.length check

The bounds check only rejects s >= 2^192 and offset > l, but not s > l:

result.length := sub(a.length, s) // wraps when s > a.length

A malformed calldata blob with a struct offset word of 65 on a 64-byte input returns a slice with length = 2^256 - 1 instead of reverting. The sibling bytesInCalldata has the equivalent check (gt(add(s, result.length), l)), so this looks like an omission rather than a design choice — the NatSpec says "Performs bounds checks".

Fix: add gt(s, l) to the revert condition, mirroring bytesInCalldata.

Why the existing suite misses these

  • testStringSplit fuzzes with subject = filler0 ++ delimiter ++ filler1, so delimiter.length <= subject.length always holds and the null path is never fuzzed. The static split tests do hit the null path (split("ab", " ")) but only with clean scratch in a fresh call frame.
  • testDynamicStructInCalldata* only pass valid abi.encode(...) outputs; the malformed-offset-word case is never exercised.

Tests

test/LibBytesEdgeCases.t.sol adds 5 tests (2 sanity, 3 that fail on current main and pass with the fix). Full suite: 1597/1597 pass with both fixes applied (forge test, 81 suites).

Notes

  • indicesOf returning a null pointer is itself unsound for direct callers too: reading result.length on a null pointer loads from scratch. The fix addresses both split and direct callers.
  • Gas impact is limited to the previously-broken path; the common path is unchanged.

- indicesOf: when needle is longer than subject, allocate a proper
  empty array instead of returning a null pointer. Previously, split
  read the array length from the scratch space at 0x00, which is not
  guaranteed to be zero, causing memory corruption or OOG on the
  null path (e.g. split("", ",")).
- dynamicStructInCalldata: add the missing gt(s, l) bounds check,
  mirroring bytesInCalldata. Previously a struct offset s > a.length
  was not rejected and result.length = a.length - s wrapped around.

Co-Authored-By: Letta Code <noreply@letta.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