Two soyjaks pointing at Daniel Boye's circled credit in the Solidity security advisory

After working with JIT bugs in V8, I wondered whether solc's Yul optimizer could make a similar mistake. V8's optimizing compilers include Maglev and TurboFan, and I started looking for cases where an optimizer kept using an assumption after the value it described had changed. This bug was one result of that compiler hunt.

Solidity's Yul optimizer remembers values written to memory and storage. It uses that knowledge to replace later loads with known values and to remove stores that would not change anything. This bug arose when a Yul variable used to calculate an address was reassigned, but the optimizer kept an address relation based on its old value. A later load could then use an outdated value, or a necessary store could be removed.

The bug affects code compiled with the Yul optimizer enabled in Solidity 0.8.19 through 0.8.37. With --via-ir, any code may be affected. With the default evmasm pipeline, the affected code is limited to compiler-generated Yul utility functions and some inline assembly blocks.

I reported this to the Ethereum Foundation bug bounty program on August 17, 2026. The patch missed the next Solidity update, 0.8.37, released on September 10. That version still miscompiles the reproducer. The fix merged on October 14 and shipped with 0.8.38, alongside the Solidity team's security advisory on October 22. The bug tracker assigned it SOL-2026-7.

Solidity 0.8.38 release announcement showing the Important Bugfixes section and Yul optimizer stale bookkeeping fix

Two writes, one address

LoadResolver can replace mload(p) with a previously stored value when it knows that nothing has overwritten the 32 bytes at p. It must discard that knowledge when another store might address the same word. The equivalent rule applies to storage loads and stores.

In this Yul PoC, both addresses evaluate to 0x20:

first write: a = v_before = 0x20
second write: b = v_after + 0x20 = 0 + 0x20 = 0x20

The first write puts 0x20 there. The second puts 0 there, so mload(a) must return 0. The broken address bookkeeping instead compares a and b as if both were based on the same value of v. It sees v and v + 0x20 as nonoverlapping memory words. In reality, v changed between the two address calculations.

The Yul PoC

Here is the complete standalone Yul PoC:

poc.yul
{
sstore(0, 0x20)
sstore(1, 0)
let v := sload(0)
mstore(v, v)
let a1 := v
let a := a1
mstore(a, a)
v := sload(1)
let b := add(v, 0x20)
mstore(b, v)
let result := mload(a)
mstore(0, result)
return(0, 0x20)
}

The first mstore(v, v) primes the optimizer's address cache before it sees mstore(a, a). The copies through a1 and a retain the old value 0x20 when v is later reassigned to zero. Removing that first memory store prevents the bad substitution in this small program. The storage round trip and isolated optimizer step preserve the shape needed to expose the stale relation.

I compiled it with the LoadResolver step alone:

solc --strict-assembly --optimize --yul-optimizations 'L:' --bin poc.yul

With 0.8.37, the optimized Yul changes let result := mload(a) into let result := a. That returns the old value, 0x20, even though the second store wrote zero to the same address. 0.8.38 keeps the load and returns 0. Here is the output from executing both L: builds as creation code on Anvil:

# solc 0.8.37
0x0000000000000000000000000000000000000000000000000000000000000020
# solc 0.8.38
0x0000000000000000000000000000000000000000000000000000000000000000

The first result is wrong, as one can see. The second matches the zero stored at address 0x20. With the default optimizer sequence, this particular Yul snippet also returns zero on 0.8.37. The Solidity example below reaches the bug with that sequence enabled.

The bytecode difference

This is the complete opcode diff between the two L: builds using the compilers' default EVM target. Both use PUSH0 to put zero on the stack:

--- solc 0.8.37
+++ solc 0.8.38
@@ -1,16 +1,16 @@
00000000: PUSH1 0x20
00000002: PUSH0
00000003: DUP2
00000004: DUP2
00000005: SSTORE
00000006: PUSH1 0x01
00000008: DUP2
00000009: SWAP1
0000000a: SSTORE
0000000b: DUP1
0000000c: DUP3
0000000d: MSTORE
-0000000e: DUP2
-0000000f: DUP2
+0000000e: DUP1
+0000000f: DUP1
00000010: MSTORE
00000011: RETURN

The shared SSTOREs set slots 0 and 1 to 0x20 and 0. The MSTORE at 0x0d writes zero at memory address 0x20, which is the second Yul memory write. At that point the remaining stack is [0, 0x20], with the top item shown first. In the 0.8.37 output, the first DUP2 copies 0x20 as the result, the second copies 0 as the destination for MSTORE. RETURN therefore emits 0x20. In 0.8.38, the two DUP1s supply zero as both the result and destination, so RETURN emits zero. The EVM instructions each do what they say. The wrong choice comes from 0.8.37 replacing mload(a) with its stale assumption that a still contains 0x20. The 0.8.38 optimized Yul retains mload(a), although final EVM code generation can reuse the zero on the stack instead of emitting an MLOAD opcode.

