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
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
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 at https://github.com/ChainSecurity/TSTORE-Low-Gas-Reentrancy.
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,
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
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
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(""))
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
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
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:
- 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.
- 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.
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
sendfunctions are no longer reentrancy-safe
- The new reentrancy can also affect contracts that use transient storage but have no
transfercall, as the
transfermight 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 at https://github.com/ChainSecurity/TSTORE-Low-Gas-Reentrancy.
At ChainSecurity, we have been securing smart contracts since 2017. Our clients comprise of blue-chip DeFi protocols, promising new Web3 projects, central banks, and large organizations.
Read our published audit reports.
Book a call to discuss auditing prospects.