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,473 @@
|
|
|
1
|
+
const BaseDetector = require('./base-detector');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read-Only Reentrancy Detector
|
|
5
|
+
* Detects vulnerabilities where view functions return stale data during reentrancy
|
|
6
|
+
*
|
|
7
|
+
* This is the attack vector used in the Curve Finance exploit and similar attacks.
|
|
8
|
+
* View functions may return incorrect values when called during the execution of
|
|
9
|
+
* another function that modifies state but hasn't completed yet.
|
|
10
|
+
*
|
|
11
|
+
* Attack vectors detected:
|
|
12
|
+
* 1. View functions reading state that's modified by non-view functions with external calls
|
|
13
|
+
* 2. Price/rate calculations that can be manipulated via reentrancy
|
|
14
|
+
* 3. Virtual price functions in LP tokens
|
|
15
|
+
* 4. Share/asset calculations during mint/burn
|
|
16
|
+
*/
|
|
17
|
+
class ReadOnlyReentrancyDetector extends BaseDetector {
|
|
18
|
+
constructor() {
|
|
19
|
+
super(
|
|
20
|
+
'Read-Only Reentrancy',
|
|
21
|
+
'Detects view functions vulnerable to read-only reentrancy attacks',
|
|
22
|
+
'CRITICAL'
|
|
23
|
+
);
|
|
24
|
+
this.currentContract = null;
|
|
25
|
+
this.viewFunctions = new Map();
|
|
26
|
+
this.stateModifyingFunctions = new Map();
|
|
27
|
+
this.externalCallFunctions = [];
|
|
28
|
+
this.sharedStateAccess = new Map();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async detect(ast, sourceCode, fileName, cfg, dataFlow) {
|
|
32
|
+
this.findings = [];
|
|
33
|
+
this.ast = ast;
|
|
34
|
+
this.sourceCode = sourceCode;
|
|
35
|
+
this.fileName = fileName;
|
|
36
|
+
this.sourceLines = sourceCode.split('\n');
|
|
37
|
+
this.cfg = cfg;
|
|
38
|
+
this.dataFlow = dataFlow;
|
|
39
|
+
this.viewFunctions.clear();
|
|
40
|
+
this.stateModifyingFunctions.clear();
|
|
41
|
+
this.externalCallFunctions = [];
|
|
42
|
+
this.sharedStateAccess.clear();
|
|
43
|
+
|
|
44
|
+
if (!cfg) {
|
|
45
|
+
return this.findings;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// First pass: categorize functions
|
|
49
|
+
this.categorizeFunctions();
|
|
50
|
+
|
|
51
|
+
// Second pass: find cross-function vulnerabilities
|
|
52
|
+
this.findReadOnlyReentrancy();
|
|
53
|
+
|
|
54
|
+
// Third pass: check for specific vulnerable patterns
|
|
55
|
+
this.checkVulnerablePatterns();
|
|
56
|
+
|
|
57
|
+
return this.findings;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
categorizeFunctions() {
|
|
61
|
+
for (const [funcKey, funcInfo] of this.cfg.functions) {
|
|
62
|
+
const funcCode = funcInfo.node?.body ? this.getCodeSnippet(funcInfo.node.loc) : '';
|
|
63
|
+
|
|
64
|
+
// Categorize view/pure functions
|
|
65
|
+
if (funcInfo.stateMutability === 'view' || funcInfo.stateMutability === 'pure') {
|
|
66
|
+
this.viewFunctions.set(funcKey, {
|
|
67
|
+
info: funcInfo,
|
|
68
|
+
code: funcCode,
|
|
69
|
+
readsState: funcInfo.stateReads.map(r => r.variable),
|
|
70
|
+
isPriceFunction: this.isPriceRelatedFunction(funcInfo.name, funcCode)
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Categorize state-modifying functions with external calls
|
|
75
|
+
if (funcInfo.externalCalls.length > 0 && funcInfo.stateWrites.length > 0) {
|
|
76
|
+
this.stateModifyingFunctions.set(funcKey, {
|
|
77
|
+
info: funcInfo,
|
|
78
|
+
code: funcCode,
|
|
79
|
+
externalCalls: funcInfo.externalCalls,
|
|
80
|
+
writesState: funcInfo.stateWrites.map(w => w.variable),
|
|
81
|
+
hasReentrancyGuard: this.hasReentrancyGuard(funcInfo)
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.externalCallFunctions.push({
|
|
85
|
+
key: funcKey,
|
|
86
|
+
info: funcInfo,
|
|
87
|
+
code: funcCode
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
findReadOnlyReentrancy() {
|
|
94
|
+
// For each state-modifying function with external calls
|
|
95
|
+
for (const [modFuncKey, modFunc] of this.stateModifyingFunctions) {
|
|
96
|
+
// Skip if has reentrancy guard (but note: guard doesn't help view functions!)
|
|
97
|
+
// Actually, regular reentrancy guards DO NOT prevent read-only reentrancy
|
|
98
|
+
|
|
99
|
+
// Find view functions that read state this function writes
|
|
100
|
+
for (const [viewFuncKey, viewFunc] of this.viewFunctions) {
|
|
101
|
+
// Check for shared state variables
|
|
102
|
+
const sharedState = modFunc.writesState.filter(writeVar =>
|
|
103
|
+
viewFunc.readsState.some(readVar =>
|
|
104
|
+
readVar === writeVar || this.variablesOverlap(readVar, writeVar)
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (sharedState.length > 0) {
|
|
109
|
+
// Check if there's a window for exploitation
|
|
110
|
+
const vulnerability = this.analyzeReentrancyWindow(modFunc, viewFunc, sharedState);
|
|
111
|
+
|
|
112
|
+
if (vulnerability.isVulnerable) {
|
|
113
|
+
this.reportReadOnlyReentrancy(modFunc, viewFunc, sharedState, vulnerability);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
analyzeReentrancyWindow(modFunc, viewFunc, sharedState) {
|
|
121
|
+
// Check if external call happens BEFORE state update (classic pattern)
|
|
122
|
+
// OR if state is partially updated when external call happens
|
|
123
|
+
|
|
124
|
+
const funcCode = modFunc.code;
|
|
125
|
+
|
|
126
|
+
// Find positions of external calls and state writes
|
|
127
|
+
let externalCallLine = 0;
|
|
128
|
+
let lastStateWriteLine = 0;
|
|
129
|
+
|
|
130
|
+
for (const call of modFunc.info.externalCalls) {
|
|
131
|
+
if (call.loc?.start?.line > externalCallLine) {
|
|
132
|
+
externalCallLine = call.loc.start.line;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const write of modFunc.info.stateWrites) {
|
|
137
|
+
if (write.loc?.start?.line > lastStateWriteLine) {
|
|
138
|
+
lastStateWriteLine = write.loc.start.line;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Vulnerable if: external call happens before last state write
|
|
143
|
+
// OR if view function reads derived value (like price) that depends on multiple state vars
|
|
144
|
+
const callBeforeWrite = externalCallLine < lastStateWriteLine;
|
|
145
|
+
const viewReadsDerivedValue = viewFunc.isPriceFunction;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
isVulnerable: callBeforeWrite || viewReadsDerivedValue,
|
|
149
|
+
callBeforeWrite,
|
|
150
|
+
viewReadsDerivedValue,
|
|
151
|
+
externalCallLine,
|
|
152
|
+
lastStateWriteLine
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
checkVulnerablePatterns() {
|
|
157
|
+
// Check for specific known vulnerable patterns
|
|
158
|
+
|
|
159
|
+
// Pattern 1: Curve-style virtual price in LP tokens
|
|
160
|
+
this.checkCurveVirtualPrice();
|
|
161
|
+
|
|
162
|
+
// Pattern 2: ERC4626-style share price
|
|
163
|
+
this.checkSharePriceVulnerability();
|
|
164
|
+
|
|
165
|
+
// Pattern 3: Balancer-style rate providers
|
|
166
|
+
this.checkRateProviderVulnerability();
|
|
167
|
+
|
|
168
|
+
// Pattern 4: Lending protocol exchange rates
|
|
169
|
+
this.checkExchangeRateVulnerability();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
checkCurveVirtualPrice() {
|
|
173
|
+
// Check for get_virtual_price or similar patterns
|
|
174
|
+
const curvePatterns = [
|
|
175
|
+
/get_virtual_price/i,
|
|
176
|
+
/virtualPrice/i,
|
|
177
|
+
/getVirtualPrice/i,
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
for (const [funcKey, viewFunc] of this.viewFunctions) {
|
|
181
|
+
const funcName = viewFunc.info.name || '';
|
|
182
|
+
const funcCode = viewFunc.code;
|
|
183
|
+
|
|
184
|
+
if (curvePatterns.some(p => p.test(funcName) || p.test(funcCode))) {
|
|
185
|
+
// Check if there are functions that modify the underlying state
|
|
186
|
+
const hasVulnerableModifier = this.hasStateModifierWithCallback(viewFunc.readsState);
|
|
187
|
+
|
|
188
|
+
if (hasVulnerableModifier) {
|
|
189
|
+
this.addFinding({
|
|
190
|
+
title: 'Curve-Style Virtual Price Read-Only Reentrancy',
|
|
191
|
+
description: `Function '${funcName}' returns a virtual price that can be manipulated during reentrancy. An attacker can:
|
|
192
|
+
1. Call a function that modifies reserves/balances with a callback (e.g., remove_liquidity with ETH)
|
|
193
|
+
2. During the callback, call this view function which returns stale price
|
|
194
|
+
3. Use the manipulated price in another protocol (lending, DEX, etc.)
|
|
195
|
+
|
|
196
|
+
This attack vector was used in the Curve Finance exploit causing >$50M in losses.`,
|
|
197
|
+
location: `Contract: ${this.currentContract}, Function: ${funcName}`,
|
|
198
|
+
line: viewFunc.info.node?.loc?.start?.line || 0,
|
|
199
|
+
column: 0,
|
|
200
|
+
code: funcCode.substring(0, 200),
|
|
201
|
+
severity: 'CRITICAL',
|
|
202
|
+
confidence: 'HIGH',
|
|
203
|
+
exploitable: true,
|
|
204
|
+
exploitabilityScore: 95,
|
|
205
|
+
attackVector: 'read-only-reentrancy',
|
|
206
|
+
recommendation: `Implement reentrancy lock that also blocks view functions:
|
|
207
|
+
1. Use a reentrancy guard that reverts in view functions
|
|
208
|
+
2. Or use Vyper's @nonreentrant decorator which protects all functions
|
|
209
|
+
3. For integrators: Don't trust virtual_price during callbacks
|
|
210
|
+
|
|
211
|
+
Example view function protection:
|
|
212
|
+
modifier nonReentrantView() {
|
|
213
|
+
require(!_locked, "Reentrancy");
|
|
214
|
+
_;
|
|
215
|
+
}`,
|
|
216
|
+
references: [
|
|
217
|
+
'https://chainsecurity.com/curve-lp-oracle-manipulation-post-mortem/',
|
|
218
|
+
'https://blog.openzeppelin.com/read-only-reentrancy-is-real'
|
|
219
|
+
],
|
|
220
|
+
foundryPoC: this.generateReadOnlyReentrancyPoC(funcName)
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
checkSharePriceVulnerability() {
|
|
228
|
+
// ERC4626 and similar share-based systems
|
|
229
|
+
const sharePricePatterns = [
|
|
230
|
+
/convertToAssets/i,
|
|
231
|
+
/convertToShares/i,
|
|
232
|
+
/pricePerShare/i,
|
|
233
|
+
/sharePrice/i,
|
|
234
|
+
/exchangeRate/i,
|
|
235
|
+
/getRate/i,
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
for (const [funcKey, viewFunc] of this.viewFunctions) {
|
|
239
|
+
const funcName = viewFunc.info.name || '';
|
|
240
|
+
|
|
241
|
+
if (sharePricePatterns.some(p => p.test(funcName))) {
|
|
242
|
+
// Check if mint/burn functions have external calls
|
|
243
|
+
const hasMintBurnWithCallback = this.hasMintBurnWithCallback();
|
|
244
|
+
|
|
245
|
+
if (hasMintBurnWithCallback) {
|
|
246
|
+
this.addFinding({
|
|
247
|
+
title: 'Share Price Vulnerable to Read-Only Reentrancy',
|
|
248
|
+
description: `Function '${funcName}' calculates share/asset conversion which may return incorrect values during mint/burn operations that include callbacks (e.g., safeTransferFrom with callback).
|
|
249
|
+
|
|
250
|
+
During these callbacks, totalSupply or totalAssets may be in an intermediate state, allowing manipulation of the apparent share price.`,
|
|
251
|
+
location: `Contract: ${this.currentContract}, Function: ${funcName}`,
|
|
252
|
+
line: viewFunc.info.node?.loc?.start?.line || 0,
|
|
253
|
+
column: 0,
|
|
254
|
+
code: viewFunc.code.substring(0, 200),
|
|
255
|
+
severity: 'HIGH',
|
|
256
|
+
confidence: 'MEDIUM',
|
|
257
|
+
exploitable: true,
|
|
258
|
+
exploitabilityScore: 70,
|
|
259
|
+
attackVector: 'read-only-reentrancy',
|
|
260
|
+
recommendation: `1. Ensure state updates complete before any external calls (CEI pattern)
|
|
261
|
+
2. Use reentrancy guards that also protect view functions
|
|
262
|
+
3. Consider caching prices at the start of transactions`
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
checkRateProviderVulnerability() {
|
|
270
|
+
// Balancer-style rate providers
|
|
271
|
+
const ratePatterns = [
|
|
272
|
+
/getRate\s*\(/i,
|
|
273
|
+
/IRateProvider/i,
|
|
274
|
+
/rateProvider/i,
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
if (ratePatterns.some(p => p.test(this.sourceCode))) {
|
|
278
|
+
// Check for implementation
|
|
279
|
+
for (const [funcKey, viewFunc] of this.viewFunctions) {
|
|
280
|
+
if (/getRate/i.test(viewFunc.info.name || '')) {
|
|
281
|
+
this.addFinding({
|
|
282
|
+
title: 'Rate Provider Potential Read-Only Reentrancy',
|
|
283
|
+
description: `Contract implements rate provider pattern (getRate). If the underlying rate calculation depends on state that can be manipulated during callbacks, this creates a read-only reentrancy vector.
|
|
284
|
+
|
|
285
|
+
Protocols integrating this rate provider may use stale rates during their operations.`,
|
|
286
|
+
location: `Contract: ${this.currentContract}, Function: ${viewFunc.info.name}`,
|
|
287
|
+
line: viewFunc.info.node?.loc?.start?.line || 0,
|
|
288
|
+
column: 0,
|
|
289
|
+
code: viewFunc.code.substring(0, 200),
|
|
290
|
+
severity: 'MEDIUM',
|
|
291
|
+
confidence: 'MEDIUM',
|
|
292
|
+
exploitable: true,
|
|
293
|
+
exploitabilityScore: 55,
|
|
294
|
+
attackVector: 'read-only-reentrancy',
|
|
295
|
+
recommendation: `Ensure rate calculations are not affected by reentrancy:
|
|
296
|
+
1. Cache rate at start of state-modifying functions
|
|
297
|
+
2. Use Balancer's rate provider update mechanism
|
|
298
|
+
3. Document reentrancy behavior for integrators`
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
checkExchangeRateVulnerability() {
|
|
306
|
+
// Compound-style exchange rates
|
|
307
|
+
const exchangePatterns = [
|
|
308
|
+
/exchangeRateStored/i,
|
|
309
|
+
/exchangeRateCurrent/i,
|
|
310
|
+
/underlying.*balance|balance.*underlying/i,
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
for (const [funcKey, viewFunc] of this.viewFunctions) {
|
|
314
|
+
const funcName = viewFunc.info.name || '';
|
|
315
|
+
const funcCode = viewFunc.code;
|
|
316
|
+
|
|
317
|
+
if (exchangePatterns.some(p => p.test(funcName) || p.test(funcCode))) {
|
|
318
|
+
if (this.hasStateModifierWithCallback(viewFunc.readsState)) {
|
|
319
|
+
this.addFinding({
|
|
320
|
+
title: 'Exchange Rate Read-Only Reentrancy Risk',
|
|
321
|
+
description: `Function '${funcName}' calculates exchange rate which may be vulnerable during reentrant calls. Lending protocols and other integrators may receive manipulated rates.`,
|
|
322
|
+
location: `Contract: ${this.currentContract}, Function: ${funcName}`,
|
|
323
|
+
line: viewFunc.info.node?.loc?.start?.line || 0,
|
|
324
|
+
column: 0,
|
|
325
|
+
code: funcCode.substring(0, 200),
|
|
326
|
+
severity: 'HIGH',
|
|
327
|
+
confidence: 'MEDIUM',
|
|
328
|
+
exploitable: true,
|
|
329
|
+
exploitabilityScore: 65,
|
|
330
|
+
attackVector: 'read-only-reentrancy',
|
|
331
|
+
recommendation: `Use non-reentrant exchange rate calculation or document the risk for integrators.`
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Helper methods
|
|
339
|
+
|
|
340
|
+
isPriceRelatedFunction(funcName, funcCode) {
|
|
341
|
+
if (!funcName) return false;
|
|
342
|
+
|
|
343
|
+
const pricePatterns = [
|
|
344
|
+
/price/i, /rate/i, /value/i, /convert/i, /exchange/i,
|
|
345
|
+
/virtual/i, /balance/i, /supply/i, /reserves/i
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
return pricePatterns.some(p => p.test(funcName) || p.test(funcCode));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
hasReentrancyGuard(funcInfo) {
|
|
352
|
+
if (!funcInfo.modifiers) return false;
|
|
353
|
+
|
|
354
|
+
const guardPatterns = [
|
|
355
|
+
/nonReentrant/i, /lock/i, /mutex/i, /noReentrancy/i
|
|
356
|
+
];
|
|
357
|
+
|
|
358
|
+
return funcInfo.modifiers.some(mod =>
|
|
359
|
+
guardPatterns.some(p => p.test(mod))
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
variablesOverlap(var1, var2) {
|
|
364
|
+
// Check if variables might refer to same storage
|
|
365
|
+
// e.g., "balances" and "balances[msg.sender]"
|
|
366
|
+
const base1 = var1.split('[')[0];
|
|
367
|
+
const base2 = var2.split('[')[0];
|
|
368
|
+
return base1 === base2;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
hasStateModifierWithCallback(stateVars) {
|
|
372
|
+
// Check if any function modifies these state vars and has external calls
|
|
373
|
+
for (const [funcKey, modFunc] of this.stateModifyingFunctions) {
|
|
374
|
+
const modifiesSharedState = stateVars.some(sv =>
|
|
375
|
+
modFunc.writesState.some(ws => this.variablesOverlap(sv, ws))
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
if (modifiesSharedState && modFunc.externalCalls.length > 0) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
hasMintBurnWithCallback() {
|
|
386
|
+
// Check for mint/burn functions with potential callbacks
|
|
387
|
+
for (const func of this.externalCallFunctions) {
|
|
388
|
+
const funcName = (func.info.name || '').toLowerCase();
|
|
389
|
+
if (/mint|burn|deposit|withdraw|redeem/i.test(funcName)) {
|
|
390
|
+
// Check if external call could be a callback (ERC20/721 hooks, etc.)
|
|
391
|
+
const hasCallback = func.code.includes('safeTransfer') ||
|
|
392
|
+
func.code.includes('_beforeTokenTransfer') ||
|
|
393
|
+
func.code.includes('_afterTokenTransfer') ||
|
|
394
|
+
func.code.includes('.call');
|
|
395
|
+
if (hasCallback) return true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
reportReadOnlyReentrancy(modFunc, viewFunc, sharedState, vulnerability) {
|
|
402
|
+
const modFuncName = modFunc.info.name || 'unknown';
|
|
403
|
+
const viewFuncName = viewFunc.info.name || 'unknown';
|
|
404
|
+
|
|
405
|
+
this.addFinding({
|
|
406
|
+
title: 'Read-Only Reentrancy Vulnerability',
|
|
407
|
+
description: `View function '${viewFuncName}' reads state (${sharedState.join(', ')}) that is modified by '${modFuncName}' which has external calls.
|
|
408
|
+
|
|
409
|
+
During the external call in '${modFuncName}', an attacker can call '${viewFuncName}' and receive stale data. This can be exploited if other protocols use this view function for price/rate calculations.
|
|
410
|
+
|
|
411
|
+
${vulnerability.callBeforeWrite ? 'External call happens BEFORE state update - classic reentrancy window.' : ''}
|
|
412
|
+
${vulnerability.viewReadsDerivedValue ? 'View function calculates derived value (price/rate) - high impact if manipulated.' : ''}`,
|
|
413
|
+
location: `Contract: ${this.currentContract}`,
|
|
414
|
+
line: viewFunc.info.node?.loc?.start?.line || 0,
|
|
415
|
+
column: 0,
|
|
416
|
+
code: viewFunc.code.substring(0, 200),
|
|
417
|
+
severity: vulnerability.viewReadsDerivedValue ? 'CRITICAL' : 'HIGH',
|
|
418
|
+
confidence: 'HIGH',
|
|
419
|
+
exploitable: true,
|
|
420
|
+
exploitabilityScore: vulnerability.viewReadsDerivedValue ? 85 : 70,
|
|
421
|
+
attackVector: 'read-only-reentrancy',
|
|
422
|
+
recommendation: `1. Apply CEI (Checks-Effects-Interactions) pattern: update all state BEFORE external calls
|
|
423
|
+
2. Use reentrancy guard that also reverts in view functions:
|
|
424
|
+
modifier nonReentrantView() {
|
|
425
|
+
require(!_locked, "ReentrancyGuard: reentrant call");
|
|
426
|
+
_;
|
|
427
|
+
}
|
|
428
|
+
3. For price functions, consider caching price at transaction start
|
|
429
|
+
4. Document for integrators that view functions may return stale data during callbacks`,
|
|
430
|
+
references: [
|
|
431
|
+
'https://blog.openzeppelin.com/read-only-reentrancy-is-real',
|
|
432
|
+
'https://chainsecurity.com/heartbreaks-curve-lp-oracles/'
|
|
433
|
+
]
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
generateReadOnlyReentrancyPoC(funcName) {
|
|
438
|
+
return `// SPDX-License-Identifier: MIT
|
|
439
|
+
pragma solidity ^0.8.0;
|
|
440
|
+
|
|
441
|
+
import "forge-std/Test.sol";
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Proof of Concept: Read-Only Reentrancy Attack
|
|
445
|
+
* Exploits stale ${funcName} during callback
|
|
446
|
+
*/
|
|
447
|
+
contract ReadOnlyReentrancyExploit is Test {
|
|
448
|
+
// ITarget target;
|
|
449
|
+
// IIntegrator integrator; // Protocol that uses target's view function
|
|
450
|
+
|
|
451
|
+
function testExploit() public {
|
|
452
|
+
// Step 1: Call function that triggers callback (e.g., remove_liquidity)
|
|
453
|
+
// This will call our receive() or fallback() during execution
|
|
454
|
+
// target.remove_liquidity{value: 1 ether}(...);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
receive() external payable {
|
|
458
|
+
// Step 2: During callback, the state is inconsistent
|
|
459
|
+
// View function returns manipulated value
|
|
460
|
+
// uint256 manipulatedPrice = target.${funcName}();
|
|
461
|
+
|
|
462
|
+
// Step 3: Use manipulated price in another protocol
|
|
463
|
+
// e.g., borrow against inflated collateral value
|
|
464
|
+
// integrator.borrow(manipulatedPrice * myCollateral / 1e18);
|
|
465
|
+
|
|
466
|
+
// Step 4: After this callback returns, target's state is updated
|
|
467
|
+
// but we already exploited the stale price
|
|
468
|
+
}
|
|
469
|
+
}`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
module.exports = ReadOnlyReentrancyDetector;
|