Conversation
The `For` arm of `HeapAnalysis::analyze_statement` copied the initializer's `OffsetInfo`, including its `static_value`, onto every loop variable and every post-loop output. A store through a loop counter was therefore analyzed as a store to the first iteration's address on every iteration: `has_dynamic_accesses` stayed false, only that word was tainted or recorded as variable-accessed, and the words later iterations write stayed eligible for native little-endian access while the loop wrote them byte-swapped. Loop variables, post inputs and outputs are now left without offset information, so a store or load through them is a dynamic access. Regression fixture `LoopOffsetNative.yul` writes words `0x80` and `0xa0` from a loop and reads `0xa0` back through a literal load. Fixes paritytech/bugbounty_reports#215 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The straight-line free memory pointer propagation drops its tracked
constant on a full-word `mstore` whose offset it cannot resolve, because
the store may land on `[0x40, 0x60)`. The region predicate consulted for
`if`, `switch`, `for` and block bodies only recognized statically
resolved writes, so `if c { mstore(calldataload(32), v) }` left the stale
constant forwarded to the `mload(0x40)` after the branch.
The predicate now applies the same rule as the straight-line walk: an
unresolvable full-word store modifies the pointer unless its region proves
it `Dynamic` or `Scratch`.
Regression fixture `FmpDynStoreBranchBug.yul` stores through a calldata
supplied offset of `0x40` inside a branch and reads the pointer back.
Second gap reported in paritytech/bugbounty_reports#215
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The reporter's reproducer loops `mstore8(add(0x30, i), 0xff)` over the free memory pointer word. Both FMP propagation arms only recognized a statically resolved `mstore8` into the word, so the memoryguard constant was forwarded across the loop. An `mstore8` with an unresolvable offset now invalidates the pointer like a full-word store does. Also adds the reporter's reproducer and trims the comments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| } | ||
| } | ||
| // Loop-carried values stay unknown offsets. | ||
| Statement::For { condition, .. } => { |
There was a problem hiding this comment.
Looks like this change regressed mstores (the ones using the loop counter) where the first iteration writes the fmp word (either from 0x40, inside the word, or starting before it while still overlapping). E.g.:
for { let p := 0x40 } ... { mstore(p, v) }In those cases the loaded value would be truncated. Before the change, fmp_could_be_unbounded was eventually set in this case since the counter's first value was checked. Now it's no longer set so the range proof is still enabled.
Also, mstore using loop counters has the same issue for counters reaching the fmp word at a later iteration. E.g. when decrementing:
for { let p := 0x80 } ... { p := sub(p, 0x20) } { mstore(p, v) }| } | ||
| } | ||
| // Loop-carried values stay unknown offsets. | ||
| Statement::For { condition, .. } => { |
There was a problem hiding this comment.
Copies also seem to have regressed where the length is the loop counter:
for { let n := 0x40 } lt(n, end) { n := add(n, 1) } { calldatacopy(0, 0, n) }Only the start word will be tainted now and fmp_could_be_unbounded won't be set for copies starting below the fmp word but reaches it due to the length. Before, the length at the first iteration was evaluated.
| || (resolved_offset.is_none() | ||
| && *region != MemoryRegion::Dynamic | ||
| && *region != MemoryRegion::Scratch) |
There was a problem hiding this comment.
This also turns every function with an unresolvable store into an fmp writer, so calling a require string helper or an event-emitting function now drops the propagated free pointer. Those functions only store at or above the fmp (with add(mload(0x40), small_literal), which can't reach the fmp word while the pointer is at least 0x60.
The previous implementation incorrectly ignored every unresolved store in a callee (e.g. f(p, v) { mstore(p, v) } called with p = 0x40), which your changes fix, but a side effect of that previous behavior was that the above issue couldn't exist either.
We could probably fix it by not counting stores at add(mload(0x40), small_literal) as fmp writes (when the fmp is known by the propagation and is at least 0x60).
There was a problem hiding this comment.
The regression does not seem to be significant (+36 bytes over the OZ test contracts). This could be a follow-up instead.
| || word_store_overlaps_free_pointer_slot(resolved_offset) | ||
| || (resolved_offset.is_none() | ||
| && *region != MemoryRegion::Dynamic | ||
| && *region != MemoryRegion::Scratch) |
There was a problem hiding this comment.
Scratch only means a store starts below 0x40, so a full-word store starting below it (but still within reach) still overwrites part of the fmp word. This change (and in preexisting propagate_statements()) makes unresolved Scratch stores be treated as if they can never touch the fmp.
For instance, in this example the offset is bound outside the branch, so the walk inside the branch can't resolve it, and mload(0x40) returns the stale 0x80:
mstore(0x40, 0x80)
let offset := 0x30
if calldataload(64) {
mstore(offset, calldataload(0))
mstore(0, mload(0x40))
return(0, 32)
}| ### Fixed | ||
| - `--newyork`: A bug in dead call value analysis. [#589](https://github.com/paritytech/revive/pull/589) | ||
| - `--newyork`: Object code that fell off the end was not terminated with the implicit EVM return, so LLVM folded away the runtime dispatch and a call would have run the constructor. [#598](https://github.com/paritytech/revive/pull/598) | ||
| - `--newyork`: a memory offset carried by a loop counter resolved to its first iteration address on every iteration, leaving words written by later iterations eligible for native byte order, and a stale free memory pointer survived an unresolvable store inside a branch. |
There was a problem hiding this comment.
| - `--newyork`: a memory offset carried by a loop counter resolved to its first iteration address on every iteration, leaving words written by later iterations eligible for native byte order, and a stale free memory pointer survived an unresolvable store inside a branch. | |
| - `--newyork`: a memory offset carried by a loop counter resolved to its first iteration address on every iteration, leaving words written by later iterations eligible for native byte order, and a stale free memory pointer survived an unresolvable `mstore8`, or an unresolvable `mstore` inside a branch, loop, or called function. [#612](https://github.com/paritytech/revive/pull/612) |
Loop counters get a lower bound for the free memory pointer checks, so a store or copy length through a counter that can reach the pointer word still marks it unbounded. Nested regions resolve offsets against the enclosing constants, and an unresolved scratch word store invalidates the propagated pointer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks for the very thorough review @elle-j! All findings should be fixed now |
| let ascending = loop_counter_ascends( | ||
| index, | ||
| counter, | ||
| body, | ||
| post_input_variables[index], | ||
| post, | ||
| ); | ||
| let counter_bound = if ascending { seed } else { 0 }; |
There was a problem hiding this comment.
Some corrections for how we can determine whether the counter is ascending and what the lower bound is:
(PR 617 has updates and tests that fix these various scenarios, feel free to merge it into this PR and change anything in there if needed 👍)
loop_counter_ascends()currently only checks that the post region adds something to the counter, but not what it adds. So anadd(pointer, not(31))(which is solc's optimized version ofsub(pointer, 0x20)) counts as ascending. The LoopStoreFmpDescending.yul loopmstoretest (which usessub) would still be miscompiled if that Yul was instead written as inline assembly in Solidity since it'd go through the solc optimizer. And the following copy will overwrite the fmp word but still keep the range proof:
mstore(0x40, 0x80)
for { let pointer := 0x80 } gt(pointer, 0x20) { pointer := add(pointer, not(0x1f)) } {
calldatacopy(pointer, 0, 0x20)
}
mstore(0, mload(0x40))
return(0, 32)- A counter
stepthat counts down gets lower bound 0 here (which is correct), but the lower bound ofadd(0x80, step)is then calculated as0x80 + 0 = 0x80, so themstorebelow looks like it never writes below0x80. But after the firstsub,stepwraps and becomes huge, soadd(0x80, step)overflows and also wraps to0x60, then0x40(i.e. it does get smaller than0x80):
for { let step := 0 } sgt(step, sub(0, 0x60)) { step := sub(step, 0x20) } {
mstore(add(0x80, step), value)
}- Only literals, loop counters, and
add/mulof values that have a lower bound get a lower bound. Other operations (likeshlandsub) currently don't get one. Without one,fmp_could_be_unboundedis not set for anmstoredestination. So this includesmulby a power of two, because newyork rewrites it intoshlbefore this analysis runs. Themul(index, 0x20)below becomesshl(5, index), and the third iteration'smstorewill be to0x40:
for { let index := 0 } lt(index, 3) { index := add(index, 1) } {
mstore(mul(index, 0x20), value)
}- Currently write pointers that advance inside the body are not counted as ascending (e.g. the loop solc emits that copies a storage array into memory, like
mpos := add(mpos, 32)). Detected in T3rminalBulletinIndex that returns astring[]from storage, which grows about +18% (at-Oz) due to losing the range proof. Part of that emitted Yul is this:
let i := 0
for { } lt(i, length_2) { i := add(i, 1) }
{
mstore(mpos, copy_array_from_storage_to_memory_string(spos))
mpos := add(mpos, 32)
spos := add(spos, 1)
}| } | ||
| if static_offset.is_none() && self.loop_offset_may_reach_fmp_word(offset) { | ||
| self.fmp_could_be_unbounded = true; | ||
| } |
There was a problem hiding this comment.
The stale forward from my earlier comment is gone now 👍, but when fuzzy dedup merges helpers and turns a Scratch offset into a parameter, a store through that parameter never sets fmp_could_be_unbounded, so the range proof stays and a later load value is truncated.
This check could look for MemoryRegion::Scratch as well since that tag only says that the store starts below 0x40 (but it could still reach into the fmp word):
- if static_offset.is_none() && self.loop_offset_may_reach_fmp_word(offset) {
+ if static_offset.is_none() && (*region == MemoryRegion::Scratch || self.loop_offset_may_reach_fmp_word(offset))
{
self.fmp_could_be_unbounded = true;
}(This change is also part of the feedback PR.)
Loop variables and outputs inherited the initializer's static offset, so every iteration resolved to the first address and later words stayed eligible for native byte order. The FMP propagation now also invalidates on unresolvable mstore and mstore8 inside regions, which the reporter's mstore8 loop needed.