Back to Overview
a penguin wearing sunglasses

TSTORE Low Gas Reentrancy

November 10, 2023

In the upcoming Cancun hardfork, Ethereum will add a new exciting feature to its Ethereum Virtual Machine (EVM). Transient storage (EIP-1153) will be available to developers as a new data location for storing data with the lifespan of one transaction.

The EIP states that transient storage “behaves identically to storage, except that transient storage is discarded after every transaction”, however, minor differences between the semantics of TSTORE and SSTORE will introduce a new and unexpected reentrancy attack vector that stems from a breaking assumption on reentrancy in low-gas environments – implemented mostly by Solidity’s address.transfer() and vyper’s send().

In this blog post, we want to draw attention to the newly created and potentially unexpected reentrancy vectors enabled by the lack of a minimum gas available requirement in TSTORE‘s specification. The first two sections will give some context on transient storage and reentrancy. The remaining parts will illustrate the new reentrancy vectors that developers should be aware of when EIP-1153 is activated.

The full versions of the examples below are available on #Github

What is transient storage?

Many smart contract design patterns require communication between execution frames of contracts. For example, an execution frame may want to signal to later execution frames that the contract has been entered already – which is known as a reentrancy lock. Such inter-frame communication has been typically implemented by writing to certain storage locations for signaling and restoring the state later. The updated gas refund caps introduced in EIP-3529 increased the cost of such operations.

EIP-1153 aims to create a cheaper solution for inter-frame communication by providing a separate data location for smart contracts that is discarded after the execution of a transaction. The temporary nature consequently will not result in disk writes so that the operations can be priced less than operations on the persistent storage. Namely, TSTORE and TLOAD can, at a cost of 100 gas, write to and read from the transient storage which is, similar to storage, a contract-specific data location. Otherwise, the semantics are defined as for their storage equivalents. For example, the same arguments are used on stack, the execution context is defined the same (e.g. in delegatecalls), and/or writing to transient storage during static calls is not allowed.

However, there is an additional but significant discrepancy between TSTORE and SSTORE in terms of low-gas executions. TSTORE will be allowed when the gas left is less than 2300 gas, while SSTORE is not.

What is reentrancy?

Reentrancy is when a contract has been entered, and hands over the execution flow control to another contract that is entering the first contract again. The dangers of reentrancy stem from data races where the reentered contract’s execution frames are competing for the same storage slots so that inconsistent reads and writes are possible. Typically, this is prevented with reentrancy locks.

Alternatively, contracts could limit reentrancy possibilities by limiting the gas sent with an untrusted call. For example, contracts that only intend to transfer out ETH, commonly use Solidity’s transfer() or vyper’s send() to prevent reentrancy attacks by transferring ETH with only 2300 gas. Currently, that non-reentrancy assumption is enforced by EIP-2200, included in the Constantinople hard fork as a consequence of the “Reentrancy on Constantinople” issue. EIP-2200 causes an SSTORE with less than 2300 gas to fail (even though sufficient gas might be available). Hence, no SSTORE is possible as a result of a transfer() or send() . Therefore, no reentrancy (by its common definition) was possible up until now.

Transient storage breaking assumptions

It is expected that the developers will be aware that the commonly known reentrancy attack vectors will also apply to transient storage. However, we want to elaborate on the particular corner case of transient storage operations breaking existing assumptions developers are making.

Recall, that the TSTORE opcode does not require a minimum gas available requirement such as SSTORE does. Hence, it actually does not behave identically as described in the EIP.

The reason why it was put in place for SSTORE was to not break assumptions of reentrancy protection mechanisms – namely, Solidity’s transfer(). As a consequence, transfer() is believed to be safe from reentrancy. While TSTORE does not compromise the security of existing contracts by breaking the assumptions made on deployment, it may very well break the, in the developer community well-established, assumption, that low-gas transfers are secure.

In the following, we will:
1. Provide three example contracts where these reentrancies can occur.
2. Explain why some previously trusted contracts can no longer be trusted.
3. Conclude with an overview of the new risks

The simple contract below illustrates the behavior. We use vyper in these examples as vyper already supports transient storage since version 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 

