Inspired by JIT bugs in JavaScript engines like V8, I spent a weekend looking for miscompilations in solc, the Solidity compiler. These are cases where the generated bytecode behaves differently from what the source code specifies.

I ended up with multiple findings. Two came back as duplicates. One earned me a public credit and a fix in 0.8.37, but the bounty program classified it ineligible for a bounty. Another did not hold up as a compiler vulnerability when I reviewed the language rules more closely.

This post covers what I found, what the compiler produced, and what happened after I reported it.


What I found

FindingPipelineOutcome
Constant helper ignores arithmetic modevia IRFixed in 0.8.37, credited, classified Informational
Spill slots alias across mutual recursionvia IRFirst duplicate; fixed in 0.8.37, credited to Ng Sze Hon (Offchain Labs)
abicoder pragma read from the wrong filelegacyDuplicate; earlier report filed August 12
LoadResolver returns a stale loadYul optimizerSubmitted; no outcome recorded in my notes
Overlapping storage aggregate copybothNot established as a compiler vulnerability on review

The two duplicate replies were for reports that had been filed privately.


A constant helper that forgets whether it is checked

I'll start with a bug in how the compiler handles constants in checked and unchecked arithmetic. Depending on which function it generated first, the compiler could remove a required overflow check or add one inside an unchecked block.

The Ethereum Foundation forwarded the Solidity team's response:

Thank you for the detailed report and reproducer, we can confirm the reported behavior. Our assessment is that this is a code generation bug in the IR pipeline, but not a security issue.

The Solidity documentation describes constants as being "copied to all the places where it is accessed and also re-evaluated each time." That matters when the constant expression overflows, because whether an overflow wraps or panics is decided by the use site: inside unchecked, it wraps; outside, it must Panic(0x11).

In the affected via-IR code generator, solc puts the evaluation in a Yul helper and calls that helper at each read. The helper's name is built in IRNames::constantValueFunction() as "constant_" + name + "_" + id. The arithmetic mode is not part of that name. MultiUseYulFunctionCollector::createFunction() keys on the name alone and, on a second request, does not invoke the creator at all. It just hands back the body that was built the first time.

So the first use site to be generated decides the arithmetic for every other use site.

The PoC

Two contracts, identical except for which function name carries the unchecked block.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.36;
uint8 constant x = uint8(200) + 200;
contract A {
function a() external pure returns (uint8) { unchecked { return x; } }
function b() external pure returns (uint8) { return x; }
}
contract B {
function a() external pure returns (uint8) { return x; }
function b() external pure returns (uint8) { unchecked { return x; } }
}

uint8(200) + 200 is 400. uint8 tops out at 255. So an unchecked read must return 400 mod 256 = 144, and a checked read must revert with Panic(0x11). Four reads, four unambiguous answers.

How it compiled

Here is the Yul that solc 0.8.36 --via-ir --ir emits, trimmed to the relevant functions. Contract A first:

function wrapping_add_t_uint8(x, y) -> sum {
sum := cleanup_t_uint8(add(x, y))
}
/// @src 0:58:93 "uint8 constant x = uint8(200) + 200"
function constant_x_9() -> ret {
let expr_5 := 0xc8
let expr_6 := convert_t_rational_200_by_1_to_t_uint8(expr_5)
let expr_7 := 0xc8
let expr_8 := wrapping_add_t_uint8(expr_6, convert_t_rational_200_by_1_to_t_uint8(expr_7))
ret := expr_8
}
/// @src 0:113:183 "function a() external pure returns (uint8) { unchecked { return x; } }"
function fun_a_18() -> var__12 {
let expr_14 := constant_x_9() // unchecked reader
var__12 := expr_14
leave
}
/// @src 0:188:244 "function b() external pure returns (uint8) { return x; }"
function fun_b_26() -> var__21 {
let expr_23 := constant_x_9() // checked reader, same helper
var__21 := expr_23
leave
}

