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.
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: MITpragma 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.