Preventing re-entrancy attacks in Solidity

Posted on

Re-entrancy is a vulnerability in smart contracts that can result in unexpected behavior and loss of funds. This vulnerability enables an attacker to execute a function call repeatedly before the original call has completed.

Therefore, re-entrancy can change the contract’s state in unforeseen ways and trigger unintended operations. This can lead to significant security risks, such as loss of funds. There are different ways to prevent these kinds of attacks and mitigate their risks. One widespread method (and one we are going to explain in this post) is the use of libraries—specifically, a modifier imported from a library—that we can include in functions to prevent this type of attack.

In Solidity, a contract can be exploited or inadvertently trigger a re-entrancy attack by calling another contract’s function, which then calls back into the original contract’s function before it finishes executing. It is crucial to understand how to exploit this vulnerability to take the necessary security measures to prevent such attacks from occurring (refer to one of our previous articles for more information on becoming a Solidity developer).

Some code examples

To illustrate this vulnerability and learn how to prevent re-entrancy, let’s consider a simple example. Suppose we have a contract called Bank, which stores a mapping of account balances, and allows users to withdraw their funds through a specific function.

contract Bank {
    mapping (address => uint) public balances;

    function deposit() public payable{
        require(msg.value > 0, "funds needed to set balance");
        balances[msg.sender] += msg.value;
    }

    function withdraw() public {
        require(balances[msg.sender] > 0, "insufficient balance");
        (bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(success, "transfer failed");
        balances[msg.sender] = 0;
    }
}

The way in which the withdraw function works is the following:

  1. First, the require statement checks if the user has funds associated to their account (meaning address) in order to withdraw them.
  2. Then, the msg.sender.call{value: amount} statement sends the corresponding amount of ether to the user’s address.
  3. Finally, the balances[msg.sender] = 0 statement sets the user’s account balance back to 0.

However, this contract is susceptible to a re-entrancy attack. This is because the external call to the user’s address can trigger the execution of a function in another contract, which can then call back into the withdraw function before it has finished executing.

Because the full function code of the original call hasn’t finished at this point, the user’s balance has not been updated yet. And so, the attacker can drain funds past the allowed amount, potentially depleting the entire contract balance.

For example, an attacker can create a malicious contract called Attack, which can look like this:

contract BankAttack {
    Bank bank;

    function setBankContract(address bankContract) public {
        bank = Bank(bankContract);
    }

    fallback () external payable {
        if (address(bank).balance >= 1 ether){
            bank.withdraw();
        }
    }

    function attack() external payable {
        require(msg.value == 1 ether);
        bank.deposit{value: 1 ether}();
        bank.withdraw();
    }
}

The BankAttack contract stores the address for the Bank contract so it can call it, and it also has a fallback function. Fallback functions allow smart contracts to receive native tokens but also execute arbitrary code when this occurs. In this case, the fallback function repeatedly calls the Bank contract’s withdraw function, up until that contract’s balance is less than 1 ether.

The BankAttack contract also has an attack function. When called, 1 ether is first deposited so that the Bank contract registers the attacking contract’s balance as greater than 0, which will allow it to call withdraw.

Then, the first call to the Bank contract’s withdraw function is successful, and it results in the withdrawal of that same ether back to the attacking contract. However, because ether is received, the fallback function in the BankAttack contract is triggered, which calls the Bank contract’s withdraw function again, repeating the cycle. This will continue as long as funds remain, despite them not being associated to the attacking contract address. Thus, this creates a re-entrancy loop, and so funds can be repeatedly withdraw from the Bank contract.

As long as the loop continues, the Bank contract still has the attacking contract address’ balance as 1 ether, because the call cycle continues to occur after the initial deposit, but before the balance is adjusted back to 0, which will only occur after the attack ends. Additionally, with the use of the setBankContract function, the attack can be replicated on other vulnerable contracts.

To prevent this type of attack, the Bank contract needs to implement effective security measures. We can take measures such as applying function modifiers. The nonReentrant modifier can be created and applied to functions to prevent them from being called recursively before they have completed their execution (you can name the modifier whatever you like).

The nonReentrant function modifier

contract Bank {
    mapping (address => uint) public balances;
    bool private _entered;

    modifier nonReentrant {
        require(!_entered, "re-entrant call");
        _entered = true;
        _;
        _entered = false;
    }

    function deposit() public payable{
        require(msg.value > 0, "funds needed to set balance");
        balances[msg.sender] += msg.value;
    }

    function withdraw() public nonReentrant { 
        require(balances[msg.sender] > 0, "insufficient balance");
        (bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(success, "transfer failed");
        balances[msg.sender] = 0;
    }
}

In this new version of the Bank contract, we have added a nonReentrant modifier to the withdraw function to ensure that it can only be executed repeatedly only when it has finished in its entirety. This is achieved by introducing a boolean variable _entered, added to track whether the function is currently being executed or not.

See also  Understanding Phishing with tx.origin in Solidity

If the _entered variable is already true, it means that the function is currently being executed, and any attempts to call it again would result in a revert of the transaction. This prevents re-entrancy attacks.

On the other hand, if the _entered variable is false, it means that the function is not currently being executed, and it is safe to execute the function code. Before doing so, the modifier sets the _entered variable to true to prevent any further recursive calls. It then executes the function code using the _ placeholder, which represents the function code which the modifier wraps. Finally, it sets the _entered variable back to false, allowing the function to be called again after it has finished its execution.

This approach ensures that the function is executed without being re-entered, allowing a safe and reliable execution of its code. By using the nonReentrant modifier, users can safely withdraw funds without the risk of losing them to a malicious attacker. You could re-utilize this modifier and apply it to any number of functions.

This modifier already exists, and is provided by OpenZeppelin as a library. In the next section we will look at how to install it.

How to install OpenZeppelin’s ReentracyGuard

Start by creating a new Solidity contract in your development environment. Let’s call it MyContract.

In the MyContract contract, include the ReentrancyGuard library by adding it to the inheritance list. Your contract should look something like this:

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract MyContract is ReentrancyGuard {
    // contract code goes here
}

Save the file and compile the contract. Then, to use the ReentrancyGuard functionality in your contract, simply apply the nonReentrant modifier to any functions that could be vulnerable to re-entrancy attacks. For example, if you once again have the withdraw function, you can have its signature written like this:

function withdraw() public nonReentrant {
    // function code goes here
}

That’s it! Your contract now includes the ReentrancyGuard library from OpenZeppelin, and any functions with the nonReentrant modifier are protected against re-entrancy attacks.

Checks, Effects, Interactions

In general, it’s a sensible idea to follow a pattern called Checks-Effects-Interactions. It essentially means: perform verifications first (requires, permissions), then update state variables for the contract (such as a balance) and finally interact with external elements like other contracts.

Let’s see how this would prevent the re-entrancy vulnerability without the use of libraries:

function withdraw() public {
    // checks first
    require(balances[msg.sender] > 0, "insufficient balance");
    // effects second
    balances[msg.sender] = 0;
    // interactions last
    (bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
    require(success, "transfer failed");
}

A malicious contract coded as previously shown would not succeed in its attack, even if the function can be re-entered. This is because the sender balance is updated before sending ether to it. When the ether is received by the attacking contract, it can still call withdraw as a fallback action. However, the balance will update after each re-entrant call. This means that even through a chained withdrawal, it can never withdraw more ether than its corresponding balance allows.

The advantage to this approach is that by importing a library (such as OpenZeppelin’s) the contract size increases, since all of the inherited code is written into the contract’s bytecode at compilation time. As a result, a contract written without the library code will be smaller in size just by not having to use the nonReentrant modifier.

The disadvantage to this approach is that its effectiveness depends on the correct application of the CEI pattern, and—just like any code in any language—this is always susceptible to bugs which stem from human error.

Conclusion

In conclusion, malicious actors can exploit the re-entrancy vulnerability, a serious attack vector in Solidity, in order to steal funds from smart contracts. It’s crucial to address this type of attack by taking precautions in your solidity functions. As we mentioned before, there are several ways of preventing the attack, but the use of audited libraries like OpenZeppelin is recommended.

Posted in Blockchain, Ethereum, Smart Contract, Solidity, TechnologiesTagged , , , ,

Martin Liguori
linkedin logo
twitter logo
instagram logo
By Martin Liguori
I have been working on IT for more than 20 years. Engineer by profession graduated from the Catholic University of Uruguay, and I believe that teamwork is one of the most important factors in any project and/or organization. I consider having the knowledge both developing software and leading work teams and being able to achieve their autonomy. I consider myself a pro-active, dynamic and passionate person for generating disruptive technological solutions in order to improve people's quality of life. I have helped companies achieve much more revenue through the application of decentralized disruptive technologies, being a specialist in these technologies. If you want to know more details about my educational or professional journey, I invite you to review the rest of my profile or contact me at martin@infuy.com