One constant_x_9. It calls wrapping_add_t_uint8. Both a() and b() call it. b() is a checked read and it got the wrapping body.

Now contract B, same compilation, same file:

function panic_error_0x11() {
mstore(0, 35408467139433450592217433187231851964531694900788300625387963629091585785856)
mstore(4, 0x11)
revert(0, 0x24)
}
function checked_add_t_uint8(x, y) -> sum {
x := cleanup_t_uint8(x)
y := cleanup_t_uint8(y)
sum := add(x, y)
if gt(sum, 0xff) { panic_error_0x11() }
}
/// @src 0:58:93 "uint8 constant x = uint8(200) + 200"
function constant_x_9() -> ret {
let expr_5 := 0xc8
let expr_6 := convert_t_rational_200_by_1_to_t_uint8(expr_5)
let expr_7 := 0xc8
let expr_8 := checked_add_t_uint8(expr_6, convert_t_rational_200_by_1_to_t_uint8(expr_7))
ret := expr_8
}
/// @src 0:326:396 "function b() external pure returns (uint8) { unchecked { return x; } }"
function fun_b_44() -> var__38 {
let expr_40 := constant_x_9() // unchecked reader, checked helper
var__38 := expr_40
leave
}

Same constant, same declaration, same AST id, same helper name, opposite body. In B the unchecked reader gets a helper that reverts.

Recorded results

$ ./poc.sh
Version: 0.8.36+commit.8a079791.Linux.g++
[*] solc --combined-json bin PoC.sol
[*] A.a()=144 A.b()=PANIC
[*] B.a()=PANIC B.b()=144
[*]
[*] solc --via-ir --combined-json bin PoC.sol
[*] A.a()=144 A.b()=144
[*] B.a()=PANIC B.b()=PANIC

Legacy is correct on all four. Via IR drops a required panic in A.b() and raises a spurious one in B.b(), in one compilation of one file by one binary.

The part I liked most is why A and B differ. It is not source order. Function bodies are generated from a FIFO queue seeded by external functions in ascending 4-byte selector order. sel(a()) = 0x0dbe671f, sel(b()) = 0x4df7e3d0, so a() always goes first and whatever a() is decides the contract. Rename b() to b2() and its selector becomes 0x0add6ef2, which sorts below a(), and the shared helper flips from wrapping to checked. Renaming an unrelated function silently changes the arithmetic of code you did not touch.

A public constant reaches the same defect with no ordering trick at all, because the auto-generated getter is emitted from a separate accessor pass that always runs first and pins the helper to checked.

The Ethereum Foundation's response

The Ethereum Foundation bounty program forwarded the Solidity team's assessment, which my notes date to August 26. The team confirmed the root cause and agreed that the IR pipeline should preserve the documented behaviour of constants. Their reason for treating it as an ordinary bug was:

All operands of the affected operation are compile-time constants, so the miscompiled read is deterministic and independent of any transaction input, and no attacker can influence it. For constants, the checked arithmetic acts as a sanity check rather than as validation, because the result cannot change based on input.

They considered a design that intentionally relies on an overflowing constant implausible. They also expected tests to catch either the spurious panic or the fixed wrapped value, and thought the pattern would be rare in practice.

Despite that assessment, the team recommended a payout:

That said, this is a genuine miscompilation and a very good find that exposes another instance of a known defect class, so we recommend a small payout despite the classification as an ordinary bug.

The bounty program's own decision appeared immediately below the forwarded response:

For now it will be considered as Informational hence, and ineligible for rewards, the Solidity team will still acknowledge you in the coming up issue/PR.

So the compiler team recommended a small payout, while the bounty program marked the report Informational and ineligible for rewards.

The fix

Issue #16967 was opened by clonker on September 1 and credits me in the first sentence. PR #16971 merged on September 3 and puts the arithmetic mode into the helper's identity, which is the direction I had sketched but not tested. It shipped in 0.8.37 on September 10.

