How to protect against a reentrancy attack in Solidity

Share

A reentrancy attack is one of the vulnerabilities we must look out for when writing our smart contract code. It can completely drain our smart contract of funds.

What is a reentrancy attack?

A reentrancy attack occurs when an original contract makes an external call to a malicious contract. This malicious contract has an untrusted code in its fallback function that makes a recursive call back to the original function. This mainly happens when the original contract fails to update the state before sending funds. The malicious contract continuously calls the smart contract until its funds are drained.

Reentrancy attack cycle
Reentrancy attack cycle

From the image above, we observe that the original contract calls the malicious fallback() function in the attacker's contract, which calls back into the original contract while it is still processing. This is the cycle in which a reentrancy attack occurs.

How does a reentrancy attack happen

pragma solidity ^0.5.0;
contract Wallet {
mapping (address => uint) private userBalance;
function withdraw() public {
uint withdrawAmount = userBalance[msg.sender];
(bool success, ) = msg.sender.call.value(withdrawAmount)(""); // An attack can come in at this point
require(success);
userBalance[msg.sender] = 0;
}
}

Explanation

  • Line 4: We create the mapping that gets the userBalance() from the address.

  • Line 5: We create a withdraw() function.

  • Line 7: Get the balance of the sender.

  • Line 8: Sends the specified amount of ETH.

  • Line 10: The balance gets updated after the ETH has been sent.

Writing a smart contract code in this manner makes the smart contract vulnerable and puts it at risk of a malicious or attacker’s contract. The attacker can create a malicious fallback() or receive() functions in their smart contract, which would be executed when it received ETH. This is done by calling the withdraw() function repeatedly since the smart contract containing the withdraw function hasn’t updated the balance of the attacker’s contract at that point to 0. This loop will continuously withdraw funds until the smart contract is drained of all its funds.

Types of reentrancy attacks

Reentrancy attacks in solidity can be classified into the two following major types:

Single-function reentrancy attack

A single-function reentrancy attack occurs when the vulnerable function is the same function the attacker is trying to call repeatedly. This attack is simple and easy to prevent. The previous code example is a single-function reentrancy attack.

Cross-function reentrancy attack

A cross-function reentrancy attack occurs when a vulnerable function shares the same contract with another function that has a desirable effect for the attacker. The code snippet below shows an example of a cross-function reentrancy attack.

Code example

pragma solidity ^0.5.0;
contract Wallet {
mapping (address => uint) private userBalance;
function transfer(address to, uint amount) external {
if (userBalance[msg.sender] >= amount) {
userBalance[to] += amount;
userBalance[msg.sender] -= amount;
}
}
function withdraw() public {
uint withdrawAmount = userBalance[msg.sender];
(bool success, ) = msg.sender.call.value(withdrawAmount)(""); // An attack can come in at this point
require(success);
userBalance[msg.sender] = 0;
}
}

Explanation

The contract contains two functions—transfer() and withdraw().

  • Line 6: We use transfer() to take in two arguments, to() and amount().

  • Line 7: We use an if statement to transfer a specified amount of ETH if the balance of the sender’s address is greater than or equal to the amount specified.

  • Line 8: We update the balance of the receiver's address.

  • Line 9: We update the sender's address after the specified amount has been deducted.

  • Line 13: We use the withdraw() function, which is the same as the single function reentrancy attack code example above.

Here, the attacker’s fallback() function recursively calls the transfer function instead of the withdraw() function. Since the balance has not been set to 0 before it runs this code, the transfer function can transfer a balance that has already been spent. This causes double-spending.

How to protect your contracts from reentrancy attacks

The following are the best practices to follow when writing your smart contract code to prevent reentrancy attacks from occurring.

Simplicity and code reuse

A simple code lowers the chances of your code encountering a bug and helps prevent an unforeseen effect from occurring. Keep the code simple and clean, following the DRY principle. Look through the smart contract code and try to find ways to make it simpler using fewer lines of code. This will help you easily locate any vulnerable area of your code.

Checks effects interactions patterns (CEI)

This pattern is an effective way to prevent reentrancy attacks in a smart contract code. The first step in using this pattern is to perform some checks and verifications in the contract flow. Effects/changes in the state variables of the current contract should be carried out before any interactions with another contract.

Smart contract audit

Auditing smart contract provides an extra layer of security to our code. This reduces the chances of the contract getting attacked. Before deploying our smart contract to the main net, it should be audited.

Setting gas limit

Using both the send() and transfer() functions set the gas limits that can be used in a transaction to 2300 units. This helps prevent a reentrancy attack from occurring because there wouldn’t be enough gas to recursively call back into the vulnerable function to exploit funds.

Note: This should not be considered a security strategy as gas costs are dependent on Ethereum’s opcodes, which are subject to change.

Mutex/reentrancy guard

OpenZeppelin has a reentrancy guard library. The reentrancy guard provides a modifier called nonReentrant() that blocks reentrancy attacks in the function it is applied.

pragma solidity ^0.5.0;
contract Reentrancy {
mapping (address => uint) private userBalance;
bool private locked;
modifier nonReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
function withdraw() public nonReentrant() {
uint withdrawAmount = userBalance[msg.sender];
(bool success, ) = msg.sender.call.value(withdrawAmount)("");
require(success);
userBalance[msg.sender] = 0;
}
}

Explanation

  • Line 7: We create a boolean locked and make it private.

  • Line 8: We create a nonReentrant() modifier that locks the withdraw() function after a single transfer.

When we apply the nonReentrant() modifier to the withdraw() function that was stated earlier, it prevents reentrancy. This is because the first required statement will equate to false and revert the transaction. With this, the attacker is no longer able to exploit the withdraw function with a recursive call. Moreover, when the modifier is applied to the cross-function reentrancy attack, it locks the function when that attacker tries to call it repeatedly.