TSTORE 低 Gas 重入 - Chainsecurity

文章详细分析了以太坊坎昆升级(Cancun hardfork)中 EIP-1153 引入的瞬态存储(Transient Storage, TSTORE)功能可能带来的新型低 gas 重入攻击风险。

a penguin wearing sunglasses

TSTORE 低 Gas 重入

在即将到来的坎昆硬分叉中,以太坊将为其以太坊虚拟机 (EVM) 添加一项新的、令人兴奋的功能。瞬态存储 (EIP-1153) 将作为一种新的数据位置供开发者使用,用于存储生命周期为单个交易的数据。

EIP 指出,瞬态存储“行为与存储相同,只是瞬态存储在每次交易后都会被丢弃”。然而,TSTORE 和 SSTORE 语义之间的微小差异将引入一种新的、意想不到的重入攻击向量,该向量源于对低 Gas 环境中重入的假设被打破——这主要由 Solidity 的 address.transfer() 和 vyper 的 send() 实现。

在这篇博客文章中,我们希望引起人们对由 TSTORE 规范中缺乏最小可用 Gas 要求而导致的新创建的、潜在意想不到的重入向量的关注。前两个部分将介绍瞬态存储和重入的一些背景信息。其余部分将说明当 EIP-1153 激活时,开发者应该注意的新重入向量。

以下示例的完整版本可在 Github 上获取。

什么是瞬态存储?

许多智能合约设计模式要求合约的执行帧之间进行通信。例如,一个执行帧可能希望向后续执行帧发出信号,表明合约已被进入——这被称为重入锁。这种帧间通信通常通过写入特定的存储位置进行信号传递,并在稍后恢复状态来实现。EIP-3529 中引入的更新的 Gas 退款上限增加了此类操作的成本。

EIP-1153 旨在为帧间通信创建一个更廉价的解决方案,通过为智能合约提供一个单独的数据位置,该位置在交易执行后被丢弃。其临时性质因此不会导致磁盘写入,从而使操作的定价低于对持久化存储的操作。也就是说,TSTORE 和 TLOAD 可以以 100 Gas 的成本对瞬态存储进行写入和读取,瞬态存储与存储类似,是合约特有的数据位置。否则,其语义定义与其存储等价物相同。例如,栈上使用相同的参数,执行上下文的定义相同(例如在委托调用中),并且/或者在静态调用期间不允许写入瞬态存储。

然而,在低 Gas 执行方面,TSTORE 和 SSTORE 之间存在一个额外但显著的差异。当剩余 Gas 少于 2300 Gas 时,TSTORE 将被允许执行,而 SSTORE 则不允许。

什么是重入?

重入是指合约被进入后,将执行流控制权移交给另一个合约,该合约又再次进入第一个合约。重入的危险源于数据竞争,即被重入合约的执行帧争用相同的存储槽,从而可能导致不一致的读写。通常,这通过重入锁来防止。

或者,合约可以通过限制对不受信任的调用发送的 Gas 量来限制重入的可能性。例如,只打算转出 ETH 的合约通常使用 Solidity 的 transfer() 或 vyper 的 send() 来防止重入攻击,通过仅发送 2300 Gas 来进行 ETH 转账。目前,这种非重入假设由 EIP-2200 强制执行,该 EIP 作为“君士坦丁堡重入”问题的结果被包含在君士坦丁堡硬分叉中。EIP-2200 导致 Gas 小于 2300 的 SSTORE 失败(即使可能提供了足够的 Gas)。因此,由于 transfer() 或 send() 而导致 SSTORE 是不可能的。因此,直到现在,(根据其常见定义)重入是不可能发生的。

瞬态存储打破假设

开发者预计会意识到,普遍已知的重入攻击向量也将适用于瞬态存储。然而,我们希望详细阐述瞬态存储操作打破开发者现有假设的特殊边缘情况。

回想一下,TSTORE 操作码不需要像 SSTORE 那样具有最小可用 Gas 要求。因此,它实际上像 EIP 中描述的那样行为完全相同。

