DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Direct Collateral Injection Vulnerability


Summary and Impact

This vulnerability arises because the vault’s calculation of its total collateral relies directly on the ERC20 token balance (using collateralToken.balanceOf(address(this))) when no active position exists. In effect, any user even one acting without malicious intent, can send additional collateral tokens directly to the vault without triggering the deposit logic. This extra balance gets included in share‐minting and withdrawal calculations, thereby inflating the apparent pool value. As a consequence, a user who deposits via the proper channel can later withdraw more than what they originally contributed, upsetting the fairness of the system.

The issue directly conflicts with the protocol’s core invariants. For example, one key invariant is that the value of a depositor’s shares should only reflect what was deposited, ensuring that no user gains an unfair advantage due to the actions of another. When the contract uses the entire token balance to compute the pool value, it ignores the fact that not every token in the vault has come through the deposit mechanism. This misalignment between external token transfers and internal accounting can allow a user to game the system. The consequence is a dilution of other users’ share value, as the extra tokens skew the share allocation formula during deposits and withdrawals.

It undermines the reliability and fairness of the protocol. Given the complexity of the system in which automated strategies and dynamic share minting are central to its operation, this vulnerability presents a high-level risk that must be addressed to maintain trust and correctness.


Vulnerability Details

The flaw stems from the following implementation in the vault’s _totalAmount function:

function _totalAmount(MarketPrices memory prices) internal view returns (uint256) {
if (positionIsClosed) {
return collateralToken.balanceOf(address(this));
} else {
IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(curPositionKey, prices);
uint256 total = IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min / prices.shortTokenPrice.min
+ collateralToken.balanceOf(address(this))
+ positionData.netValue / prices.shortTokenPrice.min;
return total;
}
}

When the vault is not engaged in an active position, it simply returns the ERC20 balance of the collateral token. This design does not differentiate between funds deposited via the vault’s controlled interface and tokens that are sent directly to the contract address.

This becomes problematic during the share minting process. Consider the following snippet from the _mint function:

function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * 1e8;
} else {
uint256 totalAmountBefore;
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
totalAmountBefore = _totalAmount(prices) - amount;
}
if (totalAmountBefore == 0) totalAmountBefore = 1;
_shares = amount * totalShares / totalAmountBefore;
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[counter].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
}
}
emit Minted(depositId, depositInfo[depositId].owner, _shares, amount);
}

Here, if a user deposits an amount that is later used to compute new shares, the denominator (totalAmountBefore) is based on the entire on‑chain balance. Should an external party send additional collateral tokens directly to the vault, this balance becomes artificially inflated. When a withdrawal occurs, the computed return (which is proportional to the vault’s total balance) will then exceed the genuine deposit.

For instance, imagine the following scenario:

  • A user deposits 1,000 tokens via the proper deposit mechanism. The vault mints shares based on an uninflated pool.

  • Separately, another user (or even a well‐meaning party unaware of the internal accounting) transfers an extra 5,000 tokens directly to the vault.

  • The vault’s balance is now 6,000 tokens, but its internal record still only reflects the 1,000 deposited via the deposit() function.

  • Upon withdrawal, the user’s share calculation is based on a 6,000-token balance, effectively letting them redeem a value greater than their original contribution.

Here's a proof of concept demonstrating the issue:

function test_DirectTransferExploit() public {
uint256 depositAmount = 1000 * 1e18;
uint256 extraInjection = 5000 * 1e18;
// A user deposits via the vault's deposit() function.
vm.startPrank(user);
collateralToken.approve(address(vault), depositAmount);
// For simplicity, assume execution fee is 0 in our test environment.
vault.deposit{value: 0}(depositAmount);
vm.stopPrank();
// A separate direct token transfer inflates the vault's balance.
vm.startPrank(user);
collateralToken.transfer(address(vault), extraInjection);
vm.stopPrank();
// Verify that the vault's balance now reflects both the deposit and the extra tokens.
uint256 vaultBalance = collateralToken.balanceOf(address(vault));
assertEq(vaultBalance, depositAmount + extraInjection);
// On withdrawal, the share calculation uses the inflated balance.
vm.startPrank(user);
vault.withdraw(user, 1);
vm.stopPrank();
// The user's final balance will be higher than expected, demonstrating the flaw.
uint256 finalBalance = collateralToken.balanceOf(user);
assertGt(finalBalance, 1e21 - depositAmount);
console.log("User final token balance:", finalBalance);
}

Tools Used

  • Manual Review

  • Foundry


Recommendations

To address this vulnerability, it is essential to decouple the vault’s notion of total collateral from the raw ERC20 token balance. One viable solution is to maintain an internal ledger that accurately tracks only those tokens deposited via the vault’s controlled interface. For example, the contract should use an internal variable (such as totalDepositAmount) for share calculations rather than relying on collateralToken.balanceOf(address(this)).

Specifically, the _totalAmount function should be revised so that when the position is closed it returns the sum of the tracked deposits, not the entire balance. Any direct transfers should be either rejected or properly segregated so they do not affect share calculations. Additionally, the deposit function could perform a pre- and post-balance check to ensure that only the intended tokens are counted as part of the deposit. This change will preserve the invariant that a depositor’s share value reflects only the funds that were intentionally contributed via the vault’s deposit mechanism, thereby preventing gaming of the system.


Updates

Lead Judging Commences

n0kto Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

invalid_collateral_balanceOf_donation

Prevent tokens from getting stuck. This is a simple donation to every shareholder, and therefore, every share has more value (profit), leading to fewer shares for future users. If this is not intended by the user, it is merely a mistake! For totalAmountBefore > amount * totalShares, Here is the worst-case scenario (with the minimum amount): first deposit 0.001000 (e4) USDC → 1e12 shares. Second deposit: 0.001 (e4) USDC * 1e12 / 0.001 (e4) USDC. So, the attacker would need to donate 1e16 tokens, meaning 1e10 USDC → 10 billion $. Even in the worst case, it's informational.

Support

FAQs

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