# ChainGuard Report — lending-protocol (fixture)

> **Demo Analysis.** Pre-recorded fixture data; not produced by a live run.

- Source: `contracts/vulnerable/multi_contract`
- Analysis ID: `demo`
- Completed: 2026-09-20T14:08:00.000Z
- Static analysis: slither (fixture)
- AI investigation: fixture

## Executive summary

ChainGuard identified **2 potential attack chain(s)** and **8 potential finding(s)** (4 in chains, 3 isolated, 1 unverified) across 6 contracts and 13 functions.

Results are potential issues derived from static analysis and evidence-validated AI reasoning. They are not confirmed exploits and require manual verification.

## Attack chains

### CG-001 — Potential oracle takeover leading to over-borrowing

**Severity:** Critical · **Status:** Supported · **Support:** Strongly supported · **Confidence:** 81%

An unprotected price-source setter lets any caller point SpotPriceOracle at a pool they control. LendingVault then values collateral at the attacker-chosen price and lends against it.

**Entry point:** `SpotPriceOracle.setPool()` (contracts/SpotPriceOracle.sol:L18–21)

**Preconditions**

- Attacker can deploy a contract implementing IPool.getReserves().
- Attacker holds a small amount of ETH to deposit as collateral.
- LendingVault holds debt-token liquidity.

**Steps**

| # | Type | Step | Location | Evidence | Confidence |
|---|------|------|----------|----------|------------|
| 1 | Attacker action | Deploy a malicious pool | `contracts/IPool.sol:L5` | E-15 | 72% |
| 2 | Vulnerability | Replace the oracle price source | `contracts/SpotPriceOracle.sol:L18–21` | E-01, E-11 | 93% |
| 3 | State change | `pool` now points to the attacker contract | `contracts/SpotPriceOracle.sol:L20` | E-02 | 95% |
| 4 | Attacker action | Deposit nominal collateral | `contracts/LendingVault.sol:L25–27` | E-19 | 95% |
| 5 | External dependency | Oracle returns an inflated spot price | `contracts/SpotPriceOracle.sol:L25` | E-03 | 88% |
| 6 | Contract function | Collateral valued at the manipulated price | `contracts/LendingVault.sol:L30` | E-04 | 90% |
| 7 | Vulnerability | Borrow limit inflated beyond real collateral | `contracts/LendingVault.sol:L38` | E-05 | 82% |
| 8 | Impact | Vault debt-token liquidity drained | `contracts/LendingVault.sol:L40` | E-06 | 74% |

**Impact:** An attacker could borrow up to the vault’s entire debt-token balance against a nominal ETH deposit, leaving the protocol with unrecoverable bad debt.

**How to break the chain**

1. **Restrict setPool to the owner** — `contracts/SpotPriceOracle.sol:L18–21`. Add an owner check to setPool (for example, require(msg.sender == owner) or an onlyOwner modifier) and consider a timelock for price-source changes. _Removes attacker control of the price source. Steps 2 and 3 can no longer occur, so the chain is broken at its entry point._
2. **Use a manipulation-resistant price** — `contracts/SpotPriceOracle.sol:L24–28`. Replace spot reserves with a TWAP or an aggregated oracle feed, with staleness and deviation checks. _Defends step 5 even if the price source is legitimate but manipulable within one transaction._
3. **Bound oracle input in the vault** — `contracts/LendingVault.sol:L29–31`. Reject prices outside configured min/max bounds or beyond a per-block deviation before using them in borrow limits. _Limits the damage of steps 6–7 regardless of oracle behaviour._

**Assumptions & limitations**

- Debt token is a standard ERC-20 held by LendingVault.
- Runtime exploitability has not been confirmed.
- The debt-token and pool implementations are outside the repository.

### CG-002 — Potential cross-function reentrancy enabling borrow against withdrawn collateral

**Severity:** High · **Status:** Supported · **Support:** Moderately supported · **Confidence:** 74%

