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.
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:
- Step one: a request is created and the input USDC is sent to the pool's deposit vault. The keeper's
deployToPool()does this, throughPoolDeployLib.deploy. - Step two: a separate keeper transaction, in a later block, prices the deposit and mints the GM pool tokens back to the vault.
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.
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:
- A victim deposits 1,000,000 USDC and gets shares at par.
- The keeper deploys 900,000 USDC to the 0xM pool. The vault's reported value drops to 100,000 while the pool really still holds about 1,000,000.
- Before the GM tokens arrive, the attacker deposits 100,000 USDC and, dividing by that 100,000 value, receives the same number of shares the victim got for 1,000,000.
- The GM tokens settle and the value recovers. The attacker now controls 550,000 USDC of pool value for a 100,000 outlay, and the victim is left with 550,000 of the 1,000,000 they put in.
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.
- Count the in-flight amount. Record the USDC that has been sent to 0xM but not yet converted to GM tokens, and add that pending amount into
_totalValueLocked()until the GM tokens arrive. Set it indeployToPool, clear it when the GM balance lands. - Or pause deposits while a deploy is in flight, so no one can be priced against the undercounted value.
- Or quote deposits against a settled value snapshot that an undelivered deploy cannot move.
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.