argotorg / argotorg/solidity

Constant Optimiser CodeCopyMethod Bug in Presence of msize Yul builtin

Open
#16,743 1 comment 0 reactions 1 assignee Claimed by @cameel View on GitHub
bug :bug:
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.