DEV Community

Logesh N
Logesh N

Posted on

COMMON VULNERABILITIES: REENTRANCY PART — II

Types of reentrancy:

The reentrancy vulnerability is divided into four different types. They are,

  • Single function reentrancy
  • Cross-function reentrancy
  • Cross-contract Reentrancy
  • Read-only Reentrancy

Single function reentrancy:

  • In single-function reentrancy, the vulnerability occurs within the same function of a contract. This typically happens when the contract performs external calls to other contracts or accounts before completing its internal state changes.
  • An attacker exploits this vulnerability by recursively calling the same function of the contract before the previous call completes, allowing them to manipulate the contract's state unpredictably.
  • Single function reentrancy vulnerability is often the result of improper sequencing of state changes and external calls within a function.
  • 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. For example:
contract Vulnerable {
    mapping (address => uint) private balances;

    function withdraw() public {
      // Effects
      uint amount = balances[msg.sender]; 
      // Interactions   
      (bool success, ) = msg.sender.call.value(amount)("");
      require(success);
      // Additional Effects (this line is vulnerable to reentrancy)
      balances[msg.sender] = 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

In the above code, the withdraw() function is vulnerable to a single function reentrancy attack. An attacker can repeatedly call the withdraw() function before updating the balance, resulting in multiple withdrawals.

Cross-function reentrancy:

  • Cross-function reentrancy involves multiple functions within the same contract. The vulnerability arises when one function makes an external call to another function before completing its own state changes.
  • An attacker can exploit this vulnerability by calling into the same contract from within the externally called function, potentially reentering the original function or other functions within the contract.
  • Cross-function reentrancy vulnerability can be challenging to detect as it involves interactions between different functions within the same contract.
  • This type of attack is more complex and harder to prevent. It occurs when the vulnerable function calls another function that is also vulnerable to reentrancy. For example:
contract Vulnerable {
    mapping (address => uint) private balances;

    function transfer(address to, uint amount) public {

        // Checks
        if (balances[msg.sender] >= amount) {

        // Effects
        balances[to] += amount;
        balances[msg.sender] -= amount;
        }
    }

    function withdraw() public {

        // Checks
        uint amount = balances[msg.sender];

        // Interactions
        (bool success, ) = msg.sender.call.value(amount)("");
        require(success);

        // Effects
        balances[msg.sender] = 0;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • The withdraw function initiates a transfer of funds to the caller using a low-level call.
  • Before the function completes and sets the balance to zero, it does not prevent other functions (such as transfer) from being called.
  • An attacker can exploit this by calling the withdraw function and, during the call, reentering the contract through another function like transfer, taking advantage of the fact that the balance has not yet been set to zero.

Explanation to above Vulnerable contract:

The Vulnerable contract does not strictly adhere to the Checks-Effects-Interactions pattern.

  1. Transfer Function:

    • Checks: The transfer function checks if the sender (msg.sender) has a sufficient balance to perform the transfer. This check ensures that the transfer is only executed when the sender has enough funds.
    • Effects: If the sender has a sufficient balance, the function updates the balances of the sender and the recipient accordingly.
    • Interactions: There is no interactions in transfer function.
  2. Withdraw Function:

    • Checks: The withdraw function retrieves the balance of the caller (msg.sender) and uses it for the withdrawal. However, it does not explicitly check if the balance is greater than zero before initiating the withdrawal, which could lead to unnecessary gas expenditure if the balance is zero.(i.e, amount = 0)
    • Effects: After retrieving the balance, the function initiates an external call using msg.sender.call.value(amount)("") to transfer funds to the caller. Then, it immediately sets the caller's balance to zero.
    • Interactions: The external call to transfer funds is performed before the state change of setting the balance to zero, violating the sequencing principle of the Checks-Effects-Interactions pattern.

In summary, while the transfer function in the Vulnerable contract follows the Checks-Effects-Interactions pattern by performing state changes before interactions, the withdraw function violates this pattern by initiating an external call before completing its internal state changes. This violation exposes the contract to reentrancy vulnerabilities. To mitigate this vulnerability, developers should ensure that all state changes are completed before interacting with external contracts or accounts, following the Checks-Effects-Interactions pattern consistently across all contract functions.

Cross-contract Reentrancy:

  • Cross-contract reentrancy occurs when a contract interacts with external contracts, and the vulnerability lies in the interaction between different contracts.
  • An attacker exploits this vulnerability by recursively calling back into the vulnerable contract from within an external contract before the original call completes, potentially causing unexpected behavior or manipulating the state of the contracts involved.
  • This type of reentrancy vulnerability often arises when contracts trust external contracts to perform certain actions without properly validating or handling the consequences of the interactions.
contract Vulnerable {
    mapping(address => uint256) public balances;

    function withdraw(uint256 amount) public {

        // Checks: Whether the `user` has neccessary amount
        require(balances[msg.sender] >= amount, "Insufficient balance");

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

        // Interactions: External contract call
        ExternalContract externalContract = ExternalContract(msg.sender);
        externalContract.withdraw(amount);

        // Additional effects (this line is vulnerable to reentrancy)
        balances[msg.sender] += amount;
    }
}

contract ExternalContract {
    function withdraw(uint256 amount) external {
        // Some logic
        // Vulnerable to reentrancy attack
        msg.sender.call("");
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the withdraw function of the ExternalContract is vulnerable to reentrancy. When called by the Vulnerable contract, an attacker can exploit this vulnerability to reenter the Vulnerable contract and manipulate the sender's balance.

Read-only Reentrancy:

  • Read-only reentrancy is a specific type of reentrancy vulnerability where the attacker is limited to reading contract state during the reentrant call, without the ability to modify state directly.
  • While read-only reentrancy may seem less severe than other types, it can still lead to information leakage or manipulation of contract behavior based on the observed state.
  • This type of vulnerability may occur when contracts allow external calls to view certain state variables without adequate protection against reentrancy attacks.

In the example below, a banking contract is presented that permits users to deposit and withdraw:

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

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

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

    function withdraw() public nonReentrant {

        // Checks
        uint amount = balances[msg.sender];

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

        // Effects
        balances[msg.sender] = 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

The Bank contract protects itself against reentrancy issues by using OpenZeppelin's ReentrancyGuard. Additionally, a third-party smart contract exists that uses the bank's publicly available balances mapping for its own business logic, e.g. managing shares based on a user's investment in the bank:

contract BankConsumer {
    Bank private bank;

    constructor(address _bank) {
        bank = Bank(_bank);
    }
    function getBalance(address account) public view returns (uint256) {
        return bank.balances(account);
    }
}
Enter fullscreen mode Exit fullscreen mode

The bank's withdraw function is safeguarded against reentrancy. Still, this guard only applies to the contract itself and does not extend to other systems. The execution flow of the external call in withdraw can be taken over by an attacker, who can then craft a smart contract that presents a misleading balance while interacting with other projects.

In this case, BankConsumer is such a project, merely exposing a view function for illustration purposes. Such a view function could be internally utilized elsewhere, e.g. for assigning shares and a check preventing users to liquidate more shares than they own. An outdated balance exposed by the Bank contract can be exploited by designing a malicious receive function:

contract Attacker {
    event Checkpoint(uint256 balance);

    Bank private bank;
    BankConsumer private consumer;

    constructor(address _bank, address _consumer) payable {
        bank = Bank(_bank);
        consumer = BankConsumer(_consumer);
    }

    function attack() public {
        emit Checkpoint(consumer.getBalance(address(this)));
        bank.deposit{value: 1 ether}();
        bank.withdraw();
        emit Checkpoint(consumer.getBalance(address(this)));
    }

    receive() external payable  {
        emit Checkpoint(consumer.getBalance(address(this)));
        // more malicious code here
    }
}
Enter fullscreen mode Exit fullscreen mode

The Attacker contract records Checkpoint events, illustrating that the balances visible to the BankConsumer contract are outdated. When the system is set up in Remix, and the attack function is executed, the following events are logged by the Attacker contract:

[
    {
        "from": "0xE3Ca443c9fd7AF40A2B5a95d43207E763e56005F",
        "topic": "0xde5ae8a37da230f7df39b8ea385fa1ab48e7caa55f1c25eaaef1ed8690f36998",
        "event": "Checkpoint",
        "args": {
            "0": "0",
            "balance": "0"
        }
    },
    {
        "from": "0xE3Ca443c9fd7AF40A2B5a95d43207E763e56005F",
        "topic": "0xde5ae8a37da230f7df39b8ea385fa1ab48e7caa55f1c25eaaef1ed8690f36998",
        "event": "Checkpoint",
        "args": {
            "0": "1000000000000000000",
            "balance": "1000000000000000000"
        }
    },
    {
        "from": "0xE3Ca443c9fd7AF40A2B5a95d43207E763e56005F",
        "topic": "0xde5ae8a37da230f7df39b8ea385fa1ab48e7caa55f1c25eaaef1ed8690f36998",
        "event": "Checkpoint",
        "args": {
            "0": "0",
            "balance": "0"
        }
    }
]
Enter fullscreen mode Exit fullscreen mode

The second Checkpoint event indicates that, during the receive function, the Bank contract still presents a balance of 1 ETH for the Attacker contract address despite the funds having been transferred to the attacker. This outdated information can be manipulated to mislead third-party infrastructure building on the Bank contract, such as BankConsumer.

Another Example:

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

    function viewBalance(address account) external view returns (uint256) {
        // Vulnerable to read-only reentrancy
        return balances[account];
    }
}

contract Attacker {
    Vulnerable vulnerableContract;

    constructor(address vulnerableAddress) public {
        vulnerableContract = Vulnerable(vulnerableAddress);
    }

    function viewAndManipulateBalance(address account) external {
        // Read the balance
        uint256 balance = vulnerableContract.viewBalance(account);

        // Manipulate contract behavior based on the observed balance
        if (balance > 100) {
            // Do something
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the viewBalance function of the Vulnerable contract is vulnerable to read-only reentrancy. An attacker can call this function recursively, observing the balance of multiple accounts, and manipulate the behavior of another contract (Attacker) based on the observed balances.

Conclusion :

Each type of reentrancy vulnerability presents unique challenges and risks, but they all share the common characteristic of allowing attackers to exploit the sequential execution of contract code to their advantage. Solidity developers should be aware of these vulnerabilities and follow best practices, such as using mutex patterns or reentrancy guards, to prevent and mitigate their impact.

To prevent reentrancy attacks, you should avoid making external calls in critical sections of your code, and use the nonReentrant modifier provided by the OpenZeppelin library or implement the Checks-Effects-Interactions pattern.

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

Top comments (0)