Summary
Two behaviors combine to produce a silent out-of-bounds storage write that the Fe source does not authorize:
- A sentinel-initialized empty-cell scan (
let mut j: usize = 16) falls through when no element matches — execution continues with the out-of-range sentinel 16.
StorageMap.get() returns the default value for ANY key. Using the sentinel as a key into an adjacency table whose default is 0, combined with an adjacency predicate where "movable to cell 0" evaluates true for the all-zero encoding, accepts a move with no empty cell on the board and then writes board.set(key: 16, val) — outside the modeled 0..15 domain.
In a packed-u256 board representation this pollutes bits ≥64 of the word; with other layouts it could hit neighboring logical fields.
Minimal reproduction
Fe source pattern (abridged from a real 15-puzzle contract):
fn find_empty(self) -> usize {
let mut j: usize = 16
let mut i: usize = 0
while i < 16 {
if self.board[i] == 0 { j = i }
i += 1
}
j // can be 16 if no zero exists
}
pub fn move_field(mut self, index: u256) {
// bounds check on `index` only; NOT on find_empty result
let empty_idx = self.find_empty()
let encoded = ADJACENCY[empty_idx] // OOB read when empty_idx == 16 → default 0
if !is_movable_to(encoded, index as u256) { revert(Error::NotMovable) }
let val = self.board[index]
self.board.set(index, 0)
self.board.set(empty_idx, val) // OOB WRITE at key 16
}
is_movable_to(0, target) decodes slot words of the default-0 encoding as "movable to target 0" → true for index == 0.
Steps
- Deploy the compiled GameBitboard (v26.2.0 build) with constructor
(validator: <addr>, board: 0x1FEDCBA987654321) — note: no zero nibble.
- Call selector
0x8bf02f32 (moveField(uint256)) with arg uint256(0).
- Call SUCCEEDS (source semantics say it must revert
NotMovable).
- Storage slot 1 changes from
0x…1FEDCBA987654321 to 0x…11fedcba987654320 — nibble 0 zeroed AND new nibble written at bits 64–67 ("cell 16" = shift 64).
Versions affected
- fe v26.1.0 — confirmed (this build is deployed on Ethereum mainnet by the Bountiful bounty contracts)
- fe v26.2.0 (commit 1fffb9e, official release binary, macOS arm64) — confirmed identical behavior; not fixed
Impact
Any fe contract combining a sentinel-initialized scan with a StorageMap lookup keyed by the scan result silently accepts inputs its source logic rejects and writes storage outside the modeled domain. Severity depends on layout: packed words corrupt adjacent bit-fields; multiple maps sharing a base word could alias unrelated entries.
Found during an independent audit of the Bountiful bug-bounty contracts (the audited instance itself cannot be exploited on mainnet because its boards are fixed and valid, but the compiler-level pattern generalizes).
Full differential harness and dual-version regression test available on request.
Summary
Two behaviors combine to produce a silent out-of-bounds storage write that the Fe source does not authorize:
let mut j: usize = 16) falls through when no element matches — execution continues with the out-of-range sentinel16.StorageMap.get()returns the default value for ANY key. Using the sentinel as a key into an adjacency table whose default is0, combined with an adjacency predicate where "movable to cell 0" evaluates true for the all-zero encoding, accepts a move with no empty cell on the board and then writesboard.set(key: 16, val)— outside the modeled 0..15 domain.In a packed-u256 board representation this pollutes bits ≥64 of the word; with other layouts it could hit neighboring logical fields.
Minimal reproduction
Fe source pattern (abridged from a real 15-puzzle contract):
is_movable_to(0, target)decodes slot words of the default-0 encoding as "movable to target 0" → true forindex == 0.Steps
(validator: <addr>, board: 0x1FEDCBA987654321)— note: no zero nibble.0x8bf02f32(moveField(uint256)) with arguint256(0).NotMovable).0x…1FEDCBA987654321to0x…11fedcba987654320— nibble 0 zeroed AND new nibble written at bits 64–67 ("cell 16" = shift 64).Versions affected
Impact
Any fe contract combining a sentinel-initialized scan with a StorageMap lookup keyed by the scan result silently accepts inputs its source logic rejects and writes storage outside the modeled domain. Severity depends on layout: packed words corrupt adjacent bit-fields; multiple maps sharing a base word could alias unrelated entries.
Found during an independent audit of the Bountiful bug-bounty contracts (the audited instance itself cannot be exploited on mainnet because its boards are fixed and valid, but the compiler-level pattern generalizes).
Full differential harness and dual-version regression test available on request.