Table of Contents

What is reentrancy?

Reentrancy is the ability of a function or a system to be executed again before the original execution has been completed. It is a broad concept that exists in both traditional (web2) and blockchain (web3) programming. 

Reentrancy in smart contracts

In Solidity smart contracts, a function is "reentrant" if it can be executed during an external call. While reentrancy attacks are notorious, it's important to note that not all instances of reentrancy are harmful. Some smart contract designs intentionally use reentrancy for specific purposes.

Reentrancy in smart contracts falls into two main categories:

  1. Malicious reentrancy: Takes advantage of outdated state to steal funds.
  2. Non-malicious reentrancy: Uses properly updated state to perform actions during a call.

The key distinction between these types lies in proper implementation and adherence to best practices, such as the CEI (Checks-Effects-Interactions) pattern, to prevent unintended state manipulations.

This pattern looks as such:

  1. Checks: Verify the caller's state (e.g., ensure the caller has a balance to withdraw).
  2. Effects: Update global state (e.g., decrease the caller's balance in a mapping).
  3. Interactions: If checks pass, perform an external call (e.g., transfer tokens).

Consider this example of a vulnerable function structure:

  1. Checks: Verify the caller's state (e.g., ensure the caller has a balance to withdraw).
  2. Interactions: If checks pass, perform an external call (e.g., transfer tokens).
  3. Effects: Update global state (e.g., decrease the caller's balance in a mapping).

In this scenario, the state update occurs after the external call. If the caller is a contract with a fallback function that re-calls the same function, it could bypass the initial check, potentially draining the contract's funds.

You can learn more about reentrancy attacks and how to prevent them in our full guide on reentrancy attacks in Solidity smart contracts.

Reentrancy examples in Solidity

Malicious Reentrancy

contract Vault {

    mapping(address => uint256) private balances;
    event Withdrawal(address indexed user, uint256 amount);
    event Deposit(address indexed user, uint256 amount);

    function deposit() public payable {
        balances[msg.sender] += msg.value;
        emit Deposit(msg.sender, amount);
    }

    function withdraw(uint256 amount) public {

        // CHECKS
        require(amount > 0, "Zero Amount");
        require(amount <= balances[msg.sender], "Insufficient Funds");

        // INTERACTION
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "Transfer Failed");

        // EFFECT
        balances[msg.sender] -= amount;
        emit Withdrawal(msg.sender, amount);
    }
}


contract StealFromVault() {

    IVault public vault;
   
    constructor(address _vault) {
        vault = IVault(_vault);
    }
   
    // Attacker must have non-zero funds in the vault in order to
    // call withdraw to drain all vault funds during the attack.
    function attack() external payable {
        vault.deposit{value: msg.value}();
        vault.withdraw(msg.value);
    }
   
    receive() external payable {
        if (address(vault).balance >= msg.value) {
            vault.withdraw(msg.value);
        }
    }

}

  1. The attacker deposits into the vault creating a non-zero balance for their address.
  2. The attacker requests a withdrawal from the vault.
  3. Inside the attacker’s receive function, they continue to request withdrawals until the vault is completely drained. Their balance is not decremented until all vault funds are stolen because the withdraw function violates the CEI pattern.

Non-Malicious Reentrancy

contract Vault {

    mapping(address => uint256) private balances;
    event Withdrawal(address indexed user, uint256 amount);

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount, address user) public {

        // CHECKS
        require(amount != 0, "Zero Amount");
        require(amount <= balances[user], "Insufficient Funds");

        // EFFECT
        balances[user] -= amount;
        emit Withdrawal(msg.sender, amount);

        // INTERACTION
        (bool success, ) = payable(msg.sender).call{value: amount}(abi.encode(user));
        require(success, "Transfer Failed");
    }

    function viewBalance(address user) public view returns (uint256) {
        return balances[user];
    }
}





contract Protocol {

    Vault public immutable vault;
    mapping(address => uint256) public points;

    constructor(address _vault) {
        vault = Vault(_vault);
    }

    function callWithdraw(uint256 amount) public {
        vault.withdraw(amount, msg.sender);
    }

    function updatePoints(address user) private {
        uint256 balance = vault.viewBalance(user);
        points[user] = balance * 100;
    }

    fallback() external payable {
        require(msg.sender == address(vault), "Not Vault");
        address user = abi.decode(msg.data, (address));
        updatePoints(user);
    }
}

The updatePoints() function is a dummy function to showcase how non-malicious actions can be performed during a callback based on properly updated state. 

  1. User makes a deposit into a vault by directly calling deposit().
  2. User withdraws from the vault through Protocol.callWithdraw()
    1. During the withdraw function execution, funds are sent to the protocol and its fallback is invoked due to the call().
  3. The protocol’s fallback executes its own private function updatePoints() to update the points mapping for the user. 
    1. The alteration of the user’s points is based on the correctly updated state of the user’s balance in the vault, found via vault.viewBalance().

Reentrancy in traditional programming

A function or subroutine is reentrant if it can be safely called again after an external interruption without any related data becoming incorrect and still produce the correct output.

A non-reentrant function produces reentrancy issues in the form of incorrect outputs by interacting with shared data that is manipulated during an interruption. 

Incorrect data is produced when the stack shares data from multiple calls on the same method, and a lower call uses the shared data that has been undesirably altered from a higher call.

C, C++, Java, Go, Rust are some common languages concerned with the concept of reentrancy issues related to non-reentrant functions.

Non-reentrant function in C++

int shared_value = 10;  
  
int non_reentrant_function(int x) {  
	// INTERRUPT HERE
	shared_value += x;
	return shared_value;  
}  

void interrupt_handler() {
    std::cout << "Interrupt: " non_reentrant_function(100) << std::endl;
}
  
int main() {  
	std::cout << "First call: " non_reentrant_function(5) << std::endl; 
	std::cout << "Second call: " non_reentrant_function(5) << std::endl;
	return 0;  
}

  1. main() is called and will accumulate shared_value from 10 to 15 using non_reentrant_function(5), but is first interrupted by the interrupt handler before it can accumulate the value.
  2. non_reentrant_function(100) is called from inside the interruption, which accumulates the shared_value from 10 to 110.
  3. After the interruption finishes, the original call finishes using the altered variable, accumulating it from 110 to 115.
  4. Assuming no other interruptions, the main function finishes another call to non_reetrant_function(5) to accumulate the shared_value to 120.

If there were no interruptions during the main call, the expectation of shared_variable is to accumulate to 20, rather than 120. The non_reetrant_function earns its name because it operates on a global variable that has been altered during an interruption.To create reentrant functions, it’s important to isolate shared variables for each call and pass shared data as parameters. Making a function reentrant improves its safety during interruptions but doesn’t automatically make it truly thread-safe.

Related Terms

No items found.