LunaFi VLFI Reward Replay
Exploit Transactions
Victim Addresses
0xfc604b6fd73a1bc60d31be111f798dd0d4137812PolygonLoss Breakdown
Similar Incidents
Polygon Uninitialized Clone Wallet Takeover and TEL Drain
28%0VIX ovGHST Oracle Inflation
27%Midas LP Oracle Read-Only Reentrancy via Curve stMATIC/WPOL
27%BonqDAO ALBT Oracle Manipulation via TellorFlex
27%StakingDYNA Reward Backdating Drain
24%SNKMiner Referral Reward Drain
22%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:
- The attacker acquires LFI through public AMM liquidity and stakes it once, minting a VLFI position to a fresh holder.
- Because
stake()never initializes the minted receiver's debt baseline, that fresh holder is already in a broken state before any claim occurs. - When the fresh holder calls
claimRewards(attacker),cleanUserMapping()forcesrewardDebt = 0, so pending rewards are computed against the full historicalaccRewardsPerShare. - The holder then transfers the same VLFI balance to another fresh helper.
_transfer()adds carried debt to the receiver, but the receiver's firstclaimRewards()call zeroes it again. - 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
-
Funding and deployment. Tx
0xff35bd6f1b0ba56c5850e469b661f5e12124e34d5f3b503662efe054ba7898a3bought LFI through public QuickSwap liquidity. Tx0xdfea766ee60a9b422171557319477a43c1f2210e534202a40b2bc890d22e8c0btransferred83864793561522410960439raw LFI into the exploit contract. Tx0x8896dfda1a3d6c543ca9b9ec7bcc4e16df1ef767bf7d380e245f70f9888a07e2deployed0x43623.... -
Initial realization. Tx
0xdd82fde0cc2fb7bdc078aead655f6d5e75a267a47c33fa92b658e3573b93ef0ccalledinit(50). After that single transaction, the exploit contract held21559051344768170555550raw LFI and helper0x5ef8...held the surviving VLFI position. Combining the post-tx LFI balance with helpergetMaxWithdrawalgives105423844906290474276257raw LFI-equivalent value, up from the pre-tx83864793561522410960439. -
Repeated exploitation. Tx
0x051f80a7ef69e1ffad889ec7e1f7d29a9e80883156b5c8528438b5bb8b7a689aand the laterrun(50)transactions repeated the helper-step loop shown above. Each fresh helper claimed rewards to0x43623..., deployed the next helper clone, and forwarded the same VLFI balance for another claim. -
Compounding and monetization. Periodic
restake(uint256)transactions such as0x6be872e12a62fa0ab53d20309919503aa174a9c9efc79f70cbab01acd02ca51dcompounded harvested LFI back into the vulnerable pool, increasing the later claimable position size. Swap transactions such as0xeaa0e68270930a49be3ff75bfb70f0aa8d35da949206fd5440a6e7bcc3818b63monetized harvested LFI through public DEX liquidity. -
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]Auxiliaryinit(50)profit accounting, summarized in/workspace/session/artifacts/auditor/iter_1/profit_accounting.json.[3]run(50)trace for tx0x051f80a7ef69e1ffad889ec7e1f7d29a9e80883156b5c8528438b5bb8b7a689a.[4]init(50)balance diff for tx0xdd82fde0cc2fb7bdc078aead655f6d5e75a267a47c33fa92b658e3573b93ef0c.[5]run(50)balance diff for tx0x051f80a7ef69e1ffad889ec7e1f7d29a9e80883156b5c8528438b5bb8b7a689a.[6]Auditor attack summary covering the 56 successful attacker-crafted transactions.[7]Incident-time VLFIV8 implementation source at0xe6e5f921c8cd480030efb16166c3f83abc85298d.[8]Proxy metadata and implementation history for0xfc604b6fd73a1bc60d31be111f798dd0d4137812.