- Published on
Capture the Ether (RareSkills Repo) - Retirement Fund - Solution
- Authors
- Name
- Marco Besier, Ph.D.
- @marcobesier
Capture the Ether (RareSkills Repo) - Retirement Fund - Solution
Contract
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract RetirementFund {
uint256 startBalance;
address owner = msg.sender;
address beneficiary;
uint256 expiration = block.timestamp + 520 weeks;
constructor(address player) payable {
require(msg.value == 1 ether);
beneficiary = player;
startBalance = msg.value;
}
function isComplete() public view returns (bool) {
return address(this).balance == 0;
}
function withdraw() public {
require(msg.sender == owner);
if (block.timestamp < expiration) {
// early withdrawal incurs a 10% penalty
(bool ok, ) = msg.sender.call{
value: (address(this).balance * 9) / 10
}("");
require(ok, "Transfer to msg.sender failed");
} else {
(bool ok, ) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer to msg.sender failed");
}
}
function collectPenalty() public {
require(msg.sender == beneficiary);
uint256 withdrawn = 0;
unchecked {
withdrawn += startBalance - address(this).balance;
// an early withdrawal occurred
require(withdrawn > 0);
}
// penalty is what's left
(bool ok, ) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer to msg.sender failed");
}
}
Solution
This challenge is conveniently solved via Remix. First, deploy the contract from Remix's default account. Set player
to the address of that default account so that it becomes the beneficiary
.
Next, observe that collectPenalty
contains a vulnerable unchecked
block. Suppose we can somehow achieve startBalance < address(this).balance
. In that case, withdrawn
will underflow, pass require(withdrawn > 0)
, and, therefore, allow us to transfer the contract's entire balance to our account by calling collectPenalty
.
Unfortunately, the contract does not implement:
- a
fallback
function - a
receive
function - any
payable
functions (except the constructor which is restricted to receiving exactly 1 ether)
Therefore, one could naively assume that it is not possible to send any more ether to the contract to achieve startBalance < address(this).balance
.
However, we can force-send ether by calling the selfdestruct
instruction on another contract containing funds, and specifying the RetirementFund
as the target!
Thus, we can carry out our attack as follows:
- Deploy a second contract (see below) with an initial balance of 1 wei.
- Call
forceSend
, specifyingRetirementFund
's address as the target. This will lead tostartBalance < address(this).balance
on theRetirementFund
contract since the new values will bestartBalance == 1 ether
andaddress(this).balance == 1 ether + 1 wei
- Call
collectPenalty
. BecausestartBalance < address(this).balance
,withdrawn
will underflow, passrequire > 0
, and result in a transfer of the contract's entire balance to our account.
NOTE: At the time of writing, selfdestruct
has been deprecated. The underlying opcode will eventually undergo breaking changes. Therefore, this solution might no longer be valid depending on when you're reading this.
// SPDX-License-Identifier: UNLICENSE
pragma solidity ^0.8.0;
contract ForceSend {
// This constructor is payable, allowing the contract to be deployed with 1 wei.
constructor() payable {}
// Function to force-send Ether to a target address.
function forceSend(address payable target) external {
// The selfdestruct function sends all remaining Ether and destroys the contract.
selfdestruct(target);
}
}