为 SSTORE 设立该要求的原因是为了不打破重入保护机制的假设——即 Solidity 的 transfer()。因此,transfer() 被认为是重入安全的。虽然 TSTORE 不会通过打破部署时所做的假设来损害现有合约的安全性,但它很可能会打破开发者社区中公认的假设,即低 Gas 转账是安全的。

接下来,我们将:

  1. 提供三个可能发生这些重入的示例合约。
  2. 解释为什么一些先前受信任的合约不再可信。
  3. 总结新风险的概述。

下面的简单合约说明了这种行为。我们在这些示例中使用 vyper,因为 vyper 从 0.3.8 版本开始已经支持瞬态存储。

##pragma evm-version cancun

event Number:
    number : indexed(uint256)

number_transient : transient(uint256)

@external
@payable
def test(callee : address):
    send(callee, msg.value)
    log Number(self.number_transient)

@external
@payable
def __default__():
    self.number_transient = 1234

当 callee 调用回退函数时,对 test() 的调用将成功。

##pragma evm-version cancun

@external
@payable
def __default__():
    raw_call(msg.sender, _abi_encode(""))

假设 number_transient 是一个存储变量。无论该存储变量是否被修改过,SSTORE 都会因 EIP-2200 而回滚。

请注意,开发者合理地会认为它们应该表现相同。

temporaryApprove: 一个真实的漏洞

诚然,上面的例子相当微不足道。更复杂的例子可能不太可能发生。然而,并非不可能。下面的合约是 Wrapped ETH (WETH) 合约的一个新颖实现,它实现了 temporaryApprove() 功能,这曾是潜在用例的建议之一。它说明了如果不小心,一个 bug 很容易就会潜入真实世界的合约中。

##pragma evm-version cancun

balanceOf: public(HashMap[address, uint256])
tempAllowance: transient(HashMap[address, HashMap[address, uint256]])

## Receive Ether and wrap it
@payable
@external
def deposit():
    self.balanceOf[msg.sender] += msg.value
    log Deposit(msg.sender, msg.value)

## Temporary approval
@external
def temporaryApprove(guy: address, wad: uint256):
    self.tempAllowance[msg.sender][guy] = wad

## Withdraw all temporary approved amount from an address
@external
def withdrawAllTempFrom(src: address, dst: address):
    assert msg.sender == src or msg.sender == dst
    assert self.tempAllowance[src][dst] <= self.balanceOf[src]
    send(dst, self.tempAllowance[src][dst])
    self.balanceOf[src] -= self.tempAllowance[src][dst]
    log Transfer(src, dst, self.tempAllowance[src][dst])
    log Withdrawal(dst, self.tempAllowance[src][dst])
    self.tempAllowance[src][dst] = 0

请注意 withdrawAllTempFrom() 函数如何首先执行临时授权检查,然后发送提取的 ETH,最后使用瞬态存储中的授权值更新存储。如果使用常规存储,合约将是安全的。然而,使用瞬态存储,合约可能会被完全耗尽,如下所示:

  1. 攻击者使用地址 A1 将 10 ETH 存入合约。
  2. 攻击者通过 temporaryApprove(A2, 10 ETH) 给予第二个地址 A2 临时授权。
  3. 攻击者调用 withdrawAllTempFrom(A1, A2)。这将把 10 ETH 发送给 A2。
  4. 在 A2 的回退函数中,A2 调用 A1,A1 调用 temporaryApprove(A2, 0)
  5. 最终,回到 withdrawAllTempFrom() 中,tempAllowance 将从瞬态存储中读取为零,因此发起合约保留其 WETH 余额,但另一个合约收到 ETH。

如上所述,这仅因瞬态存储而成为可能。如果使用常规存储,对 temporaryApprove 的重入调用将回滚。

ETHLocker: 与 SSTORE 对比

下面的示例定义了一个 ETH 保险库,它可以在两种模式下运行——通常如人们所期望的那样使用直接调用,以及使用回调(EIP-1153 相关的一个常见用例)来推迟用户的流动性检查,从而允许闪电贷。此外,它还可以轻松地批量存入和转移给许多用户,而无需触及调用者在存储中的余额(如果所有都转移给他人),从而可能降低 Gas 费用。

##pragma evm-version cancun

