A guide to reentrancy: abusing the external calls for fun and profit

The reentrancy vulnerability is one of most serious ones that can be found in solidity smart contracts. Apart from the “classic” reentrancy like the one from the infamous DAO hack, there are other types of this vulnerability. In this article, we describe them along with some popular strategies on how they can be exploited.  

What is reentrancy and what are its types?

Reentrancy is one of the most popular solidity vulnerability. Probably the best known form of it is the case of the loudest attack of that type in crypto history – the DAO hack. A simplified example of a “most basic reentrancy” is presented below:

In this article however, we would like to focus on more advanced forms of reentrancy and scenarios for their exploitation. The “more advanced” means that the exploitation scenario will not be as straightforward anymore, but will require some creativity and connecting some puzzles among the vulnerable smart contract. First, let’s start with how it is even possible that we can hijack the execution flow of a smart contract?

EVM Execution flow

Reentrancies are somewhat natural consequence of how EVM executes the contracts. Any function is executed from top to bottom.

On bytecode level, the EVM’s underlying Program counter that keeps track of what’s executed next (equivalent of Intel’s EIP) – it iterates over subsequent instructions defined by the contract bytecode. Executing each opcode burns gas from the total gas amount provided for the transaction, and once an opcode is executed program counter is incremented and another opcode comes.

On function level, each line of code is executed one by one, but if EVM encounters a call to another contract, then the following happens:

  • The address where the execution should be restored (the address of next opcode after the call to external function) is saved on the stack – similarly to x86 return address
  • The program counter is set to address of the first instruction in the external contract function
  • The function then is being executed further, if there are another external calls, then it proceeds in similar way; let’s now assume there are no more external calls
  • The external contract function has no more instructions to be executed
  • The execution flow is restored back to the first contract, to the saved address
  • Execution continues

Note, that the above does not necessarily means that always reentrancy has to utilize raw ether transfer to be present. It can as well happen with function operating on an non-standard ERC-20 token which has a callback function (or e.g. an ERC-777 which supports it natively)

During this execution flow, if a function reverts on the way, the whole transaction reverts. However if an external function just returns false, it is ok and no revert happens. The reason for revert may be out of gas, error etc. but if it happens, the whole transaction is undone. This is what an auditor should be careful of when conducting this type of attack for sake of a PoC.

Also, there can be state updates on the way. Note, that the state might be shared among functions. We will use this information later on.

Also, since the execution flow goes from top to the bottom, and executes any cross-contract or cross-function calls, then these calls are executed before the original function is completed. So if a storage variable is shared among them, then they might benefit (or rather suffer) from the un-updated state.

There are however some countermeasures which can be used against reentrancies, and from auditor standpoint, make exploitation of reentrancies more difficult. In order to prevent reentrancies, two main defense lines described below can be used. It is important to understand them before we jump into detecting reentrancy issues.

The Check Effect Interaction pattern

It is an architectural best practice, and it is the foundation of preventing reentrancy issues. Check-Effect-Interaction means that any code that changes the state of a contract, especially if using external calls should:

  • First, check if the conditions for the action are met (e.g. if user has sufficient balance)
  • Second, apply the effect on the contract state, e.g. decrease the balance
  • And only then execute the call to other function/contract etc.

Placing the call to external (out of current function) resource in the end of function makes sure, that it will not be possible to hijack the execution in order to omit some important state updates. A well used CEI pattern prevents reentrancies. However, especially in very complex contracts, it might not always be possible to strictly follow this pattern, in the same time opening up possibility of reentrancy vulnerabilities to arise.

Reentrancy guard

One of main defense lines against reentering a function is to use a reentrancy guard. There is a renowned implementation of such created and maintained by OpenZeppelin (https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol). It’s logic is pretty straightforward. In the beginning it checks whether a variable “entered” has a value. Since it’s first iteration, it does not have any value so the function proceeds. In the end it sets the entered value. If the execution flow goes normally, nothing happens. However, if one tries to reenter that function, the entered variable will be already set, so the transaction will revert. A simple but very effective lock.

Past best practices

In some older resources you can still find a recommendation to use transfer() or send() to send ether instead of using call() because they forward so little gas, the the potential reentrancy will fail after just a few iterations. However, it is not valid currently since using these functions might open up other issues, namely users might not be able to complete regular actions due to too few gas being forwarded – you can read more about these functions here. (This is why some past remediation advices used to contain suggestion to use send or transfer). Security is sometimes a double-edged sword.

Finding reentrancy issues with Slither

Slither has some helpful utility to find that kind of reentrancy issues. Let’s run slither on intentionally vulnerable contract from Dapp University.

Using slither on that contract we could get some hints on possible reentrancy vulnerabilities. However, how do we know if its exploitable or not? Again, we should use the same questions:
·         Is there CEI violation? Yes, that’s what slither reports – external calls before state change
·         Are there any state variables written after the external call? Yes, the balance change
·         Can we control the external call? Yes, because it calls function on msg.sender, so if it’s a contract, it can contain an exploit logic.

Since the potential exploit would ne very similar to the simplified example above, developing it is left as exercise for the reader.

The reentrancy gadgets

If we already have following conditions:

  • The CEI is broken at some function
  • The contract makes unsafe external calls
  • The state change is performed after the external call

