In the rapidly evolving landscape of blockchain technology, understanding the intricacies of smart contract vulnerabilities is crucial. One such vulnerability that has plagued developers is the reentrancy attack. Drawing insights from Owen Thurm's YouTube video, "The Ultimate Guide to Reentrancy," we'll delve into four types of reentrancy attacks:
Classic Reentrancy
Cross Function Reentrancy
Cross Contract Reentrancy
Read-only Reentrancy
Armed with this knowledge, developers can better safeguard their smart contracts and contribute to a more secure blockchain ecosystem.
In a classic reentrancy attack, the order of operations, specifically the sequence of check, effect, and interact, plays a pivotal role. Let's start with a typical example of a contract vulnerable to this type of attack:
function withdraw() external {
(bool sent, ) = payable(msg.sender).call{value: balances[msg.sender]}("");
if (!sent) revert NativeTokenTransfererror();
delete balances[msg.sender];
}
This function is exploitable because the balance is being sent (interaction) before it's being updated (effect). The sequence of operations is disrupted, allowing a malicious contract to exploit the vulnerability. The malicious contract withdraws the balance and then calls the withdraw function again in its receive function:
contract Attack {
function withdraw() external {
vault.withdraw();
}
receive() external payable {
try vault.withdraw() {} catch {}
}
}
To rectify this vulnerability, the effects need to be placed before the interaction. This ensures that the balance is updated before any interaction takes place:
function withdraw() external {
delete balances[msg.sender];
(bool sent, ) = payable(msg.sender).call{value: balances[msg.sender]}("");
}
With the corrected sequence of operations (check, effect, interact), the contract is no longer vulnerable to a classic reentrancy attack. This highlights the importance of following the correct sequence in smart contract development. It's a simple fix that can prevent potentially devastating exploits.
Cross function reentrancy attacks leverage secondary functions within a contract to exploit vulnerabilities. Even when the 'withdraw' function seems secure, implementing the nonReentrant modifier, a secondary 'transfer' function could provide an opportunity for reentrancy.
Consider the following contract:
function withdraw(uint256 amount) external nonReentrant {
uint balance = balances[msg.sender];
require(balance >= amount, "withdraw: not enough funds");
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "withdraw: transfer failed");
balances[msg.sender] = balance - amount;
}
function transfer(address to, uint256 amount) external {
balance[msg.sender] -= amount;
balance[to] += amount;
}
Even though the 'withdraw' function is protected against reentry, it still suffers from the same check-effects-interaction disorder as in our previous example. This problem is exacerbated by the 'transfer' function, which doesn't follow the correct pattern either.
The exploit occurs when the 'transfer' function is called in the 'receive' function of a malicious contract. This action effectively transfers a balance to a new account before the 'withdraw' function's balance update balances[msg.sender] = balance - amount; is executed.
contract Exploit {
constructor(address _owner) {
// this will be the contract that we want to transfer the balance to
owner = _owner
}
function withdraw() external {
vault.withdraw();
}
receive() external payable {
uint256 balance = vault.balance(address(this));
vault.transfer(owner, balance)
}
}
This example underscores the importance of ensuring every function in your contract follows the check-effects-interaction pattern. Even if one function is secure, a secondary function could inadvertently create a vulnerability.
As we scale the complexity of our codebase and start using contracts within contracts, we need to be aware of the potential for cross contract reentrancy. Even with a ReentrancyGuard and nonReentrant modifiers in place on both contracts, vulnerabilities can still arise due to the usage of multiple functions within these intertwined contracts.
Consider the following example:
contract First is ReentrancyGuard {
function executeSwap(uint256 _id) external onlySwapExecutor nonReentrant {
Swap memory swap = ISwapManager(swapManager).pendingSwaps(_id);
ISwapper(swapper).swap(...);
delete pendingSwaps[_id];
}
}
contract Second is ReentrancyGuard {
function createSwap(uint256 _amount, ...) external nonReentrant {
// creates a swap and transfers amount
}
function cancelSwap(uint256 _id) external nonReentrant {
// cancels swap and returns transfer to id
}
}
In this code, both contracts use guards to prevent reentrancy. However, the executeSwap function in the First contract still holds a vulnerability because the effects (deleting pending swaps) occur after the interaction (ISwapper(swapper).swap(...)).
The exploit might look like this:
contract Exploit {
retrieve() external {
swapper.cancelSwap(msg.sender);
}
}
In this case, we create a swap using the createSwap function in the Second contract. When we receive the swapped coin, we call cancelSwap, which returns our original coin back to us. Thus, we end up with both coins, exploiting the reentrancy vulnerability.
This vulnerability could have been avoided by ensuring the correct sequence of operations in the executeSwap function. As these examples show, even contracts with reentrancy guards can be vulnerable if the check-effects-interaction pattern is not adhered to strictly.
A more subtle form of reentrancy attack can emerge from read-only functions that aren't properly updated. At first glance, these functions might seem benign due to their read-only status, but they can indeed become a part of reentrancy exploits if not properly managed.
Consider the following example:
function withdraw(uint256 amount) external {
require(amount > 0, "error message")
require(isAllowedToWithdraw(msg.sender, amount), "error")
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "error");
balances[user] -= amount;
}
function isAllowedToWithdraw(address user, uint256 amount) public view returns (bool) {
return balances[user] >= amount;
}
In this case, the withdraw function does check if the user is allowed to withdraw the requested amount. However, it then proceeds to interact (sending the balance) before updating the effects (decrementing the user's balance).
This ordering, which goes against the check-effects-interaction workflow, creates an exploitable reentrancy vulnerability. Specifically, the line balances[user] -= amount; should be placed before the interaction with the external contract, ensuring that the user's balance is updated before any interactions take place.
Even read-only functions can contribute to these vulnerabilities, demonstrating the critical importance of applying the check-effects-interaction pattern to all aspects of contract design. This is another testament to the importance of careful scrutiny of all code, even code that might initially seem to be immune to traditional contract exploits.
After diving into the various forms of reentrancy attacks and exploring their potential impacts on smart contracts, we can distill some critical lessons from our analysis.
Vigilance with External Calls: Every external call made by a smart contract should be thoroughly examined for potential reentrancy attacks. The fact that an external call is being made implies that control of execution is being handed off, opening the door for potential malicious exploits.
Adherence to Check, Effects, Interaction: To minimize the risk of reentrancy attacks, it is imperative to follow the check-effects-interaction pattern. This means performing any necessary checks first, updating the state of the contract (the "effects") next, and finally, interacting with other contracts.
Guard Modifiers are not Foolproof: While guard modifiers like
nonReentrantcan help prevent reentrancy attacks, they are not a panacea. Contracts can still be exploited if they don't follow the check-effects-interaction pattern or if they have other vulnerabilities.Scrutinize Inter-Contract Interactions: When a contract interacts with another contract, each and every external call should be thoroughly checked. Remember, the complexity of potential exploits grows with the complexity of your contract system.
To systemize the prevention of such attacks, Owen Thurm recommends a three-step approach:
Catalog External Calls: Use a tool like Slither, a static analyzer for Solidity, to create a list of every external call that is made by the system.
Identify Control Knobs: Identify and understand all the ways that an external actor can influence the system during the time when they control execution.
Check State Variables: Look for all state variables that could potentially become outdated, particularly during the execution of external calls.
In conclusion, reentrancy attacks pose a significant risk to smart contracts, but with careful coding practices, rigorous analysis, and an understanding of potential vulnerabilities, it is possible to safeguard against these exploits.
