Research
Per-protocol2026-05

SPECTRE Audit Report — Kamino Finance (full Rust suite)

Target: Kamino-Financeklend, scope, kvault, limo, kfarms Codebase size: 104 Rust source files (klend) Tooling: pinpoint-cli spectre scan + spectre link (feat/spectre-solana-v2) Date: 2026-05-07

TL;DR

Full-suite audit of the five public Kamino lending programs. SPECTRE surfaces the canonical oracle-composition architecture on klend (the same shape that drained $117M from Mango Markets in 2022) and a systemic finding on the upstream Scope oracle: a single compromised Scope admin key cascades into every Kamino market that consumes Scope prices. Below klend, four additional Kamino programs contribute their own admin-surface risk, with kfarms exposing the largest governance attack surface in the suite (14 privileged handlers without timelock).

Severity Rule klend scope kvault limo kfarms
HIGH ORC-002 1
HIGH AUTH-100 1 1
HIGH GOV-001 1 6 14
HIGH ACC-012 1 1 1
HIGH ACC-010 5 1 6
HIGH ACC-013 7 1 6 3
HIGH CPI-003/021 3
HIGH CLOSE-080 2 1 2

Cross-program analysis output for the 5-program workspace: 54 SPL Token CPI links resolved with registry-match provenance (confidence 0.95), zero unresolved.

Methodology

Full SPECTRE rule pack at --profile all --min-confidence 0.0 against each program crate. Cross-program analysis run on the entire Kamino workspace with pinpoint-cli spectre link. CPI provenance classified against SPECTRE's well-known program-id registry.

Section 1 — klend (Kamino Lending)

Finding 1.1 — Oracle Composition Risk (ORC-002)

Severity: HIGH Rule: ORC-002 (Oracle composition risk) Location: src/lib.rs:55 (anchor: update_lending_market_owner instruction)

Architecture

Three components coexist in the kamino_lending program:

  1. Admin oracle writer. update_reserve_config and update_lending_market admin handlers mutate *.oracle Pubkey fields under an admin signer.
  2. Oracle readers. Many handlers consume oracle prices through Pyth / Switchboard CPIs during borrow, refresh, and liquidation flows.
  3. Liquidation paths. liquidate_obligation_and_redeem_reserve_collateral and related handlers compute liquidation amounts from oracle-reported prices.

ORC-002 anchors the finding at the deterministically-sorted writer-instruction location.

Why it matters

This is the canonical oracle-composition attack surface. A compromised admin key can whitelist an attacker-controlled oracle pubkey, push a manipulated price, and drain user funds through the liquidation path. The Mango Markets October 2022 incident lost $117M to exactly this pattern.

Recommendation

  1. Confirm admin-key custody posture (multisig threshold, timelock duration).
  2. Adopt a propose-then-accept pattern for oracle whitelist changes.
  3. Link the incident-response runbook for compromised admin keys in public docs.

Finding 1.2 — Account Reinitialization in Withdraw Queueing (ACC-012)

Severity: HIGH Location: src/handlers/handler_enqueue_to_withdraw.rs:244

The handler accepts an init_if_needed token account:

#[account(init_if_needed,
    seeds = [seeds::OWNER_QUEUED_COLLATERAL_VAULT, reserve.key().as_ref(), owner.key().as_ref()],
    bump,
    payer = owner,
    token::mint = reserve_collateral_mint,
    token::authority = lending_market_authority,
    token::token_program = collateral_token_program,
)]
pub owner_queued_collateral_vault: Box<InterfaceAccount<'info, TokenAccount>>,

ACC-012 surfaces the init_if_needed pattern wherever the account is not a typed Account<T> PDA. The seed binding restricts the address; the token::authority constraint restricts ownership; manual review should confirm the state-transition safety property holds against a pre-populated account at the same address.

Recommendation

Verify the seed uniqueness contract is sufficient under InterfaceAccount<TokenAccount> semantics. If the program documents this as intentional, mark for inclusion in the disclosure note.

