All attack chains
CG-002HighSupportedModerately supported
74%

Potential cross-function reentrancy enabling borrow against withdrawn collateral

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.

Step 1 · Attacker action

Withdraw collateral from a contract account

LendingVault.withdrawCollateral() · contracts/LendingVault.sol:L43–52

What happens
Attacker contract calls withdrawCollateral(amount); the post-withdraw health check passes.
Why it is included
Health check is evaluated on the remaining collateral, before any transfer.
Findings
F-004
Step confidence
90%

Supporting evidence

contracts/LendingVault.sol
lines 43–52
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {SpotPriceOracle} from "./SpotPriceOracle.sol";
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
/// @notice ETH-collateralised lending vault that issues a debt token.
contract LendingVault {
uint256 public constant COLLATERAL_FACTOR_BPS = 7_500;
SpotPriceOracle public immutable oracle;
IERC20 public immutable debtToken;
mapping(address => uint256) public collateral;
mapping(address => uint256) public debt;
constructor(address oracle_, address debtToken_) {
oracle = SpotPriceOracle(oracle_);
debtToken = IERC20(debtToken_);
}
function depositCollateral() external payable {
collateral[msg.sender] += msg.value;
}
function collateralValue(address account) public view returns (uint256) {
return (collateral[account] * oracle.getPrice()) / 1e18;
}
function maxBorrow(address account) public view returns (uint256) {
return (collateralValue(account) * COLLATERAL_FACTOR_BPS) / 10_000;
}
function borrow(uint256 amount) external {
require(debt[msg.sender] + amount <= maxBorrow(msg.sender), "insufficient collateral");
debt[msg.sender] += amount;
require(debtToken.transfer(msg.sender, amount), "transfer failed");
}
function withdrawCollateral(uint256 amount) external {
require(collateral[msg.sender] >= amount, "insufficient collateral");
uint256 remainingValue = ((collateral[msg.sender] - amount) * oracle.getPrice()) / 1e18;
require(debt[msg.sender] <= (remainingValue * COLLATERAL_FACTOR_BPS) / 10_000, "undercollateralized");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "ETH transfer failed");
collateral[msg.sender] -= amount;
}
}

How to break the chain

Impact

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

Assets at risk
  • LendingVault debt-token balance
  • LendingVault ETH collateral

Preconditions

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

Why this confidence

Chain confidence 74%, derived from validation checks rather than model certainty.

  • Source code located for every step
  • Referenced contracts and functions exist
  • Static detector flags the core issuereentrancy-eth
  • State dependency confirmed`collateral` written after call, read by borrow
  • Re-entry path through attacker codeDepends on attacker-controlled receive() outside the repository.
  • Runtime exploit confirmedNo transaction simulation was performed.

Assumptions & limitations

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