GitHub issue 16967 crediting DanielBoye for the report and reproducer, with the issue marked closed

Spill slots that alias across recursion

The EVM can only directly access a limited depth of the stack. When a function needs more, the via-IR pipeline can use StackLimitEvader to move local variables into memory. These memory locations are called spill slots. Two values that are still needed at the same time must not share a slot.

MemoryOffsetAllocator::run() walks the call graph depth first and memoizes how many slots each function's subtree needs. To avoid infinite recursion it writes a provisional 0 into the memo before descending:

if (cached(function))
return cachedValue(function);
cache[function] = 0; // recursion guard, indistinguishable from a finished result
required = max(run(child) for child in callees(function));
allocateFunctionLocalsStartingAt(required);
return cache[function] = required + localSlots;

That provisional zero is written into the same map that finished results live in. Nothing distinguishes "visiting" from "complete." Close a cycle against it and a function gets permanently memoized as needing zero descendant slots, even when its subtree spills.

The PoC, shortened

fa and fb form a cycle. fh is a stack heavy leaf reachable from inside the cycle. fd is a stack heavy caller outside it, whose locals stay live across fd -> fb -> fa -> fh.

object "C" {
code {
datacopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}
object "runtime" {
code {
mstore(0x40, memoryguard(0x80))
if iszero(eq(shr(224, calldataload(0)), 0x0f59f83a)) { revert(0, 0) }
for { let i := 0 } lt(i, 32) { i := add(i, 1) } {
sstore(add(0x1000, i), add(i, 11))
}
let first := fa(0x1003)
let second := fd(0x1005)
mstore(0, first)
mstore(32, second)
return(0, 64)
// stack heavy leaf, 19 live locals
function fh(p) -> r {
let h0 := sload(add(p, 0))
// ... h1 through h18 ...
r := 0
r := add(r, h0)
// ... summed ...
}
// the cycle
function fa(p) -> r {
r := fh(p)
if gt(and(p, 7), 0) { r := add(r, fb(sub(p, 1))) }
}
function fb(p) -> r {
r := p
if gt(and(p, 7), 0) { r := add(r, fa(sub(p, 1))) }
}
// stack heavy caller, locals live across fd -> fb -> fa -> fh
function fd(p) -> r {
let d0 := sload(add(p, 0))
// ... d1 through d18 ...
r := fb(p)
r := add(r, d0)
// ... summed ...
}
}
}
}

The traversal goes:

Spill-slot allocation in two stages. Visiting fa caches a provisional zero, computes three slots for fh, then finalizes fb as zero after fb reads fa's provisional result; fa finishes with three slots. Visiting fd later treats fb's cached zero as complete and assigns fd slots 0 through 2, aliasing fh's spill slots while fd's locals are still live.Spill-slot allocation in two stages. Visiting fa caches a provisional zero, computes three slots for fh, then finalizes fb as zero after fb reads fa's provisional result; fa finishes with three slots. Visiting fd later treats fb's cached zero as complete and assigns fd slots 0 through 2, aliasing fh's spill slots while fd's locals are still live.

How it compiled

This is solc 0.8.36 --strict-assembly --optimize --yul-optimizations g, the optimized Yul, and you can read the collision straight off it:

function fh(p) -> r
{
mstore(0xc0, p)
mstore(0xa0, sload(add(mload(0xc0), 0)))
let h1 := sload(add(mload(0xc0), 1))
// ...
mstore(0x80, sload(add(mload(0xc0), 17)))
let h18 := sload(add(mload(0xc0), 18))
r := add(0, mload(0xa0))
// ...
r := add(r, mload(0x80))
}
function fd(p) -> r
{
mstore(0xc0, p)
let d0 := sload(add(mload(0xc0), 0))
// ...
mstore(0xa0, sload(add(mload(0xc0), 16))) // d16 parked at 0xa0
mstore(0x80, sload(add(mload(0xc0), 17))) // d17 parked at 0x80
let d18 := sload(add(mload(0xc0), 18))
r := fb(mload(0xc0)) // reaches fa -> fh
// ...
r := add(r, mload(0xa0)) // reloads whatever fh left
r := add(r, mload(0x80))
r := add(r, d18)
}