The most straightforward scenario would include simply reentering the function over and over (e.g. making multiple withdrawals before the balance is decreased). However this might not be possible, for example if nonReentrant modifier is used. However, this does not necessarily prevent the reentrancies.

If we cannot reenter the same function again, we could now find some “gadgets” to exploit the reentrancy. The term gadgets is referring to web2 exploitation scenarios including piece of codes, like in building deserialization exploits or ROP-chains.

What will be these gadgets? Typically any other function that reuses the state that is changed after the external call. Since during the reentry the shared state might have a bogus, unexpected value, any other function that reuses it might be used as a gadget to chain it with the reentrancy exploit. Consider following scenario described below.

Cross-function reentrancy with slither

Let’s say we have following contract:

pragma solidity 0.8.13;

abstract contract ReentrancyGuard {
    bool internal locked;

    modifier nonReentrant() { //this could be imported from OpenZeppelins implementation, but for the exercise we use the simplified example
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
}


pragma solidity 0.8.13;

contract VulnerableBank is ReentrancyGuard {
mapping (address => uint256) private accountBalances;

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

	function transferToAccount(address _to, uint256 _amount) external {
	    if (accountBalances[msg.sender] >= _amount) {
	       accountBalances[_to] += _amount;
	       accountBalances[msg.sender] -= _amount;
	    }
	}

	function withdrawAllFromAccount() external nonReentrant {  // is it secure?
	    uint256 balance = getAccountBalance(msg.sender);
	    require(balance > 0, "Insufficient balance");

	    (bool success, ) = msg.sender.call{value: balance}("");
	    require(success, "Failed to send Ether");

	    accountBalances[msg.sender] = 0;
	}

	function getBankBalance() external view returns (uint256) {
	    return address(this).balance;
	}

	function getAccountBalance(address _account) public view returns (uint256) {
	    return accountBalances[_account];
	}

}

We can save all that code as one file Contract.sol and audit using slither Contract.sol. The result:

From initial analysis, we can infer that:

  • CEI is broken in function withdrawAllFromAccount()
  • Unsafe external call is made
  • We can potentially control that call, because either the function uses user-supplied address (e.g. of token, so user can supply a malicious token / token with callback)
  • Or the call does something on msg.sender, which means, the attacker could be user-controlled contract with specially crafted function.

And what’s the difference in this case vs the previous example?

There is a nonReentrant modifier which will not allow us to reenter that function. Applying the reentrancy exploitation logic it looks further like this:

  • Is there CEI violation? Yes, that’s what slither reports – external calls before state change
  • Are there any state variables written after the external call? Yes, the balance change
  • Can we control the external call? Yes, because it calls function on msg.sender, so if it’s a contract, it can contain an exploit logic.
  • We cannot reenter the function! So is it secure?

Slither gives us this info in above output, which we should pay special attention to:

What does it mean? Simply that state variable accountBalances (line 1) is used in these functions listed below, so the auditor should also check if maybe instead of reverting to the same function it could be possible to call another one, which will be affected by not updated state variable?

In this case, for example, instead of calling and reentering withdrawAllFromAccount, one can call transferToAccount, which still will not have updated variable accountBalances and will not be protected by nonReentrant modifier (which is per function).

Summarizing, the reentrancy vulnerabilities are more than only the infamous uncontrolled ether call. When auditing a smart contract, special attention should be paid to any break of CEI pattern with consideration what “gadgets” could be used to turn that into a reentrancy, which may require to combine several function calls to achieve a PoC for a vulnerability. Also note, that reentrancy does not necessarily have to always be repeatable infinite times – sometimes, even a double action (e.g. double withdraw) could have a devastating impact, especially when anyone can leverage flash loans to gain infinite leverage in vulnerable atomic operations.

References

Article, News & Post

More Post

A guide to reentrancy: abusing the external calls for fun and profit

The reentrancy vulnerability is one of most serious ones that can be found in solidity smart contracts. Apart from the “classic” reentrancy like the one from the infamous DAO hack, there are other types of this vulnerability. In this article, we describe them along with some popular strategies on how

Ethereum signatures for hackers and auditors 101

In real world you can sign documents using your personal signature, which is assumed to be unique and proves that you support, acknowledge or commit something. The same can be done on ethereum blockchain and in solidity smart contracts – but using cryptography. In this article, we will briefly explain

Common proxy vulnerabilities in Solidity – part 2

In the previous part, we explained some of typical proxy issues related to initialization, lack of state update or frontrunning. In this part, we would like to talk a bit about function and storage conflicts and also about decentralization. Proxy function clashing This vulnerability is unlikely to be found unless

Automated auditing part 2 – usage of AI for Smart Contracts testing

Introduction Creating a project or solution from scratch is a difficult and time-consuming process. A business concept must first be developed, then it must be translated into a high-level solution architecture, and finally the software development stage takes place. Because we will be focusing on hypothetical smart contracts in this

Common proxy vulnerabilities in Solidity part 1

Proxies are used to implement upgradeability in Solidity smart contracts. They serve as a middleman between a contract and its users. They are employed to change a contract’s logic without altering its address. A master copy contract and a proxy contract that refers to the master copy are how proxies

Echidna - Wikipedia

Automated auditing part 1 – fuzzing with Echidna

What is Echidna? In this part, we will cover the very basics of Echidna usage. Echidna is an animal, but it is also the name of a Solidity fuzzer. This tool is really worth mastering since a skilled user can be able to test a smart contract with it in