All incidents

DysonVault / Thena Overnight LP Unwind MEV

Share
Jun 17, 2024 06:58 UTCMEVGain: 51.97 BNBManually checked1 exploit txWindow: Atomic

Root Cause Analysis

DysonVault / Thena Overnight LP Unwind MEV

1. Incident Overview TL;DR

On BSC block 39,684,703 (chainid 56), an unprivileged EOA 0x4CeD363484dfeBd0faB1b33C3eca0eDca44a346C sent a single transaction (0xbac614f4d103939a9611ca35f4ec9451e1e98512d573c822fbff70fafdbbb5a0) to its own orchestrator contract 0x00Db72390C1843De815ef635EE58Ac19b54AF4EF using selector 0x6053b202. The orchestrator first interacted with DysonVault 0x2836B64a39d5B73d8f534c9fd6c6ABD81df2beB7 and its strategy StrategyCommonSolidlyHybridPoolLPOvernight to harvest rewards, then called DysonVault.withdrawAll() to burn all vault shares it held and redeem the corresponding Thena USD+/USDT+ LP tokens from Pair 0x1561D9618dB2Dcfe954f5D51f4381fa99C8E5689. It subsequently unwound those LP tokens through a chain of public routers and pools into WBNB and finally executed WBNB.withdraw(52.008 BNB), paying the resulting BNB back to the EOA.

The root cause is a MEV-style, anyone-can-take (ACT) opportunity. A passive DysonVault strategy concentrates depositor TVL in a single Thena USD+/USDT+ LP position and allows any share holder to withdraw and freely trade the underlying LP in one transaction. Given the publicly accessible Thena and Overnight liquidity, an adversary can deterministically unwind its share of the vault’s LP exposure into BNB with strictly positive profit, extracting value from other LPs and swap counterparties while DysonVault and its strategy maintain correct accounting invariants.

2. Key Background

DysonVault on BSC (vault proxy 0x2836B64a39d5B73d8f534c9fd6c6ABD81df2beB7, implementation 0x6e668080FF8Fa5F606cDC200229d054Aa5B8Fb13) is an upgradeable ERC20 vault that holds a single LP token as its underlying “want” and delegates active management to a strategy contract. The vault defines total TVL as its own want balance plus the strategy’s reported balance, and vault shares represent proportional claims on this total. In simplified form:

// DysonVault.sol (implementation at 0x6e66...fb13)
function balance() public view returns (uint256) {
    return want().balanceOf(address(this)) + IStrategyDystopia(strategy).balanceOf();
}

function withdraw(uint256 _shares) public nonReentrant {
    uint256 r = (balance() * _shares) / totalSupply();
    _burn(msg.sender, _shares);

    uint256 b = want().balanceOf(address(this));
    if (b < r) {
        uint256 _withdraw = r - b;
        strategy.withdraw(_withdraw);
        uint256 _after = want().balanceOf(address(this));
        uint256 _diff = _after - b;
        if (_diff < _withdraw) {
            r = b + _diff;
        }
    }

    want().safeTransfer(msg.sender, r);
}

The attached strategy is StrategyCommonSolidlyHybridPoolLPOvernight (implementation 0xaFF18b43Dfb44d9b56C2B88e8569b3B0880C2a56), which manages a Solidly-style hybrid pool on Thena for Overnight’s USD+ and USDT+. It stakes LP tokens in GaugeV2 0x3877c2C3D75aE80f2Ed8E9d4d68e3C1BFc77e5A6, harvests emissions into an output token, swaps the output into underlying stablecoins, mints USD+/USDT+ via Overnight exchanges, and adds liquidity to Thena Pair 0x1561D9618dB2Dcfe954f5D51f4381fa99C8E5689. A core part of the strategy is:

// StrategyCommonSolidlyHybridPoolLPOvernight.sol (impl 0xaFF1...2a56), addLiquidity()
function addLiquidity() internal override {
    uint256 outputBal = IERC20Upgradeable(output).balanceOf(address(this));
    uint256 lp0Amt = outputBal / 2;
    uint256 lp1Amt = outputBal - lp0Amt;
    // swap output -> lpToken0 / lpToken1 via public router
    // mint USD+ / USDT+ via Overnight
    // add liquidity to Thena pair and receive LP (want)
    ISolidlyRouter(dystRouter2).addLiquidity(
      usdPlus,
      usdtPlus,
      stable,
      lp0Bal,
      lp1Bal,
      1,
      1,
      address(this),
      block.timestamp
    );
}