withdrawCollateral hands execution to the caller before reducing collateral. A contract caller can re-enter borrow() and borrow against collateral that is already leaving the vault.

**Entry point:** `LendingVault.withdrawCollateral()` (contracts/LendingVault.sol:L43–52)

**Preconditions**

- Attacker account is a contract with a receive() or fallback() function.
- Attacker has deposited collateral.

**Steps**

| # | Type | Step | Location | Evidence | Confidence |
|---|------|------|----------|----------|------------|
| 1 | Attacker action | Withdraw collateral from a contract account | `contracts/LendingVault.sol:L43–52` | E-20 | 90% |
| 2 | Vulnerability | ETH sent before state update | `contracts/LendingVault.sol:L48` | E-07, E-12 | 92% |
| 3 | Attacker action | Re-enter borrow() from receive() | `contracts/LendingVault.sol:L37–41` | E-21 | 70% |
| 4 | State change | Stale collateral inflates the borrow limit | `contracts/LendingVault.sol:L30` | E-14 | 80% |
| 5 | State change | Collateral decremented after borrow | `contracts/LendingVault.sol:L51` | E-13 | 88% |
| 6 | Impact | Undercollateralized debt position | `contracts/LendingVault.sol:L43–52` | E-13, E-06 | 68% |

**Impact:** Each withdraw/borrow cycle can extract debt tokens backed by collateral that has already left the vault.

**How to break the chain**

1. **Apply checks-effects-interactions** — `contracts/LendingVault.sol:L43–52`. Decrement collateral[msg.sender] before sending ETH. _borrow() would read the updated balance during any re-entry, invalidating step 4._
2. **Share a reentrancy guard** — `contracts/LendingVault.sol:L37–41`. Apply a nonReentrant modifier to both withdrawCollateral and borrow. _Blocks the re-entry in step 3 entirely._

**Assumptions & limitations**

- Oracle price is unchanged during the transaction.
- Runtime exploitability has not been confirmed.

## Findings

| ID | Severity | Category | Title | Location | Relationship | Status |
|----|----------|----------|-------|----------|--------------|--------|
| F-001 | High | Access control | Unprotected oracle price-source setter | `contracts/SpotPriceOracle.sol:L18–21` | Part of attack chain | Supported |
| F-002 | High | Oracle / price manipulation | Collateral priced from single-pool spot reserves | `contracts/SpotPriceOracle.sol:L24–28` | Part of attack chain | Supported |
| F-004 | High | Reentrancy | Cross-function reentrancy from withdrawCollateral into borrow | `contracts/LendingVault.sol:L43–52` | Part of attack chain | Detected |
| F-003 | Medium | Logic / arithmetic | Borrow limit trusts an unbounded oracle price | `contracts/LendingVault.sol:L37–41` | Part of attack chain | Supported |
| F-005 | Medium | Unchecked external call | Unchecked return value of send() in claim | `contracts/RewardDistributor.sol:L22–26` | Isolated | Detected |
| F-006 | Medium | Access control | Treasury authorization relies on tx.origin | `contracts/Treasury.sol:L12–15` | Isolated | Detected |
| F-008 | Medium | Flash-loan surface | Oracle reserves may be flash-loan manipulable | `contracts/SpotPriceOracle.sol:L25` | Unverified | Unverified |
| F-007 | Informational | Other | Floating compiler pragma | `contracts/LendingVault.sol:L2` | Isolated | Detected |

## Evidence appendix

