Better Programming

Advice for programmers.

Follow publication

Solidity Smart Contract Security: 4 Ways to Prevent Reentrancy Attacks

Checks, Effects, and Interactions (CEI), Mutex, Pull Payments, and Gas Limits are all effective techniques to prevent reentrancy attacks.

insurgent
Better Programming
Published in
5 min readMay 16, 2022
Photo by Shubham Dhage on Unsplash

Reentrancy is a programming technique in which a function execution is interrupted by an external function call. Within the the logic of the external function call are conditions that allow it to recursively call itself before the original function execution is able to complete. Repeatedly re-entering a process to execute external logic may be desirable in some cases and is not necessarily a bug. However, this technique is not recommended for smart contracts, because it releases the control flow execution to an untrusted contract that may seek to exploit funds. Moreover, anti-reentrant patterns and guards should be used to prevent this type of attack from occurring while executing calls to external contracts.

There are three primary techniques to prevent reentrancy:

  • Checks, Effects, Interactions (CEI)
  • Reentrancy Guard / Mutex
  • Pull Payment

Furthermore, the last technique may be effective, but is not recommended:

  • Gas Limit

Checks, Effects, Interactions

The CEI pattern is a simple and effective method to prevent reentrancy. Checks refer to the truthiness of the conditional. Effects refer to state modifications that result from interaction. Finally, interactions refer to transactions between functions or contracts.

Here is an example of what not to do (interactions before effects):

// contract_A: holds user's fundsfunction withdraw() external {
​ ​ ​​uint userBalance = userBalances[msg.sender];
​ ​ ​​require(userBalance > 0);​ ​ ​​(bool success,) = msg.sender.call{ ​​value: ​userBalance ​​}("");
​ ​ ​​require(success,);
​ ​ ​​userBalances[msg.sender] = 0;
}

Here is the attacker’s receive function:

// contract_B: reentrancy attackreceive() external payable {
​ ​ ​​if (address(contract_A).balance >= msg.value) {
​ ​ ​​​ ​ ​​contract_A.withdraw();
​ ​ ​​}
}

The attacker’s receive function receives the withdraw funds and should just return “success,” but instead checks if contract_A contains more funds. If true, contract_B calls the withdraw function again, recursively, until all the funds are exhausted.

Here is an example of withdraw function using the CEI pattern:

function withdraw() external {
​ ​ ​​uint userBalance = userBalances[msg.sender];
​ ​ ​​require(userBalance > 0);​​​ ​ ​​userBalances[msg.sender] = 0;​ ​ ​​(bool success,) = msg.sender.call{ ​​value: ​userBalance ​​}("");
​ ​ ​​require(success,);
}

By zeroing the user’s account balance in contract_A before transferring the funds to contract_B, the conditional will be false in the withdraw function by the time contract_B launches the reentrancy attack and the execution will revert. As this case highlights, the placement of a single line of code can be the difference between having a major vulnerability and reentrancy security.

Reentrancy Guard / Mutex

A reentrancy guard or mutex (mutually exclusive flag) can be constructed as a function or function modifier, but the logic is simple: a boolean lock is placed around the function call that is vulnerable to reentrancy. The initial state of “locked” is false (unlocked), but it is set to true (locked) immediately before the vulnerable function execution begins and is then set back to false (unlocked) after it terminates.

Here is an example using the withdraw function example from above:

bool internal locked = false;function withdraw() external {
​ ​ ​​require(!locked);
​ ​ ​​locked = true;
​ ​ ​​uint userBalance = userBalances[msg.sender];
​ ​​require(userBalance > 0);
​ ​ ​​(bool success,) = msg.sender.call{ ​​value: ​userBalance ​​}("");
​ ​ ​​require(success,);
​ ​ ​​userBalances[msg.sender] = 0;
​ ​ ​​locked = false;
}

Although this withdraw function does not follow the CEI pattern and is consequently vulnerable to a reentrancy attack, the simple boolean “locked” variable prevents reentrancy because the first require statement will equate to false and revert the transaction.

Pull Payment

This last technique is recommended by Open Zeppelin as the best practice. However, there is a slight trade-off in automation. The pull payment achieves security by sending funds via an intermediary escrow and avoiding direct contact with potentially hostile contracts.

Here, contract funds are sent to an intermediary escrow:

function sendPayment(address user, address escrow) external {
​ ​ ​​require(msg.sender == authorized);
​ ​ ​​uint userBalance = userBalances[user];​ ​ ​​require(userBalance > 0);​ ​ ​​userBalances[user] = 0;​ ​ ​​(bool success,) = escrow.call{ ​​value: ​userBalance ​​}("");
​ ​ ​​require(success,);
}

Here, escrow funds can be pulled by the receiver:

function pullPayment() external {
​ ​ ​​require(msg.sender == receiver);
​ ​ ​​uint payment = account(this).balance;​ ​ ​​(bool success,) = msg.sender.call{ ​​value: payment​ ​​}("");
​ ​ ​​require(success,);
}

By sending funds via an intermediary escrow, the contract funds are protected from a reentrancy attack. The escrow could be subject to reentrancy if it holds funds for multiple accounts, so the CEI pattern and/or reentrancy guard should be implemented where applicable.

Gas Limit

Finally, gas limits can prevent reentrancy attacks, but this should not be considered a security strategy as gas costs are dependent on Ethereum’s opcodes, which are subject to change. Smart contract code, on the other hand, is immutable. Regardless, it is worth knowing the difference between the functions: send, transfer, and call.

Functions send and transfer are essentially the same, but transfer will revert if the transaction fails, whereas send will not.

// transfer will revert if the transaction failsaddress(receiver).transfer(amount);// send will not revert if the transaction failsaddress(receiver).send(amount);

In regard to reentrancy, send and transfer both have gas limits of 2300 units. Using these functions should prevent a reentrancy attack from occurring because this is not enough gas to recursively call back into the origin function to exploit funds.

Unlike send and transfer, a call does not have a gas limit and will forward its gas in order to execute complex multi-contract transactions. Of course, the latter also includes reentrancy attacks.

Conclusion

A successful reentrancy attack can be devastating and possibly drain all the funds in the victim’s contract, so it is important to be aware of potential vulnerabilities and implement effective safeguards.

The CEI pattern should be implemented by default, whether there is a vulnerability or not; it’s simply good practice. Additional security can be accomplished through the use of reentrancy guards and/or pull payments. Finally, gas limits may prevent reentrancy, but should not be considered as a security strategy.

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

insurgent
insurgent

Written by insurgent

DAOist - Mercenary of Moloch Slaying for the Public Good

No responses yet

Write a response