This design means DysonVault depositors are collectively exposed to a single LP position in the Thena USD+/USDT+ pool, whose value is determined by AMM pricing, Overnight rebasing, and external market conditions. Any address holding vault shares can invoke withdraw() or withdrawAll() to pull its proportional share of the underlying LP out of the vault.

The adversary uses a custom orchestrator contract 0x00Db72390C1843De815ef635EE58Ac19b54AF4EF, deployed by EOA 0x4CeD...346C at tx 0xabc74eea51706837d3790427e119cee1ca490ad81c3ddcd03137a2d7ca702af6. The orchestrator exposes a function with selector 0x6053b202 and is called exclusively by the same EOA in the collected tx history. Its purpose is to bundle a strategy harvest, vault withdrawal, LP redemption via Thena Pair, and multi-hop swaps through public routers, culminating in a WBNB withdrawal to BNB for the EOA.

3. Vulnerability Analysis & Root Cause Summary

At the contract level, DysonVault and its strategy preserve a standard vault accounting invariant: at all times, the total underlying LP balance equals the vault’s idle LP plus the strategy’s LP balance, and each user’s claim on the underlying is proportional to their vault shares. There is no evidence of reentrancy, access-control failure, or mis-accounting in either DysonVault or StrategyCommonSolidlyHybridPoolLPOvernight.

The vulnerability arises at the system level. The vault strategy concentrates TVL in a single Thena USD+/USDT+ LP position and exposes that LP to any vault share holder via withdrawAll(). Given existing Thena/Overnight/Algebra liquidity and pricing, an adversary can construct a cyclic transaction that (a) begins and ends with only BNB in an unprivileged EOA and its orchestrator, (b) uses withdrawn LP as the only link to DysonVault, and (c) produces strictly positive net BNB profit by trading against public pools, with losses absorbed by other LPs and swap counterparties.

In the observed incident, the adversary executes exactly such a cycle: harvest → withdraw all DysonVault shares → redeem the resulting LP in Thena Pair → route the stablecoins through public routers into WBNB → withdraw WBNB into BNB. The vault and strategy invariants hold throughout, but the combined economic state of DysonVault depositors and Thena/Overnight LPs shifts to the adversary’s advantage, making this a MEV-style ACT opportunity rooted in protocol design rather than a localized smart contract bug.

4. Detailed Root Cause Analysis

4.1 Contract-level invariant

DysonVault’s implementation enforces a standard vault invariant:

  • Let balance_t = want(vault)_t + strategy.balanceOf_t.
  • For any user with shares_t, the claim on underlying is user_underlying_t = balance_t * shares_t / totalSupply_t.

The vault’s deposit(), earn(), withdraw(), and withdrawAll() functions, together with the strategy’s deposit() and withdraw(), preserve this invariant. In particular, withdrawals burn shares first, then request underlying LP from the strategy, and will only reduce the redeemed amount if the strategy cannot return the requested LP. There are no code paths that mint extra shares, transfer want to arbitrary addresses, or mis-report balanceOf(). Our inspection of DysonVault.sol and StrategyCommonSolidlyHybridPoolLPOvernight.sol confirms that, for the seed transaction, the vault and strategy logic behave as designed.

4.2 System-level economic invariant and breakpoint

At the system level (DysonVault depositors + Thena/Overnight LPs + the adversary cluster), a natural economic invariant is:

  • Starting from pre-state σ_B at block 39,684,703, there should be no cyclic, publicly constructible transaction sequence that:
    • begins and ends with only BNB held by an unprivileged adversary-related cluster,
    • leaves the adversary with no additional DysonVault shares or LP exposure at the end of the transaction, and
    • yields strictly positive BNB profit for the adversary without an offsetting BNB-denominated loss to other participants beyond explicit AMM trading fees.