Finding 1.3 — Missing Owner Check on Reserve Initialization (ACC-010)

Severity: HIGH Five instances, two of which warrant priority review:

# File Line Account
1 handler_init_reserve.rs 145 InitReserve::reserve_liquidity_supply
2 handler_init_reserve.rs 151 InitReserve::fee_receiver
3 handler_withdraw_queued_liquidity.rs 350 WithdrawQueuedLiquidity::user_destination_liquidity
4 handler_withdraw_queued_liquidity.rs 376 WithdrawQueuedLiquidity::withdraw_ticket_owner
5 handler_recover_invalid_ticket_collateral.rs 139 RecoverInvalidTicketCollateral::withdraw_ticket_owner

Priority findings: init_reserve pair

Both accounts are typed as raw AccountInfo<'info>:

#[account(mut,
    seeds = [seeds::RESERVE_LIQ_SUPPLY, reserve.key().as_ref()],
    bump
)]
pub reserve_liquidity_supply: AccountInfo<'info>,

#[account(mut,
    seeds = [seeds::FEE_RECEIVER, reserve.key().as_ref()],
    bump
)]
pub fee_receiver: AccountInfo<'info>,

The PDA seed binding restricts which address the runtime accepts. If the PDA does not yet exist at that address (since this is a reserve-initialization handler), Anchor's seed validation does not constrain the data inside the account, only the address. There is no init constraint creating the PDA fresh and no owner = ... discriminant.

Compare with reserve_collateral_mint further down the same struct, which uses init + typed InterfaceAccount<Mint>. The asymmetry is the finding. An attacker who could create an account at the seed-derived address with arbitrary data could potentially influence the reserve-init flow.

Recommendation

Inspect handler_init_reserve::process for owner-program validation on reserve_liquidity_supply and fee_receiver before any state writes. The asymmetry against reserve_collateral_mint is the strongest disclosure candidate in this audit after ORC-002.

Finding 1.4 — Flash Loan Referrer Account Hijack Vector (ACC-013)

Severity: HIGH Locations:

File Line Account
handler_flash_borrow_reserve_liquidity.rs 107 FlashBorrowReserveLiquidity::referrer_account
handler_flash_repay_reserve_liquidity.rs 147 FlashRepayReserveLiquidity::referrer_account
#[account(mut)]
pub referrer_account: Option<AccountInfo<'info>>,

The referrer_account on flash-loan handlers is mutable, optional, untyped, and lacks has_one, address, or constraint = ... validation. Flash semantics raise the leverage of any unchecked-auth gap: an attacker who can substitute their own account as the referrer siphons fees in a single transaction.

Recommendation

Inspect process() in both handlers. Constrain referrer_account to a PDA-derived address or a signed referrer pubkey before any fee logic runs.

Cross-program analysis

Programs analyzed:    1 (klend)
CPIs observed:        33
Resolved (registry):  33  (all to SPL Token, confidence 0.95)
Unresolved:           0

Section 2 — Other Kamino programs

After the klend audit, the scan extended to every other public Rust program in the Kamino-Finance GitHub organization. Five programs reviewed: scope (oracle aggregator), kvault (lending vault), limo (limit-order execution), kfarms (yield farming), terminator (utility). The four substantive programs together produced 295 findings across all rule tiers, with the most severe single finding being scope's AUTH-100 + GOV-001 pair, which represents systemic risk across the entire Kamino market network.

Program Total findings Headline architectural
scope 25 AUTH-100 on set_admin_cached; GOV-001 on freeze_price; CPI-003 + CPI-021 on Chainlink/Lazer refresh
kvault 97 GOV-001 ×6 (admin fee/reward withdrawals); ACC-010/013 on withdraw paths; ACC-012 on update_reserve_allocation
limo 85 AUTH-100 on update_global_config_admin; ACC-010/013 ×6 on flash_take_order; CLOSE-080 ×2
kfarms 88 GOV-001 ×14 (transfer_ownership, withdraw_from_farm_vault, withdraw_reward, etc.); ACC-012 on transfer_ownership