- **E-01** (access_control, mapper, verified) — setPool is external and writes `pool` with no msg.sender check or modifier. `owner` is stored in the constructor but never enforced. `contracts/SpotPriceOracle.sol:L18–21`
- **E-02** (state_dependency, mapper, verified) — Writes state variable `pool`, which getPrice() reads to compute the collateral price. `contracts/SpotPriceOracle.sol:L20`
- **E-03** (external_dependency, mapper, verified) — Price is derived from the instantaneous reserves of one pool. No TWAP, staleness check, or deviation bound is applied. `contracts/SpotPriceOracle.sol:L25`
- **E-04** (call_dependency, mapper, verified) — Cross-contract call LendingVault.collateralValue → SpotPriceOracle.getPrice. Collateral value scales linearly with the returned price. `contracts/LendingVault.sol:L30`
- **E-05** (call_dependency, mapper, verified) — borrow → maxBorrow → collateralValue. The borrow limit has no upper bound independent of the oracle price. `contracts/LendingVault.sol:L38`
- **E-06** (external_dependency, mapper, verified) — Debt token leaves the vault immediately after the collateral check passes. `contracts/LendingVault.sol:L40`
- **E-07** (static_detector, fixture, verified) — Slither detector `reentrancy-eth`: external call sends ETH before state variable `collateral` is written; `collateral` is read by borrow(). `contracts/LendingVault.sol:L48`
- **E-08** (static_detector, fixture, verified) — Slither detector `unchecked-send`: return value of send() is ignored. `contracts/RewardDistributor.sol:L25`
- **E-09** (static_detector, fixture, verified) — Slither detector `tx-origin`: authorization uses tx.origin. `contracts/Treasury.sol:L13`
- **E-10** (static_detector, fixture, verified) — Slither detector `solc-version`: floating pragma ^0.8.20 allows compiler versions with known issues. `contracts/LendingVault.sol:L2`
- **E-11** (static_detector, fixture, verified) — Slither detector `missing-zero-check`: newPool is not validated before being assigned to `pool`. `contracts/SpotPriceOracle.sol:L20`
- **E-12** (source, mapper, verified) — Native ETH is sent to msg.sender, handing execution to the caller before collateral is decremented. `contracts/LendingVault.sol:L48`
- **E-13** (state_dependency, mapper, verified) — `collateral` is written only after the external call returns. `contracts/LendingVault.sol:L51`
- **E-14** (state_dependency, mapper, verified) — borrow() reads collateral[msg.sender] through collateralValue(); the value is stale during withdrawCollateral’s external call. `contracts/LendingVault.sol:L30`
- **E-15** (ai_reasoning, fixture, partial) — Any contract implementing IPool.getReserves() can be set as the price source, so the caller of setPool chooses the reserves getPrice() returns. `contracts/IPool.sol:L5`
- **E-16** (ai_reasoning, fixture, unverified) — Spot reserves of a legitimate pool could be moved with borrowed liquidity. No flash-loan provider or pool implementation is present in the repository, so this cannot be validated. `contracts/SpotPriceOracle.sol:L25`
- **E-17** (source, mapper, verified) — accrued[msg.sender] is zeroed before send(); a failed send silently forfeits the reward. `contracts/RewardDistributor.sol:L25`
- **E-18** (access_control, mapper, verified) — A contract the owner interacts with can call sweep() and pass the tx.origin check. `contracts/Treasury.sol:L13`
- **E-19** (function, mapper, verified) — Anyone can deposit ETH collateral; no minimum or allow-list. `contracts/LendingVault.sol:L25–27`
- **E-20** (function, mapper, verified) — Health check is computed on the post-withdraw collateral value, then ETH is sent, then state is updated. `contracts/LendingVault.sol:L43–52`
- **E-21** (call_dependency, mapper, verified) — borrow() has no reentrancy guard and is callable while withdrawCollateral’s external call is in progress. `contracts/LendingVault.sol:L37–41`

## Methodology & limitations

1. Repository ingestion with path-traversal, size, and file-type guards. Repository code is never executed.
2. Deterministic repository mapping of contracts, functions, external calls, state reads/writes, and oracle dependencies.
3. Static analysis with Slither where available.
4. AI investigation over a bounded context package; outputs are candidates only.
5. Evidence validation of every contract, function, file, and line reference against the repository map.

- This is a Demo Analysis built from a fixture repository. It is not a live scan.
- ChainGuard reports potential attack chains from static evidence; it does not confirm runtime exploitability.
- Absence of findings is not proof of security.
