Re-entracy attacks in solidity

2024 Apr 26 See all posts


Re-entracy attacks in solidity

Re-entrancy attacks typically occur when a contract calls an untrusted external contract, which then calls back into the original contract before the first execution completed. This can leads to unwanted effects like draining of funds.

Consider this:

function transfer(address to, uint amount) external {
    if (balances[msg.sender] >= amount) {
        balances[to] += amount;
        balances[msg.sender] -= amount;
    }
}
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(msg.sender.call.value(amount)());
    balances[msg.sender] = 0;
}

The withdraw function calls the attacker's fallback function, which makes a call to transfer, allowing to transfer amount to a attacker owned address (to) despite withdraw having made the transfer out. This happens because the state is not updated (balances) and a call to external contract is made, which can then re-enter the original contract, wherein some state invariant is broken.

Most re-entrancy attacks involve send, transfer and call functions. send and transfer are considered safe because they are limited to 2300 gas, which prevents them from calling other smart contracts (though gas limit can be incremented when making a send/transfer). The call function is more vulnerable, which forwards all the gas (63/64th of the gas) and can therefore call other contracts.

There are various ways to prevent this. One way which is typically used is to block SC from calling certain functions in your contract (msg.sender == tx.origin). This means only EOAs can make calls to your contract. Although, this invariant is broken in EIPs based around account abstraction (like 3074). So, apart from being limiting, this is also not a future proof startegy.

Another way to avoid is to use checks-effects-interactions pattern in your smart contracts. First, put in all the assertions, then make the state changes your contract needs (at the end of which invariants are maintained) and then interact with other smart contracts (if needed). This can also help fix the example above, where zero-ing out the balance in withdraw should be done before making the external contract call.

The most comprehensive way seems to be to use a contract storage based "lock" or mutex. The lock turns on when it enters a function (checking that it is off before), and then turns off when function execution is done. This ensures that the function (or the group of functions where the same lock is used) is not re-entered. In fact, OpenZeppelin has a ReentrancyGuard which implements this lock, by providing a nonReentrant modifier. If the chain implements EIP-1553 (transient storage), one can also used ReentrancyGuardTransient which stores the lock values in transient storage (thereby being less expensive).

There's also a "pull payment" pattern, wherein the idea is that the function needed to do the transfer puts it in an escrow account, from which the funds need to be "pulled" out in a separate tx by the user. This is implemented by openzeppelin's PullPayment.


bibliography