该文章详细分析了 Solidity 编译器(0.8.28-0.8.33 版本)在 --via-ir 模式下的 TSTORE Poison 严重漏洞。该漏洞源于内部函数缓存键冲突,导致瞬态变量与持久化变量的 delete 操作指令被错误地静默互换,可能引发存储损坏或安全绕过。开发者需升级至 0.8.34+ 版本并重新部署以修复此问题。
TL;DR -- Solidity 编译器 via-ir 代码生成器中的缓存键冲突导致对 transient 变量执行
delete时错误地发出sstore而非tstore,或者反之。受影响版本:solc 0.8.28-0.8.33。影响范围包括静默的持久存储损坏(资金被盗、所有权篡改、访问控制绕过)或持久状态未清除(永久 Token 授权、重入保护失效)。修复方案:升级至 solc 0.8.34+ 并重新编译部署。
2024 年 10 月,Solidity 团队发布了 solc 0.8.28,引入了对 transient 存储变量的一等支持。编译器获得了在高层级处理 transient 变量的能力,包括使用 delete 关键字。然而,中间表示(IR)代码生成器中的一个单行缺陷导致这些操作码在智能合约中被静默交换。该漏洞存在于 0.8.28 到 0.8.33 的所有版本中。它不需要特殊的编译器标志(只需开启 --via-ir),不产生警告,且生成的字节码能通过标准测试套件。
我们将此漏洞称为 TSTORE Poison —— 编译器的内部函数缓存被针对给定类型的第一个 delete 操作“污染”,随后该类型的所有 delete 都会静默继承错误的存储操作码。

