# Self-liquidation with receive_a_token = true bypasses collateral seizure via from == to self-transfer in transfer_on_liquidation, socializing residual debt to reserve deficit

Self-liquidation with receive_a_token=true makes transfer_on_liquidation a no-op when from==to, so router burns residual debt into reserve_deficit while borrower keeps collateral.

- Payout: Contest reward
- Severity: high
- Program: K2
- Credited: Silvermist
- Disclosed: 2026-08-20
- Write-up quality: 8/10
- Original: https://code4rena.com/reports/2026-04-k2#h-13
- Library page: https://bugbountylibrary.com/r/c4%3A2026-04-k2%3AH-13
- Tags: domain:web3 impact:funds-loss impact:integrity kind:contest platform:code4rena sev:high tech:solidity technique:incomplete-fix technique:source-review vuln:accounting-error vuln:business-logic vuln:liquidation

**Target:** Soroban aToken transfer_on_liquidation and kinetic-router liquidation_call
**Root cause:** transfer_on_liquidation lacks a from==to short-circuit (unlike sibling transfer_internal), so debit and credit round-trip to a no-op; the router then derives effective_collateral_removed from nominal inputs instead of re-reading balances.
**Steps:**
1. Open a borrow position with high LTV on K2.
2. Drive collateral price down via sub-20% moves (max_price_change_bps=2000) using permissionless Reflector refresh_prices until HF<WAD and collateral_cap_triggered=true.
3. Call `liquidation_call(attacker, collateral_asset, debt_asset, attacker, debt_to_cover, true)` with liquidator==user and receive_a_token=true.
4. Router calls `transfer_on_liquidation(caller=router, from=attacker, to=attacker, amount=liquidator_collateral, ...)` which is a storage no-op.
5. Router sets effective_collateral_removed from nominal input and enters cap-triggered branch: burns remaining debt and calls `add_reserve_deficit`.
6. Attacker retains collateral, debt wiped, loss socialized to depositors.
**How found:** Code review comparing transfer_on_liquidation against sibling transfer_internal, which has an explicit `if from == to { return Ok(()) }` WP-C6 guard; noticed the guard was never ported and that the router trusts nominal amounts rather than post-transfer balances.
**Exploitation:** Borrower self-liquidates an underwater cap-triggered position paying only cap-adjusted debt_to_cover; the collateral transfer is a self-transfer no-op so principal+bonus collateral stay with borrower, only treasury fee slice is debited, and residual debt is burned into reserve_deficit. Marginal gain vs external liquidation ≈ scaled_coll_before*(10000-flash_loan_premium_bps)/10000 scaled aTokens.
**Escalation:** Repeated across every cap-triggered underwater position; loss socialized to depositors via reserve_deficit.
**Insight:** When auditing liquidation paths, check whether the router re-reads post-transfer balances or trusts nominal inputs, and diff sibling transfer functions for missing self-transfer guards — a no-op transfer that the caller assumes succeeded is a classic accounting desync.