Equivalently, the aggregate BNB-valued portfolio of (adversary cluster + DysonVault depositors + Thena/Overnight LPs) should be conserved up to fees across any single-transaction unwind of a vault LP position.

The breakpoint occurs when the adversary uses its DysonVault shares to access the underlying LP and then trades against public liquidity in a way that violates this system-level invariant, while leaving the vault’s local invariant intact.

4.3 Seed transaction mechanism

The seed transaction is:

  • Chain: BSC (56)
  • Tx: 0xbac614f4d103939a9611ca35f4ec9451e1e98512d573c822fbff70fafdbbb5a0
  • From: EOA 0x4CeD363484dfeBd0faB1b33C3eca0eDca44a346C
  • To: Orchestrator 0x00Db72390C1843De815ef635EE58Ac19b54AF4EF
  • Input selector: 0x6053b202

The collected trace.cast.log for this transaction shows the following key steps:

SM Address: 0x6e668080ff8fa5f606cdc200229d054aa5b8fb13, caller:0x00db72390c1843de815ef635ee58ac19b54af4ef,target:0x2836b64a39d5b73d8f534c9fd6c6abd81df2beb7
    ├─ 0x2836B64a39d5B73d8f534c9fd6c6ABD81df2beB7::withdrawAll()
    │   ├─ 0x6e668080FF8Fa5F606cDC200229d054Aa5B8Fb13::withdrawAll() [delegatecall]
    │   │   ├─ StrategyCommonSolidlyHybridPoolLPOvernight::withdraw(17139886497691096)
    │   │   ├─ GaugeV2::withdraw(17139886497691096)
...
    │   │   ├─ emit Burn(sender: 0x00Db72390C1843De815ef635EE58Ac19b54AF4EF,
    │   │   │              amount0: 15571186191039571180026,
    │   │   │              amount1: 18734917395,
    │   │   │              to: 0x00Db72390C1843De815ef635EE58Ac19b54AF4EF)
...
    ├─ WBNB::withdraw(52008000000000000000)
    │   ├─ emit Withdrawal(src: 0x00Db72390C1843De815ef635EE58Ac19b54AF4EF,
    │   │                   wad: 52008000000000000000)

From this trace and the associated balance_diff.json, the concrete mechanism is:

  1. Strategy harvest and preparation
    The orchestrator first triggers a harvest path on StrategyCommonSolidlyHybridPoolLPOvernight, which realizes accrued rewards, routes them through Overnight exchanges, and adds liquidity to the Thena USD+/USDT+ pair. This step does not violate any local invariants but may change the composition and size of the LP position.

  2. Vault withdrawal of adversary’s shares
    The orchestrator calls DysonVault.withdrawAll() on vault 0x2836...beb7 from its own address 0x00Db...4EF. The vault, via its implementation, burns all shares held by the orchestrator and requests the corresponding amount of LP tokens (want) from the strategy. The strategy withdraws 17139886497691096 LP tokens from GaugeV2 and returns them to the vault, which then transfers those LP tokens to the orchestrator as the withdraw recipient. At this point:

    • The vault-level invariant balance = want(vault) + strategy.balanceOf() still holds.
    • Other DysonVault depositors retain their proportional shares of the remaining LP exposure.
  3. Thena Pair LP burn and reserve shift
    Holding the LP tokens, the orchestrator interacts with Thena Pair 0x1561...5689:

    • LP tokens are transferred from the orchestrator to the Pair contract.
    • The call Pair::burn(0x00Db...4EF) is executed, emitting a Burn event where:
      • amount0 = 15571186191039571180026 (UsdPlusToken units),
      • amount1 = 18734917395 (paired-asset units),
      • to = 0x00Db...4EF.
    • The Pair reserves are updated to new values recorded in the trace and reflected in the UsdPlusToken and Pair balance deltas in balance_diff.json. This step realizes the adversary’s share of the underlying stablecoin position and shifts the Thena pool’s reserves, with passive LPs bearing the opposite side of the trade.
  4. Multi-hop swaps into WBNB
    The orchestrator then routes the received stable balances through a fixed chain of public swap contracts:

    • Router/pool 0x51Bd5e6d3da9064D59BcaA5A76776560aB42cEb8,
    • an AlgebraPool reachable via router 0x327Dd3208f0bCF590A66110aCB6e5e6941A4EfA0,
    • additional routers 0x1b9a1120a17617D8eC4dC80B921A9A1C50Caef7d,
    • 0xd99c7F6C65857AC913a8f880A4cb84032AB2FC5b,
    • and 0x16b9a82891338f9bA80E2D6970FddA79D1eb0daE.

    The trace shows multiple Swap events on these contracts, with large stablecoin amounts in and WBNB amounts out, leaving intermediate LPs and counterparties with less favorable token compositions.

  5. WBNB withdrawal to BNB
    Finally, the orchestrator calls WBNB.withdraw(52008000000000000000) on WBNB 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c. The WBNB contract reduces its internal balance by 52.008 BNB and sends the same amount of native BNB to the orchestrator, as shown in the Withdrawal event and in the native balance deltas.