balanceOf: public(HashMap[address, int256])
transientBalanceOf: public(transient(HashMap[address, int256]))

deferredLiquidityCheck: transient(HashMap[address, bool])

@external
def withdraw(receiver : address, amount : int256):
    assert amount >= 0, "cannot withdraw negative amount"
    newBalance : int256 = 0
    if (self.deferredLiquidityCheck[msg.sender]):
        newBalance = self.transientBalanceOf[msg.sender] - amount
    else:
        newBalance = self.balanceOf[msg.sender] - amount
        assert newBalance >= 0, "user in unhealthy position"

    send(receiver, convert(amount, uint256))

    if (self.deferredLiquidityCheck[msg.sender]):
        self.transientBalanceOf[msg.sender] = newBalance
    else:
        self.balanceOf[msg.sender] = newBalance
@external
def transfer(receiver : address, amount : int256):
    assert amount >= 0, "cannot transfer negative amount"
    if (self.deferredLiquidityCheck[msg.sender]):
        self.transientBalanceOf[msg.sender] -= amount
    else:
        self.balanceOf[msg.sender] -= amount
        assert self.balanceOf[msg.sender] >= 0, "user in unhealthy position"

    if (self.deferredLiquidityCheck[receiver]):
        self.transientBalanceOf[receiver] += amount
    else:
        self.balanceOf[receiver] += amount

@external
def batch():
    assert (not self.deferredLiquidityCheck[msg.sender]), "already batching operations"
    self.deferredLiquidityCheck[msg.sender] = True
    DeferredCallee(msg.sender).callback() # callback will use transient storage
    self.deferredLiquidityCheck[msg.sender] = False
    self.balanceOf[msg.sender] += self.transientBalanceOf[msg.sender]
    self.transientBalanceOf[msg.sender] = 0
    assert self.balanceOf[msg.sender] >= 0, "user in unhealthy position"

请注意,攻击者可以首先调用 batch(),这样在 callback() 中就会调用另一个合约,该合约会调用 batch()deposit()withdraw()。在提款期间,突然间,由于两个地址的操作都将在瞬态存储上执行,因此可能重入 locker 的 transfer() 函数。然而,如果不使用瞬态存储,这种情况是不可能发生的。

增加的复杂性说明了一个真实世界的例子,并表明开发者如果假设使用 2300 Gas 是重入安全的,则很容易创建易受攻击的合约。

改变对 EIP-1153 之前合约的信任

许多 DeFi 协议集成了其他智能合约。在集成时,需要评估集成合约的安全性。在本节中,我们解释了为什么现在需要考虑新的重入来评估安全性。我们提供两个示例:

  1. Disperse 合约。它有一个 disperseEther() 函数,可以用于向多个接收者发送 ETH。在撰写本文时,该函数已被调用超过 150,000 次。它使用 transfer() 发送 ETH。到目前为止,调用 disperseEther() 的合约无需担心重入,但未来使用瞬态存储的合约必须考虑这一点。
  2. EthsMarketV2 合约。它是一个交易合约,有一个 orderBuy() 函数,在过去一个月内已被调用超过 1300 次。买方将 ETH 发送给 orderBuy(),然后使用 transfer() 转发给卖方(以及可能的创建者)。因此,调用 orderBuy() 的合约无需担心重入,但未来使用瞬态存储的合约必须考虑这一点。

结论

EIP-1153 将为 EVM 引入新的、令人兴奋的功能。然而,在语言层面所做的假设可能会被打破。开发者应仔细评估防止重入的手段。

总结如下:

  • 当使用瞬态存储时,现在可能发生带有 2300 Gas 的重入。
  • 因此,Solidity 的 transfer 和 vyper 的 send 函数不再是重入安全的。
  • 新的重入也可能影响使用瞬态存储但没有 transfer 调用的合约,因为 transfer 可能存在于另一个合约中(例如,一个交易所)。
  • 现有合约不受影响,但与新合约交互时需要小心。
  • 除了这种特殊的重入之外,所有已知的重入向量也适用于瞬态存储。

示例的完整版本可在 Github 上获取。

外部链接

  • 原文链接: chainsecurity.com/blog/t...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

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