Skip to content

NY: keep loop-carried memory offsets dynamic in the heap analysis - #612

Open
xermicus wants to merge 6 commits into
mainfrom
cl/ny-heap-opt-loop-carried-offsets
Open

xermicus wants to merge 6 commits into
mainfrom
cl/ny-heap-opt-loop-carried-offsets

Conversation

@xermicus

Copy link
Copy Markdown
Member

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.

xermicus and others added 4 commits September 22, 2026 13:11
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>
Comment thread crates/newyork/src/heap_opt.rs Outdated
}
}
// Loop-carried values stay unknown offsets.
Statement::For { condition, .. } => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) }

Comment thread crates/newyork/src/heap_opt.rs Outdated
}
}
// Loop-carried values stay unknown offsets.
Statement::For { condition, .. } => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/newyork/src/mem_opt.rs Outdated
Comment on lines +1357 to +1359
|| (resolved_offset.is_none()
&& *region != MemoryRegion::Dynamic
&& *region != MemoryRegion::Scratch)

@elle-j elle-j Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regression does not seem to be significant (+36 bytes over the OZ test contracts). This could be a follow-up instead.

Comment thread crates/newyork/src/mem_opt.rs Outdated
|| word_store_overlaps_free_pointer_slot(resolved_offset)
|| (resolved_offset.is_none()
&& *region != MemoryRegion::Dynamic
&& *region != MemoryRegion::Scratch)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
}

Comment thread CHANGELOG.md Outdated
### 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- `--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)

xermicus and others added 2 commits September 25, 2026 14:59
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>
@xermicus

Copy link
Copy Markdown
Member Author

Thanks for the very thorough review @elle-j! All findings should be fixed now

@xermicus
xermicus requested a review from elle-j September 25, 2026 13:34

@elle-j elle-j left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great thanks! Some issues still exist, I've opened PR 617 as feedback targeting this branch that addresses the remaining comments below. Should be good to go after that.

Comment on lines +438 to +445
let ascending = loop_counter_ascends(
index,
counter,
body,
post_input_variables[index],
post,
);
let counter_bound = if ascending { seed } else { 0 };

@elle-j elle-j Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 👍)

  1. loop_counter_ascends() currently only checks that the post region adds something to the counter, but not what it adds. So an add(pointer, not(31)) (which is solc's optimized version of sub(pointer, 0x20)) counts as ascending. The LoopStoreFmpDescending.yul loop mstore test (which uses sub) 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)
  1. A counter step that counts down gets lower bound 0 here (which is correct), but the lower bound of add(0x80, step) is then calculated as 0x80 + 0 = 0x80, so the mstore below looks like it never writes below 0x80. But after the first sub, step wraps and becomes huge, so add(0x80, step) overflows and also wraps to 0x60, then 0x40 (i.e. it does get smaller than 0x80):
for { let step := 0 } sgt(step, sub(0, 0x60)) { step := sub(step, 0x20) } {
    mstore(add(0x80, step), value)
}
  1. Only literals, loop counters, and add/mul of values that have a lower bound get a lower bound. Other operations (like shl and sub) currently don't get one. Without one, fmp_could_be_unbounded is not set for an mstore destination. So this includes mul by a power of two, because newyork rewrites it into shl before this analysis runs. The mul(index, 0x20) below becomes shl(5, index), and the third iteration's mstore will be to 0x40:
for { let index := 0 } lt(index, 3) { index := add(index, 1) } {
    mstore(mul(index, 0x20), value)
}
  1. 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 a string[] 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;
}

@elle-j elle-j Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

This branch has not been deployed

No deployments
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.

2 participants