# Kamino klend withdrawal-cap bypass — Immunefi PoC v2

**Target**: Kamino Finance, program `KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD`
**Audited commit**: `a26220ce127fe571d9190011737c53e0fc09c415` (master HEAD as of 2026-05-12, Release 1.21.0)
**Bug**: `Reserve.config.debt_withdrawal_cap` and `Reserve.config.deposit_withdrawal_cap` per-interval rate-limits are silently bypassed when a `Remove`-class operation (repay / deposit / fixed-term-rollover) crosses an interval boundary.
**Severity (proposed)**: Medium ($20K Immunefi flat tier). CVSS v3.1 = 7.4 High. On-chain forensics show 152 / 326 = 46.6% of configured mainnet reserves are in the bug-triggered state RIGHT NOW.

This directory contains two PoC formats so an Immunefi reviewer can choose:

| PoC | Format | Status | Files |
|---|---|---|---|
| #1 | In-tree unit test against Kamino's actual `withdrawal_cap_operations.rs` source | **PASSING** | `intree-test/`, `patch.diff`, `test-transcript.txt` |
| #2 | Anchor on-localnet end-to-end recipe | Recipe (host-build blocked by kfarms) | `anchor-recipe.md` |

---

## What is the bug in one paragraph?

`check_and_update_withdrawal_caps` runs the interval-elapsed reset for both
`Add` and `Remove` paths (`withdrawal_cap_operations.rs:184-187`). When a
`Remove` operation (repay, deposit, rollover) crosses the interval boundary,
the function first zeroes `current_total` then immediately `checked_sub`s
the request amount from it -- leaving `current_total` **negative** (e.g.
`-1_000_000`) at the start of the new interval. Subsequent
`remaining_withdrawal_caps_amount()` computes `config_capacity -
current_total = config_capacity + |current_total|`, reporting MORE than the
configured cap as available. The next `Add` (a fresh borrow or collateral
redeem) is permitted up to that inflated value. The per-interval gross-flow
cap that Kamino's risk team configured is silently disabled for the entire
new interval.

Full writeup with on-chain forensics:
`/Users/tonydavis/Projects/bug-bounty/findings/kamino-immunefi-audit.md`

---

## PoC #1 — In-tree unit test (PRIMARY)

### What it is
A `#[cfg(test)] mod tests_immunefi_poc` block appended to the bottom of
Kamino's own `programs/klend/src/lending_market/withdrawal_cap_operations.rs`.
It calls only the public surface (`add_to_withdrawal_accum`,
`sub_from_withdrawal_accum`, `remaining_withdrawal_caps_amount`) plus the
`WithdrawalCaps` struct. **No Kamino source code has been modified** -- the
test is purely additive.

The patch is captured at `patch.diff`: 191 insertions, **0 deletions**,
**0 non-test changes**.

### How to run it

#### Option A (recommended): in the shim crate

```bash
cd /tmp/kamino-poc-v2/intree-test
cargo test --lib withdrawal_cap_operations::utils::tests_immunefi_poc -- --nocapture
```

This crate (`/tmp/kamino-poc-v2/intree-test/`) is a self-contained Rust
project that **includes Kamino's actual source file byte-for-byte** via
`#[path = "../../klend/programs/klend/src/lending_market/withdrawal_cap_operations.rs"]`.
The file is NOT re-typed or transcribed: it is the literal master-HEAD
source. The shim crate's only job is to provide compiler-resolvable
substitutes for the three crate-level imports the cap module needs
(`dbg_msg!`, `LendingError`, `WithdrawalCaps`), so the file can build
without pulling in the full Anchor/Solana toolchain.

This dodges a pre-existing kfarms-on-aarch64-darwin const-assert build
failure (see `anchor-recipe.md` for details) that blocks
`cargo build-bpf` of the full crate on this host machine. The math we are
testing is unaffected -- it lives entirely in pure i64 arithmetic in one
file with no system deps.

#### Option B: in the klend crate directly