Section 2.1 — scope (oracle aggregator)

Why this matters: scope is the oracle infrastructure that klend and every other Kamino market consume. A compromise of scope's admin key or a malformed price refresh propagates to every dependent market. Findings here are systemically more severe than findings on a single market.

Finding 2.1.1 — Single-step Oracle Admin Transfer (AUTH-100)

Location: src/lib.rs:99 (set_admin_cached)

set_admin_cached mutates scope's admin pubkey in a single instruction with no propose-then-accept handshake. If the new admin pubkey is a typo, an attacker-controlled key, or a key whose private material has been lost, recovery is impossible. Given that scope's admin authorises price-source changes that downstream markets trust implicitly, the blast radius is the cumulative TVL of every Kamino market consuming scope.

Finding 2.1.2 — Price Freeze Without Timelock (GOV-001)

Location: src/lib.rs:135 (freeze_price)

The admin can freeze a price feed in one transaction with no notice or delay. Combined with Finding 2.1.1, a compromised admin can freeze price feeds at will, creating arbitrage windows or denial-of-service across dependent markets.

Finding 2.1.3 — Chainlink / Pyth Lazer CPI Surface (CPI-003 + CPI-021)

Locations:

  • src/lib.rs:55 — CPI-021 on refresh_chainlink_price (missing duplicate-account check)
  • src/lib.rs:69 — CPI-021 on refresh_pyth_lazer_price (missing duplicate-account check)
  • src/lib.rs:79 — CPI-003 on refresh_chainlink_price (config_account flows to unresolved CPI without re-validation)

Duplicate-account hazards in oracle refresh paths can enable price-feed substitution across the entire Kamino market network.

Section 2.2 — kvault (lending vault)

Finding 2.2.1 — Admin Fee + Reward Withdrawal Without Timelock (GOV-001 ×6)

Locations:

  • src/lib.rs:93withdraw_pending_fees
  • src/lib.rs:99update_admin
  • src/lib.rs:103give_up_pending_fees
  • src/lib.rs:135remove_allocation
  • src/lib.rs:165withdraw_rewards

Six admin instructions on the lending vault execute without a timelock. The most economically sensitive are withdraw_pending_fees and withdraw_rewards: a compromised admin can drain accumulated fees and rewards in one transaction. update_admin (no two-step handshake) compounds the risk per AUTH-100 logic.

Finding 2.2.2 — Account Reinitialization on update_reserve_allocation (ACC-012)

Location: src/handlers/handler_update_reserve_allocation.rs:103

update_reserve_allocation exhibits an init_if_needed-class pattern. If a reserve allocation slot can be reused with stale state from a prior allocation, vault accounting may diverge from on-chain truth.

Section 2.3 — limo (limit-order execution)

Finding 2.3.1 — Single-step admin transfer (AUTH-100)

Location: src/lib.rs:134 (update_global_config_admin)

Same architectural shape as scope and kvault.

Finding 2.3.2 — flash_take_order + take_order owner-check gaps (ACC-010 + ACC-013, 12 hits combined)

Locations:

  • src/handlers/take_order.rs:117, :126maker, pda_authority
  • src/handlers/flash_take_order.rs:238, :247maker, pda_authority
  • src/handlers/close_order_and_claim_tip.rs:96pda_authority
  • src/handlers/withdraw_host_tip.rs:46pda_authority

The flash-take-order pattern combines limit-order execution with flash-loan semantics, making it a high-leverage attacker target. Both maker and pda_authority slots are flagged as missing owner checks across multiple handlers.

Section 2.4 — kfarms (yield farming)

This program has the largest admin governance surface in the Kamino suite.

Finding 2.4.1 — 14× GOV-001 across the admin surface

