9

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!

  • You can only rely on there being a 2300 gas stipend; you can still call the fallback function, directly, with more gas if you want to. – SteveJaxon Nov 10 '17 at 15:10
  • Hi SteveJaxon, thanks for your reply! By checking the doc, it seems one could use: X.call.gas(21000000000).value(2 ether)() to transfer 2 ether to X with 21G wei gas; or could call X.call.value(2 ether)() to send 2 ether to X with all remaining gas. Thanks for pointing out this. But for the quoted example, using send() should be limited to the 2300 gas stipend, isn't it? How could reentrancy attack still be possible in the example code then, can you please elaborate? – nrek Nov 11 '17 at 8:46
  • 1
    @nrek Gas stipend didn't exist in old versions of solidity, they will pass all remaining gas to the called contract. – Ismael Nov 12 '17 at 13:29
  • @Ismael are you saying the older compiler would treat send() to forward all remaining gas to the called address if it is a contract? Just want to confirm... Thanks! – nrek Nov 13 '17 at 12:06
  • @nrek At EIP 150 they introduced changes to mitigate the recursive call exploit github.com/ethereum/EIPs/blob/master/EIPS/eip-150.md. – Ismael Nov 13 '17 at 14:38
3

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.

  • 1
    Your example and explanation is perfect, much thanks. I don't even know that CALL could just cost 700 gas if carrying no argument or value... Anyway 2300 gas is really limited that it is not even possible to write to storage variable... – nrek Nov 28 '17 at 12:16
  • @nrek FYI. It has been changed ever since: medium.com/chainsecurity/… – lllllllllllll Mar 12 at 11:14
  • @nrek, so the short answer is that even within a transfer, the storage can still be changed. – lllllllllllll Mar 12 at 11:14

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.