Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ See [`.github/workflows/audit.yml`](.github/workflows/audit.yml) for a complete
| CP-CB-READONLY | — | Read-only reentrancy via callback | High | `view` function exposes a value finalized only after the callback |
| CP-CB-SPOOF | — | Callback spoofing | High | Receiver-hook function mutates state with no `msg.sender` check |
| CP-CB-BATCH | — | Unbounded batch callback | Medium | Callback fired once per loop iteration with no length cap |
| CP-121 | [SWC-107](https://swcregistry.io/docs/SWC-107) | Multi-hop cross-contract reentrancy | Critical | DFS traversal of cross-contract call graph; flags chains (A→B→…→A) where originating contract has unfinalized state |
| GAS-\* | — | Gas optimizations | Gas | Storage in loops, packing, `keccak256`, etc. |

When Slither is installed, all [Slither detectors](https://github.com/crytic/slither/wiki/Detector-Documentation) are merged in with deduplication by line + title. Slither findings are prefixed with `SLITHER-`.
Expand Down Expand Up @@ -561,6 +562,20 @@ node packages/cli/dist/cli.js scan examples/contracts/VulnerableVault.sol
node packages/cli/dist/cli.js scan examples/contracts/SecureVault.sol
```

CP-121 fixture contracts live under [`examples/contracts/cross-contract-reentrancy/`](examples/contracts/cross-contract-reentrancy/):

| File | Purpose |
| ---- | ------- |
| `TwoHopVulnerable.sol` | 2-hop exploitable chain (VaultA → AttackerB → VaultA) |
| `ThreeHopVulnerable.sol` | 3-hop exploitable chain (VaultX → RouterY → ReceiverZ → VaultX) |
| `TwoHopGuarded.sol` | CEI-guarded equivalent — should produce zero CP-121 findings |
| `DeepChain.sol` | 5-hop chain used to verify traversal depth cap enforcement |

```bash
node packages/cli/dist/cli.js scan examples/contracts/cross-contract-reentrancy/TwoHopVulnerable.sol
node packages/cli/dist/cli.js scan examples/contracts/cross-contract-reentrancy/TwoHopGuarded.sol
```

### Callback, Hook & Reentrancy Analysis (CP-90)

`packages/core/src/rules/callback-analysis/` models the **implicit control-flow
Expand Down Expand Up @@ -639,6 +654,57 @@ callback-specific rules that feed into that broader picture.

---

## Multi-hop Cross-Contract Reentrancy (CP-121)

`packages/core/src/rules/cp121-cross-contract-reentrancy.ts` detects **cross-contract
reentrancy chains** — the class of exploit behind several real-world vault/strategy
drains that are invisible to single-function analysis.

**Threat model.** An attacker deploys Contract B. Contract A calls B (or a chain of
intermediary contracts eventually reaches B), and B calls back into A while A still
has unfinalized state. Classic example:

```
VaultA.withdraw() ──external call──► AttackerB.execute()
AttackerB.execute() ──re-enters──► VaultA.withdraw() ← balances still stale
```

**How it works:**

1. A `CrossContractCallGraph` is built from all `MergedContractView` objects collected
during the scan. Edges are added for typed external calls (state variables of a known
contract type, explicit casts like `IVault(addr).withdraw()`). Low-level `.call(bytes)`
with no resolvable type are not added.
2. A bounded DFS (default 3 hops, hard cap 10) searches from every node that has at
least one cross-contract outgoing edge.
3. When a path returns to the originating contract, the originating function is checked
for **unfinalized state** — a state variable that is *read* before the first external
call but not *written* before it.
4. If unfinalized state is found and no `nonReentrant`-style modifier is present, a
`CP-121` finding is emitted with the full `callPath`, an `evidence` array naming the
unfinalized variables, `severity: "critical"`, and `swcId: "SWC-107"`.

**Configuration:**

```typescript
import { detectCrossContractReentrancy } from "@chainproof/core";

const findings = detectCrossContractReentrancy(allViews, { maxDepth: 5 });
```

| Option | Default | Hard cap | Description |
| ------ | ------- | -------- | ----------- |
| `maxDepth` | `3` | `10` | Maximum cross-contract hops to follow |

**Performance.** CP-121 runs once per scan session (not per file). The DFS short-circuits
any branch whose origin function has no unfinalized state, keeping analysis fast even for
large protocol codebases. Exceeding the hard cap of 10 silently clamps the depth and emits
one `info`-severity `CP-121-DEPTH-CAP` finding to inform operators.

**Fixtures.** See [`examples/contracts/cross-contract-reentrancy/`](examples/contracts/cross-contract-reentrancy/) for 2-hop and 3-hop vulnerable contracts, a CEI-guarded safe equivalent, and a deep-chain depth-cap test fixture.

---

## Data Model

### `Finding`
Expand Down
86 changes: 86 additions & 0 deletions examples/contracts/cross-contract-reentrancy/DeepChain.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;

/**
* Deep-chain reentrancy fixture (depth > default cap of 3)
*
* This creates a 5-hop chain: A -> B -> C -> D -> E -> A
* With the default traversal depth of 3, CP-121 should NOT follow beyond
* 3 hops and therefore should NOT report a finding for the tail of this chain.
*
* Used by the depth-limit unit test to verify the traversal cap is enforced.
*/

contract HopE {
address public hopA;

constructor(address _hopA) {
hopA = _hopA;
}

function bounce() external {
// 5th hop: tries to re-enter HopA, but traversal cap prevents detection
(bool ok, ) = hopA.call(abi.encodeWithSignature("entry()"));
require(ok, "bounce failed");
}
}

contract HopD {
HopE public hopE;

constructor(address _hopE) {
hopE = HopE(_hopE);
}

function relay() external {
hopE.bounce();
}
}

contract HopC {
HopD public hopD;

constructor(address _hopD) {
hopD = HopD(_hopD);
}

function pass() external {
hopD.relay();
}
}

contract HopB {
HopC public hopC;

constructor(address _hopC) {
hopC = HopC(_hopC);
}

function forward() external {
hopC.pass();
}
}

/// @notice Entry contract with unfinalized state — but the re-entry path is 5
/// hops deep, beyond the default cap of 3.
contract HopA {
mapping(address => uint256) public ledger;
HopB public hopB;

constructor(address _hopB) {
hopB = HopB(_hopB);
}

function deposit() external payable {
ledger[msg.sender] += msg.value;
}

function entry() external {
uint256 amount = ledger[msg.sender]; // READ — unfinalized
require(amount > 0, "empty");

hopB.forward(); // chain: A -> B -> C -> D -> E -> A (5 hops)

ledger[msg.sender] = 0; // WRITE — too late, but chain too deep to flag
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;

/**
* 3-hop cross-contract reentrancy fixture (VULNERABLE)
*
* Attack path:
* VaultX.withdraw() ──calls──► RouterY.forward()
* RouterY.forward() ──calls──► ReceiverZ.onReceive()
* ReceiverZ.onReceive() ──re-enters──► VaultX.withdraw()
*
* VaultX.withdraw() has unfinalized `deposits[msg.sender]` when it calls
* RouterY, which chains through to ReceiverZ, which calls back into VaultX.
*/

contract ReceiverZ {
address public vault;

constructor(address _vault) {
vault = _vault;
}

/// @notice Called by RouterY; re-enters VaultX with unfinalized state.
function onReceive(address target) external {
// Re-enter VaultX directly
(bool ok, ) = target.call(abi.encodeWithSignature("withdraw()"));
require(ok, "reentry failed");
}
}

contract RouterY {
ReceiverZ public receiver;

constructor(address _receiver) {
receiver = ReceiverZ(_receiver);
}

/// @notice Intermediate hop: forwards the call to ReceiverZ.
function forward(address origin) external {
receiver.onReceive(origin);
}
}

contract VaultX {
mapping(address => uint256) public deposits;
RouterY public router;

event Withdrawal(address indexed user, uint256 amount);

constructor(address _router) {
router = RouterY(_router);
}

function deposit() external payable {
deposits[msg.sender] += msg.value;
}

/// @notice Vulnerable: deposits[msg.sender] is read before router.forward()
/// is called. The 3-hop chain re-enters here with stale deposits.
function withdraw() external {
uint256 amount = deposits[msg.sender]; // READ — unfinalized
require(amount > 0, "nothing");

// 3-hop chain: VaultX -> RouterY -> ReceiverZ -> VaultX
router.forward(address(this)); // external call with unfinalized state

deposits[msg.sender] = 0; // WRITE — too late
emit Withdrawal(msg.sender, amount);
}
}
54 changes: 54 additions & 0 deletions examples/contracts/cross-contract-reentrancy/TwoHopGuarded.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;

/**
* 2-hop cross-contract reentrancy fixture (SAFE / GUARDED)
*
* Same structural shape as TwoHopVulnerable.sol but VaultSafe applies the
* Checks-Effects-Interactions pattern: balances is decremented BEFORE the
* external call, so re-entry finds a zero balance and cannot drain funds.
*
* CP-121 MUST produce zero findings for this contract pair.
*/

contract VaultSafe {
mapping(address => uint256) public balances;

event Withdrawal(address indexed user, uint256 amount);

function deposit() external payable {
balances[msg.sender] += msg.value;
}

/// @notice Safe: state update (CEI) before external call.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");

// WRITE first — CEI pattern applied correctly
balances[msg.sender] = 0;

// External call happens AFTER state finalization
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");

emit Withdrawal(msg.sender, amount);
}
}

/// @dev Attacker contract that mirrors TwoHopVulnerable's AttackerB
contract AttackerSafe {
VaultSafe public vault;

constructor(address _vault) {
vault = VaultSafe(_vault);
}

function execute() external {
vault.withdraw(); // re-entry finds balance == 0, harmless
}

receive() external payable {
vault.withdraw(); // balance already zero, no effect
}
}
72 changes: 72 additions & 0 deletions examples/contracts/cross-contract-reentrancy/TwoHopVulnerable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;

/**
* 2-hop cross-contract reentrancy fixture (VULNERABLE)
*
* Attack path:
* VaultA.withdraw() ──external call──► AttackerB.execute()
* AttackerB.execute() ──re-enters──► VaultA.withdraw()
*
* VaultA.withdraw() reads `balances[msg.sender]` before the external call
* but only decrements it after — classic unfinalized state window.
*/

/// @dev The re-entrant attacker (Contract B)
interface IAttacker {
function execute() external;
}

contract VaultA {
mapping(address => uint256) public balances;

event Withdrawal(address indexed user, uint256 amount);

function deposit() external payable {
balances[msg.sender] += msg.value;
}

/// @notice Vulnerable: balance is read before the external call and only
/// decremented after, leaving an unfinalized-state window.
function withdraw() external {
uint256 amount = balances[msg.sender]; // READ before call — unfinalized state
require(amount > 0, "nothing to withdraw");

// External call to msg.sender — control leaves VaultA here.
// An attacker can call withdraw() again before balances is decremented.
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");

balances[msg.sender] = 0; // WRITE after call — too late
emit Withdrawal(msg.sender, amount);
}

/// @notice A second entry point that also reads balances — re-entry target.
function getBalance() external view returns (uint256) {
return balances[msg.sender];
}
}

contract AttackerB {
VaultA public vault;
uint256 public callCount;

constructor(address _vault) {
vault = VaultA(_vault);
}

/// @notice AttackerB.execute() re-enters VaultA.withdraw()
function execute() external {
if (callCount < 3) {
callCount++;
vault.withdraw(); // re-enters VaultA with balances still unfinalized
}
}

receive() external payable {
if (callCount < 3) {
callCount++;
vault.withdraw();
}
}
}
Loading
Loading