Constant Optimiser CodeCopyMethod Bug in Presence of msize Yul builtin
- Dominant language
- C++
- Stars
- 25.7k
- Forks
- 6.2k
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 21
Description
(Initially reported by @bshastry)
## Attack scenario
When `ConstantOptimiser` selects `CodeCopyMethod` to materialize a 32-byte constant, it injects a scratch-memory routine (`MLOAD(0)` / `CODECOPY(0,…,32)` / `MSTORE(0,backup)`) that unconditionally expands active memory to `0x20`. Any subsequent `MSIZE` observes `0x20` instead of `0`, producing different runtime behavior between optimized and unoptimized builds.
The CSE pass already guards against this by checking for `MSIZE` usage. The constant optimiser has no such guard.
Trigger requires: `msize()` usage + repeated 32-byte constants (enough for `CodeCopyMethod` selection) + `--optimize --no-optimize-yul`.
## Impact
When all three preconditions are met, `msize()` returns a different value in optimized vs unoptimized builds. This is not a gas-only discrepancy, it is a runtime semantic divergence that can cause contracts to return wrong values, write to wrong memory locations, or take different control-flow paths.
Most production contracts compiled with standard `--optimize` enable the Yul optimizer, which rejects `msize` usage. The risk is to contracts compiled in strict-assembly mode or with the Yul optimizer explicitly disabled (`--optimize --no-optimize-yul`) that also happen to repeat a 32-byte constant enough times for `CodeCopyMethod` to be selected.
## Components
- `libevmasm/ConstantOptimiser.cpp`: `CodeCopyMethod::copyRoutine()` uses memory at offset 0, unconditionally expanding active memory to `0x20`. - `libevmasm/Assembly.cpp`: CSE scans `usesMSize` and passes it to the CSE pass, but constant optimisation runs without any `MSIZE` guard. - `libsolidity/analysis/SyntaxChecker.cpp`: `msize` is rejected only when the Yul optimizer is enabled. Users compiling with `--optimize --no-optimize-yul` remain exposed.
## Proof of concept
### PoC source (deployable Yul)
Save as `Bug3_MSize.yul`:
```yul
object "Deploy" {
code {
datacopy(0, dataoffset("Runtime"), datasize("Runtime"))
return(0, datasize("Runtime"))
}
object "Runtime" {
code {
let x := 0
if eq(calldataload(0), 0) { x := 0x1111111111111111111111111111111111111111111111111111111111111111 }
if eq(calldataload(0), 1) { x := 0x1111111111111111111111111111111111111111111111111111111111111111 }
if eq(calldataload(0), 2) { x := 0x1111111111111111111111111111111111111111111111111111111111111111 }
let p := msize() // expected: 0 actual (optimized): 0x20
mstore(p, x)
return(0, 0x20)
}
}
}
```
### Reproduction steps
```bash
solc --evm-version osaka --strict-assembly --bin Bug3_MSize.yul > noopt.txt
solc --evm-version osaka --strict-assembly --optimize --no-optimize-yul --bin Bug3_MSize.yul > opt.txt
```
Deploy each on `anvil` and call with empty calldata:
```bash
set -euo pipefail
NOOPT_BIN=$(awk '/Binary representation:/{getline; print; exit}' noopt.txt)
OPT_BIN=$(awk '/Binary representation:/{getline; print; exit}' opt.txt)
anvil --silent --port 8545 >/tmp/anvil-msize.log 2>&1 &
ANVIL_PID=$!
trap 'kill $ANVIL_PID >/dev/null 2>&1 || true' EXIT
sleep 1
RPC_URL=http://127.0.0.1:8545
PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
NOOPT_ADDR=$(cast send --rpc-url "$RPC_URL" --private-key "$PK" --create "$NOOPT_BIN" --json | jq -r .contractAddress)
OPT_ADDR=$(cast send --rpc-url "$RPC_URL" --private-key "$PK" --create "$OPT_BIN" --json | jq -r .contractAddress)
cast call --rpc-url "$RPC_URL" "$NOOPT_ADDR" # => 0x1111…1111
cast call --rpc-url "$RPC_URL" "$OPT_ADDR" # => 0x0000…0000
```
### Validation on current `develop`
Validated on `2026-03-05` with `build/solc/solc` at `develop @ 0409c5c67d`:
```text
NOOPT_RET=0x1111111111111111111111111111111111111111111111111111111111111111
OPT_RET=0x0000000000000000000000000000000000000000000000000000000000000000
```
Expected vs actual (empty calldata)
- Unoptimized: `msize()` returns `0`, so `mstore(0, x)` writes into the returned region. `return(0, 0x20)` yields `0x1111…1111`.
- Optimized: `msize()` returns `0x20` (memory expanded by `CodeCopyMethod`), so `mstore(0x20, x)` writes outside the returned region. `return(0, 0x20)` yields `0x0000…0000`.
## Suggested fix
Skip `CodeCopyMethod` when `MSIZE` is present:
1. Scan each code section for `MSIZE` (and conservatively `VerbatimBytecode`, matching the CSE pass).
2. If present, set `CodeCopyMethod` cost to infinite so it falls back to `LiteralMethod` or `ComputeMethod`.
## Additional information
- `CodeCopyMethod` is the only constant materialization strategy that touches memory. `LiteralMethod` (direct PUSH) and `ComputeMethod` (arithmetic) are unaffected.
- Commit `1c5ce16fd` ("Bring back ConstantOptimizer without codecopy method", on `origin/eofOptimizerExperiments`) adds an EOF-only guard (`!_assembly.eofVersion().has_value()`) around `CodeCopyMethod` selection. This report is about non-EOF builds (`--optimize --no-optimize-yul`), which remain affected.
### Environment
- Compiler: `0.8.35-develop.2026.3.5+commit.0409c5c67.Darwin.appleclang` (develop branch, commit `0409c5c67`)
- Target EVM version: any (tested with `osaka`)
- Required flags: `--optimize --no-optimize-yul` (evmasm optimizer on, Yul optimizer off)
- OS: macOS Darwin 24.6.0 (arm64); bug is platform-independent
Contributor guide
Assessment
This issue has not been assessed yet.