89524 bc medium retarget schedule silently reassigns a beneficiary s already vested funds to their replacement
Submitted on Aug 19th 2026 at 18:12:10 UTC by @ilov3c4ndy for Audit Comp | Quantus
Report ID: #89524
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Theft of unclaimed yield
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Target: Substrate L1 node, pallet-vesting (runtime index 22)
Severity: Medium. Loss of vested user funds, in violation of the invariant the function documents for itself. The vulnerable state is reachable and holdable permissionlessly by an unrelated account; the final step is a routine administrative action. Per schedule the loss is bounded by one MinClaimInterval of vesting, and it multiplies across a grantee's schedules.
Summary
retarget_schedule is documented to pay out whatever the outgoing beneficiary could currently claim before swapping in the new one, precisely so the result does not depend on transaction ordering. It does not. When the outgoing beneficiary has claimed within MinClaimInterval, the settlement is skipped and their accrued entitlement transfers to the incoming beneficiary.
On the live runtime MinClaimInterval is MILLIS_PER_DAY, so the window is a full day.
The beneficiary does not have to be an active claimer for this to bite. claim is permissionless by design, so any account can stamp last_claim_at on any schedule. An unrelated third party who gains nothing from the outcome can therefore put a schedule into the vulnerable window, and keep it there indefinitely for one transaction per schedule per interval, so that whenever the treasury next performs a routine rotation the outgoing beneficiary is silently shorted.
Root cause
retarget_schedule settles through claim_plan, then treats three distinct outcomes as "nothing to settle":
NothingToClaim genuinely means nothing is owed. TooSoon does not. It is a rate limit, returned after the payable amount has already been computed:
schedule.claimed is never advanced, so the unpaid amount stays in the schedule and is paid to whoever holds beneficiary at the next successful claim.
The invariant this breaks
The doc comment on the extrinsic:
Settle any payout a permissionless claim could currently force, then change the beneficiary. This makes retargeting independent of claim transaction ordering.
And PR #646, which introduced the pallet:
retarget_schedulefirst settles exactly the payout a permissionless claim could currently force to the old beneficiary, then changes the beneficiary. This makes the result independent of claim/retarget transaction ordering.
The outcome depends directly on when the outgoing beneficiary last claimed, which is the ordering dependence both statements rule out.
end_schedule confirms the intent. It pays quantize_down(vested - claimed) to the beneficiary with no MinClaimInterval gate at all, so the two administrative exits disagree about whether an outgoing beneficiary is owed their accrued amount.
Why the existing regression test does not catch it
The pallet already has a dedicated test for this invariant, and it passes:
It retargets a schedule that has never been claimed, so last_claim_at is None. The TooSoon check is guarded by exactly that:
With last_claim_at == None the branch is unreachable and claim_plan can only return Pay, so the test exercises the settling path every time and never the one that skips it.
The first proof of concept below uses the same fixture (default_schedule(BOB)) and the same starting timestamp (300_000) as that test. The only difference is one prior claim.
Impact
The outgoing beneficiary permanently loses everything that vested since their last claim, capped at one full day of the schedule's linear vesting rate. The funds are not burned or frozen: they are paid to the incoming beneficiary at the next successful claim.
Magnitude scales with the allocation. The only schedules currently in the tree are the testnet presets (GENESIS_VESTING_TOTAL = 10_000 QUAN over 365 days, marked "Testnet example grant size"), which put one day at roughly 27 QUAN. The pallet exists to carry a real allocation table that PR #642 explicitly deferred ("A real per-allocation table (amounts, varying terms) is deliberately left for a follow-up PR"), so the per-incident loss on a production table scales linearly with grant size and inversely with vesting duration.
The precondition is attacker-controlled, not incidental. A second test drives the whole sequence from an unrelated account:
MALLORY is neither the beneficiary nor the treasury and takes nothing. BOB never touches his schedules. MALLORY claims on his behalf, which pays BOB correctly and is not itself an attack, but stamps last_claim_at; every rotation afterwards is silently unsettled. Because one account may hold any number of schedules, a grantee holding N schedules loses N times the per-schedule amount in a single rotation.
Keeping a schedule armed is cheap and durable. MinClaimInterval is both the rate limit and the refresh period, so one claim per schedule per day holds last_claim_at permanently inside the window. Any grant vesting at least MinimumPayout (1 QUAN) per day qualifies, which the shipped preset does by a wide margin at roughly 27 QUAN per day.
The loss is silent. No error is returned, and the emitted ScheduleRetargeted event carries vested_paid: 0, which is indistinguishable from the legitimate NothingToClaim case. Neither party has any on-chain signal that value moved between them.
Triggering it requires AdminOrigin (Root, or the treasury multisig via EnsureTreasury), but no malice: rotating a grant to a new address is the documented purpose of the call. The condition is not rare, it is the default state of any beneficiary claiming at the cadence the pallet's own rate limit implies.
The final step still needs AdminOrigin, and that gate is structural: TreasuryAccount is a genesis-only storage value with no setter extrinsic, and retarget_schedule is the only code path that mutates beneficiary. What an attacker controls is the precondition, not the trigger. Front-running a specific retarget with a claim is not the attack and would protect the outgoing party; the attack is holding schedules in the vulnerable window so that the treasury's own routine maintenance destroys value whenever it happens.
Public History check
The Equilibrium chain audit (13 May 2026) reports no
pallet-vestingfindings at all; its components are MULTISIG, SCHEDULER, TREASURY, REVERSIBLE and RUNTIME.pallet-vestingwas added on 8 Aug 2026 in PR #646, after that audit.The two V12 vesting passes touch
retarget_scheduleon unrelated points: #656 rejects atreasury_and_pot()lookup suggestion, #649 extracts the sharedsettleblock. Neither mentionsTooSoon,MinClaimIntervalorlast_claim_atin relation to retargeting.None of the three published Known Issues relate.
Still unfixed upstream:
Quantus-Network/chainat94c4ca7a, later than the pinned3b243b87, carries the identicalClaimPlan::TooSoon => Zero::zero()arm.
Remediation
Settle the outgoing beneficiary before the swap regardless of the rate limit. MinClaimInterval exists to bound how often a permissionless caller can force a payout, and a retarget is neither permissionless nor repeatable, so the limit should not apply to it.
The smallest correct fix is to compute the payout without the interval gate on this path, for example by having claim_plan take a flag that skips the last_claim_at check, and treating only NothingToClaim and WouldLeaveDust as zero. WouldLeaveDust should additionally be reconsidered: it strands up to two minimum payouts with the outgoing beneficiary's replacement for the same reason.
Link to Proof of Concept
https://gist.github.com/joksdz/dd81e3671d368d0a71b4970bd35a2f42
Proof of Concept
Two tests against the pinned tree, repo audit-comp-quantus-chain, commit 3b243b870a5442a6e5f443056f67bbba87d472f5. Both append to pallets/vesting/src/tests.rs and use only the pallet's existing mock. Full runnable sources: https://gist.github.com/joksdz/dd81e3671d368d0a71b4970bd35a2f42
Mock constants differ from the runtime's and are scaled down: MinClaimInterval is 100_000 in the mock versus MILLIS_PER_DAY live, and the default schedule vests TOTAL = 4_000_000 linearly between START = 100_000 and END = 500_000.
Test 1: a routine rotation silently keeps the outgoing beneficiary's accrued funds
Output:
Test 2: an unrelated account can arm this, and the loss multiplies
This one starts from three separate grants to BOB, the shape a real allocation table produces, and never has BOB touch any of them.
Was this helpful?