Locations (src/lib.rs):

  • :83transfer_ownership
  • :87reward_user_once
  • :105stake
  • :118unstake
  • :126withdraw_unstaked_deposits
  • :138withdraw_from_farm_vault
  • :149update_farm_admin
  • :157withdraw_reward
  • :165update_second_delegated_authority

transfer_ownership and withdraw_from_farm_vault are the most economically sensitive. The volume of admin instructions without timelock (14 instances) is the architectural shape SPECTRE catches even when each individual handler is operationally mitigated.

Finding 2.4.2 — Account Reinitialization on Ownership Transfer (ACC-012)

Location: src/handlers/handler_transfer_ownership.rs:174

Account reinitialization on the ownership-transfer handler is the highest-leverage ACC-012 hit across the Kamino suite. If the new ownership state can be set from stale data, an attacker could game the transfer outcome.

Finding 2.4.3 — Reward / Treasury Withdrawal Authority Gaps (ACC-013 ×3)

Locations:

  • src/handlers/handler_withdraw_reward.rs:104admin_reward_token_ata
  • src/handlers/handler_withdraw_treasury.rs:84withdraw_destination_token_account
  • src/handlers/handler_harvest_reward.rs:127user_reward_token_account

Cross-suite implications

Combining klend (Section 1) with the rest of the suite (Section 2):

  1. Scope AUTH-100 + GOV-001 is the systemic finding. A compromised scope admin can cascade attacks across every Kamino market. This sits structurally above the per-market findings.
  2. The propose-then-accept pattern is missing across the suite. klend, scope, kvault, limo, and kfarms all surface AUTH-100 / GOV-001 hits. A single design pass (Squads multisig + Realms-style governance) closes the category.
  3. ACC-012 hits cluster around state-transition handlers in klend's enqueue_to_withdraw, kvault's update_reserve_allocation, and kfarms's transfer_ownership.
  4. Flash + DEX patterns concentrate ACC-010/013 hits. klend's flash-loan handlers and limo's flash_take_order both surface authority-check gaps on raw AccountInfo fields.

Disclosure priorities (full Kamino suite)

# Finding Why it ranks
1 scope AUTH-100 (set_admin_cached) + GOV-001 (freeze_price) Systemic. Oracle admin compromise cascades to every Kamino market.
2 klend ORC-002 (update_lending_market_owner) Mango-class architecture on $500M+ TVL lending.
3 kfarms transfer_ownership ACC-012 Reinitialization hazard on ownership transfer.
4 kfarms 14× GOV-001 Largest admin surface; includes vault and reward withdrawals.
5 kvault withdraw_pending_fees + withdraw_rewards GOV-001 Direct admin economic risk on vault.
6 klend init_reserve.rs:145, :151 ACC-010 Fee receiver + reserve liquidity supply without owner check at reserve creation.
7 limo flash_take_order ACC-010/013 Compound flash + limit-order risk surface.
8 scope refresh_chainlink_price CPI-003 + CPI-021 ×2 Cross-program oracle refresh paths.

Reproduction (full Kamino suite)

for repo in klend scope kvault limo kfarms terminator; do
  rm -rf "/tmp/kamino-$repo"
  git clone --depth 1 "https://github.com/Kamino-Finance/$repo.git" "/tmp/kamino-$repo"
done

cd code/cli
for repo in klend scope kvault limo kfarms terminator; do
  case $repo in
    klend) path="/tmp/kamino-$repo/programs/klend" ;;
    scope) path="/tmp/kamino-$repo/programs/scope" ;;
    terminator) path="/tmp/kamino-$repo/terminator" ;;
    *) path="/tmp/kamino-$repo/programs/$repo" ;;
  esac
  echo "=== $repo ==="
  cargo run --release -- spectre scan --local \
      --profile all --min-confidence 0.0 --output summary "$path"
done

Generated by SPECTRE on feat/spectre-solana-v2. Pinned manifests in the bench corpus enable byte-stable reproduction at any future date.