Block timestamp manipulation
block.timestamp is set by the block proposer, within limits the protocol enforces. That drift is small, but it is enough to matter for anything with a short window, and it is fatal for anything using the timestamp as a source of randomness.
Vulnerable code
function playLottery() external payable {
require(msg.value == 1 ether);
uint256 rand = uint256(
keccak256(abi.encodePacked(block.timestamp, block.prevrandao))
);
if (rand % 10 == 0) {
payable(msg.sender).transfer(10 ether);
}
}
function endAuction() external {
require(block.timestamp >= start + 30, "too early"); // 30s window
_settle();
}
Why it breaks
The lottery's randomness is derived from values the proposer influences and every caller can read. A contract can compute the same value in the same block and only submit the transaction when it wins, since reverting otherwise costs only gas. The auction's thirty-second window is inside the range a proposer can shift, so whoever proposes the block chooses whether settlement happens now or next block.
Watch the attack run
The same bug as a stepped sequence. Play it, or walk through with the arrow keys.
The fix
// Randomness: use a verifiable source, not chain state.
function requestRandom() external {
requestId = COORDINATOR.requestRandomWords(
keyHash, subId, confirmations, gasLimit, 1
);
}
function fulfillRandomWords(
uint256, uint256[] memory words
) internal override {
_settleLottery(words[0]);
}
// Durations: keep windows long relative to proposer drift.
uint256 constant AUCTION_LENGTH = 3 days;
function endAuction() external {
require(block.timestamp >= start + AUCTION_LENGTH, "too early");
_settle();
}
The invariant
The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.
Timestamps that decide an outcome fall inside the expected block cadence for the chain.
invariant block.timestamp - parent.timestamp
within [min_cadence, max_cadence]
at any block that settles a payout
How to test for it
A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.
function testTimestampDriftDoesNotChangeOutcome() public {
uint256 snap = vm.snapshot();
vm.warp(start + AUCTION_LENGTH);
auction.endAuction();
address winnerA = auction.winner();
vm.revertTo(snap);
vm.warp(start + AUCTION_LENGTH + 12); // one block later
auction.endAuction();
assertEq(auction.winner(), winnerA);
}
Where it has caused real losses
On-chain lotteries and games seeded from block variables have been drained repeatedly, usually by a contract that computes the outcome first and reverts when it would lose. The attack costs almost nothing and can be repeated every block.
What we check during review
The practical rule: timestamps are fine for durations measured in hours or days, unsafe for windows measured in seconds, and never acceptable as randomness. If you need randomness on-chain, use a VRF with a commitment made before the outcome is knowable. And remember that block.timestamp behaves differently on L2s: on some rollups it reflects sequencer time and can be manipulated more freely than on mainnet.
Want us to check your contract for this?
A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.
Found this useful?
We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.
- Reply written by the auditor who read your code
- No sales sequence, no drip campaign, no retargeting
- We’ll tell you if you don’t need a paid audit yet
- Report published only with your written permission