(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:
- Scan each code section for
MSIZE (and conservatively VerbatimBytecode, matching the CSE pass).
- 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
(Initially reported by @bshastry)
Attack scenario
When
ConstantOptimiserselectsCodeCopyMethodto materialize a 32-byte constant, it injects a scratch-memory routine (MLOAD(0)/CODECOPY(0,…,32)/MSTORE(0,backup)) that unconditionally expands active memory to0x20. Any subsequentMSIZEobserves0x20instead of0, producing different runtime behavior between optimized and unoptimized builds.The CSE pass already guards against this by checking for
MSIZEusage. The constant optimiser has no such guard.Trigger requires:
msize()usage + repeated 32-byte constants (enough forCodeCopyMethodselection) +--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
--optimizeenable the Yul optimizer, which rejectsmsizeusage. 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 forCodeCopyMethodto be selected.Components
libevmasm/ConstantOptimiser.cpp:CodeCopyMethod::copyRoutine()uses memory at offset 0, unconditionally expanding active memory to0x20. -libevmasm/Assembly.cpp: CSE scansusesMSizeand passes it to the CSE pass, but constant optimisation runs without anyMSIZEguard. -libsolidity/analysis/SyntaxChecker.cpp:msizeis rejected only when the Yul optimizer is enabled. Users compiling with--optimize --no-optimize-yulremain exposed.Proof of concept
PoC source (deployable Yul)
Save as
Bug3_MSize.yul:Reproduction steps
Deploy each on
anviland call with empty calldata:Validation on current
developValidated on
2026-03-05withbuild/solc/solcatdevelop @ 0409c5c67d:Expected vs actual (empty calldata)
msize()returns0, somstore(0, x)writes into the returned region.return(0, 0x20)yields0x1111…1111.msize()returns0x20(memory expanded byCodeCopyMethod), somstore(0x20, x)writes outside the returned region.return(0, 0x20)yields0x0000…0000.Suggested fix
Skip
CodeCopyMethodwhenMSIZEis present:MSIZE(and conservativelyVerbatimBytecode, matching the CSE pass).CodeCopyMethodcost to infinite so it falls back toLiteralMethodorComputeMethod.Additional information
CodeCopyMethodis the only constant materialization strategy that touches memory.LiteralMethod(direct PUSH) andComputeMethod(arithmetic) are unaffected.1c5ce16fd("Bring back ConstantOptimizer without codecopy method", onorigin/eofOptimizerExperiments) adds an EOF-only guard (!_assembly.eofVersion().has_value()) aroundCodeCopyMethodselection. This report is about non-EOF builds (--optimize --no-optimize-yul), which remain affected.Environment
0.8.35-develop.2026.3.5+commit.0409c5c67.Darwin.appleclang(develop branch, commit0409c5c67)osaka)--optimize --no-optimize-yul(evmasm optimizer on, Yul optimizer off)