📘Understanding Reentrancy
Reentrancy, a term originating from computer science, refers to a situation where a piece of code can be interrupted and run again before its previous execution has completed. In the context of smart contracts and blockchain, a reentrancy attack is a kind of malicious action which allows an attacker to repeatedly call a function of a smart contract before the first function call has ended, potentially draining funds or causing other harm. To fully grasp the complexities of reentrancy, let's unpack this concept step by step.
Smart Contracts and Function Calls
A smart contract is a self-executing contract with the terms of the agreement directly written into lines of code. Ethereum was the first blockchain to introduce smart contracts. These are autonomous scripts deployed on the Ethereum network that automatically execute transactions if certain conditions are met.
Function calls and external interactions
When interacting with smart contracts, external accounts (like a user's wallet) or other smart contracts can trigger functions within the contract.
For example
Let's explore a scenario where we have two smart contracts: ContractA
and ContractB
. In this scenario, ContractA
wants to trigger a function within ContractB
.
Here is what our ContractB
might look like:
ContractB
is straightforward, and it just contains a public variable data
and a function setData
which updates the data
variable.
Next, let's take a look at ContractA
, which will interact with ContractB
.
In ContractA
, we store the address of ContractB
in a variable contractBAddress
. In the callSetDataOnContractB
function, we create an instance of ContractB
using the stored address. Now we can call the setData
function of ContractB
directly from ContractA
.
The key to making this interaction is that ContractA
must have the address of ContractB
. This address is how it knows where to send the function call in the Ethereum network. Moreover, it needs to know the interface of ContractB
(i.e., the function signatures of ContractB
), so it knows what functions it can call and what parameters they require. This is why we can see ContractB
's setData
function being called within ContractA
with the correct number and type of parameters.
This example shows a simple interaction between two contracts, but it demonstrates how one contract can trigger functions within another contract. A function is a piece of code designed to perform a specific task. For instance, a smart contract managing a digital wallet might have functions to deposit or withdraw funds.
One key feature of smart contracts is that they can interact with other smart contracts by calling their functions. Such an interaction is an external call. A function in smart contract A can call a function in smart contract B, causing the code in B to execute.
The Crux of Reentrancy Attacks
Reentrancy attacks exploit the potential vulnerability in the order of operations in smart contracts, particularly when a function calls an external contract and then continues to execute.
In a reentrancy attack, a malicious contract manipulates the control flow of the contract by causing it to execute certain parts of its code multiple times, changing its state in unexpected ways. This occurs when a function of the vulnerable contract, in the midst of its execution, calls another function (possibly external), which in return calls the original function, creating a loop. Example
Let's imagine there is a bank-like contract that allows users to deposit and withdraw Ether. This contract might look something like this:
The VulnerableBank
contract has three main functions:
deposit()
: Allows a user to deposit ether. The ether sent with the transaction (msg.value
) is added to the sender's (msg.sender
) balance.withdraw(uint _amount)
: Allows a user to withdraw ether. It first checks whether the user has sufficient balance. Then, it sends the specified_amount
of ether to the sender. If the ether transfer is successful, it subtracts the_amount
from the sender's balance.getBalance()
: Allows a user to check their balance.
Now, let's say there is a malicious actor who wants to exploit the withdraw
function using a reentrancy attack. They could create a malicious contract that looks like this:
VulnerableBank public vulnerableBank;
: This line declares a state variablevulnerableBank
of the typeVulnerableBank
, which is the contract to be attacked. Thepublic
keyword allows this variable to be read from outside the contract.constructor(address _vulnerableBankAddress) {
: This line defines the constructor function for theMaliciousContract
. The constructor function is called when the contract is deployed. This constructor takes one parameter: the address of theVulnerableBank
contract.vulnerableBank = VulnerableBank(_vulnerableBankAddress);
: This line casts the address_vulnerableBankAddress
to typeVulnerableBank
and assigns it tovulnerableBank
. This allowsMaliciousContract
to interact with theVulnerableBank
contract using this variable.fallback() external payable {
: This line defines the fallback function of the contract. It is marked asexternal
, meaning it can be called from outside the contract, andfunction attack() external payable {
: This line defines a new function namedattack
. It is marked asexternal
, so it can be called from outside the contract, andpayable
, so it can receive Ether.require(msg.value >= 1 ether);
: This line checks if the incoming transaction (the one callingattack
) includes at least 1 ether. If it doesn't, the function will revert and stop executing.vulnerableBank.deposit{value: 1 ether}();
: This line deposits 1 ether into theVulnerableBank
contract by calling itsdeposit
function. Thevalue: 1 ether
syntax specifies that 1 ether should be sent with this function call.vulnerableBank.withdraw(1 ether);
: This line withdraws 1 ether from theVulnerableBank
contract. If theVulnerableBank
contract is vulnerable to reentrancy attacks, this will trigger the fallback function in theMaliciousContract
, potentially starting a cycle of reentrant calls that could drain theVulnerableBank
contract's balance.
Let's dissect the malicious contract and the reentrancy attack step-by-step:
Initialization: The attacker deploys the
MaliciousContract
and provides the address of theVulnerableBank
contract during the deployment (passed as_vulnerableBankAddress
to the constructor).Start of Attack: The attacker triggers the attack by calling the
attack
function and sends along 1 Ether. The function does two things: It first deposits 1 Ether to theVulnerableBank
contract (viavulnerableBank.deposit{value: 1 ether}();
) thereby increasing the attacker's balance in theVulnerableBank
. Next, it attempts to withdraw the same 1 Ether (viavulnerableBank.withdraw(1 ether);
).First Withdrawal Attempt: When the
withdraw
function is called, theVulnerableBank
contract checks whether the attacker has enough balance. Since we've just deposited 1 Ether, this check passes. Next, the contract attempts to send the 1 Ether back to the attacker's contract.Fallback Function: As soon as the
MaliciousContract
receives the Ether, its fallback function is triggered. If there's more than 1 Ether left in theVulnerableBank
contract, it calls thewithdraw
function again.Second Withdrawal Attempt: Now, back in the
VulnerableBank
contract, thewithdraw
function starts its execution again. It checks whether the attacker has enough balance. As the balance has not yet been updated from the previous withdrawal (because the execution hasn't completed yet), this check still sees the original 1 Ether balance and passes. The contract sends another Ether to theMaliciousContract
.Loop Continues: Steps 4 and 5 repeat, causing the fallback function to repeatedly call the
withdraw
function before theVulnerableBank
contract can update its state. This is the loop that drains Ether from theVulnerableBank
contract. Each loop withdraws 1 Ether from theVulnerableBank
and adds it to theMaliciousContract
.End of Attack: The attack continues as long as there's at least 1 Ether left in the
VulnerableBank
contract. Once the balance drops below 1 Ether, the conditional check in the fallback function ofMaliciousContract
fails, breaking the loop. By the end of the attack, theVulnerableBank
is drained of its Ether, and the balance recorded in theVulnerableBank
for the attacker is much higher than what's actually left in the contract.
This example illustrates how reentrancy attacks take advantage of the order of operations in a contract, manipulating the contract to perform actions repeatedly before it can update its state.
Why does this happen?
In Solidity (and many other programming languages), when a function calls another function, control is passed to the called function. This means that until the called function finishes executing and returns, the calling function is paused and waits for the completion of the called function.
This concept is also true when the function is not just a different function within the same contract, but a function from an entirely different contract. So when a contract function calls another contract's function, the control flow is passed to that other contract until the function finishes executing.
In the context of reentrancy attacks, this transfer of control flow becomes significant. If the called function is part of a malicious contract and it manages to call back into the original calling contract before it finishes execution, it can potentially exploit vulnerabilities and manipulate the state of the original contract.
For instance, if the original contract's function was supposed to decrease the balance of an account after it has transferred funds to that account, but the malicious contract calls back into the original contract before it finishes execution, the balance might not be decreased as intended. The malicious contract can continue this loop of calling the original contract's function to drain all the funds.
That's why it's crucial to manage the order of operations in smart contracts correctly, especially in the context of calls to other contracts, to avoid potential reentrancy attacks.
Last updated