Level Finance ReferralControllerV2 claimMultiple() epoch-reuse drain (2023-05-01)
- Name: Level Finance ReferralControllerV2
claimMultiple()epoch-reuse drain - Date: 2023-05-01
- Protocol: Level Finance
- Chain: BNB Chain
- Category: duplicate claim / idempotency failure / reward-epoch replay / post-audit upgrade regression
- Loss: about
214,000 LVL, swapped for roughly3,345 BNBand commonly reported around$1.1M - Summary: Level Finance’s referral-rewards controller let the same reward epoch be consumed more than once inside a single batch claim. In the upgraded
LevelReferralControllerV2implementation,claim()correctly diduser.claimed += reward, butclaimMultiple()instead overwrote storage withusers[epoch][msg.sender].claimed = reward. That madeclaimable(epoch, user)non-idempotent when the same epoch appeared repeatedly in_epoches: the remaining unclaimed reward and the already-claimed amount could alternate as fresh positive payouts instead of converging to zero. The attacker first farmed large referral rewards by creating many referrals and using flash-loaned swaps to boost reward tier and points, then replayed the same epochs insideclaimMultiple()until the referral controller paid out about214k LVL. - Trigger / entry point:
- The vulnerable user entry point was
LevelReferralControllerV2.claimMultiple(uint256[] calldata _epoches, address _to)on Level’s referral-controller proxy0x977087422C008233615b572fBC3F209Ed300063a. - Level’s referral system let traders accumulate
tradingPointand let referrers accumulatereferralPoint, with epoch rewards priced throughclaimable(epoch, user). - Public reporting and onchain source showed the bug was introduced by an April 18, 2023 proxy upgrade to implementation
0x9f00fbd6c095d2c542687ed5afb68d9c3fb2f464, after the audited code snapshot. - The exploit was limited to the referral controller balance; Level said LP funds and treasury funds were not directly at risk from this path.
- The vulnerable user entry point was
- Root cause:
- Level violated the
consume onceinvariant on reward epochs. A batch claim treated caller-supplied epoch IDs like honest distinct entitlements instead of adversarial input. claimable(epoch, user)returnstotalVestedReward - user.claimed, so the storage update must accumulate claimed amount monotonically.claim()handled this correctly withuser.claimed += reward, butclaimMultiple()usedusers[epoch][msg.sender].claimed = reward.- Because the batch function overwrote
claimedwith the just-computed delta instead of adding that delta to the already-claimed total, repeating one epoch could make later loop iterations see fresh positive claimable amounts again. - The exploit was therefore both a duplicate-epoch batching bug and an upgrade-regression bug: the vulnerable implementation differed from the public GitHub source audited earlier.
- Level violated the
- Exploit path:
- The attacker prepared a large referral-reward position by creating many referrals and using flash-loaned trading activity to increase referral points and reward tier.
- They interacted with Level’s referral controller through attacker address
0x70319d1c09e1373fc7b10403c852909e5b20a9d5. - They called
claimMultiple()with repeated epoch IDs rather than a set of distinct claimable epochs. - Inside the loop,
claimable(epoch, msg.sender)computed reward fromtotalRewardForEpoch - users[epoch][msg.sender].claimed. - Instead of adding the claimed amount cumulatively, the vulnerable code did
users[epoch][msg.sender].claimed = reward, so the same epoch could remain partially claimable again on later iterations in the same batch. - The loop kept adding those repeated positive values into
totalReward, and the controller transferred the inflatedLVLpayout at the end of the batch. - The attacker repeated the process until roughly
214,000 LVLhad been drained, then sold the tokens for about3,345 BNB.
- What the attacker needed to be true:
- The referral controller had to hold enough
LVLto make replayed epoch claims worth extracting. - The attacker needed a sufficiently large accrued reward balance, which public writeups say was achieved by creating many referrals and using flash-loaned swaps to boost points and tier.
claimMultiple()had to accept caller-supplied epoch arrays without deduplication or canonicalization.- The upgraded implementation had to overwrite
claimedwith the current delta instead of monotonically increasing it. - The vulnerable implementation needed to remain live long enough after the April 18 upgrade for the attacker to discover and exploit the regression.
- The referral controller had to hold enough
- Technical anatomy worth remembering:
- This was not a forged-reward or oracle bug. The underlying reward epochs were real; the bug was that one
(user, epoch)reward entitlement could be re-consumed inside a batch because the consumed-state update was wrong. - The decisive contrast is local and memorable:
claim()used+=, whileclaimMultiple()used=. - Public analysis described the resulting payout stream as an alternating positive sequence when the same epoch was reused, which is exactly the sort of
this batch element is still unique, right?assumption duplicate-claim reviews should attack first. - The sharper design question is not only
should duplicate epoch IDs be rejected?, butwhat one reward-epoch entitlement object should every claim path consume exactly once before later loop iterations can still see it as live? - The audit story matters. Quantstamp later said the bug was introduced after their audit in an upgraded implementation that was not the same code as the audited GitHub commit.
- Level’s LP and treasury were not directly drained because the exploit surface sat in a rewards helper contract. But helper contracts still hold real assets, so idempotency bugs there are custody bugs.
- This was not a forged-reward or oracle bug. The underlying reward epochs were real; the bug was that one
Three reward-authority promotions this incident collapsed
- A reward-looking epoch artifact existed at all
- The attacker really did have referral activity, epoch IDs, and some non-zero referral reward state attached to those epochs.
- At this stage the strongest true fact was only
there is some epoch-shaped reward artifact the controller can price, notthis batch contains several distinct spendable reward rights.
- That epoch artifact was promoted into one canonical still-live entitlement for each loop iteration
- This was the decisive failure.
claimable(epoch, user)was supposed to speak about one monotone(user, epoch)consumed-state object, butclaimMultiple()broke that identity by writingclaimed = rewardinstead of advancing the already-consumed amount. - Repeating the same epoch in
_epochestherefore did not keep resolving tothe same right, now already consumed; it kept re-opening the same reward artifact as if later iterations were seeing fresh entitlement state.
- This was the decisive failure.
- That accepted entitlement still deserved immediate payout authority through the batch helper
- Once the batch helper treated each repeated epoch occurrence as newly payable, every sibling loop iteration kept adding to
totalReward, and the controller still honored that aggregate with liveLVLinventory. - The practical loss was therefore not just
reward accounting looked odd; it wasone real epoch-level reward right inherited repeated cash-out authority against a token-bearing controller.
- Once the batch helper treated each repeated epoch occurrence as newly payable, every sibling loop iteration kept adding to
- Representative transactions and contracts:
- Attacker address:
0x70319d1c09e1373fc7b10403c852909e5b20a9d5 - Vulnerable referral-controller proxy:
0x977087422C008233615b572fBC3F209Ed300063a - Vulnerable upgraded implementation:
0x9f00fbd6c095d2c542687ed5afb68d9c3fb2f464 - Representative exploit transaction:
0xe1f257041872c075cbe6a1212827bc346df3def6d01a07914e4006ec43027165 - Upgrade transaction that introduced the vulnerable implementation, per rekt.news / Quantstamp statement:
0xe0a8e635f4778f6cc935873d8c17a92979eb9adfa77b249dec1d4a15b3e82909
- Attacker address:
- Response / mitigation:
- Level confirmed the exploit on 2023-05-01 and said the incident was contained to the referral-controller contract.
- Public analysis says the fix changed the batch path to cumulative accounting, i.e.
users[epoch][msg.sender].claimed += reward. - Quantstamp publicly noted that the vulnerable logic was introduced after their audit, highlighting upgrade drift rather than an audited-path miss.
- The incident also drew attention to the earlier warning window: public reporting said the attacker had tested the exploit path more than a week before the full drain.
Pattern tags
- duplicate-claim-idempotency-failure
- reward-entitlement-identity-and-batch-canonicalization
- exit-right-consumed-state-binding
- One real
(user, epoch)referral-reward entitlement stayed replayable inside a batch becauseclaimMultiple()overwrote consumed progress with the current delta instead of monotonically advancing the already-consumed state.
Why the exploit worked
- Level’s batch helper did not consume the same reward-epoch entitlement object that the single-claim path consumed.
claim()advanceduser.claimedmonotonically with+=, whileclaimMultiple()reopened the same entitlement by resettingclaimedtorewardfor each loop iteration. - The caller-controlled
_epochesarray therefore acted like an attacker-authored multiplicity surface, not a harmless convenience list. Repeating one real epoch let the loop keep repricing one live reward right instead of proving a set of distinct entitlements first. - The protocol did not fail because the reward points were fake. It failed because one authentic reward entitlement and one broken consumed-state write together were enough to make later batch iterations treat the same entitlement as fresh economic authority again.
- The post-audit proxy upgrade matters because it broke economic equivalence between the single-claim and batch-claim cousins. Once those paths stopped sharing one monotone consumed-state story, the helper contract itself became a custody surface.
Detection and audit pivots
- Diff every single-claim path against its batch cousin and ask whether both routes call the same internal consume primitive or at least advance the same
(user, epoch)consumed-state object in the same monotone way. - Fuzz adversarial epoch arrays such as
[e],[e, e],[e, e, e], and mixed-order duplicates while holding the underlying reward entitlement constant; total payout should collapse to the one-epoch amount or revert before any extra value accumulates. - Trace the reward-consumption invariant literally: once
claimable(epoch, user)has been priced into payout, what exact state update makes every sibling loop iteration see that entitlement as already consumed? - Treat
=versus+=changes in partial-claim or reward-progress state as treasury-risk diffs, especially in upgradeable helper contracts that still hold spendable token balances. - Reconstruct the audited-versus-deployed code lineage, not just the GitHub lineage. If the live proxy implementation differs from the reviewed source snapshot, re-review the exact consumed-state writes that guard payouts.
Failure-prevention checklist
- Force
claimMultiple()to consume the exact same canonical(user, epoch)entitlement object thatclaim()consumes; batching should be a convenience wrapper around the same monotone claimed-state transition, not a second accounting universe. - Reject or canonicalize duplicate epoch IDs before payout aggregation. A caller-supplied epoch array should never be trusted as proof that each batch element is economically distinct.
- Advance partial-claim progress monotonically with
+=-style semantics or an equivalent canonical consume primitive; never overwrite already-consumed reward progress with the latest delta. - Property-test economic equivalence between
claim(epoch)andclaimMultiple([epoch]), and betweenclaimMultiple([e, e])versus one distinct-epoch claim; repeated epochs must revert or add zero extra value. - Diff reviewed code against the live proxy implementation at every upgrade. Reward helpers and referral controllers may look like side contracts, but if they hold live emissions they are custody surfaces and deserve upgrade-blocking invariant tests.
- Add treasury-side telemetry on per-user, per-epoch claims so sudden repeated consumption of one epoch or impossible cumulative payouts trigger immediate pause review before the contract balance is exhausted.
Audit prompts
- What exact storage object represents the one spendable referral-reward right here: the epoch id alone, or the
(user, epoch)claimed-progress record, and do every claim paths mutate that same object? - If a user submits
[e, e],[e, e, e], or mixed-order duplicate epochs, what exact state transition makes the second and third occurrences economically inert beforetotalRewardgrows? - Are single-claim and batch-claim functions thin wrappers over one shared internal consume primitive, or are they manually synchronized cousins that can drift on one line like
=versus+=? - Does
claimable(epoch, user)depend on partially consumed progress, and if so can assignment, truncation, stale-cache use, or loop ordering reopen already-paid reward intervals? - What deployment or upgrade control would have forced reviewers to inspect the exact live implementation rather than relying on the older audited GitHub snapshot?
- If this helper contract emptied its own reward inventory tomorrow, what alert, cap, or emergency brake would have surfaced the anomaly before the controller paid out the full balance?
Lessons that should already be standard
- A reward epoch is still a one-shot economic entitlement. Treating a batch array as harmless user convenience instead of as attacker-authored multiplicity input is how helper contracts become printing presses.
- The line between safe and catastrophic can be one operator:
+=versus=on consumed progress is not a style issue when real treasury tokens sit behind the accounting. - Batch helpers should not get their own independent finality story. If
claim()andclaimMultiple()do not obviously spend the same entitlement in the same way, the batch path is already suspect. Non-corerewards infrastructure is still custody infrastructure when it holds tokens users can immediately transfer away.- Post-audit upgrades break more systems than failed happy-path reasoning. If the live proxy implementation is not the reviewed code, auditors and operators should assume the security story must be re-earned from the deployed bytecode outward.
Why this incident matters
- It is a strong reward-system exemplar for duplicate-claim / idempotency failure: the replay key was not a Merkle proof or lock ID, but a reward epoch inside a batch array.
- It preserves a crisp code-review lesson: one mismatched state update in a batch cousin of an otherwise-correct single-claim path can silently turn accounting helper code into a drain primitive.
- It is also a useful post-audit-upgrade case. The most relevant vulnerable logic was not the code people thought had been reviewed.
Internal linkages
- Core recurring pattern: duplicate-claim-idempotency-failure.
- Positive-design companion: exit-right-consumed-state-binding — the sharper question here is what one
(user, epoch)reward entitlement objectclaim()andclaimMultiple()should both have been forced to consume exactly once before anyLVLpayout accumulated. - Adjacent pattern: reward-debt-desynchronization-denominator-collapse-reward-amplification — both families live in rewards logic, but Level’s exploit was fundamentally about replaying one epoch entitlement rather than exploding a global accumulator.
- Adjacent pattern: privileged-upgrade-abuse — not because the upgrade key itself stole funds, but because a post-audit upgrade introduced the exploitable logic that users and auditors were no longer actually looking at.
Control surface
-
The leverage sat in the referral-controller proxy upgrade path, the caller-controlled
_epochesarray passed intoclaimMultiple(), and the broken guarantee that one(user, epoch)reward entitlement’s claimed state could only move forward. -
Level did not fail because referral rewards existed. It failed because a batched reward-consumption path stopped being idempotent after an upgrade, and the controller still held real
LVLready to honor the fiction. -
The practical security boundary was the consumed-state write for one reward epoch: once
claimable(epoch, user)had been priced into payout, no sibling loop iteration should still have been able to observe that same epoch as fresh economic authority. -
Sources:
- https://rekt.news/level-finance-rekt
- https://blog.solidityscan.com/level-finance-hack-analysis-16fda3996ecb/
- https://bscscan.com/address/0x977087422C008233615b572fBC3F209Ed300063a#code
- https://bscscan.com/address/0x9f00fbd6c095d2c542687ed5afb68d9c3fb2f464#code
- https://bscscan.com/tx/0xe1f257041872c075cbe6a1212827bc346df3def6d01a07914e4006ec43027165
- https://bscscan.com/tx/0xe0a8e635f4778f6cc935873d8c17a92979eb9adfa77b249dec1d4a15b3e82909
- https://raw.githubusercontent.com/level-fi/level-core-contracts/master/src/referral/LevelReferralControllerV2.sol
-
Last reviewed: 2026-08-03 UTC