Part 2

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

Critical vulnerability in the withdrawal mechanism (VaultRouterBranch::redeem)

Summary

The redeem() function in VaultRouterBranch.sol contains a race condition vulnerability in its withdrawal mechanism. The function verifies that the withdrawal delay has passed before updating the vault's credit capacity. However, an attacker can front-run the transaction and manipulate the vault’s credit capacity using flash loans, leading to unauthorized withdrawals and potential insolvency.

Vulnerability Details

// Revert if withdrawal request delay not yet passed
498: if (withdrawalRequest.timestamp + vault.withdrawalDelay > block.timestamp) {
revert Errors.WithdrawDelayNotPassed();
}
// Prepare the `Vault::recalculateVaultsCreditCapacity` call
503: uint256[] memory vaultsIds = new uint256[](1);
504: vaultsIds[0] = uint256(vaultId);
// Updates the vault's credit capacity before redeeming
507: Vault.recalculateVaultsCreditCapacity(vaultsIds);
  1. The function first checks if the withdrawal delay has passed.

  2. Before the withdrawal is finalized, it updates the vault's credit capacity.

  3. Exploitation:

    • An attacker can front-run the transaction and manipulate the credit capacity.

    • They can inflate credit availability using flash loans before the transaction executes.

    • This can bypass protocol constraints, allowing withdrawals that should have been restricted.

Impact

  • Attackers can withdraw more than their fair share by artificially inflating credit availability.

  • The vault could become undercollateralized, leading to financial losses for the protocol.

  • The vulnerability makes the protocol susceptible to flash loan-based exploits, where an attacker temporarily inflates assets to gain undue withdrawals.

Tools Used

  • Manual Code Review

Recommendations

  • Store and validate credit capacity before verifying the withdrawal delay

function redeem(uint128 vaultId, uint128 withdrawalRequestId, uint256 minAssets) external {
// fetch storage slot for vault by id
Vault.Data storage vault = Vault.loadLive(vaultId);
// load storage slot for previously created withdrawal request
WithdrawalRequest.Data storage withdrawalRequest =
WithdrawalRequest.loadExisting(vaultId, msg.sender, withdrawalRequestId);
// revert if withdrawal request already fulfilled
if (withdrawalRequest.fulfilled) revert Errors.WithdrawalRequestAlreadyFulfilled();
+ // Fetch and store the initial credit capacity before verification
+ uint256 initialCreditCapacity = Vault.getCreditCapacity(vaultId);
// revert if withdrawal request delay not yet passed
if (withdrawalRequest.timestamp + vault.withdrawalDelay > block.timestamp) {
revert Errors.WithdrawDelayNotPassed();
}
+ // Ensure credit capacity remains unchanged before executing the withdrawal
+ if (Vault.getCreditCapacity(vaultId) != initialCreditCapacity) {
+ revert Errors.CreditCapacityModified();
+ }
// prepare the `Vault::recalculateVaultsCreditCapacity` call
uint256;
vaultsIds[0] = uint256(vaultId);
// updates the vault's credit capacity before redeeming
Vault.recalculateVaultsCreditCapacity(vaultsIds);
// define context struct, get withdraw shares and associated assets
RedeemContext memory ctx;
ctx.shares = withdrawalRequest.shares;
ctx.expectedAssetsX18 = getIndexTokenSwapRate(vaultId, ctx.shares, false);
// load the mm engine configuration from storage
MarketMakingEngineConfiguration.Data storage marketMakingEngineConfiguration =
MarketMakingEngineConfiguration.load();
// cache vault's redeem fee
ctx.redeemFee = vault.redeemFee;
// get assets minus redeem fee
ctx.expectedAssetsMinusRedeemFeeX18 =
ctx.expectedAssetsX18.sub(ctx.expectedAssetsX18.mul(ud60x18(ctx.redeemFee)));
// calculate assets minus redeem fee as shares
ctx.sharesMinusRedeemFeesX18 =
getVaultAssetSwapRate(vaultId, ctx.expectedAssetsMinusRedeemFeeX18.intoUint256(), false);
// get the shares to send to the vault deposit and redeem fee recipient
ctx.sharesFees = ctx.shares - ctx.sharesMinusRedeemFeesX18.intoUint256();
// cache the vault's credit capacity before redeeming
ctx.creditCapacityBeforeRedeemUsdX18 = vault.getTotalCreditCapacityUsd();
// cache the locked credit capacity before redeeming
ctx.lockedCreditCapacityBeforeRedeemUsdX18 = vault.getLockedCreditCapacityUsd();
// redeem shares previously transferred to the contract at `initiateWithdrawal` and store the returned assets
address indexToken = vault.indexToken;
uint256 assets =
IERC4626(indexToken).redeem(ctx.sharesMinusRedeemFeesX18.intoUint256(), msg.sender, address(this));
// get the redeem fee
if (ctx.sharesFees > 0) {
IERC4626(indexToken).redeem(
ctx.sharesFees, marketMakingEngineConfiguration.vaultDepositAndRedeemFeeRecipient, address(this)
);
}
// require at least min assets amount returned
if (assets < minAssets) revert Errors.SlippageCheckFailed(minAssets, assets);
// invariant: received assets must be > 0 even when minAssets = 0
if (assets == 0) revert Errors.RedeemMustReceiveAssets();
// 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();
}
// set withdrawal request to fulfilled
withdrawalRequest.fulfilled = true;
// emit an event
emit LogRedeem(vaultId, msg.sender, ctx.sharesMinusRedeemFeesX18.intoUint256());
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Lack of quality

Support

FAQs

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