All incidents

LunaFi VLFI Reward Replay

Share
May 22, 2023 21:03 UTCAttackLoss: 86,820,932.19 LFIPending manual check2 exploit txWindow: 27m
Estimated Impact
86,820,932.19 LFI
Label
Attack
Exploit Tx
2
Addresses
1
Attack Window
27m
May 22, 2023 21:03 UTC → May 22, 2023 21:30 UTC

Exploit Transactions

TX 1Polygon
0xdd82fde0cc2fb7bdc078aead655f6d5e75a267a47c33fa92b658e3573b93ef0c
May 22, 2023 21:03 UTCExplorer
TX 2Polygon
0x051f80a7ef69e1ffad889ec7e1f7d29a9e80883156b5c8528438b5bb8b7a689a
May 22, 2023 21:30 UTCExplorer

Victim Addresses

0xfc604b6fd73a1bc60d31be111f798dd0d4137812Polygon

Loss Breakdown

86,820,932.19LFI

Similar Incidents

Root Cause Analysis

LunaFi VLFI Reward Replay

1. Incident Overview TL;DR

At Polygon blocks 43025777 through 43026917, the LunaFi VLFI pool proxy at 0xfc604b6fd73a1bc60d31be111f798dd0d4137812 was still using incident-time implementation 0xe6e5f921c8cd480030efb16166c3f83abc85298d. An unprivileged adversary cluster funded itself through public QuickSwap liquidity, deployed exploit contract 0x43623b96936e854f8d85f893011f22ac91e58164, minted a VLFI position once, and then repeatedly claimed LFI rewards by transferring that same VLFI balance through fresh attacker-controlled holder contracts. The campaign used one init(uint256) transaction, many run(uint256) transactions, periodic restake(uint256) compounding transactions, and later swap() transactions to monetize the drained LFI.

The root cause was broken reward-debt initialization for first-time VLFI holders. stake(address,uint256) minted VLFI without assigning the receiver a reward-debt baseline, and cleanUserMapping() later reset a fresh holder's rewardDebt to zero immediately before claimRewards(address) computed pending rewards. Because of that combination, the same attacker-controlled VLFI position could re-claim historical rewards every time it moved to a fresh helper holder.

2. Key Background

LunaFi VLFI used the standard farming pattern of accRewardsPerShare plus per-user rewardDebt. Correct behavior requires each minted or transferred balance to start with a debt baseline equal to its share of the current accumulated reward index, so that future claimRewards() only pays rewards accrued after the holder acquired that balance.

The victim entrypoint was a transparent proxy. Storage at the EIP-1967 implementation slot confirms that exploit block 43025777 still pointed to 0xe6e5f921c8cd480030efb16166c3f83abc85298d, not the newer implementation now live on the proxy. The pool also held a large LFI reward balance at exploit time, so the broken accounting was economically exploitable as soon as an attacker could obtain any positive VLFI balance.

The attacker-side execution was contract based, but still permissionless. The owner EOA 0x11576cb3d8d6328cf319e85b10e09a228e84a8de deployed unverified exploit contract 0x43623b96936e854f8d85f893011f22ac91e58164. Bytecode analysis shows that runtime hardcoded the owner, VLFI pool, LFI token, QuickSwap router, and QuickSwap pair. The fresh holder addresses were EIP-1167 minimal proxies that delegate back to 0x43623..., so the repeated claim loop did not depend on third-party users or privileged helper contracts.

3. Vulnerability Analysis & Root Cause Summary

This was an on-chain accounting bug in the incident-time VLFI implementation, not an MEV-only pricing opportunity. The broken invariant was: after minting or transferring VLFI to holder h, userInfo[h].rewardDebt must equal balanceOf(h) * farm.accRewardsPerShare / ACC_REWARD_PRECISION. The contract violated that invariant in two places. First, stake() called farmUtil() for msg.sender and then minted VLFI to onBehalfOf, but never assigned the minted receiver an equivalent reward-debt baseline. Second, cleanUserMapping() set userInfo[msg.sender].rewardDebt = 0 for a first-time holder, so the first subsequent claimRewards() treated the holder as if it had been entitled to the full historical reward index all along. _transfer() tried to carry debt forward to the recipient, but that protection was erased when the fresh holder's first claimRewards() call ran through cleanUserMapping() and overwrote the carried debt with zero. The exploit therefore needed only three public conditions: positive historical accRewardsPerShare, any attacker-funded positive VLFI position, and a stream of fresh attacker-controlled recipient addresses.

