Skip to content

bugc: revert on out-of-bounds indexing - #344

Merged
gnidan merged 5 commits into
mainfrom
bugc-bounds
Oct 6, 2026
Merged

gnidan merged 5 commits into
mainfrom
bugc-bounds

Conversation

@gnidan

@gnidan gnidan commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

An index out of bounds now reverts, as in Solidity. Before, a[i] past the end read or wrote whatever memory followed: with let m: array<array<uint256>> = [[1], [2]], the write m[0][1] = 99 overwrote the length word of m[1].

What is checked, for reads and writes, at O0 to O3:

  • arrays in memory, at each level of a nested array (locals, parameters, and arrays read out of other arrays);
  • bytes in memory, and msg.data through its slices;
  • slices: b[s:e] reverts unless s <= e <= b.length, so msg.data[0:4] reverts on shorter calldata;
  • fixed-size arrays in storage (array<T, N>), against N.

A dynamic array in storage is not checked. BUG has no push, so writing past the end is how such an array grows today (the nested-arrays example writes matrix[2][5] on an empty array). Checking those needs push first.

The revert data is Solidity's Panic(uint256) with code 0x32 (selector 0x4e487b71), so tools that decode Solidity panics decode these too.

Four commits do the work:

  • Assert instruction. A new IR instruction, assert <condition>, panic <code>, reverts with Panic(code) unless the condition holds. Code generation emits it inline: the condition, a JUMPI over the revert, then mstore(0, selector) mstore(0x20, code) revert(0x1c, 0x24), then a JUMPDEST. The jump target is a label inside the block, patched like a block jump, so the CFG and the optimizer passes do not change. The REVERT has the access's code range and a revert context with panic: 50 and a pointer to the revert data in memory. Dead code elimination keeps asserts; constant folding removes an assert whose condition is a true constant, so f[2] on an array<uint256, 3> in storage costs nothing.
  • Array literal type. An array literal's value had a scalar type, so [1, 2, 3].length was 32 (on main too), and so a check on [1, 2, 3][i] would compare against 32. It is now a memory reference.
  • Unsigned indexes. As in Solidity, an index and a slice bound must be an unsigned integer; the type checker says to cast a signed one (a[i as uint256]). Since bugc: use signed opcodes for signed operands #339 a comparison with a signed operand is signed, so -1 would pass a check.
  • The checks. The shared helpers in irgen/generate/memory.ts check every element and byte access, so every memory element and byte access gets them; the slice path and the storage chain (for fixed-size arrays) call the same check. The check compares as unsigned words whatever the operands' types, so a negative value cast to an index reverts.

The new bounds.test.ts runs each case at levels 0 to 3: the first and last index, an index at the length, from calldata, huge, and wrapped below zero, an index after the variable takes a shorter array, a loop that runs exactly to the length and one that runs one past it, nested arrays (the case above), a parameter, bytes, slices, fixed storage arrays, the revert data, the REVERT's context, an array literal indexed past its length, and a negative value cast to an index. That includes f[0 - 1] on a fixed storage array, where the optimizer folds the index and then the check; that is sound now that folding wraps modulo 2^256 (#340). The locals soundness test for a bytes slice took msg.data[0:4] with no calldata, which now reverts, so it slices msg.data[0:0].

Code size, deployed bytecode summed over the examples that compile (token-registry fails at O2 and O3 before and after):

Level Before After Change
O0 12922 15690 +21.4%
O1 11232 12945 +15.3%
O2 6277 7401 +17.9%
O3 6193 7319 +18.2%

Only examples that index or slice change; arrays.bug grows the most (1076 to 1492 bytes at O3). Each check costs about 22 bytes plus loading the length and comparing. Sharing one revert sequence per function would save about 17 bytes per check after the first, if that matters.

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-06 15:02 UTC

@gnidan
gnidan force-pushed the bugc-mem-element-write branch from 5cb4b63 to 45910e8 Compare October 6, 2026 14:23
Base automatically changed from bugc-mem-element-write to main October 6, 2026 14:30
gnidan added 5 commits October 6, 2026 10:31
An `assert` instruction takes a condition and a panic code. Code
generation jumps over a revert when the condition holds; otherwise the
code reverts with Solidity's `Panic(uint256)` error data, and the
REVERT carries a `revert` context with the panic code and a pointer
to that data. Dead code elimination keeps asserts, and constant
folding removes an assert whose condition is a true constant.
An array literal's value had a scalar type, so code generation took its
length as the scalar's size: [1, 2, 3].length was 32.
As in Solidity, an array or bytes index and a slice bound must be an
unsigned integer. Before, a signed index compiled, and since #339 its
comparisons are signed, so -1 would pass a bounds check.
Indexing an array or bytes in memory, or a fixed-size array in
storage, now reverts with Panic(0x32) when the index is not less than
the length, as Solidity does. This holds for reads and writes, at
every level of a nested array. A slice x[a:b] reverts unless
a <= b <= x.length.

The check compares as unsigned words whatever the index's type, so a
negative value cast to an index is a huge index and reverts.

Before, an index past the end read or wrote whatever memory followed:
writing m[0][1] on [[1], [2]] overwrote m[1]'s length word.

The locals soundness test that sliced msg.data[0:4] runs with no
calldata, so it slices msg.data[0:0] now.
@gnidan
gnidan merged commit 718b019 into main Oct 6, 2026
7 checks passed
@gnidan
gnidan deleted the bugc-bounds branch October 6, 2026 14:58
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