Install in Claude Code
Copygit clone --depth 1 https://github.com/PurpleAILAB/Vigilo /tmp/reentrancy && cp -r /tmp/reentrancy/packages/opencode/skills/reentrancy ~/.claude/skills/reentrancyThen start a new Claude Code session; the skill loads automatically.
Definition
SKILL.md
# Reentrancy Vulnerability Analysis
**OWASP SC05:2025** - Reentrancy ranks **#5** in OWASP Smart Contract Top 10 (2025). Historical losses exceed **$500M+**, with 2024-2025 attacks including Penpie ($27M), Clober, GemPad, and multiple ERC721/1155 callback exploits.
---
## Why Reentrancy Happens (Root Causes)
### Root Cause 1: State Update After External Call
The fundamental CEI (Checks-Effects-Interactions) violation.
```solidity
// VULNERABLE: State update AFTER external call
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
(bool success,) = msg.sender.call{value: amount}(""); // @audit External call
require(success);
balances[msg.sender] -= amount; // @audit State update AFTER!
}
```
**Attacker's view**: "Between the call and the state update, I control execution. I'll call withdraw again before my balance updates."
### Root Cause 2: Execution Flow Transfer
Every external call hands control to potentially hostile code.
```solidity
// Even "safe" patterns can transfer control:
IERC20(token).transfer(recipient, amount); // Could be ERC777 with hooks!
NFT.safeTransferFrom(from, to, id); // Triggers onERC721Received!
Token1155.safeTransferFrom(...); // Triggers onERC1155Received!
```
**Detection**: Any external call is a potential callback. Check what standards the token implements.
### Root Cause 3: Shared State Dependency
Multiple contracts/functions depend on same state variable.
```solidity
// Contract has two functions sharing `balances`
function withdraw() external {
uint256 amount = balances[msg.sender];
msg.sender.call{value: amount}(""); // @audit Callback opportunity
balances[msg.sender] = 0; // @audit Updated after
}
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount); // @audit Same balance!
balances[msg.sender] -= amount;
balances[to] += amount;
}
// During withdraw callback, attacker calls transfer() with STALE balance!
```
**Attacker's view**: "ReentrancyGuard on withdraw() doesn't protect transfer(). I'll call transfer() during withdraw callback."
### Root Cause 4: View Function Exposure
View functions return stale data during callbacks, affecting external protocols.
```solidity
// Contract A (Vault)
function withdraw() external {
uint256 assets = userAssets[msg.sender];
msg.sender.call{value: assets}(""); // @audit Callback
userAssets[msg.sender] = 0;
totalAssets -= assets; // @audit Updated after
}
function getTotalAssets() public view returns (uint256) {
return totalAssets; // @audit Returns STALE value during callback!
}
// Contract B (Lending) reads from Contract A
function getCollateralValue(address user) view returns (uint256) {
return vaultA.getTotalAssets() * userShares / totalShares;
// During withdraw callback, totalAssets is WRONG!
}
```
**Attacker's view**: "The view function shows the old value. External protocols that read this will make wrong decisions."
---
## The State Timeline (Core Artifact)
Every reentrancy finding MUST include a state timeline:
```
T0: balances[attacker] = 100, contract.balance = 1000
T1: withdraw(100) called
T2: call{value: 100}("") → attacker.receive() triggered
T3: [CALLBACK] balances[attacker] STILL = 100! ← INCONSISTENT STATE
T4: Re-enter withdraw(100) with same balance
T5: Another 100 sent
T6: ... repeat until drained
T7: balances[attacker] -= 100 (executed N times, but all see 100)
```
Document each finding:
| Phase | Contract State | Attacker Action | Balance Check |
|-------|---------------|-----------------|---------------|
| T0 | Initial | - | 100 |
| T2 | Sending ETH | receive() callback | 100 (stale) |
| T4 | Re-entered | withdraw again | 100 (stale) |
| T7 | Unwind | - | Multiple decrements fail |
---
## Detection Patterns
### Pattern 1: Classic CEI Violation
**Root Cause**: State Update After External Call
```solidity
// VULNERABLE: Textbook reentrancy
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount); // CHECK
(bool success,) = msg.sender.call{value: amount}(""); // INTERACTION
require(success);
balances[msg.sender] -= amount; // EFFECT - Wrong order!
}
```
**Attack Flow**:
1. Attacker calls withdraw(100)
2. Contract sends ETH via call{value}
3. Attacker's receive() callback triggers
4. In callback: call withdraw(100) again (balance still 100!)
5. Repeat until contract drained
6. All state updates execute with wrong values
**Search Queries**:
```
Grep("\\.call\\{value", glob="**/*.sol")
Grep("transfer\\(|send\\(", glob="**/*.sol")
```
**Verification Questions**:
- Is state updated BEFORE external call?
- Is there a reentrancy guard?
- Can callback reach this function again?
### Pattern 2: Cross-Function Reentrancy
**Root Cause**: Shared State Dependency
```solidity
// Both functions use same `balances` mapping
function withdraw() external nonReentrant { // Has guard
uint256 amount = balances[msg.sender];
msg.sender.call{value: amount}(""); // @audit Callback
balances[msg.sender] = 0;
}
function transfer(address to, uint256 amt) external { // NO guard!
require(balances[msg.sender] >= amt); // @audit Same state!
balances[msg.sender] -= amt;
balances[to] += amt;
}
```
**Attack Flow**:
1. Attacker calls withdraw() with 100 balance
2. During callback, attacker calls transfer(accomplice, 100)
3. transfer() sees balances[attacker] = 100 (not yet updated!)
4. Attacker "transfers" 100 to accomplice
5. withdraw() completes, sets balances[attacker] = 0
6. Accomplice has 100 tokens created from nothing
**Search Queries**:
```
Grep("nonReentrant", glob="**/*.sol")
Grep("ReentrancyGuard", glob="**/*.sol")
```
**Verification Questions**:
- Does guard cover ALL functions sharing this state?
- Can other functions be called during callback?
- What state do they depend on?
### Pattern 3: Cross-Contract Reentrancy
**Root Cause**: Ree