SPECTRE Audit Report — Kamino Finance (full Rust suite)
Target: Kamino-Finance — klend, 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:
- Admin oracle writer.
update_reserve_configandupdate_lending_marketadmin handlers mutate*.oraclePubkey fields under an admin signer. - Oracle readers. Many handlers consume oracle prices through Pyth / Switchboard CPIs during borrow, refresh, and liquidation flows.
- Liquidation paths.
liquidate_obligation_and_redeem_reserve_collateraland 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
- Confirm admin-key custody posture (multisig threshold, timelock duration).
- Adopt a propose-then-accept pattern for oracle whitelist changes.
- 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 onrefresh_chainlink_price(missing duplicate-account check)src/lib.rs:69— CPI-021 onrefresh_pyth_lazer_price(missing duplicate-account check)src/lib.rs:79— CPI-003 onrefresh_chainlink_price(config_accountflows 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:93—withdraw_pending_feessrc/lib.rs:99—update_adminsrc/lib.rs:103—give_up_pending_feessrc/lib.rs:135—remove_allocationsrc/lib.rs:165—withdraw_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, :126—maker,pda_authoritysrc/handlers/flash_take_order.rs:238, :247—maker,pda_authoritysrc/handlers/close_order_and_claim_tip.rs:96—pda_authoritysrc/handlers/withdraw_host_tip.rs:46—pda_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):
:83—transfer_ownership:87—reward_user_once:105—stake:118—unstake:126—withdraw_unstaked_deposits:138—withdraw_from_farm_vault:149—update_farm_admin:157—withdraw_reward:165—update_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:104—admin_reward_token_atasrc/handlers/handler_withdraw_treasury.rs:84—withdraw_destination_token_accountsrc/handlers/handler_harvest_reward.rs:127—user_reward_token_account
Cross-suite implications
Combining klend (Section 1) with the rest of the suite (Section 2):
- 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.
- 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.
- ACC-012 hits cluster around state-transition handlers in
klend's
enqueue_to_withdraw, kvault'supdate_reserve_allocation, and kfarms'stransfer_ownership. - Flash + DEX patterns concentrate ACC-010/013 hits. klend's
flash-loan handlers and limo's
flash_take_orderboth surface authority-check gaps on rawAccountInfofields.
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.