fd writes d16 to 0xa0 and d17 to 0x80, calls fb, and the recursive path reaches fh, which writes its own values to 0xa0, 0x80 and 0xc0. Then fd reloads and adds them as if they were still its own. No revert, no warning, just a different number.

With s[0x1000+i] = i+11, the source says go() returns (9030, 14026). In the build I tested, fd's saved 32 and 33 come back as 11 and 28, a net loss of 26:

$ ./poc.sh
Version: 0.8.36+commit.8a079791.Linux.g++
[*] running solidity poc
[*] expected (9030, 14026) actual (9030, 14000)
[*]
[*] running yul poc
[*] expected (9030, 14026) actual (9030, 14000)

The Solidity via-IR example and the standalone Yul example both produced the same wrong answer.

I checked this against the public UnsoundSpillInMutualRecursion bug fixed in 0.8.36 and it is not the same thing. That one was cycle detection: a genuinely recursive function got misclassified and spilled. Here fa and fb are correctly classified as recursive and neither of them spills. The corrupted values belong to fd and fh, two functions that are correctly non-recursive. 0.8.36 contains that fix and still returns 14000.

The reply

On August 13, the bounty program told me my report was a duplicate.

August 13 email from the Ethereum Bug Bounty program confirming that my spill-slot report was a duplicate

The follow-up gave the original report date as July 30. I asked how many duplicates there were, and they confirmed I was the first.

August 13 email thread giving July 30 as the original report date and confirming that I was the first duplicate

By the time I received the reply on August 13, the team had already known about it for two weeks. The public disclosure on September 10 credits Ng Sze Hon, Smart Contract Lead at Offchain Labs. It identifies 0.8.37 as the fixed release.


The abicoder pragma from the wrong file

This is the one that stings.

pragma abicoder v1|v2 is a per-file setting. For the malformed uint8 value in this example, v1 masks off the excess bits while v2 rejects the value. The documented rule is that it governs all code defined in the file where it is activated, no matter where that code ends up.

In legacy codegen, ContractCompiler holds one coder setting in the compiler context. initializeContext() installs the most derived contract's setting for the whole compilation, and a save/restore added back in 0.7.4 swaps it to the defining file's setting around function and modifier bodies. Three sites sit outside that swap:

  • state variable initialisers of inherited bases,
  • modifier invocation arguments (the loop sits a few lines above the guard),
  • base constructor arguments, in both the is Base(expr) and constructor() Base(expr) forms.

Those three compile under whatever setting happens to be installed.

The PoC, shortened

0x1ff is 511 and does not fit in uint8. Under v2 it must revert. Under v1 it masks to 255. Victim.sol is abicoder v2 and holds every decode. It is never edited.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.36;
pragma abicoder v2;
contract InitSite { // site 1
uint8 public x = abi.decode(hex"...01ff", (uint8));
}
contract ModSite { // site 2
uint8 public seen;
modifier record(uint8 v) { seen = v; _; }
function submit(bytes calldata payload)
external record(abi.decode(payload, (uint8))) returns (uint8)
{ return seen; }
function submitBody(bytes calldata payload) external returns (uint8) {
seen = abi.decode(payload, (uint8)); // the 0.7.4 fix covers this
return seen;
}
}
contract CtorBase { uint8 public x; constructor(uint8 a) { x = a; } }
contract CtorSite is CtorBase(abi.decode(hex"...01ff", (uint8))) {} // site 3

Importer_v1.sol declares pragma abicoder v1 and inherits each of them. Importer_v2.sol declares v2, inherits the same contracts as controls, and imports the v1 file, so one compilation produces every contract the runner deploys. The only variable left is which file declares the inheriting contract.

How it compiled

