Skip to content

Constant Optimiser CodeCopyMethod Bug in Presence of msize Yul builtin #16743

@matheusaaguiar

Description

@matheusaaguiar

(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:

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

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:

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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions