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 realizePnL function executes in the proxy’s storage context via DELEGATECALL to facets:
    • 0x0ef31752a4d7bef5a46b378873613f706255b9cd (UtilMath — contains _calcPnL)
    • 0x78197fe93999e34d5a688e1819923c66dcf8f4db (CurveMath — contains computeShortReturn)
    • 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) — in Vault.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:

StepNormal LPAttacker LP
AMM state at realizePnLFair, organic order flowHeavily skewed by attacker’s own large trade
globalLiquidityStableReflects genuine LP depositsInflated +100K by attacker’s long
globalLiquidityAssetReflects genuine LP depositsDrained by attacker’s long
computeShortReturn outputReasonable vAsset exit price~6× inflated vs collateral deposited
PnL added to collateralProportional to position183,283 USDC from 30,000 USDC collateral

Attack Execution

High-Level Flow

  1. Attacker EOA calls run() on the pre-deployed Orchestrator contract (0xb875...).
  2. Orchestrator takes a flash loan of 60,000 USDC from Aave V3 on Linea.
  3. Round 1: Orchestrator deploys two ephemeral contracts — AttackerLP0 and AttackerTrader1 — and funds them (30,000 and 15,000 USDC respectively).
  4. AttackerLP0 deposits 30,000 USDC as collateral into Vault, then calls addLiquidity(20K vStable, 0 vAsset) on PerpPair.
  5. AttackerTrader1 deposits 15,000 USDC as collateral, then calls trade(long, 100,000 vStable) — an oversized long that skews globalLiquidityStable up and globalLiquidityAsset down, distorting the virtual AMM curve.
  6. AttackerLP0 calls realizePnL(). The live _calcPnL computation 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.
  7. AttackerLP0 calls removeCollateral to withdraw ~183,283 USDC from the Vault; it forwards the funds to the Orchestrator.
  8. 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 calls realizePnL to collect ~42,364 USDC.
  9. AttackerLP2 withdraws ~42,364 USDC and forwards to Orchestrator.
  10. 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

AddressRoleUSDC 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 VariablePre-txPost-txChange
globalLiquidityStable310,392.65 vStable468,334.04 vStable+157,941 vStable
globalLiquidityAsset4.3255 vAsset2.4329 vAsset−1.8926 vAsset
vStable/vAsset ratio71,758192,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.json file 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