Gas Reentrant Attacks – How Fallback Function Causes Reentrancy in Ether Transactions

fallback-functiongasreentrant-attacks

Below is quoted from solidity docs:

pragma solidity ^0.4.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
    /// Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() {
        if (msg.sender.send(shares[msg.sender]))
            shares[msg.sender] = 0;
    }
}

The problem is: Ether transfer always includes code execution, so the recipient could be a contract that calls back into withdraw. This would let it get multiple refunds and basically retrieve all the Ether in the contract.

To avoid re-entrancy, you can use the Checks-Effects-Interactions pattern as outlined further below:

pragma solidity ^0.4.11;

contract Fund {
    /// Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() {
        var share = shares[msg.sender];
        shares[msg.sender] = 0;
        msg.sender.transfer(share);
    }
}

The highlighted part is where I have question: I do understand that in theory reentrancy attack exists, but given the fact that fallback function has 2300 gas limitation (link), which is not enough to make a message call back into current contract. So in reality, how could the reentrancy attack work? Can some expert give an live example? Thanks!

Best Answer

Update

Before Sep 2016 send and transfer pass all available gas to CALL op by default. This was changed in the Solidity compiler in this commit https://github.com/ethereum/solidity/commit/9ca7472089a9f4d8bfec20e9e55c4f7ed2fb502e.

// Provide the gas stipend manually at first because we may send zero ether.

// Will be zeroed if we send more than zero ether.

The above comments mean that if you send 0 ether the compiler will explicitly set the gas to 2300 for the CALL op. Otherwise the compiler sets 0 gas for the CALL op in which case the EVM will add 2300 gas stipend as explained on the Subtleties page.

The code that adds all available gas to the external function call is in ExpressionCompiler::appendExternalFunctionCall():

if (_functionType.gasSet())
    m_context << dupInstruction(m_context.baseToCurrentStackOffset(gasStackPos));
else if (m_context.experimentalFeatureActive(ExperimentalFeature::V050))
    // Send all gas (requires tangerine whistle EVM)
    m_context << Instruction::GAS;
else
{
    // send all gas except the amount needed to execute "SUB" and "CALL"
    // @todo this retains too much gas for now, needs to be fine-tuned.
    u256 gasNeededByCaller = eth::GasCosts::callGas + 10;
    if (_functionType.valueSet())
        gasNeededByCaller += eth::GasCosts::callValueTransferGas;
    if (!existenceChecked)
        gasNeededByCaller += eth::GasCosts::callNewAccountGas; // we never know
    m_context << gasNeededByCaller << Instruction::GAS << Instruction::SUB;
}

The documentation was written in June 2016 https://github.com/ethereum/solidity/commit/2df142c49618138ba7f38f32a76022caecc68abb. Here is the pull request to fix it https://github.com/ethereum/solidity/pull/3197


Previous answer

Here is the transaction trace that demonstrates that it's possible to make a reentrant call: https://rinkeby.etherscan.io/vmtrace?txhash=0x2e77009bda0fc9c07a01a4589d9b426382521c6e04b84008ffda637a4268824f

On step 183 CALL operation is performed which costs 700 gas:

Step PC Operation Gas GasCost Depth

[183] 118 CALL 1189 1182 3

[184] 0 PUSH1 482 3 4

The reason it costs only 700 gas is because no value is passed with the call.

As explained on the Subtleties page in Ethereum's wiki:

CALL has a multi-part gas cost:

  • 700 base
  • 9000 additional if the value is nonzero
  • 25000 additional if the destination account does not yet exist (note: there is a difference between zero-balance and nonexistent!)

The corresponding contracts code:

pragma solidity ^0.4.8;

contract Fund {

    bool mutex = false;

    function withdraw() {
        if (!mutex) {
            mutex = true;
            msg.sender.send(1);
            mutex = false;
        }
    }

    // deposit some funds for testing
    function deposit() payable {}
}

contract Attacker {

    Fund f;

    function Attacker(address fund) payable {
        f = Fund(fund);
    }

    function attack() {
        f.withdraw();
    }

    function () payable {
        f.withdraw();
    }
}

Note that while the original contract in Solidity docs is vulnerable to reentrancy attack it can't be exploited to send value more than once because the 2nd nested send call would deplete the 2300 gas stipend. That's why in my example I had to use the mutex.

Note also that in the DAO hack call method was used which passes all available gas to the nested call unlike send which only passes the 2300 stipend by default.