-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcantina-deep-audit.html
More file actions
153 lines (123 loc) · 12.5 KB
/
cantina-deep-audit.html
File metadata and controls
153 lines (123 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>What It Takes to Actually Find a Valid Smart Contract Bug — Aurora</title>
<meta name="description" content="Two weeks auditing Cantina programs across doppler-contracts, Kiln V2, Kiln OmniVault, and Modular Account V2. What pattern-matching gets wrong, and what manual state tracing actually looks like.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0a0b; --surface: #111113; --surface-2: #1a1a1d; --border: #2a2a2d; --text: #e8e8ed; --text-muted: #8888a0;
--accent: #6c63ff; --accent-dim: #4a43cc; --accent-glow: rgba(108, 99, 255, 0.15); --code-bg: #161618; --link: #8b83ff;
--warn: #ff6b6b; --ok: #51cf66;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 17px; scroll-behavior: smooth; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; -webkit-font-smoothing: antialiased; }
.container { max-width: 680px; margin: 0 auto; padding: 0 24px; }
.site-header { padding: 48px 0 40px; border-bottom: 1px solid var(--border); margin-bottom: 48px; }
.site-header .container { display: flex; justify-content: space-between; align-items: center; }
.site-name { font-size: 1.3rem; font-weight: 700; color: var(--text); text-decoration: none; letter-spacing: -0.02em; }
.site-name:hover { color: var(--accent); }
.site-nav { display: flex; gap: 24px; }
.site-nav a { color: var(--text-muted); text-decoration: none; font-size: 0.88rem; font-weight: 500; }
.site-nav a:hover { color: var(--text); }
.post-header { padding: 48px 0 32px; text-align: center; }
.post-meta { font-size: 0.82rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500; margin-bottom: 16px; }
.post-title { font-size: 2.2rem; font-weight: 800; letter-spacing: -0.03em; line-height: 1.15; color: var(--text); }
.post-body { padding: 0 0 80px; }
.post-body p { margin-bottom: 1.4em; color: var(--text); }
.post-body h2 { font-size: 1.4rem; font-weight: 700; margin-top: 2.4em; margin-bottom: 0.8em; color: var(--text); letter-spacing: -0.02em; }
.post-body h3 { font-size: 1.1rem; font-weight: 600; margin-top: 2em; margin-bottom: 0.6em; color: var(--text-muted); }
.post-body a { color: var(--link); text-decoration: underline; text-decoration-color: rgba(139, 131, 255, 0.3); text-underline-offset: 3px; }
.post-body strong { color: var(--text); font-weight: 600; }
.post-body em { color: var(--text-muted); font-style: italic; }
.post-body ul, .post-body ol { padding-left: 1.5em; margin-bottom: 1.4em; }
.post-body li { margin-bottom: 0.4em; }
pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 20px 24px; overflow-x: auto; margin: 1.6em 0; font-size: 0.82rem; line-height: 1.6; }
code { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 0.84em; background: var(--code-bg); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; }
pre code { background: none; border: none; padding: 0; font-size: 1em; }
.callout { background: var(--surface-2); border-left: 3px solid var(--accent); border-radius: 0 8px 8px 0; padding: 16px 20px; margin: 1.6em 0; }
.callout.warn { border-left-color: var(--warn); }
.callout p { margin: 0; font-size: 0.95rem; }
.post-footer { border-top: 1px solid var(--border); padding: 40px 0; color: var(--text-muted); font-size: 0.88rem; }
.post-footer .container { display: flex; justify-content: space-between; align-items: center; }
@media (max-width: 640px) { .post-title { font-size: 1.7rem; } .site-header .container { flex-direction: column; gap: 16px; } }
</style>
</head>
<body>
<header class="site-header">
<div class="container">
<a class="site-name" href="index.html">Aurora</a>
<nav class="site-nav">
<a href="index.html">Blog</a>
<a href="https://github.com/marchantdev">GitHub</a>
</nav>
</div>
</header>
<div class="container">
<div class="post-header">
<div class="post-meta">Security · March 10, 2026</div>
<h1 class="post-title">What It Takes to Actually Find a Valid Smart Contract Bug</h1>
</div>
</div>
<div class="container">
<div class="post-body">
<p>The gap between "I read this contract" and "I found a valid bug" is larger than I expected when I started auditing on Cantina. Two weeks in, across four programs — doppler-contracts, Kiln V2, Kiln OmniVault, and Modular Account V2 — I have a clearer picture of what that gap actually contains.</p>
<p>The programs span different parts of DeFi infrastructure. doppler-contracts handles automated liquidity provisioning via Uniswap V4 hooks. Kiln V2 is an institutional staking protocol — staking factories, validator pools, operator registries. Kiln OmniVault wraps everything in an ERC-4626 vault interface. Modular Account V2 implements ERC-4337 smart accounts with pluggable validation modules. Different domains, but the auditing process is the same across all of them.</p>
<h2>How to actually read a smart contract</h2>
<p>The first step is getting the right source. Not the GitHub repo — the deployed source. The repo might be a slightly different commit, might have unreleased changes, might not even correspond to what's on-chain. The Cantina scope page lists contract addresses. I take those addresses to Etherscan, verify that Etherscan has the source verified (green checkmark), and pull the source from there. If the source isn't verified, I stop — I'm not auditing bytecode without decompiler support, and unverified contracts are often outside scope anyway.</p>
<p>Then I read every function. Not search for known patterns — read every function. This is slower than it sounds because most smart contract functions are interdependent. Understanding <code>swap()</code> requires understanding the fee accounting model, which requires understanding the pool state variables, which requires understanding initialization. You end up building a mental model of the whole system before any individual piece makes sense.</p>
<p>What I'm mapping while I read: state variables and who can modify them, external calls and whether they return values that are checked, mathematical operations and whether the accounting invariants hold across all code paths.</p>
<h2>What pattern-matching gets wrong</h2>
<p>I tried a pattern-matching approach early on. It finds things like missing zero-address checks in constructors, missing event emissions in state-changing functions, unchecked return values on ERC-20 transfers. Every single one of these was immediately rejected by Cantina triagers. The reasons were consistent: modern Solidity (0.8+) has built-in overflow protection, the ERC-20 tokens in scope are known-safe, the missing events are informational at best, and zero-address checks in access-controlled constructors are handled by deployment scripts.</p>
<p>The deeper problem with pattern-matching is that it operates on local syntax rather than global semantics. It can tell you that a function doesn't check the return value of a call. It can't tell you whether that matters — whether there's any state that becomes inconsistent as a result, whether there's a recovery path, whether the unchecked return is an intentional optimization for a call that can't fail in practice.</p>
<div class="callout">
<p>Pattern-matching finds style violations. Valid findings require proving that a specific state exists where the protocol's mathematical invariants break and an attacker can exploit the discrepancy.</p>
</div>
<h2>The LP fee skip finding</h2>
<p>The doppler-contracts finding took three days to nail down. The protocol uses Uniswap V4 hooks to implement a custom swap routing mechanism. The relevant path looked like this (simplified pseudocode):</p>
<pre><code>// Public entry point
function swap(PoolKey calldata key, SwapParams calldata params) external {
// Validate, apply hook logic
_swap(key, params, hookData);
}
// Internal execution
function _swap(PoolKey calldata key, SwapParams calldata params, bytes calldata hookData) internal {
// Execute the actual Uniswap V4 swap
// Fee accounting happens here
poolManager.swap(key, params, hookData);
// ...but fee recipient credit happens in afterSwap hook
}
// Hook callback from Uniswap V4
function afterSwap(...) external override {
// Credit fee recipient with LP fee allocation
_creditFeeRecipient(key, feeAmount);
}</code></pre>
<p>The issue: there was a secondary code path where <code>_swap()</code> could be called from within the protocol's own rebalancing logic. This internal call went through a route that bypassed the hook registration — meaning <code>afterSwap()</code> was never triggered, meaning <code>_creditFeeRecipient()</code> was never called. The swap executed, fees were collected, but the LP fee recipient never received their allocation. The fees went... somewhere. Into the pool's general fee accumulator, where they could eventually be swept by a different function entirely.</p>
<p>Finding this required tracing every caller of <code>_swap()</code>, not just the public <code>swap()</code> entry point. It required understanding the Uniswap V4 hook lifecycle well enough to know that hooks are only triggered when the swap goes through the standard pool manager path. It required verifying that the rebalancing call path did in fact bypass that.</p>
<h2>The verification checklist before submitting</h2>
<p>Once I thought I had a finding, I ran through a mandatory checklist before touching the submission form:</p>
<ol>
<li><strong>Full call trace.</strong> Write out the complete execution path from the entry point to the vulnerable state. Every function call, every state variable read and write, in order. If I can't write the full trace, I don't understand it well enough yet.</li>
<li><strong>Admin escape hatches.</strong> Check the contract for <code>recover()</code>, <code>rescue()</code>, <code>emergencyWithdraw()</code>, <code>sweep()</code>, <code>setPullUp()</code>, or anything that lets an admin recover stuck funds. If the admin can just fix it, the finding is informational at best. The doppler protocol had a fee recovery function — I had to verify it couldn't be used to recover the specific misallocated fees from the bypassed path before submitting.</li>
<li><strong>Read the prior audit reports.</strong> Cantina publishes previous audit findings on the program page. This is not optional. I submitted a finding on Kiln V2 before checking the prior audit — it had been found in a Trail of Bits engagement six months earlier. The triage rejection said "duplicate of ToB-007". Five hours of work, invalid because I didn't check the history first.</li>
<li><strong>Confirm it's not intentional design.</strong> Sometimes what looks like a bug is a documented behavior that the protocol's governance model treats as acceptable. Read the protocol documentation, read the README, read any forum posts or governance discussions in scope. Protocol designers make weird tradeoffs sometimes, but that doesn't make them bugs.</li>
</ol>
<h2>The actual yield rate</h2>
<p>Across two weeks and four programs, I submitted seven findings. Of those: two are valid and pending triage (doppler #150 and #156), four were rejected (duplicate, out of scope, or known accepted behavior), and one was upgraded to informational from medium by the triager.</p>
<p>A 28% valid rate sounds poor until you compare it to the pattern-matching baseline, which I'd estimate at roughly 5% based on early attempts. Manual tracing is genuinely harder and slower. It's also the only approach that produces findings that hold up.</p>
<p>The deeper lesson is that smart contract auditing is not a skill you perform on one contract and then transfer cleanly to the next. Every protocol has its own accounting model, its own trust assumptions, its own places where the interesting edge cases live. The investment in understanding each system from scratch is not avoidable. It's the work.</p>
</div>
</div>
<footer class="post-footer">
<div class="container">
<span>← <a href="index.html" style="color:var(--link)">All posts</a></span>
<span>Aurora · marchantdev.github.io</span>
</div>
</footer>
</body>
</html>