As is often the case with blockchain technology, the problems surrounding reentrancy in smart contracts do not originate in blockchain, but rather provide a novel and complex example of them.
Reentrancy is a term that has been present in computing for many years, and simply refers to the process whereby a process can be interrupted mid way through execution, have a different occurrence of the same function begin, then have both processes finish to completion. Reentrant functions are safely used in computing everyday. One good example is beginning an email draft in a server, exiting it to send another email, then being able to return to the draft to finish and send it.
So that’s a benign case of reentrancy that is simple, useful and not a threat. The problems begin to arise when this example is shifted away from a person sending an email, to a smart contract sending money. It’s a classic example of how cryptocurrencies and blockchain technology have upped the stakes of computing, providing some of its most sophisticated applications, whilst also making its pitfalls far more painful. The scale and cost of such reentrancy attacks should be a reminder that it is impossible to be too safe when it comes to code, and that a third party smart contract audit should be a staple of any project taking the security of their smart contracts seriously.
Image 1: https://www.certik.com/resources/blog/3K7ZUAKpOr1GW75J2i0VHh-what-is-a-reentracy-attack
The following image contains a function vulnerable to a reentrancy attack. When the low level
call() function sends ether to the
msg.sender address, it becomes vulnerable; if the address is a smart contract, the payment will trigger its fallback function with what's left of the transaction gas:
Image 2: Function Vulnerable to Reentrancy
An attacker can carefully construct a contract at an external address which contains malicious code in the fallback function. Thus, when a contract sends ether to this address, it will invoke the malicious code. Typically the malicious code executes a function on the vulnerable contract, performing operations which were not anticipated by the developer. The name "re-entrancy" comes from the fact that the external malicious contract calls back a function on the vulnerable contract and "re-enters" code execution at an arbitrary location on the vulnerable contract.
To clarify this, consider the simple vulnerable contract, which acts as an Ethereum vault only allowing depositors to withdraw 1 ether per week.
Image 3: Contract Vulnerable to Reentrancy
This contract has two public functions.
depositFunds() function simply increments the senders balances. The
withdrawFunds() function allows the sender to specify the amount of wei to withdraw. It will only succeed if the requested amount to withdraw is less than 1 ether and a withdrawal hasn't occurred in the last week.
The vulnerability comes on line  where the requested amount of ether is sent to the user. Consider a malicious attacker creating the following contract
Image 4: Attacker Contract
Let's see how this malicious contract can exploit the
EtherStore contract. The attacker would create the above contract with the
EtherStore's contract address as the constructor parameter. This will initialize and point the public variable
etherStore to the contract to be attacked.
The attacker would then call the
pwnEtherStore() function, with some amount of ether (greater than or equal to 1), let's say
1 ether for this example. Assume a number of other users have deposited ether into this contract, such that it's current balance is
10 ether. The following would then occur:
Attack.sol - Line  - The
depositFunds()function of the EtherStore contract will be called with a
1 ether(and a lot of gas). The sender (
msg.sender) will be the malicious contract (
balances[address] = 1 ether.
Attack.sol - Line  - The malicious contract will then call the
withdrawFunds()function of the
EtherStorecontract with a parameter of
1 ether. This will pass all the requirements (Lines - of the
EtherStorecontract) as no previous withdrawals have been made.
EtherStore.sol - Line  - The contract will then send
1 etherback to the malicious contract.
Attack.sol - Line  - The ether sent to the malicious contract will then execute the fallback function.
Attack.sol - Line  - The total balance of the EtherStore contract was
10 etherand is now
9 etherso this if statement passes.
Attack.sol - Line  - The fallback function then calls the
withdrawFunds()function again and "re-enters" the
EtherStore.sol - Line  - In this second call to
withdrawFunds(), the balance is still
1 etheras line  has not yet been executed. Thus, the value remains as
balances[address] = 1 ether. This is also the case for the
lastWithdrawTimevariable. Again, all the requirements are passed.
EtherStore.sol - Line  - Another
1 ether is withdrawn.
Steps 4-8 will repeat - until
EtherStore.balance >= 1as dictated by line  in
Attack.sol - Line  - Once there is less than 1 ether left in the
EtherStorecontract, this if statement will fail. This will then allow lines  and  of the
EtherStorecontract to be executed (for each call to the
EtherStore.sol - Lines  and  - The
lastWithdrawTimemappings will be set and the execution will end.
The final result, is that the attacker has withdrawn all ether from the
EtherStore contract, instantaneously with a single transaction.
Types of reentrancy attacks
There are three main types of reentrancy attacks: single function reentrancy, cross-function reentrancy and cross-contract reentrancy.
Single Function Reentrancy
This type of attack is the simplest and easiest to prevent. It occurs when the vulnerable function is the same function the attacker is trying to recursively call.
Image 5: Single Function Reentrancy
Since the user's balance is not set to 0 until the very end of the function, the second (and later) invocations will still succeed and will withdraw the balance over and over again.
In the example given, the best way to prevent this attack is to make sure an external function is not called until all the required internal work has been completed:
Image 6: Single Function Reentrancy Fixed
Note that if another function also called
withdrawBalance(), it would be potentially subject to the same attack, so any function which calls an untrusted contract must also be treated as untrusted.
These attacks are harder to detect. A cross-function reentrancy attack is possible when a vulnerable function shares state with another function that has a desirable effect for the attacker.
Image 7: Cross-function Reentrancy
In this case, the attacker calls
transfer() when their code is executed on the external call in
withdrawBalance. Since their balance has not yet been set to 0, they are able to transfer the tokens even though they already received the withdrawal. The same solutions will work, with the same caveats. Also note that in this example, both functions were part of the same contract. However, the same bug can occur across multiple contracts, if those contracts share state.
Cross-contract reentrancy can happen when the state from one contract is used in another contract, but that state is not fully updated before getting called.
The conditions required for the cross-contract reentrancy to be possible are as follows:
- The execution flow can be controlled by the attacker to manipulate the contract state.
- The value of the state in the contract is shared or used in another contract.
More Examples of the Vulnerability
More examples of this vulnerability can be found on my github: https://github.com/ylevalle/SolidityReentrancy
There are a number of common techniques which help avoid potential reentrancy vulnerabilities in smart contracts:
- For the first two variations, Single Function Reentrancy and Cross-Function Reentrancy, a mutex lock can be implemented in the contract to prevent the functions in the same contract from being called repeatedly, thus, preventing reentrancy. A widely used method to implement the lock is inheriting OpenZeppelin’s ReentrancyGuard and use the
- Another solution is to check and try updating all states before calling for external contracts, or the so-called “Checks-Effects-Interactions” pattern. This way, even when a reentrant calling is initiated, no impact can be made since all states have finished updating.
- An alternative choice is to prevent the attacker from taking over the control flow of the contract. A set of whitelisted addresses can prevent the attacker from injecting unknown malicious contracts into the contract.
- Another technique is pull payment, that achieves security by sending funds via an intermediary escrow and avoiding direct contact with potentially hostile contracts.
Finally, gas limits can prevent reentrancy attacks, but this should not be considered a security strategy as gas costs are dependent on Ethereum’s opcodes, which are subject to change. Smart contract code, on the other hand, is immutable. Regardless, it is worth knowing the difference between the functions:
call. Functions send and transfer are essentially the same, but transfer will revert if the transaction fails, whereas send will not. In regard to reentrancy, send and transfer both have gas limits of 2300 units. Using these functions should prevent a reentrancy attack from occurring because this is not enough gas to recursively call back into the origin function to exploit funds.
Nevertheless, the contracts that integrate with other contracts, especially when the states are shared, should be checked in detail to make sure that the states used are correct and cannot be manipulated.
In general, a detailed manual inspection of the smart contracts code is what is needed to detect reentrancy vulnerabilities. But some of the smart contracts security tools like MythX and Mythril, can also help detecting reentrancy bugs, with the following limitations:
- These detection tools analyze the smart contract code based on predefined attack patterns, and if the patterns match any part in the code, then the tools discover the vulnerability. Thus, these approaches mainly rely on complete patterns and the specific quality of these patterns.
- The patterns these solutions rely on are based on the observation of the previous attacks and known vulnerabilities, which makes them limited and difficult to generalize.
- All the solutions are only applicable before the deployment of smart contracts. This means once the smart contract is deployed on the Ethereum network, these solutions cannot prevent reentrancy attacks and cannot detect the attacker.
- If a new reentrancy pattern is introduced after the deployment of the smart contracts, these solutions need to be updated; otherwise, they will not be able to detect the new attack patterns.
However, there are projects and researches about static analysis tools and frameworks that, given a contract’s source code, can identify functions vulnerable to reentrancy attacks. At a high level, these tools parse contract source code to extract particular keywords such as global variables and modifiers, tokenize the source code of each function in the contract, and feed embedded representations of these tokens through a model which classifies the function as reentrant or safe:
Real Life Examples and Attacks using Reentrancy
1) The DAO: The DAO (Decentralized Autonomous Organization) was one of the major hacks that occurred in the early development of Ethereum. At the time, the contract held over $150 million USD. Re-entrancy played a major role in the attack which ultimately lead to the hard-fork that created Ethereum Classic (ETC). For a good analysis of the DAO exploit, see Phil Daian's post.
2) Rari Capital hack in May 2021 used a reentrancy vulnerability. Over the last years, several DeFi platforms have been hit with reentrancy attacks, including Revest Finance and Ola Finance.
3) Uniswap/Lendf.Me: Back in April 2020, this reentrancy hack resulted in $25 million being snatched.
4) Cream Finance: In September 2021, this DeFi protocol suffered a hard blow. The hackers behind the reentrancy attack took over $34 million worth of AMP and ETH.
5) BurgerSwap: This token swap protocol, based on Binance Smart Chain (BSC), was attacked in May 2021. Using a fake token address and a reentrancy exploit, the attackers stole about $7.2 million worth of tokens.
6) SurgeBNB: This is another noticeable reentrancy attack worth $4 million. It took place in August 2021.
7) Siren Protocol: Back in September 2021, attackers managed to take $3.5 million worth of tokens from AMM pools by exploiting the reentrancy weakness.
There is always the risk of future updates introducing more opportunities for attacks. The Constantinople update was delayed because a flaw was discovered in EIP 1283 that introduced a new reentrancy attack using certain
SSTORE operations. Had this update been deployed to the mainnet, even send and transfer functions would have been vulnerable. Attacks will get increasingly advanced and involve more complex interactions between functions and contracts to effect state. The best approach to stay ahead, is to keep interactions as simple as possible and employ best practices such as using the checks-effects-interactions pattern to structure all functions.
The continued development of blockchain technology and defi security is unimaginable without encountering new vulnerabilities, failures, and even hacks. Whilst these pain points are a natural and essential part of any innovation, they also should impel developers and serious blockchain projects to put their code to the test with a smart contract audit before launching. That way, any potential vulnerabilities can serve as useful teachers, rather than catastrophic failures.
Looking for a Smart Contract audit service? We can help you! Feel free to contact us for more information.
References and Useful Links:
Security Researcher at Dreamlab Technologies