From balance_diff.json, the net result in BNB is:

{
  "native_balance_deltas": [
    {
      "address": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c",
      "delta_wei": "-52008000000000000000"
    },
    {
      "address": "0x4ced363484dfebd0fab1b33c3eca0edca44a346c",
      "before_wei": "1317724903000000000",
      "after_wei":  "53304321053000000000",
      "delta_wei":  "51986596150000000000"
    }
  ]
}

Gas usage is 2,680,770 at 5 gwei (0.01340385 BNB). Thus:

  • Gross BNB gained by the adversary EOA: 51.98659615 BNB.
  • Gas cost: 0.01340385 BNB.
  • Net BNB profit: 51.9731923 BNB.

This profit is obtained in a single transaction, without any new DysonVault shares or persistent LP exposure for the adversary at the end, and with all contract-level invariants satisfied. The “missing” BNB comes from the WBNB contract and, economically, from liquidity providers and counterparties in the stablecoin and WBNB pools that were traded against along the path.

5. Adversary Flow Analysis

The adversary-related cluster consists of:

  • EOA: 0x4CeD363484dfeBd0faB1b33C3eca0eDca44a346C (sender, profit recipient, and deployer of orchestrator).
  • Orchestrator contract: 0x00Db72390C1843De815ef635EE58Ac19b54AF4EF (callee of 0x6053b202 that bundles the exploit path).

This cluster is minimal: no additional helper contracts are required, and all other contracts involved (DysonVault, strategy, Thena Pair and Gauge, Overnight exchanges, routers, WBNB) are public and permissionless.

The adversary’s execution flow in the seed transaction is:

  1. Preparation (off-chain / prior state)

    • The adversary has previously acquired DysonVault shares (e.g., via deposits) so that the orchestrator holds a non-trivial vault balance.
    • The vault owner has already set the strategy to StrategyCommonSolidlyHybridPoolLPOvernight, and the vault TVL is concentrated in the Thena USD+/USDT+ LP position.
  2. Step 1 – Orchestrator call

    • The EOA submits tx 0xbac6...bb5a0 calling 0x00Db...4EF with selector 0x6053b202. No access control or whitelist gates this call; it is a standard public transaction with 5 gwei gas price and sufficient gas limit.
  3. Step 2 – Strategy harvest

    • The orchestrator triggers a harvest on StrategyCommonSolidlyHybridPoolLPOvernight, which claims accumulated rewards, swaps them into desired assets, mints Overnight USD+/USDT+, and adds liquidity back into the Thena pair. This increases or reshapes the vault’s LP exposure but does not directly realize profit for the adversary.
  4. Step 3 – Vault withdrawAll

    • The orchestrator calls DysonVault.withdrawAll() from its own address, causing the vault to:
      • Compute the adversary’s share of balance() based on its vault shares.
      • Burn all the orchestrator’s shares.
      • Pull LP from the strategy and transfer the corresponding LP tokens (17139886497691096 units) to the orchestrator.
  5. Step 4 – LP redemption via Thena Pair::burn

    • Using the LP tokens, the orchestrator interacts with Thena Pair 0x1561...5689:
      • LP is transferred to the Pair contract.
      • Pair::burn(0x00Db...4EF) is called, which burns the LP and sends out the underlying USD+/USDT+ balances to the orchestrator, while updating pool reserves.
  6. Step 5 – Multi-hop swaps to WBNB

    • The orchestrator routes the stablecoins through a deterministic series of swaps across public routers and pools, ending in WBNB. The trace shows large positive amount0In / amount1Out values for these swaps, indicating significant stable-to-WBNB conversion.
  7. Step 6 – WBNB withdrawal to BNB

    • The orchestrator calls WBNB.withdraw(52.008 BNB), which reduces WBNB’s internal balances and transfers 52.008 BNB to the orchestrator. After accounting for gas, the EOA ends the transaction with 51.9731923 BNB more than it started with, and with no remaining DysonVault shares or LP tokens from this cycle.