A call to test() where the callee is calling the fallback function will succeed.

#pragma evm-version cancun

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

Assume, that number_transient had been a storage variable. No matter whether the storage variable had been dirty or not, an SSTORE would have reverted as a consequence of EIP-2200.

Note that it is reasonable to assume that developers would assume that they should behave the same.

temporaryApprove: A realistic vulnerability

Admittedly, the above example is rather trivial. More complex examples can become more unlikely. However, not impossible. The contract below is a novel implementation for the Wrapped ETH (WETH) contract that implements the temporaryApprove() functionality which has been one of the suggestions for potential use cases. It illustrates how easily a bug could slip into real-world contracts if one is not careful.

#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


Note how the withdrawAllTempFrom() function first performs the temporary allowance check, then sends out the withdrawn ETH and last updates the storage with the value of the allowance in the transient storage. If regular storage would have been used, the contract would have been safe. However, with transient storage, the contract could be fully drained, like this:
1. An attacker uses address A1 to deposit() 10 ETH into the contract.
2. The attacker gives temporary approval to a second address A2 with temporaryApprove(A2, 10 ETH)
3. The attacker calls withdrawAllTempFrom(A1, A2). This will send the 10 ETH to A2.
4. In the fallback function of A2, A2 calls A1, and A1 calls temporaryApprove(A2, 0) .
5. Ultimately, back in withdrawAllTempFrom(), tempAllowance will be read as zero from transient storage so that the initiating contract keeps its WETH balance but the other contract receives the ETH.

As mentioned above, this is only possible due to transient storage. With regular storage, the reentrant call to temporaryApprove would have reverted.

ETHLocker: Contrasting with SSTORE

The below example defines an ETH vault that can operate in two modes – regularly as one would expect using direct calls and with a callback (a common use case described in relation to EIP-1153) that defers the liquidity check of a user, allowing for flashloans. Additionally, it could allow depositing and transferring to many users in one batch easily without touching the callers’ balance in storage (if all is transferred to others) resulting in potentially lower gas fees.

#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" 

Note that an attacker could start by calling batch() so that then in callback() another contract that calls batch() to deposit() and withdraw() is called. During the withdrawal, suddenly, it would would be possible to reenter into the transfer() function of the locker as the operations for both addresses would be performed on transient storage. However, if transient storage was not to be used, such a scenario would not be possible.

The increased complexity illustrates a real-world example and shows that developers assuming that using 2300 gas is reentrancy-safe could easily create vulnerable contracts.

Changing the trust to pre-EIP1153 contracts

Many DeFi protocols integrate other smart contracts. When doing so the security of the integrated contracts needs to be evaluated. In this section, we explain why that security evaluation now needs to consider the new reentrancy. We provide two examples:

  1. The Disperse contract It has a disperseEther() that can be used to send ETH to multiple recipients. At the time of writing this function had been called more than 150,000 times. It uses transfer() to send the ETH. Up until now contracts calling disperseEther() did not have to worry about reentrancy, but in the future contracts that use transient storage have to consider it.
  2. The EthsMarketV2 contract It is an exchange contract that has an orderBuy() which has been called over 1300 times in the past month. The buyer sends ETH to the orderBuy() which is then forwarded to the seller (and potentially the creator) using transfer() . Hence, contracts calling orderBuy() did not have to worry about reentrancy, but in the future contracts that use transient storage have to consider it.

Conclusion

EIP-1153 will introduce new and exciting functionality to the EVM. However, assumptions made on the language-level could be broken. Developers should carefully evaluate the means for protecting against reentrancy.
The summary is:

  • Reentrancies with 2300 gas are now possible when transient storage is used
  • Hence, Solidity’s transfer and vyper’s send functions are no longer reentrancy-safe
  • The new reentrancy can also affect contracts that use transient storage but have no transfer call, as the transfer might be in another contract (e.g. an exchange)
  • Existing contracts are not affected, but need to be careful when interacting with new contracts
  • Apart from this special reentrancy all the known reentrancy vectors also apply to transient storage


The full versions of the examples are available on #Github