Silent But Vulnerable: Ethereum Gas Security Concerns

Photo courtesy of Mahkeo

Every transaction sent to the Ethereum blockchain requires a nontrivial amount of work to process. Gas is how that work is measured and paid for. Users tend to think of gas as a confusing annoyance, and developers think of it in terms of optimizing their costs.

As a smart contract auditor, I often think of gas as a potential attack vector. In this post, I’ll examine three ways that gas can lead to security vulnerabilities. The third issue is one that I haven’t seen written about before.

He who sent it spent it

A fundamental truth about transactions is that they’re paid for by the sender—the account that signed the transaction.

This fact can result in an attack vector if:

  1. An attacker can cause you to send a transaction, and
  2. They can cause that transaction to consume a lot of gas.

The first condition isn’t difficult to satisfy. If I own an account at a centralized exchange (e.g. Coinbase), I can instruct the exchange to transfer funds to an account of my choosing. A typical implementation involves the exchange sending a transaction from one of their accounts. Because the exchange is sending the transaction, they are paying for gas.

Satisfying the second condition is easy for a smart contract developer. Smart contracts can run arbitrary code in response to an incoming transaction. The following smart contract burns a bunch of gas when it receives an ether transfer:

pragma solidity 0.5.1;

contract GasBurner {
    uint256 counter;

    function() external payable {
        for (uint256 i = 0; i < 100; i++) {
            counter += 1;
        }
    }
}

The sender of a transaction specifies an upper bound on how much gas will be consumed, known as a gas limit. This limit is often determined automatically by simulating the transaction and seeing how much gas will be consumed. If an exchange does this, they’re vulnerable to being tricked into consuming large quantities of gas.

Further reading

The above code burns the gas pointlessly, but it could be put to a more productive use. Perhaps the attacker’s contract could do some valuable computation with that gas. As Level K recently observed, a great use for excess gas is to use it to mint GasTokens, which can be turned around and sold.

Mitigation

To keep yourself safe from this sort of exploit, make sure that you always set a reasonable gas limit on your transactions.


He who filled it killed it

Because Ethereum’s computing resources are finite, there’s a limit to how much gas can be used in a single block. This is known as the block gas limit. Miners try to pack transactions into a block to get as close as possible to that gas limit because the gas fees are paid to the miner.

At the time of this writing, the block gas limit for the Ethereum main network is about 8,000,000. A transaction that consumes more gas than that cannot be mined at all.

This can become a denial of service attack vector—a way for an attacker to stop a smart contract from being able to function. The following is an example of a vulnerable contract:

pragma solidity 0.5.1;

contract TerribleBank {
    struct Deposit {
        address depositor;
        uint256 amount;
    }
    Deposit[] public deposits;

    function deposit() external payable {
        deposits.push(Deposit({
            depositor: msg.sender,
            amount: msg.value
        }));
    }

    function withdrawAll() external {
        uint256 amount = 0;

        for (uint256 i = 0; i < deposits.length; i++) {
            if (deposits[i].depositor == msg.sender) {
                amount += deposits[i].amount;
                delete deposits[i];
            }
        }

        msg.sender.transfer(amount);
    }
}

If the deposits array gets long enough, it will no longer be possible to call withdrawAll(), because such a transaction wouldn’t fit within a block. An attacker can easily cause this condition by calling deposit() repeatedly until the right array length is reached. This would lock all existing ether in the contract.

It’s also possible to mount a denial of service attack on the entire blockchain by completely filling up blocks with your transactions. As long as those transactions specify a generous enough gas price, rational miners will include the attacker’s transactions at the exclusion of all others.

Futher reading

SWC-128, “DoS With Block Gas Limit,” describes the general class of bugs.

“MadMax: surviving out-of-gas conditions in Ethereum smart contracts” by Grech et al. is a recent academic paper from OOPSLA’18 that attempts to measure how many smart contracts are vulnerable to block gas limit attacks.

