First to clear all 11 challenges
1 full SCH seat · €450
Bonus Challenge / SCH
Review the protocol contracts, then complete Exploit.s.sol.
Unlocked hints
Unlock a hint to view it here.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;
import "./CivicToken.sol";
import "./CivicVault.sol";
import "./CivicCustody.sol";
/**
* Proposals snapshot a member's balance and may be settled immediately.
*/
contract CivicCouncil {
struct Proposal {
uint256 snapshotId;
address payable recipient;
uint256 amount;
uint256 votes;
bool executed;
}
CivicToken public immutable token;
CivicVault public immutable vault;
CivicCustody public immutable custody;
uint256 public proposalCount;
mapping(uint256 => Proposal) public proposals;
constructor(CivicToken tokenAddress, CivicVault vaultAddress, CivicCustody custodyAddress) {
token = tokenAddress;
vault = vaultAddress;
custody = custodyAddress;
}
function propose(address payable recipient, uint256 amount) external returns (uint256 proposalId) {
require(token.balanceOf(msg.sender) > 0, "no voting power");
proposalId = ++proposalCount;
uint256 snapshotId = token.snapshot();
proposals[proposalId] = Proposal({
snapshotId: snapshotId,
recipient: recipient,
amount: amount,
votes: token.balanceOfAt(msg.sender, snapshotId),
executed: false
});
}
function execute(uint256 proposalId) external {
Proposal storage proposal = proposals[proposalId];
require(!proposal.executed, "already executed");
require(proposal.recipient != address(0), "unknown proposal");
require(proposal.votes > token.totalSupplyAt(proposal.snapshotId) / 4, "not enough votes");
proposal.executed = true;
vault.sendPayment(proposal.recipient, proposal.amount);
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;
/**
* A deliberately small ERC20-like civic token with snapshots. The verifier
* seeds only a handful of holders, so a full snapshot is practical here.
*/
contract CivicToken {
string public constant name = "Civic Token";
string public constant symbol = "CVC";
uint8 public constant decimals = 18;
address public immutable owner;
uint256 public totalSupply;
uint256 public lastSnapshotId;
mapping(address => uint256) private _balances;
mapping(uint256 => uint256) private _totalSupplyAt;
mapping(uint256 => mapping(address => uint256)) private _balanceAt;
mapping(address => bool) private _knownHolder;
address[] private _holders;
constructor() {
owner = msg.sender;
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
function mint(address to, uint256 amount) external {
require(msg.sender == owner, "only owner");
totalSupply += amount;
_balances[to] += amount;
_rememberHolder(to);
}
function transfer(address to, uint256 amount) external returns (bool) {
require(_balances[msg.sender] >= amount, "insufficient balance");
_balances[msg.sender] -= amount;
_balances[to] += amount;
_rememberHolder(to);
return true;
}
function snapshot() external returns (uint256 snapshotId) {
snapshotId = ++lastSnapshotId;
_totalSupplyAt[snapshotId] = totalSupply;
for (uint256 index = 0; index < _holders.length; index++) {
address holder = _holders[index];
_balanceAt[snapshotId][holder] = _balances[holder];
}
}
function balanceOfAt(address account, uint256 snapshotId) external view returns (uint256) {
return _balanceAt[snapshotId][account];
}
function totalSupplyAt(uint256 snapshotId) external view returns (uint256) {
return _totalSupplyAt[snapshotId];
}
function _rememberHolder(address account) private {
if (!_knownHolder[account]) {
_knownHolder[account] = true;
_holders.push(account);
}
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;
contract CivicVault {
address public immutable owner;
address public governance;
constructor() {
owner = msg.sender;
}
function setGovernance(address governanceAddress) external {
require(msg.sender == owner, "only owner");
require(governance == address(0), "governance set");
governance = governanceAddress;
}
function sendPayment(address payable receiver, uint256 amount) external {
require(msg.sender == governance, "only governance");
require(address(this).balance >= amount, "insufficient treasury");
(bool sent,) = receiver.call{value: amount}("");
require(sent, "payment failed");
}
receive() external payable {}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;
import "./CivicToken.sol";
interface ICustodyRecipient {
function receiveCustody(uint256 amount) external;
}
contract CivicCustody {
CivicToken public immutable token;
constructor(CivicToken tokenAddress) {
token = tokenAddress;
}
function checkout(uint256 amount) external {
require(msg.sender.code.length > 0, "recipient must be contract");
uint256 balanceBefore = token.balanceOf(address(this));
require(balanceBefore >= amount, "insufficient custody");
token.transfer(msg.sender, amount);
ICustodyRecipient(msg.sender).receiveCustody(amount);
require(token.balanceOf(address(this)) >= balanceBefore, "custody not restored");
}
}
Starter exploit
// Already wired up for you - do NOT add imports or a contract wrapper:
// civicCouncilInstance -> the deployed CivicCouncil contract
// PLAYER_PRIVATE_KEY / PLAYER_ADDRESS -> your key and address
// Write only the code below.
contract TakeoverHelper {
// TODO: implement the exploit.
}
function run() external {
vm.startBroadcast(PLAYER_PRIVATE_KEY);
// TODO: implement the exploit.
vm.stopBroadcast();
}
CTF tour
This is your guided Solidity CTF workspace.
€900 worth · Two ways to win
1 full SCH seat · €450
1 full SCH seat · €450
August 4 to August 11, 2026. Winners are emailed after the event ends. One prize per player.
Sign in to save private notes on contract lines and continue your review later.
This removes it permanently from your private workspace.