Because all contracts involved are public and unprivileged, and the orchestrator is merely a convenience wrapper, any actor with DysonVault shares in the same pre-state could deploy an equivalent orchestrator and replay this strategy. This satisfies the ACT adversary model: the opportunity is not tied to special permissions, only to access to shares and public liquidity.

6. Impact & Losses

From balance_diff.json and the seed trace:

  • The adversary EOA’s native BNB balance increases from 1.317724903 BNB to 53.304321053 BNB, a gross gain of 51.98659615 BNB.
  • WBNB (0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c) loses 52.008 BNB via withdraw, consistent with the WBNB contract semantics.
  • Gas costs for the seed tx are 0.01340385 BNB, leaving a net profit of 51.9731923 BNB for the adversary cluster.

The economic loss is distributed across:

  • Thena and Algebra LPs whose pools were used to route stablecoins into WBNB and whose reserves shifted against them.
  • Swap counterparties who traded into less favorable prices during the adversary’s large, orchestrated swaps.
  • DysonVault depositors indirectly, via reduced vault TVL corresponding to the legitimately withdrawn shares (though the vault’s internal accounting remains correct and no depositor’s share is mis-accounted).

There is no on-chain evidence of insolvency in DysonVault, but the incident demonstrates that a passive LP vault design, combined with deep public AMM liquidity, can expose users to deterministic MEV extraction on their pooled position.

7. References

  • Seed transaction (exploit):
    • BSC tx 0xbac614f4d103939a9611ca35f4ec9451e1e98512d573c822fbff70fafdbbb5a0 (EOA → orchestrator).
  • Adversary-related accounts:
    • EOA: 0x4CeD363484dfeBd0faB1b33C3eca0eDca44a346C
    • Orchestrator: 0x00Db72390C1843De815ef635EE58Ac19b54AF4EF (deployed in tx 0xabc74eea51706837d3790427e119cee1ca490ad81c3ddcd03137a2d7ca702af6).
  • Victim and strategy contracts:
    • DysonVault proxy: 0x2836B64a39d5B73d8f534c9fd6c6ABD81df2beB7 (implementation at 0x6e668080FF8Fa5F606cDC200229d054Aa5B8Fb13).
    • StrategyCommonSolidlyHybridPoolLPOvernight implementation: 0xaFF18b43Dfb44d9b56C2B88e8569b3B0880C2a56 (proxy 0x2b9BDa587ee04fe51C5431709afbafB295F94bB4).
    • Thena USD+/USDT+ Pair: 0x1561D9618dB2Dcfe954f5D51f4381fa99C8E5689.
    • GaugeV2 for the LP: 0x3877c2C3D75aE80f2Ed8E9d4d68e3C1BFc77e5A6.
    • UsdPlusToken proxy: 0xe80772Eaf6e2E18B651F160Bc9158b2A5caFCA65 (implementation 0x6002054688d62275d80cc615f0f509d9b2ff520d).
    • WBNB: 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c.
  • Key logs and artifacts (for reproduction):
    • Seed tx metadata and receipt for 0xbac6...bb5a0.
    • Seed tx trace.cast.log showing DysonVault.withdrawAll, strategy withdraw, Pair::burn, swap, and WBNB::withdraw calls and events.
    • balance_diff.json for tx 0xbac6...bb5a0, showing native and ERC20 balance changes used in the profit calculation.
    • Verified source code for DysonVault.sol, StrategyCommonSolidlyHybridPoolLPOvernight.sol, Thena Pair.sol, Router.sol, and WBNB.sol, which allows independent verification of the invariants and flow described above.