The FOMO3D prize, worth millions of dollars, was claimed by someone who successfully mounted a “block stuffing attack.”

Mitigation

Smart contract auditors start sweating as soon as they see a for loop. Avoid them where possible, unless they’re bounded by a small constant number of iterations.

Block stuffing attacks are expensive to mount, so to mitigate them, design your contracts to minimize the financial impact of fixed deadlines. For example, an auction often has a deadline for accepting bids, so a block stuffing attack could prevent people from bidding. Make sure the goods being auctioned off in such a system are not valuable enough that a block stuffing attack becomes financially viable.


He who relayed it paid it

Finally, I’d like to examine a vulnerability class that I haven’t seen described before.

As a workaround for the rule that “he who sent it spent it,” I’ve seen a lot of recent discussion about so-called “meta transactions.” A meta transaction is a lot like a transaction but is able to be relayed by a third party. The third-party relayer is the account actually sending the transaction, so it pays for the gas.

This trick is accomplished via a signature and a proxy contract. The user signs their meta transaction and broadcasts it to the world. Anyone who sees the meta transaction can relay it to the proxy contract. As long as the message contains a valid signature, the proxy contract will execute the specified call. The relayer is often paid (e.g. with a token) to cover their expenses.

The relayer has an unusual attack vector in such a scheme. As the transaction sender, they get to dictate how much gas they provide. By providing too little gas, they can cause the call to fail. This is a problem if they have incentive to do that, as in the following contract:

pragma solidity 0.5.1;

contract IERC20 {
    function transfer(address target, uint256 amount) external returns (bool);
}

contract RelayProxy {
    address owner = msg.sender;
    uint256 nonce = 0;
    IERC20 token = IERC20(0x...);

    function execute(
        address payable target,
        bytes calldata data,
        uint256 _nonce,
        uint8 v,
        bytes32 r,
        bytes32 s
    )
        external
    {
        uint256 startGas = gasleft();

        require(_nonce == nonce, "Bad nonce.");

        bytes32 h = hash(target, data, _nonce);
        require(ecrecover(h, v, r, s) == owner, "Bad signature.");

        (bool success, ) = target.call(data);
        if (success) {
            nonce += 1;
        }

        // pay relayer for consumed gas in tokens
        require(token.transfer(msg.sender, startGas - gasleft()));
    }

    function hash(
        address target,
        bytes memory data,
        uint256 _nonce
    )
        internal
        pure
        returns (bytes32)
    {
        return keccak256(abi.encodePacked(target, data, _nonce));
    }
}

A nonce is used to prevent replay attacks, but note that the nonce is only incremented when the call succeeds. A malicious relayer can, by manipulating the gas limit, cause the call to fail repeatedly. Each time the relayer calls execute, they get paid for whatever gas gets used.

Mitigation

A tempting solution is to increment the nonce regardless of the success or failure of the call, but this opens up a denial of service attack vector. Every time the user broadcasts their meta transaction, a malicious relayer can pick it up and cause it to fail as before. Now that the nonce has been incremented, the user needs to sign a new meta transaction, and the process repeats.

Another tempting solution would be to revert the transaction if the call fails, but this means it’s impossible to compensate relayers when calls fail for legitimate reasons.

The best solution is to directly address the root problem, which is that the relayer is allowed to specify the transaction’s gas limit. To lock this down, be sure to include a gas limit in the signed message and check that it’s being obeyed in the proxy contract. This is the approach taken in Status’s relay proxy.

Christian Lundqvist’s “simple multisig wallet” mitigates this issue primarily by allowing the user to dictate the transaction sender as part of their signed message. This means a malicious relayer can’t get involved in the first place.


Summary

Failing to take proper care with gas can lead to serious smart contract vulnerabilities. When reading code, such vulnerabilities are often invisible, but they really stink.


Thinking about smart contract security? We can provide training, ongoing advice, and smart contract auditing. Contact us.

All posts chevronRight icon

`