This is the constructor assembly for two contracts that both inherit InitSite and add nothing, from a single solc --asm invocation. Every instruction that differs carries a source map back into Victim.sol, the v2 file.

The v1 side inlines a masking read:

### Importer_v1.sol:Init (declared in the abicoder v1 file)
/* "Victim.sol":511:601 abi.decode(hex"...01ff", (uint8)) */
...
0x01ff
dup2
mstore
pop
dup1
0x20
add
swap1
mload
0x20
dup2
lt
iszero
tag_1
jumpi
revert(0x00, 0x00)
tag_1:
dup2
add
...

The v2 side calls the generated decoder helper instead:

### Importer_v2.sol:InitControl (declared in the abicoder v2 file)
/* "Victim.sol":511:601 abi.decode(hex"...01ff", (uint8)) */
...
0x01ff
dup2
mstore
pop
dup1
0x20
add
swap1
mload
dup2
add
swap1
tag_1
swap2
swap1
tag_2
jump // in <-- abi_decode helper, validator ends in cleanup(v) == v
tag_1:

Same source expression, same file, two different decoders, chosen by a pragma the victim author never wrote.

=== site 1, state-variable initialiser of an inherited base
[*] Init must REVERT : deploy OK, x()=255
[*] InitControl must REVERT : deploy REVERTS
[*] InitSite must REVERT : deploy REVERTS
=== site 2, modifier invocation argument, payload from the caller
[*] Mod submit(0x1ff)=255 submitBody(0x1ff)=REVERT submit(0x0ff)=255
[*] ModControl submit(0x1ff)=REVERT submitBody(0x1ff)=REVERT submit(0x0ff)=255
=== site 3, base-constructor argument
[*] Ctor must REVERT : deploy OK, x()=255
[*] CtorControl must REVERT : deploy REVERTS
=== opposite direction, a v2 file imposes validation on a v1 author
[*] V1Victim must SUCCEED : deploy OK, x()=255
[*] ReverseControl must SUCCEED : deploy REVERTS
=== nested modifier, no derived contract: the decode is in the v1 file itself
[*] NestedOuterV1 must SUCCEED : deploy OK, x()=255
[*] NestedOuterV2 must SUCCEED : deploy REVERTS

The Mod row makes the problem particularly clear. submit and submitBody are textually identical decodes of the same caller supplied value, in one deployed contract, from one compilation, and they disagree. Whatever you think the pragma's scope should be, the compiler is inconsistent with itself.

The last block shows why inheritance alone does not explain the behaviour. "The most derived contract's pragma wins" does not cover it: at modifier nesting depth one or more, the recursion happens inside the body swap, so an inner modifier's arguments inherit the enclosing modifier's file. NestedOuterV1 and NestedOuterV2 are both declared in the v1 file, both hold their decode there, and both are deployed directly. They differ only in which file their outer modifier came from, and one of them reverts.

