TSTORE Poison:静默损坏存储的 SOLC Bug

  • hexens
  • 发布于 2026-01-09 15:59
  • 阅读 2

该文章详细分析了 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 都会静默继承错误的存储操作码。

summary_image

漏洞背景

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 会静默获得错误的操作码。

冲突方向的确定

编译器按特定顺序生成函数体:

  1. dispatchRoutine() 遍历合约接口函数,该映射按 4 字节选择器的升序排列。
  2. generateQueuedFunctions() 按 FIFO(先进先出)顺序处理函数。
  3. 在生成函数体期间,工具函数在第一次被引用时内联创建。

结论:具有最低选择器且包含给定类型 delete 操作的外部函数,决定了整个合约中该类型所有 delete 操作的缓存操作码。

触发条件

冲突需要在同一编译单元中针对某种类型 T 同时满足以下两个部分:

A 部分:transient 侧

只有一种路径会调用带有 Location::TransientstorageSetToZeroFunction

address internal transient _cached;
delete _cached;
// 编译器生成:使用 tstore 的 storage_set_to_zero_t_address

B 部分:持久侧

许多路径会生成带有 Location::Unspecifiedsstore 变体:

  1. delete 状态变量delete _admin;
  2. delete 映射值delete _approvals[tokenId];
  3. delete 数组特定元素delete _whitelist[i];
  4. 动态数组 .pop()_recipients.pop();
  5. bytesstring.pop():在 uint8 类型上产生冲突。
  6. 将较短的内存数组赋值给较长的存储数组:会触发多余槽位的清零。
  7. 将空数组赋值给存储数组
  8. delete 整个动态数组
  9. delete 固定大小数组
  10. 通过汇编缩小动态数组
  11. delete 包含类型 T 成员的结构体
  12. delete 值类型为结构体的映射项
  13. delete 嵌套结构体
  14. 用较短的 calldata 数组覆盖存储数组
  15. 用较短的字符串覆盖存储字符串

注意:这些操作甚至不需要在同一个函数或同一个合约中,基类和子类之间的操作也会触发冲突。

类型匹配逻辑

缓存键基于 _type.identifier()。虽然 addressaddress payable 被视为不同类型,但存在跨类型冲突的情况。

在清理或调整存储数组大小时,编译器会进行类型扩展:如果数组的基础类型在存储中占用少于 32 字节,编译器会在传递给 storageSetToZeroFunction 之前将其替换为 uint256

这意味着 bool[]address[]uint8[] 等数组的清理操作都会生成 storage_set_to_zero_t_uint256。如果合约中同时存在 uint256 transient 变量的 delete 操作,就会发生冲突。

案例 1:通过 transient 删除导致重初始化

一个带有 transient 重入保护的保险库。transferFrom(选择器较小)包含持久化的 delete address,缓存了 sstore。随后 deposit() 的修饰器执行 delete _txSendertransient),复用了 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() 夺取所有权并提取资金。

案例 2:NFT 授权未清除(反向冲突)

反向冲突:execute 函数(选择器较小)包含 transient delete address,缓存了 tstore。随后 transferFrom 尝试通过 delete approvals[id] 清除持久授权,但它错误地使用了 tstore。授权在交易内看起来已清除,但在交易结束后依然存在于持久存储中。

结果:获得过一次授权的攻击者可以无限次地从后续所有者手中偷走 NFT。

漏洞影响

严重程度:紧急(Critical)。 该漏洞允许在正常的合约操作中静默且永久地损坏持久存储。无需专门的攻击交易,任何触发 transient delete 的合法交互都可能破坏状态。

  • Slot 0-3 冲突:最常见,可能清零 owneradmin 或初始化标记,导致所有权被盗或访问控制绕过。
  • 重入保护绕过:如果持久化的重入标记清除操作错误使用了 tstore,保护将失效。
  • 授权持久化:如案例 2 所示,授权无法被正确撤销。

漏洞检测与评估

该漏洞无法通过源码审计、标准测试套件或运行时监控发现。字节码仅在单个操作码上有所不同(0x550x5d)。形式化验证工具通常假设编译器是正确的,因此也难以捕捉此类问题。

检查清单

  1. 是否使用 --via-ir 编译? 如果不是,则不受影响。
  2. solc 版本是否在 0.8.28 至 0.8.33 之间? 如果不是,则不受影响。
  3. 合约(包括依赖项)是否对 transient 变量使用了 delete 如果是,极有可能受影响,应立即重新部署。

修复建议

  • 升级到 solc 0.8.34 或更高版本。
  • 重新编译并部署受影响的合约。
  • 对于可升级合约,部署新实现并升级。注意:如果持久存储已被损坏(如 _owner 被清零),仅升级代码无法恢复状态,可能需要执行迁移脚本修复存储槽。

修复方案

修复方法是在 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 公开披露

附录:受影响的 Solidity 版本

版本 状态
< 0.8.28 不受影响(无 transient 关键字)
0.8.28 - 0.8.33 受影响(仅限 --via-ir
0.8.34+ 已修复
  • 原文链接: hexens.io/research/solid...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
hexens
hexens
江湖只有他的大名,没有他的介绍。