web3crit-scanner 7.0.1
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/README.md +685 -0
- package/bin/web3crit +10 -0
- package/package.json +59 -0
- package/src/analyzers/control-flow.js +256 -0
- package/src/analyzers/data-flow.js +720 -0
- package/src/analyzers/exploit-chain.js +751 -0
- package/src/analyzers/immunefi-classifier.js +515 -0
- package/src/analyzers/poc-validator.js +396 -0
- package/src/analyzers/solodit-enricher.js +1122 -0
- package/src/cli.js +546 -0
- package/src/detectors/access-control-enhanced.js +458 -0
- package/src/detectors/base-detector.js +213 -0
- package/src/detectors/callback-reentrancy.js +362 -0
- package/src/detectors/cross-contract-reentrancy.js +697 -0
- package/src/detectors/delegatecall.js +167 -0
- package/src/detectors/deprecated-functions.js +62 -0
- package/src/detectors/flash-loan.js +408 -0
- package/src/detectors/frontrunning.js +553 -0
- package/src/detectors/gas-griefing.js +701 -0
- package/src/detectors/governance-attacks.js +366 -0
- package/src/detectors/integer-overflow.js +487 -0
- package/src/detectors/oracle-manipulation.js +524 -0
- package/src/detectors/permit-exploits.js +368 -0
- package/src/detectors/precision-loss.js +408 -0
- package/src/detectors/price-manipulation-advanced.js +548 -0
- package/src/detectors/proxy-vulnerabilities.js +651 -0
- package/src/detectors/readonly-reentrancy.js +473 -0
- package/src/detectors/rebasing-token-vault.js +416 -0
- package/src/detectors/reentrancy-enhanced.js +359 -0
- package/src/detectors/selfdestruct.js +259 -0
- package/src/detectors/share-manipulation.js +412 -0
- package/src/detectors/signature-replay.js +409 -0
- package/src/detectors/storage-collision.js +446 -0
- package/src/detectors/timestamp-dependence.js +494 -0
- package/src/detectors/toctou.js +427 -0
- package/src/detectors/token-standard-compliance.js +465 -0
- package/src/detectors/unchecked-call.js +214 -0
- package/src/detectors/vault-inflation.js +421 -0
- package/src/index.js +42 -0
- package/src/package-lock.json +2874 -0
- package/src/package.json +39 -0
- package/src/scanner-enhanced.js +816 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
const BaseDetector = require('./base-detector');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enhanced Reentrancy Detector
|
|
5
|
+
* Uses control flow and data flow analysis instead of simple pattern matching
|
|
6
|
+
* Tracks cross-function reentrancy and validates exploitability
|
|
7
|
+
*/
|
|
8
|
+
class ReentrancyEnhancedDetector extends BaseDetector {
|
|
9
|
+
constructor() {
|
|
10
|
+
super(
|
|
11
|
+
'Reentrancy Vulnerability (Enhanced)',
|
|
12
|
+
'Advanced reentrancy detection using control/data flow analysis',
|
|
13
|
+
'CRITICAL'
|
|
14
|
+
);
|
|
15
|
+
this.cfg = null;
|
|
16
|
+
this.dataFlow = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Override detect to use CFG and data flow analysis
|
|
21
|
+
*/
|
|
22
|
+
async detect(ast, sourceCode, fileName, cfg, dataFlow) {
|
|
23
|
+
this.cfg = cfg;
|
|
24
|
+
this.dataFlow = dataFlow;
|
|
25
|
+
this.sourceCode = sourceCode;
|
|
26
|
+
this.fileName = fileName;
|
|
27
|
+
this.sourceLines = sourceCode.split('\n');
|
|
28
|
+
this.findings = [];
|
|
29
|
+
|
|
30
|
+
if (!cfg || !dataFlow) {
|
|
31
|
+
// Fallback to basic detection if advanced analysis not available
|
|
32
|
+
return super.detect(ast, sourceCode, fileName);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Analyze each function for reentrancy
|
|
36
|
+
for (const [funcKey, funcInfo] of cfg.functions) {
|
|
37
|
+
this.analyzeFunctionReentrancy(funcKey, funcInfo);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return this.findings;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Analyze a function for reentrancy vulnerabilities
|
|
45
|
+
*/
|
|
46
|
+
analyzeFunctionReentrancy(funcKey, funcInfo) {
|
|
47
|
+
// Skip private/internal functions (not directly exploitable)
|
|
48
|
+
if (funcInfo.visibility === 'private' || funcInfo.visibility === 'internal') {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check for classic reentrancy: external call before state write
|
|
53
|
+
const classicReentrancy = this.detectClassicReentrancy(funcInfo);
|
|
54
|
+
if (classicReentrancy) {
|
|
55
|
+
this.reportClassicReentrancy(funcInfo, classicReentrancy);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check for cross-function reentrancy
|
|
59
|
+
const crossFunction = this.detectCrossFunctionReentrancy(funcKey, funcInfo);
|
|
60
|
+
if (crossFunction) {
|
|
61
|
+
this.reportCrossFunctionReentrancy(funcInfo, crossFunction);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check for read-only reentrancy
|
|
65
|
+
const readOnly = this.detectReadOnlyReentrancy(funcKey, funcInfo);
|
|
66
|
+
if (readOnly) {
|
|
67
|
+
this.reportReadOnlyReentrancy(funcInfo, readOnly);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Detect classic reentrancy: external call before state modification
|
|
73
|
+
*/
|
|
74
|
+
detectClassicReentrancy(funcInfo) {
|
|
75
|
+
const externalCalls = funcInfo.externalCalls;
|
|
76
|
+
const stateWrites = funcInfo.stateWrites;
|
|
77
|
+
|
|
78
|
+
// Check if any state write happens after an external call
|
|
79
|
+
for (const call of externalCalls) {
|
|
80
|
+
for (const write of stateWrites) {
|
|
81
|
+
if (call.loc && write.loc) {
|
|
82
|
+
// Compare positions: if call comes before write, it's vulnerable
|
|
83
|
+
if (this.comesBefore(call.loc, write.loc)) {
|
|
84
|
+
// Verify this is actually exploitable
|
|
85
|
+
if (this.isExploitable(funcInfo, call, write)) {
|
|
86
|
+
// Adjust confidence based on call type
|
|
87
|
+
// transfer/send have 2300 gas stipend but aren't fully safe post-Istanbul
|
|
88
|
+
const isLimitedGas = call.type === 'transfer' || call.type === 'send';
|
|
89
|
+
return {
|
|
90
|
+
call: call,
|
|
91
|
+
write: write,
|
|
92
|
+
severity: isLimitedGas ? 'HIGH' : 'CRITICAL',
|
|
93
|
+
confidence: isLimitedGas ? 'MEDIUM' : 'HIGH'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Detect cross-function reentrancy
|
|
106
|
+
* Function A makes external call, allowing reentrant call to Function B
|
|
107
|
+
* which modifies shared state
|
|
108
|
+
*/
|
|
109
|
+
detectCrossFunctionReentrancy(funcKey, funcInfo) {
|
|
110
|
+
if (funcInfo.externalCalls.length === 0) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Find other public/external functions in same contract that modify state
|
|
115
|
+
const contractFunctions = Array.from(this.cfg.functions.values())
|
|
116
|
+
.filter(f => f.contract === funcInfo.contract)
|
|
117
|
+
.filter(f => f.visibility === 'public' || f.visibility === 'external')
|
|
118
|
+
.filter(f => f.name !== funcInfo.name);
|
|
119
|
+
|
|
120
|
+
for (const otherFunc of contractFunctions) {
|
|
121
|
+
// Check if other function modifies state that this function depends on
|
|
122
|
+
const sharedState = this.findSharedStateVariables(funcInfo, otherFunc);
|
|
123
|
+
|
|
124
|
+
if (sharedState.length > 0) {
|
|
125
|
+
// Check if this function lacks reentrancy protection
|
|
126
|
+
if (!this.hasReentrancyGuard(funcInfo) && !this.hasReentrancyGuard(otherFunc)) {
|
|
127
|
+
return {
|
|
128
|
+
vulnerableFunc: funcInfo,
|
|
129
|
+
reentrantFunc: otherFunc,
|
|
130
|
+
sharedState: sharedState,
|
|
131
|
+
severity: 'CRITICAL',
|
|
132
|
+
confidence: 'MEDIUM'
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Detect read-only reentrancy
|
|
143
|
+
* External call in a view/getter allowing state inconsistency
|
|
144
|
+
*
|
|
145
|
+
* HARDENED: Only report if:
|
|
146
|
+
* 1. Function is used by external protocols (has dependent contracts)
|
|
147
|
+
* 2. State read is for value calculation (balances, shares, prices)
|
|
148
|
+
* 3. High confidence of actual exploitation path
|
|
149
|
+
*
|
|
150
|
+
* We skip low-confidence read-only reentrancy as it rarely leads to direct fund theft
|
|
151
|
+
* and creates noise. The Curve LP oracle attack was specific to cross-contract composition.
|
|
152
|
+
*/
|
|
153
|
+
detectReadOnlyReentrancy(funcKey, funcInfo) {
|
|
154
|
+
// Skip low-value read-only reentrancy detection to reduce noise
|
|
155
|
+
// Read-only reentrancy requires:
|
|
156
|
+
// 1. External contract depending on this view function
|
|
157
|
+
// 2. View function reading state that can be manipulated mid-call
|
|
158
|
+
// 3. External contract making financial decisions based on stale view
|
|
159
|
+
//
|
|
160
|
+
// Without cross-contract analysis, we cannot reliably detect this.
|
|
161
|
+
// Keeping this disabled to optimize for precision over recall.
|
|
162
|
+
//
|
|
163
|
+
// For protocols that need this, recommend manual audit of view functions
|
|
164
|
+
// called by external protocols (oracles, composability).
|
|
165
|
+
|
|
166
|
+
// Only report CRITICAL cases: view/pure function making external calls
|
|
167
|
+
// This is a code smell that violates the view/pure guarantee
|
|
168
|
+
if (funcInfo.stateMutability === 'view' || funcInfo.stateMutability === 'pure') {
|
|
169
|
+
if (funcInfo.externalCalls.length > 0) {
|
|
170
|
+
// Check if any external call could trigger reentrancy to state-modifying function
|
|
171
|
+
const hasCallWithValue = funcInfo.externalCalls.some(c =>
|
|
172
|
+
c.type === 'call' || c.type === 'delegatecall'
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (hasCallWithValue) {
|
|
176
|
+
// Only report if function name suggests it's used for pricing/valuation
|
|
177
|
+
const funcNameLower = funcInfo.name.toLowerCase();
|
|
178
|
+
const isPricingFunction = /price|value|balance|amount|rate|convert|preview|quote|get.*assets|get.*shares/i.test(funcNameLower);
|
|
179
|
+
|
|
180
|
+
if (isPricingFunction) {
|
|
181
|
+
return {
|
|
182
|
+
func: funcInfo,
|
|
183
|
+
calls: funcInfo.externalCalls,
|
|
184
|
+
severity: 'HIGH',
|
|
185
|
+
confidence: 'MEDIUM'
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Do NOT report low-confidence read-only reentrancy (state read after external call)
|
|
193
|
+
// This pattern is too common and rarely exploitable without cross-contract context
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check if reentrancy is actually exploitable
|
|
199
|
+
* Returns object with exploitability details for severity adjustment
|
|
200
|
+
*/
|
|
201
|
+
isExploitable(funcInfo, call, write) {
|
|
202
|
+
// Check 1: Has reentrancy guard?
|
|
203
|
+
if (this.hasReentrancyGuard(funcInfo)) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check 2: Is function publicly accessible?
|
|
208
|
+
if (funcInfo.visibility === 'private' || funcInfo.visibility === 'internal') {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Does external call allow reentrancy?
|
|
213
|
+
// Note: Post-Istanbul (EIP-1884), transfer/send with 2300 gas stipend
|
|
214
|
+
// are less reliable due to increased SLOAD costs. While they provide
|
|
215
|
+
// some protection, they should not be considered fully safe.
|
|
216
|
+
// We still report these but with adjusted confidence.
|
|
217
|
+
if (call.type === 'transfer' || call.type === 'send') {
|
|
218
|
+
// Return true but caller should adjust confidence to MEDIUM
|
|
219
|
+
// These have gas limits but are not guaranteed safe post-Istanbul
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Find state variables modified by both functions
|
|
228
|
+
*/
|
|
229
|
+
findSharedStateVariables(func1, func2) {
|
|
230
|
+
const shared = [];
|
|
231
|
+
|
|
232
|
+
const writes1 = new Set(func1.stateWrites.map(w => w.variable));
|
|
233
|
+
const writes2 = new Set(func2.stateWrites.map(w => w.variable));
|
|
234
|
+
|
|
235
|
+
for (const varName of writes1) {
|
|
236
|
+
if (writes2.has(varName)) {
|
|
237
|
+
shared.push(varName);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return shared;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if function has reentrancy protection
|
|
246
|
+
*/
|
|
247
|
+
hasReentrancyGuard(funcInfo) {
|
|
248
|
+
// Check modifiers
|
|
249
|
+
for (const modName of funcInfo.modifiers) {
|
|
250
|
+
const modKey = `${funcInfo.contract}.${modName}`;
|
|
251
|
+
const modInfo = this.cfg.modifiers.get(modKey);
|
|
252
|
+
|
|
253
|
+
if (modInfo) {
|
|
254
|
+
// Check if modifier implements reentrancy guard pattern
|
|
255
|
+
const hasLockCheck = modInfo.requireStatements.some(stmt =>
|
|
256
|
+
stmt.includes('_status') ||
|
|
257
|
+
stmt.includes('locked') ||
|
|
258
|
+
stmt.includes('_notEntered')
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
if (hasLockCheck) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Check modifier name
|
|
266
|
+
const modNameLower = modName.toLowerCase();
|
|
267
|
+
if (modNameLower.includes('nonreentrant') ||
|
|
268
|
+
modNameLower.includes('lock') ||
|
|
269
|
+
modNameLower.includes('guard')) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Check if location A comes before location B in source code
|
|
280
|
+
*/
|
|
281
|
+
comesBefore(locA, locB) {
|
|
282
|
+
if (!locA || !locB) return false;
|
|
283
|
+
|
|
284
|
+
if (locA.start.line < locB.start.line) {
|
|
285
|
+
return true;
|
|
286
|
+
} else if (locA.start.line === locB.start.line) {
|
|
287
|
+
return locA.start.column < locB.start.column;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Report classic reentrancy finding
|
|
295
|
+
*/
|
|
296
|
+
reportClassicReentrancy(funcInfo, vuln) {
|
|
297
|
+
this.addFinding({
|
|
298
|
+
title: 'Classic Reentrancy Vulnerability',
|
|
299
|
+
description: `Function '${funcInfo.name}' performs external ${vuln.call.type} before updating state variable '${vuln.write.variable}'. This allows attackers to reenter the function and exploit the stale state. EXPLOITABLE: No reentrancy guard detected.`,
|
|
300
|
+
location: `Contract: ${funcInfo.contract}, Function: ${funcInfo.name}`,
|
|
301
|
+
line: vuln.call.loc ? vuln.call.loc.start.line : 0,
|
|
302
|
+
column: vuln.call.loc ? vuln.call.loc.start.column : 0,
|
|
303
|
+
code: this.getCodeSnippet(vuln.call.loc),
|
|
304
|
+
severity: vuln.severity,
|
|
305
|
+
confidence: vuln.confidence,
|
|
306
|
+
exploitable: true,
|
|
307
|
+
recommendation: 'CRITICAL FIX REQUIRED: Move state updates before external calls (Checks-Effects-Interactions pattern) OR add nonReentrant modifier from OpenZeppelin ReentrancyGuard.',
|
|
308
|
+
references: [
|
|
309
|
+
'https://consensys.github.io/smart-contract-best-practices/attacks/reentrancy/',
|
|
310
|
+
'https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard'
|
|
311
|
+
]
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Report cross-function reentrancy finding
|
|
317
|
+
*/
|
|
318
|
+
reportCrossFunctionReentrancy(funcInfo, vuln) {
|
|
319
|
+
this.addFinding({
|
|
320
|
+
title: 'Cross-Function Reentrancy Vulnerability',
|
|
321
|
+
description: `Function '${funcInfo.name}' makes external calls without reentrancy protection, allowing reentrant calls to '${vuln.reentrantFunc.name}' which modifies shared state: ${vuln.sharedState.join(', ')}. This is exploitable even without classic reentrancy pattern.`,
|
|
322
|
+
location: `Contract: ${funcInfo.contract}, Functions: ${funcInfo.name} <-> ${vuln.reentrantFunc.name}`,
|
|
323
|
+
line: funcInfo.node.loc ? funcInfo.node.loc.start.line : 0,
|
|
324
|
+
column: funcInfo.node.loc ? funcInfo.node.loc.start.column : 0,
|
|
325
|
+
code: this.getCodeSnippet(funcInfo.node.loc),
|
|
326
|
+
severity: vuln.severity,
|
|
327
|
+
confidence: vuln.confidence,
|
|
328
|
+
exploitable: true,
|
|
329
|
+
recommendation: 'Add nonReentrant modifier to ALL public/external functions that modify state or make external calls. Cross-function reentrancy requires contract-wide protection.',
|
|
330
|
+
references: [
|
|
331
|
+
'https://github.com/pcaversaccio/reentrancy-attacks#cross-function-reentrancy',
|
|
332
|
+
'https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard'
|
|
333
|
+
]
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Report read-only reentrancy finding
|
|
339
|
+
*/
|
|
340
|
+
reportReadOnlyReentrancy(funcInfo, vuln) {
|
|
341
|
+
this.addFinding({
|
|
342
|
+
title: 'Read-Only Reentrancy Risk',
|
|
343
|
+
description: `Function '${funcInfo.name}' makes external calls and then reads state. While this function doesn't modify state, it can return inconsistent data if reentered, potentially affecting other contracts that depend on it.`,
|
|
344
|
+
location: `Contract: ${funcInfo.contract}, Function: ${funcInfo.name}`,
|
|
345
|
+
line: vuln.call?.loc ? vuln.call.loc.start.line : 0,
|
|
346
|
+
column: vuln.call?.loc ? vuln.call.loc.start.column : 0,
|
|
347
|
+
code: this.getCodeSnippet(vuln.call?.loc || funcInfo.node.loc),
|
|
348
|
+
severity: vuln.severity,
|
|
349
|
+
confidence: vuln.confidence,
|
|
350
|
+
exploitable: false,
|
|
351
|
+
recommendation: 'If this function is used by other contracts for critical decisions, add reentrancy protection. Consider using snapshot-based reads or reentrancy guards.',
|
|
352
|
+
references: [
|
|
353
|
+
'https://chainsecurity.com/curve-lp-oracle-manipulation-post-mortem/'
|
|
354
|
+
]
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
module.exports = ReentrancyEnhancedDetector;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
const BaseDetector = require('./base-detector');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unprotected Selfdestruct Detector (Hardened)
|
|
5
|
+
*
|
|
6
|
+
* Only reports CRITICAL findings with concrete fund-theft impact:
|
|
7
|
+
* - Unprotected selfdestruct (anyone can destroy and steal funds)
|
|
8
|
+
* - Weak access control on selfdestruct (bypassable protection)
|
|
9
|
+
*
|
|
10
|
+
* Does NOT report:
|
|
11
|
+
* - Properly protected selfdestruct (admin-only with valid checks)
|
|
12
|
+
* - Informational "selfdestruct present" notices
|
|
13
|
+
*/
|
|
14
|
+
class UnprotectedSelfdestructDetector extends BaseDetector {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(
|
|
17
|
+
'Unprotected Selfdestruct',
|
|
18
|
+
'Detects exploitable selfdestruct calls that enable fund theft',
|
|
19
|
+
'CRITICAL'
|
|
20
|
+
);
|
|
21
|
+
this.reportedLocations = new Set(); // Dedupe by location
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async detect(ast, sourceCode, fileName, cfg, dataFlow) {
|
|
25
|
+
this.reportedLocations = new Set();
|
|
26
|
+
return super.detect(ast, sourceCode, fileName, cfg, dataFlow);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
visitFunctionDefinition(node) {
|
|
30
|
+
// Store current function context for nested checks
|
|
31
|
+
// NOTE: Do NOT call this.traverse() here - base class handles traversal
|
|
32
|
+
this.currentFunction = node;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
visitFunctionCall(node) {
|
|
36
|
+
const code = this.getCodeSnippet(node.loc);
|
|
37
|
+
const line = node.loc ? node.loc.start.line : 0;
|
|
38
|
+
|
|
39
|
+
// Check for selfdestruct or suicide (deprecated)
|
|
40
|
+
if (code.includes('selfdestruct(') || code.includes('suicide(')) {
|
|
41
|
+
// Dedupe: only report once per line
|
|
42
|
+
const locationKey = `${this.fileName}:${line}`;
|
|
43
|
+
if (this.reportedLocations.has(locationKey)) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.reportedLocations.add(locationKey);
|
|
47
|
+
|
|
48
|
+
this.checkSelfdestructCall(node, code, line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
checkSelfdestructCall(node, code, line) {
|
|
53
|
+
const functionContext = this.currentFunction;
|
|
54
|
+
const functionName = functionContext?.name || 'fallback/receive';
|
|
55
|
+
const visibility = functionContext?.visibility || 'public';
|
|
56
|
+
|
|
57
|
+
// Skip private/internal functions (not directly exploitable)
|
|
58
|
+
if (visibility === 'private' || visibility === 'internal') {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check access control quality
|
|
63
|
+
const accessAnalysis = this.analyzeAccessControl(functionContext, code);
|
|
64
|
+
|
|
65
|
+
// Determine if user controls recipient (increases severity)
|
|
66
|
+
const userControlledRecipient = this.isUserControlledRecipient(code);
|
|
67
|
+
|
|
68
|
+
if (accessAnalysis.level === 'none') {
|
|
69
|
+
// CRITICAL: Completely unprotected selfdestruct
|
|
70
|
+
this.addFinding({
|
|
71
|
+
title: 'Unprotected Selfdestruct',
|
|
72
|
+
description: `Function '${functionName}' contains selfdestruct without ANY access control. ` +
|
|
73
|
+
`Any user can destroy the contract and ${userControlledRecipient ? 'redirect all funds to their address' : 'steal all funds'}.\n\n` +
|
|
74
|
+
`Attack: Simply call ${functionName}() to destroy contract and extract ${userControlledRecipient ? 'funds to attacker address' : 'all ETH'}.`,
|
|
75
|
+
location: `Function: ${functionName}`,
|
|
76
|
+
line: line,
|
|
77
|
+
column: node.loc ? node.loc.start.column : 0,
|
|
78
|
+
code: code,
|
|
79
|
+
severity: 'CRITICAL',
|
|
80
|
+
confidence: 'HIGH',
|
|
81
|
+
exploitable: true,
|
|
82
|
+
exploitabilityScore: 100,
|
|
83
|
+
attackVector: 'selfdestruct',
|
|
84
|
+
recommendation: 'Remove selfdestruct entirely, or add strict multi-sig + timelock protection. Note: selfdestruct is deprecated (EIP-6049) and will change behavior in future upgrades.',
|
|
85
|
+
references: [
|
|
86
|
+
'https://swcregistry.io/docs/SWC-106',
|
|
87
|
+
'https://eips.ethereum.org/EIPS/eip-6049'
|
|
88
|
+
],
|
|
89
|
+
foundryPoC: this.generateSelfdestructPoC(functionName, userControlledRecipient)
|
|
90
|
+
});
|
|
91
|
+
} else if (accessAnalysis.level === 'broken') {
|
|
92
|
+
// CRITICAL: Access control exists but is broken/bypassable
|
|
93
|
+
this.addFinding({
|
|
94
|
+
title: 'Broken Access Control on Selfdestruct',
|
|
95
|
+
description: `Function '${functionName}' has selfdestruct with BROKEN access control: ${accessAnalysis.reason}. ` +
|
|
96
|
+
`Attacker can bypass the check and destroy the contract.\n\n` +
|
|
97
|
+
`Vulnerability: ${accessAnalysis.reason}`,
|
|
98
|
+
location: `Function: ${functionName}`,
|
|
99
|
+
line: line,
|
|
100
|
+
column: node.loc ? node.loc.start.column : 0,
|
|
101
|
+
code: code,
|
|
102
|
+
severity: 'CRITICAL',
|
|
103
|
+
confidence: 'HIGH',
|
|
104
|
+
exploitable: true,
|
|
105
|
+
exploitabilityScore: 95,
|
|
106
|
+
attackVector: 'selfdestruct',
|
|
107
|
+
recommendation: `Fix the access control: ${accessAnalysis.fix}`,
|
|
108
|
+
references: [
|
|
109
|
+
'https://swcregistry.io/docs/SWC-106'
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
} else if (accessAnalysis.level === 'weak') {
|
|
113
|
+
// HIGH: Weak access control (tx.origin, balance-based, timestamp)
|
|
114
|
+
this.addFinding({
|
|
115
|
+
title: 'Weak Access Control on Selfdestruct',
|
|
116
|
+
description: `Function '${functionName}' has selfdestruct with WEAK access control: ${accessAnalysis.reason}. ` +
|
|
117
|
+
`This may be exploitable under certain conditions.`,
|
|
118
|
+
location: `Function: ${functionName}`,
|
|
119
|
+
line: line,
|
|
120
|
+
column: node.loc ? node.loc.start.column : 0,
|
|
121
|
+
code: code,
|
|
122
|
+
severity: 'HIGH',
|
|
123
|
+
confidence: 'MEDIUM',
|
|
124
|
+
exploitable: true,
|
|
125
|
+
exploitabilityScore: 75,
|
|
126
|
+
attackVector: 'selfdestruct',
|
|
127
|
+
recommendation: `Strengthen access control: ${accessAnalysis.fix}`,
|
|
128
|
+
references: [
|
|
129
|
+
'https://swcregistry.io/docs/SWC-106'
|
|
130
|
+
]
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// Note: Strong access control = no finding (not a vulnerability)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
analyzeAccessControl(functionNode, code) {
|
|
137
|
+
if (!functionNode) {
|
|
138
|
+
return { level: 'none', reason: 'No function context (fallback/receive)' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check for modifiers
|
|
142
|
+
const modifiers = functionNode.modifiers || [];
|
|
143
|
+
if (modifiers.length === 0) {
|
|
144
|
+
// Check for inline require statements
|
|
145
|
+
if (this.hasInlineAccessControl(code)) {
|
|
146
|
+
return this.analyzeInlineAccessControl(code);
|
|
147
|
+
}
|
|
148
|
+
return { level: 'none', reason: 'No access control modifiers or require checks' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Analyze modifier quality
|
|
152
|
+
for (const modifier of modifiers) {
|
|
153
|
+
const modName = (modifier.name || '').toLowerCase();
|
|
154
|
+
|
|
155
|
+
// Check for known strong patterns
|
|
156
|
+
if (/^only(owner|admin|governance|role)$/i.test(modName)) {
|
|
157
|
+
// Need to verify the modifier actually works (check CFG if available)
|
|
158
|
+
if (this.cfg) {
|
|
159
|
+
const modKey = `${this.currentContract}.${modifier.name}`;
|
|
160
|
+
const modInfo = this.cfg.modifiers?.get(modKey);
|
|
161
|
+
if (modInfo && modInfo.requireStatements.length === 0) {
|
|
162
|
+
return { level: 'broken', reason: 'Modifier has empty body - no actual check', fix: 'Implement proper ownership check in modifier' };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { level: 'strong', reason: 'Protected by ownership modifier' };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check code for weak patterns
|
|
170
|
+
return this.analyzeInlineAccessControl(code);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
hasInlineAccessControl(code) {
|
|
174
|
+
return /require\s*\(|if\s*\(.*revert/i.test(code);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
analyzeInlineAccessControl(code) {
|
|
178
|
+
const codeLower = code.toLowerCase();
|
|
179
|
+
|
|
180
|
+
// Check for tx.origin (phishing vulnerable)
|
|
181
|
+
if (/require\s*\(\s*tx\.origin\s*==|tx\.origin\s*==.*require/i.test(code)) {
|
|
182
|
+
return { level: 'weak', reason: 'Uses tx.origin (vulnerable to phishing attacks)', fix: 'Use msg.sender instead of tx.origin' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check for timestamp-based (manipulable)
|
|
186
|
+
if (/require\s*\(.*block\.timestamp|block\.timestamp.*require/i.test(code)) {
|
|
187
|
+
return { level: 'weak', reason: 'Uses block.timestamp (manipulable by miners, will eventually pass)', fix: 'Use proper ownership check, not time-based' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check for balance-based (flash loan vulnerable)
|
|
191
|
+
if (/require\s*\(.*\.balance\s*>|\.balance\s*>=.*require/i.test(code)) {
|
|
192
|
+
return { level: 'weak', reason: 'Uses balance-based access control (flash loan vulnerable)', fix: 'Use ownership check, not balance-based' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check for proper msg.sender check
|
|
196
|
+
if (/require\s*\(\s*msg\.sender\s*==\s*(owner|admin|_owner)/i.test(code)) {
|
|
197
|
+
return { level: 'strong', reason: 'Protected by msg.sender ownership check' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check for always-true conditions
|
|
201
|
+
if (/require\s*\(\s*true\s*\)|require\s*\(\s*1\s*==\s*1\s*\)/i.test(code)) {
|
|
202
|
+
return { level: 'broken', reason: 'Always-true require condition', fix: 'Implement actual access control check' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Has require but unclear what it checks
|
|
206
|
+
if (/require\s*\(/i.test(code)) {
|
|
207
|
+
return { level: 'unknown', reason: 'Has require but unclear protection' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { level: 'none', reason: 'No access control detected' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
isUserControlledRecipient(code) {
|
|
214
|
+
// Check if recipient is user-controllable
|
|
215
|
+
const userControlledPatterns = [
|
|
216
|
+
/selfdestruct\s*\(\s*payable\s*\(\s*msg\.sender\s*\)\s*\)/,
|
|
217
|
+
/selfdestruct\s*\(\s*msg\.sender\s*\)/,
|
|
218
|
+
/selfdestruct\s*\(\s*payable\s*\(\s*[_a-zA-Z]\w*\s*\)\s*\)/, // Parameter
|
|
219
|
+
/selfdestruct\s*\(\s*[_a-zA-Z]\w*\s*\)/ // Parameter without payable
|
|
220
|
+
];
|
|
221
|
+
return userControlledPatterns.some(p => p.test(code));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
generateSelfdestructPoC(functionName, userControlled) {
|
|
225
|
+
return `// SPDX-License-Identifier: MIT
|
|
226
|
+
pragma solidity ^0.8.0;
|
|
227
|
+
|
|
228
|
+
import "forge-std/Test.sol";
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* PoC: Unprotected Selfdestruct Exploit
|
|
232
|
+
* Demonstrates complete fund theft via unprotected selfdestruct
|
|
233
|
+
*/
|
|
234
|
+
contract SelfdestructExploit is Test {
|
|
235
|
+
address victim;
|
|
236
|
+
address attacker = address(0xBAD);
|
|
237
|
+
|
|
238
|
+
function setUp() public {
|
|
239
|
+
// Deploy victim contract and fund it
|
|
240
|
+
// victim = address(new VictimContract());
|
|
241
|
+
// vm.deal(victim, 100 ether);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function testExploit() public {
|
|
245
|
+
uint256 victimBalanceBefore = victim.balance;
|
|
246
|
+
uint256 attackerBalanceBefore = attacker.balance;
|
|
247
|
+
|
|
248
|
+
vm.prank(attacker);
|
|
249
|
+
// VictimContract(victim).${functionName}(${userControlled ? 'payable(attacker)' : ''});
|
|
250
|
+
|
|
251
|
+
// Assert: Contract destroyed, funds stolen
|
|
252
|
+
// assertEq(victim.code.length, 0, "Contract should be destroyed");
|
|
253
|
+
// assertEq(attacker.balance, attackerBalanceBefore + victimBalanceBefore, "Attacker should have stolen funds");
|
|
254
|
+
}
|
|
255
|
+
}`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = UnprotectedSelfdestructDetector;
|