This is also the remainder of an incomplete fix. Commit 3128e82a9aa0 (issue #9969, PR #9971) shipped in 0.7.4 as "Fix ABIEncoderV2 pragma from the current module affecting inherited functions and applied modifiers." It fixed bodies. The argument loop is in the same function, a few lines above the guard. I think it survived because every modifier in the eight tests that commit added takes no parameters, so the argument path is never reached, and my August review found no test declaring a parameterised modifier across an abicoder boundary in the develop checkout I inspected.

The reply

This report also came back as a duplicate. I asked when the original had been submitted and how many duplicates there were. The reply gave August 12, 2026 at 00:09 UTC, with three reports total: one original and two duplicates, including mine.

Ethereum Bug Bounty email thread confirming the ABI-coder report was a duplicate, with an original submission on August 12, 2026 at 00:09 GMT+0 and two duplicates among three reports

My local report was finished on August 13. That is close, but the file timestamp is not a submission receipt, so I cannot use it to calculate exactly how far behind I was.

What frustrated me was how long the behaviour had survived. My recorded version checks reproduce it as far back as 0.7.5, and someone else reported it just as I was finishing my own write-up.

My September 12 attribution review did not find a matching public disclosure for the August 12 report. I do not have a confirmed name for the original reporter or a verified fix version for this finding.


LoadResolver returns a stale value

This finding comes down to an optimizer replacing a memory read with a value that has already been overwritten.

LoadResolver can replace an mload with a value it believes is still there. It asks KnowledgeBase whether two addresses are disjoint. KnowledgeBase caches variable-offset groups and invalidates them only when the callback's expression pointer changes. If you reassign a variable using a non-movable expression, both generations are represented by a null pointer, and the cache sees no change. DataFlowAnalyzer clears direct referrers of the reassigned variable but not transitive ones.

Here is the example from my report:

{
// both opcodes are zero for this creation call, but opaque to the optimizer.
sstore(0, add(0x400, calldatasize()))
sstore(1, add(0x300, callvalue()))
let dummyPosition := 0
let dummyValue := 99
mstore(dummyPosition, dummyValue) // prime the symbolic-offset cache
let v := sload(0)
let a1 := add(v, 0x100)
let a := add(a1, 0x10)
let oldValue := 67
mstore(a, oldValue)
v := sload(1)
let b := add(add(v, 0x200), 0x10)
let newValue := 7
mstore(b, newValue)
// the second store overwrote the first, so this load must return 7.
let result := mload(a)
mstore(0, result)
return(0, 0x20)
}

At runtime the old v = 0x400 makes a = 0x510, and the new v = 0x300 makes b = 0x510. Same address. The second store overwrites the first, so mload(a) must be 7.

KnowledgeBase cached a = v + 0x110 under the old generation, later analysed b = v + 0x210 against that stale entry, and proved the two addresses were 0x100 apart. They are not.

How it compiled

mstore(b, newValue)
- let result := mload(a)
+ let result := oldValue
mstore(0, result)

That is the whole bug. solc --strict-assembly --optimize --yul-optimizations 'L:' against the control with no optimisation, one line of difference, and the contract returns 67 where the source says 7.

The default optimizer sequence converts the code to SSA form first and is unaffected by this example. In SSA form, each variable is assigned only once. The problem appears when LoadResolver receives code that still reassigns variables, and its cached knowledge outlives the value it described.

The FullInliner bug behind the comparison

The comparison in my report is based on the FullInliner argument evaluation order bug, discovered by Robert Chen, CEO of OtterSec, in July 2023.

FullInliner replaces a function call with the function's body. That transformation must preserve the order in which its arguments are evaluated. In Yul, arguments are evaluated from right to left. If an argument contains a call with side effects, changing that order can change what the program does.

The inliner expected ExpressionSplitter to have simplified those arguments into variables beforehand. It was supposed to leave calls alone when that preparation had not happened. Instead, it could inline them and change their evaluation order. The default optimizer sequence supplied the preparation, so the defect became visible through custom sequences. Solidity fixed it in 0.8.21.

That is the connection to LoadResolver: an earlier transformation makes the default sequence safe, while another supported arrangement exposes an assumption inside a later pass. The mechanisms differ. FullInliner changed evaluation order; my LoadResolver report concerns stale information about memory addresses. The earlier report gives a useful way to explain why a correct result under the default settings does not settle whether an individual optimizer pass preserves the program's behaviour.

My notes record the submission, but no assessment or final outcome.


Overlapping storage aggregate copy

Struct and fixed-array assignment destructively copying partially overlapping ranges. The runtime behaviour reproduces, but on review the package did not establish an actual specification violation: the demonstrated alias requires assigning a local storage pointer's .slot through inline assembly, which is outside what the language promises. I stopped treating it as a validated compiler vulnerability. The tracker assigns it report ID bd9d9e8e3117, while the package is marked withdrawn. The correspondence is still needed to establish the submission and closure history. The technical conclusion is clear enough: reproducing a surprising result did not prove that the compiler violated the language rules.