top of page
  • FYEO

Denial of Service (DoS) with block GAS limit


Every transaction in Ethereum requires 21000 GAS on top of computations made in the contract. Bulk operations with the array of data allow saving the GAS on transaction execution, but there is a limit of calculation that can be done in one block. When this limit is reached, the transaction will be reverted. If a smart contract is poorly designed, this may lead to a case where it is no longer operable. In this post, we will review such cases and how to resolve them.


Processing of stored array

In most cases, the problem with block GAS limit appears on stored arrays usage. To understand the problem, let's look at a simple example:


contract ExampleDoS {
    address[] private accounts;
 
    function register() external {
        accounts.push(msg.sender);
    }
   
    function distribute() external {
        for (uint256 i = 0; i < accounts.length; ++i) {
            sendRewardToAccount(accounts[i]);
        }
    }
 
    function sendRewardToAccount(address account) internal {
        //...
    }
}

This contract doesn’t restrict the amount of registered accounts and the distribution of rewards is applied to the whole array in one call of the ditribute() function.

Let's assume that each call of the sendRewardToAccount() function costs 100,000 GAS. Given that the GAS block limit in Ethereum mainnet is 30M, distribute() will only work with less than 300 accounts. If there are too many registered accounts, the contract will not be able to do reward distribution.


Solution #1

We can limit the amount of registration, so the array will not grow too large:


    function register() external {
        require(accounts.length < 100, "Too many registrations");
        accounts.push(msg.sender);
    }

This will ensure that the distribute() function will not exceed block GAS limit, but at the same time the contract has lost scalability.


Solution #2

Instead of operating the whole array, we can pass start and end indexes as a parameters:


    function distribute(uint256 start, uint256 end) external {
        require(start < end, "Incorrect indexes");
        require(end <= accounts.length, "Out of bounds");
        for (uint256 i = start; i < end; ++i) {
            sendRewardToAccount(accounts[i]);
        }
    }

We need to verify that those indexes are correct and also there should be an added mechanism to ensure that each element is processed only once.


Solution #3

The contract can automatically detect the amount of GAS left for a function execution and we can build logic upon it:


    uint256 private nextIndex;
   
    function distribute() external {
        uint256 i = nextIndex;
        while (i < accounts.length && gasleft() > 100000) {
            sendRewardToAccount(accounts[i]);
            i++;
        }
        nextIndex = i;
    }

This change will allow split execution onto several transactions, but that brings a new risk - what if between those transactions the array of accounts is changed? When designing such logic, it is important to ensure data immutability.


Logic depends on the length of the array

When a function accepts a huge array of values as a parameter and fails because of DoS, in most cases it can be executed again with a subarray without impact on the logic. The GAS spent on failed transactions will be lost, but the contract will still be operable and correct. However, there are some cases where the logic of the function will change for subarrays:


    function distribute(address[] calldata accounts) external {
        require(accounts.length > 0, "No accounts");
        uint256 rewardValue = totalBalance / accounts.length;
        totalBalance -= rewardValue * accounts.length;
        require(rewardValue > 0, "No reward");
        for (uint256 i = 0; i < accounts.length; ++i) {
            sendRewardToAccount(accounts[i], rewardValue);
        }
    }

In this example, the reward is equally distributed between all accounts and the exact reward is calculated using the length of the given array. When divided on subarrays - all reward will be distributed between accounts of the first subarray.


Solution

Ensure that logic doesn't heavily depend on the array length. Provide additional functions to set “length” dependent parameters.


    function calculateReward(uint256 totalAccounts) external {
        require(rewardValue == 0, "Reward is already set");
        rewardValue = totalBalance / totalAccounts;
        totalBalance -= rewardValue * totalAccounts;
    }

It is required to ensure that preset parameters are valid.


Array elements are processed differently with each call

Another example of problematic DoS is when the state of the contract changes, so each subarray will be processed differently from what it would be if passed as a one array:


contract ExampleDoS {
    struct RegisteredAccount {
        address account;
        uint256 epoch;
    }
 
    RegisteredAccount[] private registeredAccounts;
    uint256 private currentEpoch;
 
 
    function register(address[] calldata accounts) external {
        for (uint256 i = 0; i < accounts.length; ++i) {
            registeredAccounts.push(RegisteredAccount({
                account: accounts[i],
                epoch: currentEpoch
            }));
        }
        ++currentEpoch;
    }
 
    function distribute() external {
        // reward = (currentEpoch - account.epoch) * rewardPerEpoch;
    }
}

In the example above, the amount of reward depends on the epoch of account registration. The automatic update of the contract state limits the amount of accounts that can be registered in one epoch.


Solution #1

Some state variables could be manually updated in a separate function:


    function updateEpoch() external {
        ++currentEpoch;
    }

With this modification the system requires additional actions from external parties.

Solution #2

Update of state variables can be managed by additional function parameters:


    function register(address[] calldata accounts, bool updateEpoch) external {
        //...
        if (updateEpoch) {
            ++currentEpoch;
        }
    }

Conclusion

In this post we reviewed several simple examples with functions that are affected by DoS with GAS block limit. Here are some recommendations that will help to identify and improve processing of big arrays:

  • Stored arrays have a higher probability of being subjected to DoS

  • Calculate the maximum length of the array that can be processed and validate that it is sufficient for business logic

  • If possible, add the ability to execute operations with subarrays

  • Validate that operations on subarrays always produce the same result as operations on the whole array

In big projects, it is much harder to identify the consequences of DoS because they might be scattered between several functions. An audit can help with identifying and remediating all of these potential issues. Ready for an audit or want to learn more about our process? Fill out this form here and we’ll be in touch!

bottom of page