Reentrancy is the ability of a function or a system to be executed again before the original execution has been completed. It is a broad concept that exists in both traditional (web2) and blockchain (web3) programming.
In Solidity smart contracts, a function is "reentrant" if it can be executed during an external call. While reentrancy attacks are notorious, it's important to note that not all instances of reentrancy are harmful. Some smart contract designs intentionally use reentrancy for specific purposes.
Reentrancy in smart contracts falls into two main categories:
The key distinction between these types lies in proper implementation and adherence to best practices, such as the CEI (Checks-Effects-Interactions) pattern, to prevent unintended state manipulations.
This pattern looks as such:
Consider this example of a vulnerable function structure:
In this scenario, the state update occurs after the external call. If the caller is a contract with a fallback function that re-calls the same function, it could bypass the initial check, potentially draining the contract's funds.
You can learn more about reentrancy attacks and how to prevent them in our full guide on reentrancy attacks in Solidity smart contracts.
contract Vault {
mapping(address => uint256) private balances;
event Withdrawal(address indexed user, uint256 amount);
event Deposit(address indexed user, uint256 amount);
function deposit() public payable {
balances[msg.sender] += msg.value;
emit Deposit(msg.sender, amount);
}
function withdraw(uint256 amount) public {
// CHECKS
require(amount > 0, "Zero Amount");
require(amount <= balances[msg.sender], "Insufficient Funds");
// INTERACTION
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer Failed");
// EFFECT
balances[msg.sender] -= amount;
emit Withdrawal(msg.sender, amount);
}
}
contract StealFromVault() {
IVault public vault;
constructor(address _vault) {
vault = IVault(_vault);
}
// Attacker must have non-zero funds in the vault in order to
// call withdraw to drain all vault funds during the attack.
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw(msg.value);
}
receive() external payable {
if (address(vault).balance >= msg.value) {
vault.withdraw(msg.value);
}
}
}
contract Vault {
mapping(address => uint256) private balances;
event Withdrawal(address indexed user, uint256 amount);
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount, address user) public {
// CHECKS
require(amount != 0, "Zero Amount");
require(amount <= balances[user], "Insufficient Funds");
// EFFECT
balances[user] -= amount;
emit Withdrawal(msg.sender, amount);
// INTERACTION
(bool success, ) = payable(msg.sender).call{value: amount}(abi.encode(user));
require(success, "Transfer Failed");
}
function viewBalance(address user) public view returns (uint256) {
return balances[user];
}
}
contract Protocol {
Vault public immutable vault;
mapping(address => uint256) public points;
constructor(address _vault) {
vault = Vault(_vault);
}
function callWithdraw(uint256 amount) public {
vault.withdraw(amount, msg.sender);
}
function updatePoints(address user) private {
uint256 balance = vault.viewBalance(user);
points[user] = balance * 100;
}
fallback() external payable {
require(msg.sender == address(vault), "Not Vault");
address user = abi.decode(msg.data, (address));
updatePoints(user);
}
}
The updatePoints()
function is a dummy function to showcase how non-malicious actions can be performed during a callback based on properly updated state.
deposit()
.Protocol.callWithdraw()
. call()
.updatePoints()
to update the points
mapping for the user. vault.viewBalance()
.
A function or subroutine is reentrant if it can be safely called again after an external interruption without any related data becoming incorrect and still produce the correct output.
A non-reentrant function produces reentrancy issues in the form of incorrect outputs by interacting with shared data that is manipulated during an interruption.
Incorrect data is produced when the stack shares data from multiple calls on the same method, and a lower call uses the shared data that has been undesirably altered from a higher call.
C, C++, Java, Go, Rust are some common languages concerned with the concept of reentrancy issues related to non-reentrant functions.
int shared_value = 10;
int non_reentrant_function(int x) {
// INTERRUPT HERE
shared_value += x;
return shared_value;
}
void interrupt_handler() {
std::cout << "Interrupt: " non_reentrant_function(100) << std::endl;
}
int main() {
std::cout << "First call: " non_reentrant_function(5) << std::endl;
std::cout << "Second call: " non_reentrant_function(5) << std::endl;
return 0;
}
main()
is called and will accumulate shared_value
from 10 to 15 using non_reentrant_function(5)
, but is first interrupted by the interrupt handler before it can accumulate the value.non_reentrant_function(100)
is called from inside the interruption, which accumulates the shared_value
from 10 to 110.non_reetrant_function(5)
to accumulate the shared_value
to 120.If there were no interruptions during the main call, the expectation of shared_variable
is to accumulate to 20, rather than 120. The non_reetrant_function
earns its name because it operates on a global variable that has been altered during an interruption.To create reentrant functions, it’s important to isolate shared variables for each call and pass shared data as parameters. Making a function reentrant improves its safety during interruptions but doesn’t automatically make it truly thread-safe.