Base-unit withdrawals exhaust Child LINK and pin global settlement
Withdrawals are floored at one share unit rather than one token, so a single USDC can be split into about a million base-unit withdrawal epochs. Each one pays a full CCIP fee from the Child's shared LINK balance, exhausting it and pinning global settlement at negligible attacker cost.
Description
At frozen revision a08ecdfdb5aa603366ac9a342791cc532052ee52, a user deposit is floored at one underlying token, but a withdrawal is floored only at one nonzero share unit. An ordinary holder can therefore split a single one-USDC share position into roughly one million remote withdrawal epochs whose net withdrawal is one USDC base unit each.
Every such epoch makes the Child withdraw that one atom from the active lending adapter and construct a CCIP message. BaseVaultCcipLib._sendPacked quotes the message, then pays the entire fee from the Child vault's shared LINK balance:
uint256 fee = IRouterClient(params.ccipRouter).getFee(params.destinationChainSelector, message);IERC20(params.link).forceApprove(params.ccipRouter, fee);IERC20(params.asset).forceApprove(params.ccipRouter, params.bridgeAmount);IRouterClient(params.ccipRouter).ccipSend(params.destinationChainSelector, message);
The fee is not charged to the requesting holder, is not related to the amount bridged, and is not subject to a fee-linked minimum flow. The holder receives one atom per successful epoch, retains almost the entire share position, and can repeat once per minimum one-hour epoch without another deposit.
When the finite Child LINK reserve falls below one quote, executeEpochWithdraw has already successfully removed the atom from the lending adapter. Child's _ccipSend catches the insufficient-LINK failure and stores CCIP_SEND recovery rather than reverting the adapter withdrawal:
try this.tryCcipSend(...) {}catch {_storeCcipSendRecovery(...);}
The corresponding Parent epoch remains EXECUTING. ParentVaultEpochLib._closeEpoch requires the preceding epoch to be CLAIMABLE, so every later epoch close reverts. New intents can be cancelled while their current epoch remains open, but no later deposit or withdrawal settlement can advance until someone externally supplies LINK and retries recovery.
Vulnerable scenario
- The deployment has normal live share supply, incumbent principal in an active remote Child strategy, and a finite Child LINK reserve
B. - The attacker deposits the minimum one USDC once, the ordinary remote-deposit epoch completes, and the attacker claims approximately
1e18shares. - In each subsequent epoch, the attacker withdraws only the shares whose truthful pro-rata value floors to one USDC atom.
- Ordinary CRE closes the epoch. Parent records a one-atom remote net withdrawal and enters
EXECUTING. - Child successfully withdraws the atom from its adapter and pays the full CCIP quote
qfrom shared LINK. The attacker may claim the atom and repeats with the remaining shares. - When Child has
< q, the next adapter withdrawal still succeeds, butccipSendfails. Child stores the atom and fixed message parameters inCCIP_SENDrecovery. - Parent cannot complete that epoch, and its preceding-epoch guard rejects all future closes until Child receives new LINK and
executeRecovery()succeeds.
No privileged role, false or stale report, bootstrap/coarse supply, donation, abnormal token behavior, upstream pause, governance change, market insolvency, or impossible external behavior is used.
KI-016 considers this class on the Parent side and accepts it, recording that an underfunded LINK
balance is an operational concern and that "no attacker-controlled trigger for a CCIP send failure
has been identified". That is the statement this sequence contradicts: the trigger is permissionless,
costs one USDC once, and commits Child recovery after value has already left the lending adapter,
rather than resolving through the atomic Parent-side revert that entry accepts.
The sequence relies on the documented workflow closing an epoch that has activity and has been open
past MIN_EPOCH_PERIOD; a one-atom withdrawal is activity. A dust filter in the automation would
defeat this particular path, but settlement liveness would then depend on an off-chain policy that no
on-chain rule requires and nothing enforces. What the contract is missing is a fee-linked economic
floor on the flow it will pay to bridge — the withdrawal minimum is one nonzero share unit while the
cost of servicing it is a full cross-chain quote.
Impact
This is a permissionless, capital-efficient, persistent denial of withdrawal and deposit settlement.
- Attacker capital: one USDC once. Only the shares worth one atom are escrowed per recurring epoch; almost the entire position remains transferable.
- Permanent attacker loss: zero principal and zero LINK; normal transaction gas only. Each successful iteration redeems rather than burns economically unclaimed value.
- Protocol loss: one full CCIP LINK fee per one-atom withdrawal.
- Victim impact: after exhaustion, later settlement for the full live strategy position is blocked until external recapitalization. The PoC demonstrates this against
1,000,000 USDCof normal incumbent backing. - Repeatability: the PoC executes eight consecutive one-atom withdrawals and proves that each consumes exactly one full Child fee. A one-USDC position contains roughly one million atoms.
- Recurring gas: the suite's gas report measured
ParentVault.withdrawat most103,285gas. One unlimited share approval can serve every iteration; claiming each atom is unnecessary. - Cadence: one withdrawal per minimum one-hour epoch plus ordinary CCIP/report latency.
Pinned production Router quotes at the frozen audit date were:
Base -> Ethereum initial 1-USDC deposit: 0.171990247115359784 LINKEthereum -> Base recurring 1-atom withdraw: 0.045632987993576443 LINK
At the recurring quote, a 10-LINK Child reserve reaches the failing request after approximately 220 withdrawal epochs (9.2 days); 100 LINK takes approximately 2,192 epochs (91.3 days). The attacker can keep draining operator top-ups using the same share position.
The identical no-attack control uses the same initially sufficient Child reserve and successfully settles an incumbent withdrawal exceeding 99,000 USDC with no recovery.
Recommendation
Charge the actual quoted CCIP fee to the epoch cohort causing the remote message. One robust design is to escrow LINK, or a conservatively oracle-priced underlying service fee, with the intent; consume the actual getFee amount at settlement and refund excess pro rata.
If fees remain protocol-subsidized, segregate and carry forward undersized remote flows until a fee-linked economic threshold is reached without letting those requests block unrelated users. A fixed minimum should be calibrated against the live fee rather than merely copying the one-USDC deposit minimum.
As defense in depth, quote/check the Child fee balance before withdrawing from the adapter and keep a protected reserve. Monitoring and reserve floors alone are insufficient: they detect or move the exhaustion boundary but do not remove the permissionless resource amplification.
Resolution
Mitigated across 959087c, 93c3582 and 4864ee0: the settlement charge was removed and closeEpoch now reverts when a remote net withdrawal falls below one whole asset unit, with the off-chain workflow deferring settlement via the totalShares getter added in e3c5f25. The residual is that closeEpoch is operator-gated, so the on-chain revert constrains only the operator while blocking every participant in an epoch whose net remote withdrawal lands in that window, until organic flow moves it.
Affected files
evm/src/vaults/ParentVault.sol#L37-L53,#L160-L170evm/src/libraries/vaults/ParentVaultUserEpochLib.sol#L187-L205evm/src/libraries/vaults/ParentVaultEpochLib.sol#L192-L200,#L216-L284evm/src/vaults/ChildVault.sol#L148-L178,#L233-L253,#L553-L604evm/src/libraries/vaults/BaseVaultCcipLib.sol#L178-L204evm/src/vaults/BaseVault.sol#L359-L364