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 roughly 3,345 BNB and 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 LevelReferralControllerV2 implementation, claim() correctly did user.claimed += reward, but claimMultiple() instead overwrote storage with users[epoch][msg.sender].claimed = reward. That made claimable(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 inside claimMultiple() until the referral controller paid out about 214k LVL.
  • Trigger / entry point:
    • The vulnerable user entry point was LevelReferralControllerV2.claimMultiple(uint256[] calldata _epoches, address _to) on Level’s referral-controller proxy 0x977087422C008233615b572fBC3F209Ed300063a.
    • Level’s referral system let traders accumulate tradingPoint and let referrers accumulate referralPoint, with epoch rewards priced through claimable(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.
  • Root cause:
    • Level violated the consume once invariant on reward epochs. A batch claim treated caller-supplied epoch IDs like honest distinct entitlements instead of adversarial input.
    • claimable(epoch, user) returns totalVestedReward - user.claimed, so the storage update must accumulate claimed amount monotonically.
    • claim() handled this correctly with user.claimed += reward, but claimMultiple() used users[epoch][msg.sender].claimed = reward.
    • Because the batch function overwrote claimed with 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.
  • Exploit path:
    1. 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.
    2. They interacted with Level’s referral controller through attacker address 0x70319d1c09e1373fc7b10403c852909e5b20a9d5.
    3. They called claimMultiple() with repeated epoch IDs rather than a set of distinct claimable epochs.
    4. Inside the loop, claimable(epoch, msg.sender) computed reward from totalRewardForEpoch - users[epoch][msg.sender].claimed.
    5. 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.
    6. The loop kept adding those repeated positive values into totalReward, and the controller transferred the inflated LVL payout at the end of the batch.
    7. The attacker repeated the process until roughly 214,000 LVL had been drained, then sold the tokens for about 3,345 BNB.
  • What the attacker needed to be true:
    • The referral controller had to hold enough LVL to 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 claimed with 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.
  • 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 +=, while claimMultiple() 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?, but what 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.

Three reward-authority promotions this incident collapsed

  1. 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, not this batch contains several distinct spendable reward rights.
  2. 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, but claimMultiple() broke that identity by writing claimed = reward instead of advancing the already-consumed amount.
    • Repeating the same epoch in _epoches therefore did not keep resolving to the same right, now already consumed; it kept re-opening the same reward artifact as if later iterations were seeing fresh entitlement state.
  3. 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 live LVL inventory.
    • The practical loss was therefore not just reward accounting looked odd; it was one real epoch-level reward right inherited repeated cash-out authority against a token-bearing controller.
  • 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
  • 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

Why the exploit worked

  • Level’s batch helper did not consume the same reward-epoch entitlement object that the single-claim path consumed. claim() advanced user.claimed monotonically with +=, while claimMultiple() reopened the same entitlement by resetting claimed to reward for each loop iteration.
  • The caller-controlled _epoches array 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 that claim() 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) and claimMultiple([epoch]), and between claimMultiple([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 before totalReward grows?
  • 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() and claimMultiple() do not obviously spend the same entitlement in the same way, the batch path is already suspect.
  • Non-core rewards 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

Control surface