Level 1
Noob Hacker
SCH Bonus 11
Hostile Takeover
0 / 100 EXP
100 EXP to Junior Hacker
0%
Bonus Challenge / SCH
Goal
- Move all ETH held by CivicVault to the player address.
- Leave CivicCustody's CVC balance unchanged.
Your task
Review the protocol contracts, then complete Exploit.s.sol.
Starting facts
- CivicVault balance: 500 ETH.
- CivicCustody balance: 2,000,000 CVC.
- Execution threshold: More than 25% of the 6,000,000-token supply settles a proposal immediately.
Unlocked hints
Hostile Takeover
0/3
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
Exploit.s.sol
// TODO: implement the exploit.
contract TakeoverHelper {
}
function run() external {
vm.startBroadcast(PLAYER_PRIVATE_KEY);
vm.stopBroadcast();
}
Ready to test your exploit?
The starter code is yours to inspect. Sign in to edit it, run Foundry, and keep every clear.
Contract line
Add audit note
0 / 300
CTF tour
Exploit Challenge
This is your guided Solidity CTF workspace.
Keep your audit trail
Sign in to save private notes on contract lines and continue your review later.
Delete this audit note?
This removes it permanently from your private workspace.