4. Detailed Root Cause Analysis

The critical victim-side logic is visible in the incident-time implementation:

function stake(address onBehalfOf, uint256 amount) public {
    uint256 lpTokensToMint = (amount * 10 ** MAX_PRECISION) / lpTokenPrice;
    farmUtil(lpTokensToMint);
    _mint(onBehalfOf, lpTokensToMint);
}

function cleanUserMapping() internal {
    if (userCleanMapping[msg.sender] != true) {
        userInfo[msg.sender].amount = balanceOf(msg.sender);
        userInfo[msg.sender].rewardDebt = 0;
        userCleanMapping[msg.sender] = true;
    }
}

function farmUtil(uint256 _amount) internal {
    cleanUserMapping();
    FarmInfo memory farm = updateFarm();
    UserInfo storage user = userInfo[msg.sender];
    if (balanceOf(msg.sender) > 0) {
        uint256 pending = uint256(
            int256((balanceOf(msg.sender) * farm.accRewardsPerShare) / ACC_REWARD_PRECISION) - user.rewardDebt
        );
        if (pending > 0) pendingRewards[msg.sender] += pending;
    }
    user.rewardDebt = int256((balanceOf(msg.sender) * farm.accRewardsPerShare) / ACC_REWARD_PRECISION);
}

Origin: incident-time VLFIV8 implementation 0xe6e5f921....

The exploit path is deterministic:

  1. The attacker acquires LFI through public AMM liquidity and stakes it once, minting a VLFI position to a fresh holder.
  2. Because stake() never initializes the minted receiver's debt baseline, that fresh holder is already in a broken state before any claim occurs.
  3. When the fresh holder calls claimRewards(attacker), cleanUserMapping() forces rewardDebt = 0, so pending rewards are computed against the full historical accRewardsPerShare.
  4. The holder then transfers the same VLFI balance to another fresh helper. _transfer() adds carried debt to the receiver, but the receiver's first claimRewards() call zeroes it again.
  5. The same underlying VLFI position can therefore repeat the claim on every fresh hop without being burned or reduced.

The later run(50) trace shows this exact loop on-chain:

VLFI_8::claimRewards(0x43623B96936E854f8d85F893011f22ac91e58164)
emit RewardsClaimed(from: 0xF307ca18..., to: 0x43623B96936E854f8d85F893011f22ac91e58164, amount: 431550248066228501595)
→ new helper proxy 0x73C18e7c3B1AB61a64Ce7544d7E8A3237c8BAA23
VLFI_8::transfer(0x73C18e7c3B1AB61a64Ce7544d7E8A3237c8BAA23, 82218686839775932629)
VLFI_8::claimRewards(0x43623B96936E854f8d85F893011f22ac91e58164)
emit RewardsClaimed(from: 0x73C18e7c..., to: 0x43623B96936E854f8d85F893011f22ac91e58164, amount: 431550248066228501595)

Origin: run(50) trace for tx 0x051f80a7ef69e1ffad889ec7e1f7d29a9e80883156b5c8528438b5bb8b7a689a.

Independent checks support the rest of the finalized accounting. cast storage at block 43025777 confirms the proxy implementation slot contains 0xe6e5f921.... cast call at the same block confirms getMaxWithdrawal(0x5ef8a60839599cfe155eeca7d6a17424ef00e03c) = 83864793561522303720707, matching the auditor's auxiliary init(50) profit accounting. cast code on 0x5ef8... returns minimal-proxy runtime 0x363d3d373d3d3d363d7343623b96936e854f8d85f893011f22ac91e581645af43d82803e903d91602b57fd5bf3, which delegates back to exploit contract 0x43623....