Solidity 编译器的 via-ir 流水线在转换为 EVM 字节码之前会生成 Yul 作为中间表示。在 IR 生成期间,可重用的 Yul 辅助函数(如 ABI 编码、存储访问、类型转换等)被收集在名为 MultiUseYulFunctionCollector 的共享池中,以避免重复代码并保持输出紧凑。
收集器通过名称缓存函数。当请求工具函数时,createFunction(name, creator) 会检查该名称是否已存在。如果不存在,则执行回调生成函数体并存储;如果已存在,则直接返回缓存的函数体。
// libsolidity/codegen/MultiUseYulFunctionCollector.cpp
std::string MultiUseYulFunctionCollector::createFunction(
std::string const& _name,
std::function<std::string()> const& _creator
) {
if (!m_requestedFunctions.count(_name)) { // 仅在第一次调用时执行
m_requestedFunctions.insert(_name);
std::string fun = _creator(); // 生成器运行一次
m_code += std::move(fun);
}
return _name; // 所有调用返回相同的名称
}
只要函数名能唯一标识其行为,该机制就能正常工作。如果不能,就会导致编译错误。
storageSetToZeroFunction 用于生成将存储槽清零的辅助函数。自 Solidity 0.8.24 起,变量可以存在于两个不同的存储域:持久存储(sstore/sload)和 transient 存储(tstore/tload)。该函数接受 _location 参数来区分它们,但未将其包含在缓存键中:
// libsolidity/codegen/YulUtilFunctions.cpp
std::string YulUtilFunctions::storageSetToZeroFunction(
Type const& _type,
VariableDeclaration::Location _location // <-- 用于代码生成
) {
std::string const functionName = "storage_set_to_zero_" + _type.identifier();
return m_functionCollector.createFunction(functionName, [&]() {
// ...
("store", updateStorageValueFunction(_type, _type, _location))
// sstore 或 tstore 取决于 _location,但仅在第一次调用时评估
});
}
结果是:当合约同时拥有相同类型的持久变量和 transient 变量,并且两者都被执行了 delete 操作时,IR 生成过程中遇到的第一个 delete 会污染该类型的所有后续缓存。第二个 delete 会静默获得错误的操作码。
编译器按特定顺序生成函数体:
dispatchRoutine() 遍历合约接口函数,该映射按 4 字节选择器的升序排列。generateQueuedFunctions() 按 FIFO(先进先出)顺序处理函数。结论:具有最低选择器且包含给定类型 delete 操作的外部函数,决定了整个合约中该类型所有 delete 操作的缓存操作码。
冲突需要在同一编译单元中针对某种类型 T 同时满足以下两个部分:
只有一种路径会调用带有 Location::Transient 的 storageSetToZeroFunction:
address internal transient _cached;
delete _cached;
// 编译器生成:使用 tstore 的 storage_set_to_zero_t_address
许多路径会生成带有 Location::Unspecified 的 sstore 变体:
delete 状态变量:delete _admin;delete 映射值:delete _approvals[tokenId];delete 数组特定元素:delete _whitelist[i];.pop():_recipients.pop();bytes 或 string 的 .pop():在 uint8 类型上产生冲突。delete 整个动态数组。delete 固定大小数组。delete 包含类型 T 成员的结构体。delete 值类型为结构体的映射项。delete 嵌套结构体。注意:这些操作甚至不需要在同一个函数或同一个合约中,基类和子类之间的操作也会触发冲突。
缓存键基于 _type.identifier()。虽然 address 和 address payable 被视为不同类型,但存在跨类型冲突的情况。
在清理或调整存储数组大小时,编译器会进行类型扩展:如果数组的基础类型在存储中占用少于 32 字节,编译器会在传递给 storageSetToZeroFunction 之前将其替换为 uint256。
这意味着 bool[]、address[]、uint8[] 等数组的清理操作都会生成 storage_set_to_zero_t_uint256。如果合约中同时存在 uint256 transient 变量的 delete 操作,就会发生冲突。
一个带有 transient 重入保护的保险库。transferFrom(选择器较小)包含持久化的 delete address,缓存了 sstore。随后 deposit() 的修饰器执行 delete _txSender(transient),复用了 sstore 版本,导致每次调用都会错误地清零持久存储槽 0(即 _owner 变量)。
pragma solidity 0.8.29;
contract UpgradeableVault {
address internal _owner; // slot 0
address internal transient _txSender; // tslot 0
function initialize(address owner_) external {
require(_owner == address(0), "already initialized");
_owner = owner_;
}
// 选择器: 0x23b872dd,包含第一个 "delete address"
// 缓存了带有 sstore 的 storage_set_to_zero_t_address
function transferFrom(address from, address to, uint256 id) public {
// ... 逻辑 ...
delete _nftApprovals[id];
}
modifier senderGuard() {
require(_txSender == address(0), "reentrant");
_txSender = msg.sender;
_;
delete _txSender; // 错误地执行了 sstore,清零了 slot 0 (_owner)
}
function deposit() external payable senderGuard {
// ...
}
}
攻击序列:用户调用 deposit() 时会清零 _owner,攻击者随后可以调用 initialize() 夺取所有权并提取资金。
反向冲突:execute 函数(选择器较小)包含 transient delete address,缓存了 tstore。随后 transferFrom 尝试通过 delete approvals[id] 清除持久授权,但它错误地使用了 tstore。授权在交易内看起来已清除,但在交易结束后依然存在于持久存储中。
结果:获得过一次授权的攻击者可以无限次地从后续所有者手中偷走 NFT。
严重程度:紧急(Critical)。 该漏洞允许在正常的合约操作中静默且永久地损坏持久存储。无需专门的攻击交易,任何触发 transient delete 的合法交互都可能破坏状态。
owner、admin 或初始化标记,导致所有权被盗或访问控制绕过。tstore,保护将失效。该漏洞无法通过源码审计、标准测试套件或运行时监控发现。字节码仅在单个操作码上有所不同(0x55 与 0x5d)。形式化验证工具通常假设编译器是正确的,因此也难以捕捉此类问题。
--via-ir 编译? 如果不是,则不受影响。delete? 如果是,极有可能受影响,应立即重新部署。修复方法是在 storageSetToZeroFunction 的缓存键中包含存储位置,这与 updateStorageValueFunction 的模式一致:
std::string const functionName =
(_location == VariableDeclaration::Location::Transient ? "transient_" : "") +
"storage_set_to_zero_" +
_type.identifier();
这将为持久和 transient 变体生成不同的函数名,从而避免冲突。
| 日期 | 事件 |
|---|---|
| 2026-02-11 | 在编译器源码审计中发现漏洞 |
| 2026-02-11 | 报告给 Solidity/Argot 团队 |
| 2026-02-12 | 启动全链范围的受影响合约扫描 |
| 2026-02-13 | 确认漏洞并建立 SEAL911 战时会议室 |
| 2026-02-13 | 协调披露并通知受影响的协议 |
| 2026-02-18 | solc 0.8.34 发布修复版本 |
| 2026-02-18 | 公开披露 |
| 版本 | 状态 |
|---|---|
| < 0.8.28 | 不受影响(无 transient 关键字) |
| 0.8.28 - 0.8.33 | 受影响(仅限 --via-ir) |
| 0.8.34+ | 已修复 |
- 原文链接: hexens.io/research/solid...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!