Day 70
Code
pragma solidity ^0.4.21;
contract FiftyYearsChallenge {
struct Contribution {
uint256 amount;
uint256 unlockTimestamp;
}
Contribution[] queue;
uint256 head;
address owner;
function FiftyYearsChallenge(address player) public payable {
require(msg.value == 1 ether);
owner = player;
queue.push(Contribution(msg.value, now + 50 years));
}
function isComplete() public view returns (bool) {
return address(this).balance == 0;
}
function upsert(uint256 index, uint256 timestamp) public payable {
require(msg.sender == owner);
if (index >= head && index < queue.length) {
// Update existing contribution amount without updating timestamp.
Contribution storage contribution = queue[index];
contribution.amount += msg.value;
} else {
// Append a new contribution. Require that each contribution unlock
// at least 1 day after the previous one.
require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days);
contribution.amount = msg.value;
contribution.unlockTimestamp = timestamp;
queue.push(contribution);
}
}
function withdraw(uint256 index) public {
require(msg.sender == owner);
require(now >= queue[index].unlockTimestamp);
// Withdraw this and any earlier contributions.
uint256 total = 0;
for (uint256 i = head; i <= index; i++) {
total += queue[i].amount;
// Reclaim storage.
delete queue[i];
}
// Move the head of the queue forward so we don't have to loop over
// already-withdrawn contributions.
head = index + 1;
msg.sender.transfer(total);
}
}
Breakdown
- Contribution is a structure that contains an amount and a timestamp required when unlocking after the passage of 50 years â i.e. a lockup contract storing a withdrawal queue;
- It is worth noting that
contribution
is a dynamic array that takes the queue; FiftyYearsChallenge
imposes an initial requirement that the msg.sender has exactly one ether and the player has contributed and must wait for 50 years to access the fund;upsert
takes the uint256 index and a timestamp, requiring the msg.sender to be the owner. If the index is greater than the head and the index is less than the queue length (remember the contribution above?), then it updates the existing contribution amount without updating timestamp; and- The way the
withdraw
function embeds the 50 year wait feature is by processing all matured withdrawals (evidenced byrequire(now >= queue[index].unlockTimestamp
) by iterating from the index stored in the head storage variable up to the index value passed as an argument ((uint256 i = head; i <= index; i++)
).
Problem
The challenge is complete once the address of the contract is completely drained of funds prior to the 50 years.
Solution
The else
branch of the upsert
function uses an uninitialized storage pointer again as in the Donation challenge. Again, an issue touched on when we discussed proxies.
The storage issue stems from the following:
contribution.amount = msg.value
writes to storage slot 0, wherequeue.length
is stored; and- timestamp is being written to the head variable (slot 1).
It wasnât immediately obvious to me but when queue.push is called (i.e. to add a new contribution), the queueâs length is first incremented then the queue entry is copied. This presents an issue because thequeue
points to storage slot 0 and 1. Provided that storage slot 1, the queueâs length, has been incremented, the queue entryâs amount is actually msg.value + 1, notcontribution.amount = msg.value
. To exploit this, as alluded to above we can call thewithdraw
function with an index where the corresponding queue item has an unlockTimestamp in the past.
This is achieved by three steps: - create a new queue entry that calls
upsert
and choose the timestamp value such that it would overflowqueue[queue.length - 1].unlockTimestamp + 1 days
in a way to equal zero; - call the
upsert
a second time given overflow will occur (i.e. head uint256 will be equal to 0, meaning timestamp = 0); and - withdraw wei after force sending two wei to be entered into the contract (use the script outlined in day 64 changing the value to 2.