5. Adversary Flow Analysis

  1. Funding and deployment. Tx 0xff35bd6f1b0ba56c5850e469b661f5e12124e34d5f3b503662efe054ba7898a3 bought LFI through public QuickSwap liquidity. Tx 0xdfea766ee60a9b422171557319477a43c1f2210e534202a40b2bc890d22e8c0b transferred 83864793561522410960439 raw LFI into the exploit contract. Tx 0x8896dfda1a3d6c543ca9b9ec7bcc4e16df1ef767bf7d380e245f70f9888a07e2 deployed 0x43623....

  2. Initial realization. Tx 0xdd82fde0cc2fb7bdc078aead655f6d5e75a267a47c33fa92b658e3573b93ef0c called init(50). After that single transaction, the exploit contract held 21559051344768170555550 raw LFI and helper 0x5ef8... held the surviving VLFI position. Combining the post-tx LFI balance with helper getMaxWithdrawal gives 105423844906290474276257 raw LFI-equivalent value, up from the pre-tx 83864793561522410960439.

  3. Repeated exploitation. Tx 0x051f80a7ef69e1ffad889ec7e1f7d29a9e80883156b5c8528438b5bb8b7a689a and the later run(50) transactions repeated the helper-step loop shown above. Each fresh helper claimed rewards to 0x43623..., deployed the next helper clone, and forwarded the same VLFI balance for another claim.

  4. Compounding and monetization. Periodic restake(uint256) transactions such as 0x6be872e12a62fa0ab53d20309919503aa174a9c9efc79f70cbab01acd02ca51d compounded harvested LFI back into the vulnerable pool, increasing the later claimable position size. Swap transactions such as 0xeaa0e68270930a49be3ff75bfb70f0aa8d35da949206fd5440a6e7bcc3818b63 monetized harvested LFI through public DEX liquidity.

  5. Persistent attack surface. The collected attack summary records 56 successful attacker-crafted transactions following the same general pattern. The exploit did not rely on private keys beyond the adversary's own cluster, off-chain privileged data, or any protocol-admin operation.

6. Impact & Losses

The observed campaign drained at least 86820932187817664061071311 raw LFI from the pool proxy to the attacker contract after accounting for direct restakes. At 18 decimals, that is 86,820,932.187817664061071311 LFI. The loss was not a one-time payout bug on newly minted capital; it was repeated extraction of historical reward entitlement from a single recyclable VLFI position.

For the single init(50) anchor transaction alone, the adversary cluster's auxiliary LFI-denominated value increased by 21559051344768063315818 raw units. That auxiliary figure is separate from the full-campaign net loss figure above, which comes from direct transfer-log accounting across all attacker-crafted transactions.

7. References

  • [1] Attacker contract bytecode analysis: 0x43623b96936e854f8d85f893011f22ac91e58164, summarized in /workspace/session/artifacts/auditor/iter_1/attacker_contract_analysis.json.
  • [2] Auxiliary init(50) profit accounting, summarized in /workspace/session/artifacts/auditor/iter_1/profit_accounting.json.
  • [3] run(50) trace for tx 0x051f80a7ef69e1ffad889ec7e1f7d29a9e80883156b5c8528438b5bb8b7a689a.
  • [4] init(50) balance diff for tx 0xdd82fde0cc2fb7bdc078aead655f6d5e75a267a47c33fa92b658e3573b93ef0c.
  • [5] run(50) balance diff for tx 0x051f80a7ef69e1ffad889ec7e1f7d29a9e80883156b5c8528438b5bb8b7a689a.
  • [6] Auditor attack summary covering the 56 successful attacker-crafted transactions.
  • [7] Incident-time VLFIV8 implementation source at 0xe6e5f921c8cd480030efb16166c3f83abc85298d.
  • [8] Proxy metadata and implementation history for 0xfc604b6fd73a1bc60d31be111f798dd0d4137812.