Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Smart contract security audit — 10 DeFi bug classes (accounting desync, access control, incomplete path, off-by-one, oracle, ERC4626, reentrancy, flash loan, signature replay, proxy), pre-dive kill signals (TVL < $500K etc), Foundry PoC template, grep patterns for each class, and real Immunefi paid examples. Use for any Solidity/Rust contract audit or when deciding whether a DeFi target is worth hunting.
.claude/skills/elementalsouls-web3-audit/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 256% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 216% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 274% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 283% | 0% |
| case-15 | ✓→✓ | = Same ✓ | 473% | 0% |
10 bug classes. Pre-dive kill signals. Foundry PoC template. Real paid examples.
> ZKsync lesson: $322M TVL + OZ audit + 750K LOC + 5 sessions = 0 findings. Large well-audited bridges are extremely hard.
max_realistic_payout = min(10% × TVL, program_cap) — if < $10K, skipSoft kill: OZ/ToB/Cyfrin audit on current version + codebase > 500K LOC → expect 40+ hours for maybe 1 finding. Only proceed if bounty floor > $50K AND you have protocol-specific expertise.
Target scoring (go if >= 6/10):
> "Read ALL sibling functions. If vote() has a modifier, check poke(), reset(), harvest(). The missing modifier on the sibling IS the bug."
This single rule explains 19% of all Critical findings.
> #1 Critical bug class — 28% of all Criticals on Immunefi.
Two state variables supposed to stay in sync. One code path updates A but forgets B. Later code reads both and makes decisions based on stale B.
Real Value = A - B
If A updated but B isn't → Real Value appears larger → phantom valueVariant 1: Phantom Yield (Yeet protocol — 35 duplicate reports)
solidityfunction startUnstake(uint256 amount) external { totalSupply -= amount; // decremented BEFORE transfer // aToken.balanceOf(this) still reflects old value // yieldAmount = aToken.balanceOf - totalSupply = phantom yield }
Variant 2: Fast Path Skips State Update (Alchemix V3)
solidityfunction claimRedemption(uint256 tokenId) external { if (transmuter.balance >= amount) { transmuter.transfer(user, amount); _burn(tokenId); return; // EARLY RETURN — cumulativeEarmarked, _redemptionWeight, totalDebt never updated } // Slow path: updates all state vars correctly alchemist.redeem(...); }
Variant 3: Update Happens in Wrong Order (Alchemix)
solidityfunction deposit(uint256 amount) external { _shares = (amount * totalShares) / totalAssets; // calculated BEFORE deposit totalAssets += amount; // assets added AFTER shares calculated → wrong rate }
bash# Find all accounting variables grep -rn "totalSupply\|totalShares\|totalAssets\|totalDebt\|cumulativeReward\|rewardPerShare" contracts/ # Find all early returns in claim/redeem functions grep -rn "\breturn\b" contracts/ -B3 | grep -B3 "if\b" # For each early return: which state updates in normal path are skipped?
> #2 Critical — 19% of Criticals. $953M lost in 2024 alone.
solidityfunction vote(uint256 tokenId) external onlyNewEpoch(tokenId) { // guarded function reset(uint256 tokenId) external onlyNewEpoch(tokenId) { // guarded function poke(uint256 tokenId) external { // NO GUARD → infinite FLUX inflation }
solidityfunction split(uint256 tokenId, uint256 amount) external { _requireOwned(tokenId); // checks if token EXISTS, not if caller OWNS it _burn(tokenId); _mint(msg.sender, amount); // attacker steals tokens they don't own }
solidity// VULNERABLE — non-admin silently gets through: modifier onlyAdmin() { if (msg.sender == admin) { _; // body only executes for admin, but non-admin doesn't revert } } // CORRECT: require(msg.sender == admin, "Not admin"); _;
solidityfunction initialize(address _owner) public { // MISSING: initializer modifier owner = _owner; // anyone can call → become owner } // Fix: constructor() { _disableInitializers(); }
bash# Find sibling function families — do ALL have the same modifier set? grep -rn "function vote\|function poke\|function reset\|function update\|function claim\|function harvest" contracts/ -A2 # Ownership check: existence vs ownership? grep -rn "_requireOwned\|ownerOf\|_isApprovedOrOwner\|_checkAuthorized" contracts/ -B5 # Silent modifiers grep -rn "modifier\b" contracts/ -A8 | grep -B3 "if (" | grep -v "require\|revert" # Uninitialized initializer grep -rn "function initialize\b" contracts/ -A3 grep -rn "_disableInitializers()" contracts/
| Protocol | Payout | Bug | |---|---|---| | Wormhole | $10M | Uninitialized UUPS proxy → anyone calls initialize() | | ZeroLend | n/a | split() uses existence check, not ownership check | | Alchemix | n/a | poke() missing onlyNewEpoch → infinite FLUX inflation | | Parity | $150M frozen | No access control on initWallet() in library |
> #3 Critical — 17% of Criticals.
1. List all state changes in function A (deposit/place/create)
2. List all state changes in function B (withdraw/update/cancel)
3. For each state change in A: does B have the corresponding reverse?
4. For each token transfer in A: does B have the corresponding refund?
If A does X but B doesn't do the reverse of X → BUG.solidityfunction place_order(OrderInput calldata order) external { token.safeTransferFrom(msg.sender, address(this), order.price); // takes tokens orders[orderId] = order; } function update_order(OrderInput calldata updatedOrder) external { // BUG: NO REFUND for sell orders when price decreases → tokens permanently stuck orders[orderId] = updatedOrder; }
solidityfunction swapForETH(uint256 amountIn) external { token.safeTransferFrom(msg.sender, address(this), amountIn); uint256 filled = dex.swap(amountIn); // partial fill possible _refundExcessEth(amountIn - filled); // BUG: refunds ETH only, not ERC20 }
solidityfunction deposit(uint256 assets, address receiver) public override { shares = _deposit(assets, receiver); // includes receipt validation } function mint(uint256 shares, address receiver) public override { assets = convertToAssets(shares); _mint(receiver, shares); // MISSING: _deposit() validation → mints without receiving assets }
bashgrep -rn "function place_\|function create_\|function add_\|function open_" contracts/ -A5 grep -rn "function update_\|function modify_\|function cancel_" contracts/ -A5 grep -rn "safeApprove\b" contracts/ # safeApprove without zero-reset before grep -rn "delete\b" contracts/ -B5 -A5 # delete before operation completes grep -rn "function deposit\|function mint\|function withdraw\|function redeem" contracts/ -A10
> #4 High — 22% of Highs. Single character change. Massive impact.
solidity// VeChain Stargate — post-exit reward drain: function _claimableDelegationPeriods(address delegator) internal view returns (uint256) { if (endPeriod > nextClaimablePeriod) { // BUG: should be >= return 0; // exited users get nothing } return nextClaimablePeriod - lastClaimedPeriod; // rewards for period AFTER exit }
> For every if (A > B): "What happens when A == B?" Is that correct?
> vs >= at period endblock.timestamp == deadline lock or unlock?break with > vs >=i <= array.length (should be i < array.length)>= amount allows exact full withdrawal?bash# Boundaries in comparisons grep -rn "Period\|Epoch\|Round\|Deadline\|period\|epoch\|deadline" contracts/ -A3 | grep "[<>][^=]" # Loop breaks grep -rn "\bbreak\b" contracts/ -B10 # Off-by-one in array access grep -rn "\.length\s*-\s*1\|i\s*<=\s*.*\.length\b" contracts/
> 12% of all reports. Largest individual payouts. $117M Mango, $70M Curve.
solidity// VULNERABLE: (, int256 price,,,) = priceFeed.latestRoundData(); return uint256(price); // If Chainlink node goes down, stale price returned indefinitely // CORRECT: (, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData(); require(block.timestamp - updatedAt <= MAX_PRICE_AGE, "Stale price"); require(price > 0, "Invalid price");
solidity// VULNERABLE: PythStructs.Price memory p = pyth.getPriceUnsafe(priceFeed); return p.price; // ignores p.conf (confidence interval) // CORRECT: require(p.conf * 10 <= uint64(p.price), "Price too uncertain"); // conf > 10% of price = untrustworthy
solidity// VULNERABLE: 60-second TWAP uint32[] memory secondsAgos = new uint32[](2); secondsAgos[0] = 60; secondsAgos[1] = 0; // Flash loan can shift price for entire 60s window // CORRECT: 1800s minimum TWAP (30 min)
solidity// VULNERABLE: only Uniswap spot price uint price = getUniswapSpotPrice(token); // flash loan manipulatable // CORRECT: Chainlink primary, Uniswap TWAP as fallback, require close agreement
bash# Missing staleness check grep -rn "latestRoundData" contracts/ -A5 | grep -v "updatedAt\|timestamp" # Pyth price usage — confidence interval checked? grep -rn "getPriceUnsafe\|getPrice\b" contracts/ -A8 | grep -v "conf\|confidence" # TWAP windows — short TWAP flag grep -rn "secondsAgo\|TWAP\|cardinality" contracts/ -A5
solidity// VULNERABLE — first depositor attack: // 1. Attacker deposits 1 wei → gets 1 share // 2. Attacker donates large amount directly (transfer, not deposit) // 3. Exchange rate: 1 share = (1 + donation) assets // 4. Victim deposits → rounds down to 0 shares → free donation to attacker // CORRECT: virtual shares (OpenZeppelin v4.9+) function _decimalsOffset() internal view virtual override returns (uint8) { return 9; // add 1e9 virtual shares + assets to prevent manipulation }
solidity// VULNERABLE: shares transferred, but lock records stay with original owner // → shares stuck, can't redeem → permanent freeze (Belong pattern) function transfer(address to, uint256 amount) external override { _transfer(msg.sender, to, amount); // moves shares // MISSING: transfer lock record from msg.sender to `to` }
bashgrep -rn "function transfer\|function transferFrom" contracts/ -A15 grep -rn "function deposit\|function mint\|function withdraw\|function redeem" contracts/ -A10
> 2016–present. CEI pattern prevents it. Still found in DeFi.
solidity// VULNERABLE (effects after interaction): function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount); (bool success,) = msg.sender.call{value: amount}(""); // INTERACTION first require(success); balances[msg.sender] -= amount; // EFFECT after → reentrancy window } // CORRECT (CEI — Checks, Effects, Interactions): function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount); // CHECK balances[msg.sender] -= amount; // EFFECT (bool success,) = msg.sender.call{value: amount}(""); // INTERACTION last require(success); }
bash# External calls before state updates grep -rn "\.call{value\|safeTransfer\|transfer(" contracts/ -B10 | grep -v "require\|revert" # Missing nonReentrant modifier on critical functions grep -rn "function withdraw\|function redeem\|function claim" contracts/ -A2 | grep -v "nonReentrant" # Storage slot for reentrancy guard grep -rn "nonReentrant\|ReentrancyGuard\|_notEntered" contracts/
solidity// Attack flow: // 1. Borrow $100M from Aave flash loan // 2. Dump token in Uniswap pool → crash spot price // 3. Protocol reads Uniswap spot → undercollateralized loans accepted // 4. Borrow max against cheap collateral // 5. Repay flash loan, keep profits
bashgrep -rn "getReserves\|getAmountsOut\|slot0\b" contracts/ -A5 # spot price from reserves = manipulatable with flash loan # slot0 = Uniswap V3 spot price = manipulatable
solidity// VULNERABLE: function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external { bytes32 hash = keccak256(abi.encodePacked(owner, spender, value, deadline)); // MISSING: nonce not included → same signature usable multiple times require(ecrecover(hash, v, r, s) == owner); }
solidity// VULNERABLE: signature valid on mainnet AND testnet AND all forks bytes32 hash = keccak256(abi.encodePacked(params)); // MISSING: block.chainid not in hash → works on any chain
bashgrep -rn "ecrecover\|ECDSA\.recover" contracts/ -B20 # Check: does the signed hash include nonce + chainId + contract address? grep -rn "nonce\|_nonces\|nonces\[" contracts/
solidity// Implementation and proxy share storage layout // Proxy slot 0: _owner // Implementation slot 0: _initialized // → writing to _initialized overwrites _owner
solidity// If implementation can be initialized directly → anyone becomes owner of implementation // Attack: call initialize() on implementation contract → call upgradeTo() → replace logic
solidityfunction execute(address target, bytes calldata data) external onlyOwner { target.delegatecall(data); // target is validated, but what if owner is compromised? }
bash# UUPS initialization protection grep -rn "function initialize\b\|_disableInitializers\|initializer" contracts/ # Delegate call grep -rn "delegatecall\b" contracts/ -B3 -A5 # Storage layout — proxy uses EIP-1967 slots? grep -rn "0x360894\|EIP1967\|_IMPLEMENTATION_SLOT" contracts/
solidity// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../src/VulnerableContract.sol"; contract ExploitTest is Test { VulnerableContract target; address attacker = makeAddr("attacker"); address victim = makeAddr("victim"); function setUp() public { // Fork mainnet at specific block vm.createSelectFork("mainnet", BLOCK_NUMBER); // Deploy or load target target = VulnerableContract(TARGET_ADDRESS); // Fund accounts deal(address(token), attacker, INITIAL_BALANCE); deal(address(token), victim, VICTIM_BALANCE); } function test_exploit() public { console.log("Attacker balance before:", token.balanceOf(attacker)); vm.startPrank(attacker); // Step 1: Setup conditions // Step 2: Execute exploit // Step 3: Verify impact vm.stopPrank(); console.log("Attacker balance after:", token.balanceOf(attacker)); assertGt(token.balanceOf(attacker), INITIAL_BALANCE, "Exploit failed"); } }
solidityvm.prank(address) // next call from address vm.startPrank(address) // all calls from address until stopPrank() vm.deal(address, amount) // set ETH balance deal(token, address, amount) // set ERC20 balance vm.warp(timestamp) // set block.timestamp vm.roll(blockNumber) // set block.number vm.createSelectFork("mainnet", blockNumber) // fork mainnet vm.expectRevert(bytes) // next call should revert vm.label(address, "name") // label for trace output vm.assume(condition) // fuzz: discard inputs where false
bash# Run specific test forge test --match-test test_exploit -vvvv # Run with fork forge test --match-test test_exploit -vvvv --fork-url $MAINNET_RPC # Gas report forge test --gas-report # Coverage forge coverage --report summary
meme-coin-audit — When the target is a meme coin / SPL token rather than a DeFi protocol. Workflow primitive: pre-dive kill signals diverge — this skill's "TVL < $500K skip" doesn't apply to meme coins where the rug check (mint authority, freeze authority, LP lock) is the entire audit; route to meme-coin-audit instead.triage-validation — When a contract finding is ready to be filed on Immunefi. Workflow primitive: Immunefi has its own report format, but the impact-validated, chain-end-to-end discipline of triage-validation still applies; run the 7Q gate against the Foundry PoC before submitting.report-writing — When writing the Immunefi report body. Workflow primitive: report-writing's Immunefi template (with Foundry PoC, root cause code snippet, quantified economic impact) is the body skeleton this skill's findings feed into.offensive-osint — When auditing a protocol's off-chain attack surface (frontend, admin API, RPC gateways). Workflow primitive: on-chain audit is this skill's job; any web2 component of the protocol (web-frontend, admin panel, indexer API) routes to offensive-osint for recon.bb-methodology — When deciding whether to dive at all. Workflow primitive: PART 0 of bb-methodology confirms engagement (web3 bug bounty / private audit / smart-contract review); this skill's pre-dive kill signals replace the standard scoring rubric for that engagement type.> Engagement-derived + 2026-specific additions to the vendored foundation. > Wisdom from real authorized engagements + Phase 2 verification across > this repo's 31+ skill-area live tests. The upstream content covers the WHAT; > this layer covers the WHEN-IT-WORKS-vs-WHEN-IT-DOESN'T.
Flash-loan attacks remain top-paid on Immunefi (top 5 in 2024-2026 by bounty). The economic primitive — borrow $50M, manipulate price oracle, drain pool, repay — keeps reappearing because new protocols keep shipping with composability assumptions that don't hold under flash-loaned imbalance.
Reentrancy IS still paying because new protocols keep shipping with ERC-777 / hooks / callbacks. Don't assume the class is dead — the 2023-2025 paid corpus contains 40+ reentrancy bugs against post-Checks-Effects-Interactions codebases (cross-function reentrancy, read-only reentrancy via view functions called during state-mid-flight).
Oracle manipulation: still paid heavily but harder. Most projects use Chainlink price feeds now; the attack target is the SECONDARY oracle most projects also use (TWAP from a low-liquidity Uniswap V2 pair, the protocol's own internal oracle, a stale fallback path). Audit the failover chain, not just the primary feed.
tload/tstore usage for read-after-external-call.Foundry remains the test framework. forge test --gas-report --debug for invariant testing; forge fuzz for property-based testing; forge inspect for storage-layout audits. Slither + Echidna for static + fuzz. Mythril for symbolic execution on smaller contracts. tenderly.co for forking + simulation (best UX for replicating attacks against mainnet state).
For Solana: anchor framework, sealevel-attacks corpus (curated PoCs by anchor maintainers), soteria-sec / sec3 scanner. For Move (Aptos, Sui): move-prover, aptos-cli aptos move test.
For cross-chain: hyperlane and LayerZero each have audit-tooling repos; bridge bugs require simulating both endpoints, not just one.
TVL under $500K isn't worth the audit time unless the bounty floor is high. Audit firm already covered it = low ROI unless you find what they missed — look at the audit-report scope-exclusion section for what they EXPLICITLY didn't audit (oracles, governance, off-chain components, frontend, the admin path).
Multisig signers > 5 + timelock > 48h = low rug-pull risk; if your finding requires team-malicious assumptions, it's low-impact and likely out of scope per Immunefi's "centralization risk" exclusion. Read the program brief — most Immunefi programs explicitly downgrade or reject findings that assume admin malice.
Immunefi requires Foundry PoC. Submission without PoC is auto-rejected. Submission with a PoC that requires manual setup ("first deploy this, then call that") usually gets downgraded — the PoC should be a single forge test invocation that proves the impact, with explicit assertEq on the drained balance / minted token / corrupted state.
Severity claims must use Immunefi's severity matrix exactly; don't invent severities. The matrix gates on direct economic loss percentage of TVL — a critical against a $500K protocol pays differently than a critical against a $500M one. Read the program's specific severity assignment before claiming Critical.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 14,963 | 23,433 | +57% | 1 | 1 | 0% | 2,353 | 8,379 | +256% | 0 | 0 | — |
case-02 | fail→pass | 25,083 | 21,690 | -14% | 1 | 1 | 0% | 2,888 | 9,133 | +216% | 0 | 0 | — |
case-03 | fail→fail | 21,261 | 92,138 | +333% | 1 | 1 | 0% | 2,877 | 8,835 | +207% | 0 | 0 | — |
case-04 | pass→pass | 12,872 | 10,659 | -17% | 1 | 1 | 0% | 2,116 | 8,099 | +283% | 0 | 0 | — |
case-15 | pass→pass | 7,133 | 5,879 | -18% | 1 | 1 | 0% | 1,254 | 7,183 | +473% | 0 | 0 | — |
case-05 | pass→pass | 22,508 | 25,637 | +14% | 1 | 1 | 0% | 1,916 | 8,658 | +352% | 0 | 0 | — |
case-06 | pass→pass | 19,085 | 18,519 | -3% | 1 | 1 | 0% | 2,836 | 9,365 | +230% | 0 | 0 | — |
case-07 | pass→pass | 15,057 | 7,748 | -49% | 1 | 1 | 0% | 2,306 | 7,704 | +234% | 0 | 0 | — |
case-08 | pass→pass | 12,616 | 7,996 | -37% | 1 | 1 | 0% | 1,925 | 7,750 | +303% | 0 | 0 | — |
case-09 | pass→pass | 15,978 | 5,478 | -66% | 1 | 1 | 0% | 2,305 | 7,379 | +220% | 0 | 0 | — |
case-10 | pass→pass | 11,292 | 6,983 | -38% | 1 | 1 | 0% | 1,982 | 7,740 | +291% | 0 | 0 | — |
case-11 | pass→pass | 7,575 | 9,191 | +21% | 1 | 1 | 0% | 1,343 | 7,917 | +490% | 0 | 0 | — |
case-12 | fail→pass | 12,809 | 8,892 | -31% | 1 | 1 | 0% | 2,117 | 7,911 | +274% | 0 | 0 | — |
case-13 | pass→pass | 16,886 | 9,993 | -41% | 1 | 1 | 0% | 2,764 | 8,082 | +192% | 0 | 0 | — |
case-14 | pass→pass | 14,154 | 10,091 | -29% | 1 | 1 | 0% | 2,761 | 8,426 | +205% | 0 | 0 | — |
case-16 | pass→pass | 9,793 | 9,290 | -5% | 1 | 1 | 0% | 1,737 | 7,979 | +359% | 0 | 0 | — |
case-17 | pass→pass | 12,379 | 8,867 | -28% | 1 | 1 | 0% | 1,991 | 7,888 | +296% | 0 | 0 | — |
case-18 | pass→pass | 5,814 | 6,684 | +15% | 1 | 1 | 0% | 983 | 7,522 | +665% | 0 | 0 | — |
case-19 | pass→pass | 14,196 | 13,851 | -2% | 1 | 1 | 0% | 2,509 | 8,578 | +242% | 0 | 0 | — |
case-20 | pass→pass | 14,312 | 12,361 | -14% | 1 | 1 | 0% | 2,269 | 8,517 | +275% | 0 | 0 | — |
case-21 | pass→pass | 17,272 | 18,748 | +9% | 1 | 1 | 0% | 2,687 | 9,179 | +242% | 0 | 0 | — |
case-22 | pass→pass | 11,638 | 3,378 | -71% | 1 | 1 | 0% | 1,496 | 6,867 | +359% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +14 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.