A Kamino developer or reviewer with a working klend dev environment
(Linux x86_64 with anchor 0.29.0 and solana 1.17.18 -- the toolchain the
repo's own CI uses) can apply `patch.diff` to klend and run:

```bash
cd /path/to/klend
git checkout a26220ce127fe571d9190011737c53e0fc09c415
git apply /tmp/kamino-poc-v2/patch.diff
cargo test --package kamino_lending --lib \
  lending_market::withdrawal_cap_operations::utils::tests_immunefi_poc -- --nocapture
```

The test code is identical in both options (Option A's shim crate uses
`#[path]` to include the exact same modified file).

### Expected output

```
running 2 tests
[t=0]    Borrow 1000000 (= debt_cap). Pre-state: ...
[t=0]    After borrow: current_total=1000000

[t=1]    Borrow 1 more should be denied...
[t=1]    Denied as expected: WithdrawalCapReached

[t=3601] Repay 1000000 after interval rolls over.
         State after:  current_total=-1000000, last_int_start=3601

[t=3611] remaining_withdrawal_caps_amount = 2000000 (config cap = 1000000)
[t=3611] Attempt Add 2000000 (= 2x cap) in new interval...
[t=3611] Result: ALLOWED (BUG)

=== BYPASS CONFIRMED (in-tree, Kamino's actual source) ===

test result: ok. 2 passed; 0 failed
```

### Verification checklist

Run from this directory:

```bash
# 1. Run the test
cd /tmp/kamino-poc-v2/intree-test && cargo test --lib \
  withdrawal_cap_operations::utils::tests_immunefi_poc -- --nocapture

# 2. Confirm sentinel was written (proves test actually executed bypass path)
cat /tmp/KAMINO_POC_V2.txt
# expect:
#   KAMINO-WITHDRAWAL-CAP-BYPASS-CONFIRMED-IN-TREE
#   current_total_after_repay=-1000000
#   remaining_reported=2000000 (config_capacity=1000000)
#   oversized_borrow_allowed=2000000

# 3. Confirm patch is test-only
grep -c "^-[^-]" /tmp/kamino-poc-v2/patch.diff   # expect 0
git -C /tmp/kamino-poc-v2/klend diff --name-only
# expect: programs/klend/src/lending_market/withdrawal_cap_operations.rs

# 4. Review the included source file vs. mainnet Kamino source
md5 /tmp/kamino-poc-v2/klend/programs/klend/src/lending_market/withdrawal_cap_operations.rs
# Compare against md5 of the file at master commit; the patch only ADDS a
# #[cfg(test)] mod at the bottom (no modifications to production code).
```

---

## PoC #2 — Anchor on-localnet recipe (SECONDARY)

See `anchor-recipe.md` for the 6-step recipe to reproduce on
`solana-test-validator` with the deployed Kamino program. Because the bug
surface is pure i64 math with no Solana / Anchor / oracle interaction, PoC
#1 is byte-equivalent to running this recipe -- but the recipe is provided
for reviewers who want the full end-to-end on-chain proof.

---

## File index

| File | Purpose |
|---|---|
| `klend-master-commit.txt` | Exact commit hash audited: `a26220c...` |
| `patch.diff` | Git diff of the in-tree test addition (191 insertions, 0 deletions) |
| `test-transcript.txt` | Full `cargo test --nocapture` stdout/stderr of PoC #1 |
| `anchor-recipe.md` | Step-by-step on-localnet reproducer recipe |
| `README.md` | This file |
| `intree-test/` | Shim Rust crate that includes Kamino's source byte-for-byte and runs the test |
| `intree-test/Cargo.toml` | Crate manifest (only dep: `thiserror`) |
| `intree-test/src/lib.rs` | Shim layer + `#[path]` include of Kamino's `withdrawal_cap_operations.rs` |
| `intree-test/src/main.rs` | Same scenario as a binary (`cargo run --bin run-poc`) |
| `klend/` | Cloned Kamino repo at the audited commit (with the test patch applied) |
| `/tmp/KAMINO_POC_V2.txt` | Sentinel file, written ONLY when bypass is confirmed by the test |

---

## Suggested fix

In `withdrawal_cap_operations.rs::check_and_update_withdrawal_caps`,
treat `Remove` as a no-op when the interval was just reset:

```rust
if caps.config_interval_length_seconds != 0 {
    let was_just_reset = if check_last_interval_elapsed(caps, curr_timestamp)? {
        reset_current_interval_and_counter(caps, curr_timestamp);
        true
    } else {
        false
    };
    // FIX: do not push counter negative on a freshly-reset interval.
    if was_just_reset && action == WithdrawalCapAction::Remove {
        return Ok(());
    }
    if action == WithdrawalCapAction::Add
        && requested_amount > remaining_withdrawal_caps_amount(caps, curr_timestamp)
    {
        return Err(LendingError::WithdrawalCapReached);
    }
    update_counter(caps, requested_amount, action,
                   WithdrawalCapOverflowAction::ErrorOnOverflow)
} else {
    ...
}
```

Equivalent fix: append `caps.current_total = caps.current_total.max(0);`
after every `update_counter` call. The clamp-on-update variant also
naturally fixes the 152 already-negative live reserves on next use.