No assembly required

The Solidity PoC below uses a fixed-size array of structs, each occupying one storage slot. The first loop establishes i, then k = i + 2. It writes through a[k], increments i in a second loop, writes through a[i], and finally returns a[k].v:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19 <0.9.0;
contract C {
struct S { uint256 v; }
S[20] public a;
function g(uint256 max1, uint256 max2, uint256 aaaa, uint256 bbbb) external returns (uint256) {
uint256 i;
for (uint256 j; j < max1; j++) i++;
uint256 k = i + 2;
a[k].v = aaaa;
for (uint256 j; j < max2; j++) i++;
a[i].v = bbbb;
return a[k].v;
}
}

For g(0, 2, 0xaaaa, 0xbbbb), the first loop does not run, so i = 0 and k = 2. The first write sets a[2].v to 0xaaaa. The second loop increments i twice, and the second write sets that same slot to 0xbbbb. The final read must return 0xbbbb. Because the struct occupies exactly one slot and the static array begins at slot zero, array index 2 maps directly to storage slot 2.

forge test --use 0.8.37 -vv
[FAIL: returned stale first value] test_second_write_is_returned()
Logs:
actual return:
0x000000000000000000000000000000000000000000000000000000000000aaaa
stored a[2].v:
0x000000000000000000000000000000000000000000000000000000000000bbbb
forge test --use 0.8.38 -vv
[PASS] test_second_write_is_returned()
Logs:
actual return:
0x000000000000000000000000000000000000000000000000000000000000bbbb
stored a[2].v:
0x000000000000000000000000000000000000000000000000000000000000bbbb

The 0.8.37 run shows g returning 0xaaaa, while the generated public getter a(2) immediately reads 0xbbbb from storage. The first assertion confirms the second write happened, the second fails because the function's return value is stale. With 0.8.38, both the return value and storage getter are 0xbbbb.

Looking at the optimized Yul pins down the miscompilation. After the second sstore, 0.8.37 writes the first call argument straight into the return buffer:

sstore(var_i, calldataload(100)) // second write: bbbb
mstore(memPos, value_2) // first value: aaaa

With 0.8.38, the return path actually reloads the original slot:

sstore(var_i, calldataload(100))
let current := sload(sum) // sum is the slot for a[k]
mstore(memPos, current)

These snippets are reduced from the generated Yul, variable names and comments are adjusted for readability. Whether the bug fires depends on the intermediate code that reaches LoadResolver. Surrounding code, compiler version, and earlier optimizer steps can all change that code.

EqualStoreEliminator drops a required store

Another Yul optimizer step, EqualStoreEliminator, uses the same remembered contents as LoadResolver. It removes a write when the destination is already known to hold that value. The compiler's regression test shows the failure mode:

equalStoreEliminator/reassigned_reference_after_join.yul
{
let t := sload(0)
if calldataload(64) { t := sload(5) }
let a := t
let c := add(a, 2)
let v := calldataload(0)
let w := calldataload(32)
let old := sload(c)
sstore(c, v)
// The value of `t` is unknown both before and after this.
if calldataload(96) { t := sload(6) }
sstore(t, w)
// `t` and `c` can be equal, so this cannot be removed.
sstore(c, v)
}

I adapted it into a self-contained Yul variant that runs as creation code. At runtime, c and the new t both equal 0x102. The final store is necessary because the store through t changed the slot from 11 to 22. With only E enabled, 0.8.37 removes the final store and the creation call returns 22 (0x16). 0.8.38 keeps it and returns 11 (0x0b). The earlier sload(c) primes the address cache, removing it also stops this small reproducer from miscompiling.

A cached address outlives its base

When a store might overwrite a remembered value, DataFlowAnalyzer must discard that value. In its storage branch, vars->first is the new address and key is a tracked address:

libyul/optimiser/DataFlowAnalyzer.cpp: operator()(ExpressionStatement&)
boost::unordered::erase_if(m_state.environment.storage, mapTuple([&](auto&& key, auto&& value) {
return
!m_knowledgeBase.knownToBeDifferent(vars->first, key) &&
vars->second != value;
}));

The memory branch makes the same check with knownToBeDifferentByAtLeast32: two 32-byte words are separate only when their starting addresses are at least 32 bytes apart. Both checks use KnowledgeBase::differenceIfKnownConstant, which compares each address as a base variable plus an offset:

libyul/optimiser/KnowledgeBase.cpp: differenceIfKnownConstant()
std::optional<u256> KnowledgeBase::differenceIfKnownConstant(YulName _a, YulName _b)
{
VariableOffset offA = explore(_a);
VariableOffset offB = explore(_b);
if (offA.reference == offB.reference)
return offA.offset - offB.offset;
else
return std::nullopt;
}

In 0.8.37, a reassignment could leave a cached relation intact when the variable had no tracked expression before or after the change. In the Yul PoC, a keeps the old v = 0x20, while b uses the new v = 0 plus 0x20. Both addresses are 0x20, but the cache compares them as v and v + 0x20, keeps the old memory value, and replaces mload(a) with 0x20.

The Solidity PoC repeats the error in storage. The cached relation k = i_old + 2 survives the second loop. When i reaches 2, sstore(i, 0xbbbb) overwrites slot k, but the optimizer compares symbolic slots i and i + 2 and retains 0xaaaa for the final load. The compiler's loop regression test shows the same sequence:

loadResolver/reassigned_reference_after_loop.yul
{
let i := 0
for { let j := 0 } lt(j, calldataload(0)) { j := add(j, 1) } { i := add(i, 1) }
// Only becomes a copy of `i` after the common subexpression eliminator, so `k` does not reference `i` directly.
let i1 := add(i, 0)
let k := add(i1, 2)
sstore(k, add(sload(k), calldataload(64)))
// The value of `i` is unknown both before and after this.
for { let j := 0 } lt(j, calldataload(32)) { j := add(j, 1) } { i := add(i, 1) }
sstore(i, calldataload(96))
// `i` and `k` can be equal, so this cannot be resolved.
mstore(0, sload(k))
return(0, 32)
}

The offset-base regression test catches another lookup order: c is derived from the old t, then t changes. The later store through c may overwrite the value read through the new t:

loadResolver/reassigned_offset_base.yul
{
let t := calldataload(0)
// Only becomes a copy of `t` after the common subexpression eliminator, so `c` does not reference `t` directly.
let a := add(t, 0)
let c := add(a, 2)
let v := calldataload(64)
let old := sload(c)
sstore(c, v)
// `c` still refers to the old value of `t`.
t := calldataload(32)
let x := sload(t)
let w := calldataload(96)
// `c` and `t` can be equal, so this store invalidates what is known about `sload(t)`.
sstore(c, w)
mstore(0, sload(t))
return(0, 32)
}

The keccak regression test shows the same aliasing mistake affecting a hash instead of a load. After the second write, keccak256(a, 32) must hash the new contents:

loadResolverSolo/reassigned_reference_keccak.yul
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)
// `a` and `b` can be equal, so this cannot be replaced by the hash of `oldValue`.
let len := 32
let hash := keccak256(a, len)
sstore(0, hash)

How the patch fixes it

The fix explicitly marks changed variables in DataFlowAnalyzer::clearValues:

libyul/optimiser/DataFlowAnalyzer.cpp: clearValues()
// Only the variables to clear changed. The values of the referencing variables did not
// change, we just forgot their expressions because we can no longer evaluate them.
// What the knowledge base computed for them is still valid though.
for (auto const& name: _variablesToClear)
m_knowledgeBase.variableChanged(name);

KnowledgeBase::variableChanged increments that variable's version, and VersionedVariable holds its name and version:

libyul/optimiser/KnowledgeBase.h: variableChanged()
/// Has to be called whenever the value of @a _var changes.
void variableChanged(YulName const _var)
{
yulAssert(!m_valuesAreSSA);
yulAssert(!_var.empty());
++m_currentVersions[_var];
}
private:
struct VersionedVariable
{
YulName name;
std::size_t version = 0;
auto operator<=>(VersionedVariable const&) const = default;
};

Finally, withCurrentVersion and explore read that version and use it to look up the cached offset:

libyul/optimiser/KnowledgeBase.cpp: withCurrentVersion() / explore()
KnowledgeBase::VersionedVariable KnowledgeBase::withCurrentVersion(YulName _var) const
{
return {
.name = _var,
.version = util::valueOrDefault(m_currentVersions, _var, 0U)
};
}
KnowledgeBase::VariableOffset KnowledgeBase::explore(YulName _var)
{
yulAssert(!_var.empty());
VersionedVariable const currentVar = withCurrentVersion(_var);
AssignedValue const* assignedValueWrapper = m_variableValues(_var);
Expression const* assignedValue = assignedValueWrapper ? assignedValueWrapper->value : nullptr;
// A versioned variable always refers to the same value,
// so we check if we already computed it
if (CachedOffset const* cachedOffset = util::valueOrNullptr(m_offsets, currentVar))

The old and new values of v now have different cache keys. A store whose address might alias a invalidates the remembered value, so the load stays correct.