KToken redeemFresh Logic Flaw — Full Market Drain via redeemUnderlying

On 2026-03-17 (block 30488585), a lending protocol deployed on Polygon zkEVM (chain ID 1101) was attacked through a logic error in its Compound-fork KToken implementation. The vulnerability is in internal function 0x3dff (redeemFresh): when redeemUnderlying() is called, the function (1) computes totalSupplyNew using an uncapped redeemTokens value, then (2) caps redeemTokens to the caller’s actual cToken balance without ever recalculating redeemAmount. An attacker minted a tiny amount of kETH with 0.001 ETH, then called redeemUnderlying(getCash()), burning only their 0.049 kETH while withdrawing the market’s entire 38.60 ETH reserve. Net attacker profit: approximately 38.60 ETH (~$97,000 USD at ~$2,500/ETH). Root cause confirmed from Dedaub decompilation of implementation 0xf7faa3f174e780e3d317dd475fde0de0dfe358fb.

Root Cause

Vulnerable Contract

  • Name: KToken Implementation (Compound-style cToken)
  • Address: 0xf7faa3f174e780e3d317dd475fde0de0dfe358fb
  • Proxy: EIP-1967 transparent proxy 0xee1727f5074e747716637e1776b7f7c7133f16b1 (kETH market) DELEGATECALLs to this implementation
  • Source type: Dedaub decompilation (authoritative) — no verified source available for Polygon zkEVM

Vulnerable Function

  • Internal function: 0x3dff(uint256 varg0, uint256 varg1, uint256 varg2) — this is redeemFresh()
    • Called as 0x3dff(redeemAmount, 0, msg.sender) by 0x2674 (the body of redeemUnderlying)
    • Called as 0x3dff(0, redeemTokens, msg.sender) by 0x2b68 (the body of redeem)
  • Public entry point: redeemUnderlying(uint256) (selector 0x852a12e3) → 0x26740x3dff
  • Location: KToken implementation 0xf7faa3f174e780e3d317dd475fde0de0dfe358fb

Vulnerable Code

Translated from Dedaub decompilation of internal function 0x3dff into readable Solidity. Structure is faithful to the decompiler output; only the raw variable names (varg0, v3.word3, etc.) have been replaced with meaningful identifiers. The two bugs are annotated inline.

// Called by redeemUnderlying(uint redeemAmountIn):  redeemFresh(redeemAmountIn, 0,         msg.sender)
// Called by redeem(uint redeemTokensIn):             redeemFresh(0,             redeemTokensIn, msg.sender)
function redeemFresh(uint redeemAmountIn, uint redeemTokensIn, address redeemer) internal returns (uint) {
    require(redeemTokensIn == 0 || redeemAmountIn == 0, "tokensIn or amountIn must be 0");

    uint exchangeRate = exchangeRateStoredInternal();   // 0x25aa()

    uint redeemTokens;
    uint redeemAmount;

    if (redeemTokensIn == 0) {
        // ── redeemUnderlying path ──────────────────────────────────────────
        if (redeemAmountIn != type(uint256).max) {
            redeemAmount = redeemAmountIn;                          // = 38,601,532,887,381,810,523 wei  (user-supplied)
            redeemTokens = redeemAmountIn * 1e18 / exchangeRate;   // = 189,868,728,525 kETH units      (full requested tokens, UNCAPPED)
        } else {
            // redeem-all shortcut
            redeemTokens = balanceOf[redeemer];
            redeemAmount = redeemTokens * exchangeRate / 1e18;
        }
    } else {
        // ── redeem path ────────────────────────────────────────────────────
        redeemTokens = min(redeemTokensIn, balanceOf[redeemer]);
        redeemAmount = redeemTokens * exchangeRate / 1e18;
    }

    // Comptroller receives the UNCAPPED redeemTokens (189,868,728,525).
    // It approves because the attacker had no outstanding borrows.
    require(comptroller.redeemAllowed(address(this), redeemer, redeemTokens) == 0);

    require(accrualBlockTimestamp == block.timestamp, "market not fresh");

    // ✗ BUG 1 — totalSupplyNew is calculated with the UNCAPPED redeemTokens.
    //   207,965,446,161 − 189,868,728,525 = 18,096,717,636
    //   (No underflow: 189B < 207B, so the subtraction succeeds silently.)
    uint totalSupplyNew = totalSupply - redeemTokens;       // ← wrong: drops by 189B instead of 4.9M

    // Cap redeemTokens to the caller's actual balance — but totalSupplyNew is already committed above.
    if (redeemTokens > balanceOf[redeemer]) {
        redeemTokens = balanceOf[redeemer];                 // capped: 189,868,728,525 → 4,918,683
    }
    // ✗ BUG 2 — redeemAmount is NEVER recalculated after the cap.
    //   It stays at 38,601,532,887,381,810,523 wei (the full market cash).

    // accountTokensNew is computed correctly with the now-capped redeemTokens.
    uint accountTokensNew = balanceOf[redeemer] - redeemTokens; // = 4,918,683 − 4,918,683 = 0

    require(getCash() >= redeemAmount, "insufficient cash");    // 38.60 ETH ≥ 38.60 ETH ✓

    totalSupply         = totalSupplyNew;       // ← BUG 1 result: 18,096,717,636 (should be ~207,960,527,478)
    balanceOf[redeemer] = accountTokensNew;     // 0  (attacker's kETH fully burned — this part is correct)

    emit Transfer(redeemer, address(this), redeemTokens);  // burns only 4,918,683 kETH
    emit Redeem(redeemer, redeemAmount, redeemTokens);     // logs the mismatch: 38.60 ETH out, 4.9M kETH in

    comptroller.redeemVerify(address(this), redeemer, redeemAmount, redeemTokens);

    doTransferOut(redeemer, redeemAmount);  // ← BUG 2 result: sends 38,601,532,887,381,810,523 wei (38.60 ETH)
}

Two bugs, one exploitable combination:

#WhereWhat goes wrongAttack value
BUG 1totalSupplyNew = totalSupply - redeemTokens before the capSupply accounting reduced by 189B tokens instead of 4.9M — market bookkeeping corruptedState corruption (secondary)
BUG 2redeemAmount never updated after redeemTokens is cappeddoTransferOut sends the original user-supplied amount (38.60 ETH) despite only 4.9M kETH being burned38.60 ETH stolen (primary)

Why It’s Vulnerable

Expected behavior: In a correct Compound-fork implementation, redeemFresh() with redeemAmountIn > 0 should compute redeemTokens = ceil(redeemAmountIn / exchangeRate) and then verify that redeemTokens <= accountTokens[redeemer]. If the caller requests more underlying than their cToken balance can cover, the transaction should revert. The redeemAmount and redeemTokens must remain consistent throughout.

Actual behavior (confirmed from Dedaub decompilation of 0x3dff): The implementation computes redeemTokens from redeemAmountIn (which can be the full market cash balance). It then immediately computes totalSupplyNew = totalSupply - redeemTokens using this uncapped value (BUG 1). Only afterwards does it cap redeemTokens to the caller’s actual balance. The critical second error is that redeemAmount is never recalculated after the cap — it remains equal to the original user-supplied value (BUG 2). The protocol then calls doTransferOut(redeemer, redeemAmount) to transfer the full original redeemAmount (38.60 ETH) while burning only the capped redeemTokens (0.049 kETH). Note: accountTokensNew is computed correctly (balanceOf[redeemer] - redeemTokens = 0) because the cap happens before that line — the attacker’s kETH balance is properly zeroed. The damage is that (a) they receive 38.60 ETH they weren’t entitled to, and (b) totalSupply is permanently corrupted.

Why this matters: Any caller who holds even a dust amount of kETH can call redeemUnderlying(getCash()) — requesting the entire market’s available cash — and walk away with all underlying assets while burning only their tiny cToken balance. There is no check that the requested redeemAmountIn is proportional to what the caller’s cToken balance entitles them to.

Normal flow vs Attack flow:

StepNormal user (proportional redeem)Attacker
InputredeemAmountIn = proportional to cToken balanceredeemAmountIn = getCash() = 38.60 ETH
redeemTokens computedMatches actual balanceComputed as 1,898.69 kETH (attacker holds only 0.049 kETH)
Cap applied?No cap needed (already proportional)Silently capped to 0.049 kETH
redeemAmount after capUnchanged (correct)Still 38.60 ETH (never recalculated)
Tokens burnedProportional amount0.049 kETH
ETH receivedProportional amount38.60 ETH (entire market)

Attack Execution

High-Level Flow

  1. Attacker EOA (0xb343) deploys helper contract (0x5a2f) with 0.002 ETH funding via CREATE.
  2. Helper contract constructor: calls mint() on kETH market with 0.001 ETH, receiving ~0.049 kETH.
  3. Helper calls exitMarket(kETH) on the Comptroller to exit the collateral market (reducing any borrow constraints).
  4. Helper queries getCash() on the kETH market — reads the current available cash: 38.60153 ETH.
  5. Helper calls redeemUnderlying(38.60153 ETH) on kETH market. The vulnerable redeemFresh() computes redeemTokens = 1,898.69 kETH (proportional to 38.60 ETH), caps to attacker’s balance of 0.049 kETH, but transfers the full 38.60 ETH out without recalculating.
  6. kETH market sends 38.60 ETH to the helper contract; helper burns only 0.049 kETH.
  7. Helper contract forwards all ETH (38.60 ETH + 0.001 ETH residual) to attacker EOA.
  8. Attacker EOA net gain: ~38.60 ETH.

Detailed Call Trace

All calls derived from trace_callTracer.json and decoded_calls.json. Selectors verified with cast sig.

[depth 0] EOA 0xb343 → CREATE AttackerContract 0x5a2f  {value: 0.002 ETH}
  |
  ├─ [depth 1] 0x5a2f → kETH Proxy 0xee17  CALL  mint()  0x1249c58b  {value: 0.001 ETH}
  │     └─ [depth 2] 0xee17 → KToken Impl 0xf7fa  DELEGATECALL  mint()  0x1249c58b
  │           ├─ [depth 3] 0xee17 → InterestRateModel 0x71d9  STATICCALL  getBorrowRate(uint256,uint256,uint256)  0x15f24053
  │           └─ [depth 3] 0xee17 → Comptroller Proxy 0x6ea3  CALL  mintAllowed(address,address,uint256)  0x4ef4c3e1
  │                 └─ [depth 4] 0x6ea3 → Comptroller Impl 0x968d  DELEGATECALL  mintAllowed(address,address,uint256)  0x4ef4c3e1
  │                       ├─ [depth 5] 0x6ea3 → 0xee17  STATICCALL  getCash()  0x3b1d21a2  [returns pre-mint cash]
  │                       │     └─ [depth 6] 0xee17 → 0xf7fa  DELEGATECALL  getCash()
  │                       ├─ [depth 5] 0x6ea3 → 0xee17  STATICCALL  underlying()  0x6f307dc3
  │                       ├─ [depth 5] 0x6ea3 → 0xee17  STATICCALL  totalBorrows()  0x47bd3718
  │                       ├─ [depth 5] 0x6ea3 → 0xee17  STATICCALL  totalReserves()  0x8f840ddd
  │                       └─ [depth 5] 0x6ea3 → 0xee17  STATICCALL  balanceOf(address)  0x70a08231  [querying 0x5a2f]
  │
  ├─ [depth 1] 0x5a2f → Comptroller Proxy 0x6ea3  CALL  exitMarket(address kETH)  0xede4edd0
  │     └─ [depth 2] 0x6ea3 → Comptroller Impl 0x968d  DELEGATECALL  exitMarket(address)  0xede4edd0
  │           ├─ [depth 3] 0x6ea3 → 0xee17  STATICCALL  getAccountSnapshot(address)  0xc37f68e2  [0x5a2f snapshot]
  │           │     └─ [depth 4] 0xee17 → 0xf7fa  DELEGATECALL  getAccountSnapshot(address)
  │           ├─ [depth 3] 0x6ea3 → 0xee17  STATICCALL  getAccountSnapshot(address)  0xc37f68e2  [second call]
  │           │     └─ [depth 4] 0xee17 → 0xf7fa  DELEGATECALL  getAccountSnapshot(address)
  │           └─ [depth 3] 0x6ea3 → PriceOracle 0x483a  STATICCALL  getUnderlyingPrice(address)  0xfc57d4df
  │                 └─ [depth 4] 0x483a → ChainlinkFeedProxy 0x97d9  STATICCALL  decimals() + latestRoundData()
  │                       └─ [depth 5] 0x97d9 → ChainlinkAggregator 0xbc43  STATICCALL  decimals() + latestRoundData()
  │
  ├─ [depth 1] 0x5a2f → kETH Proxy 0xee17  STATICCALL  getCash()  0x3b1d21a2
  │     └─ [depth 2] 0xee17 → KToken Impl 0xf7fa  DELEGATECALL  getCash()  [returns 38601532887381810523 wei = 38.60153 ETH]
  │
  ├─ [depth 1] 0x5a2f → kETH Proxy 0xee17  CALL  redeemUnderlying(38601532887381810523)  0x852a12e3
  │     └─ [depth 2] 0xee17 → KToken Impl 0xf7fa  DELEGATECALL  redeemUnderlying(uint256)  0x852a12e3
  │           ├─ [depth 3] 0xee17 → Comptroller Proxy 0x6ea3  CALL  redeemAllowed(address,address,uint256)  0xeabe7d91
  │           │     [redeemTokens=189868728525 passed — uncapped value — Comptroller approves]
  │           │     └─ [depth 4] 0x6ea3 → Comptroller Impl 0x968d  DELEGATECALL  redeemAllowed(...)
  │           ├─ [depth 3] 0xee17 → Comptroller Proxy 0x6ea3  CALL  redeemVerify(address,address,uint256,uint256)  0x51dff989
  │           │     └─ [depth 4] 0x6ea3 → Comptroller Impl 0x968d  DELEGATECALL  redeemVerify(...)
  │           └─ [depth 3] 0xee17 → 0x5a2f  CALL  (ETH transfer)  {value: 38601532887381810523 wei = 38.60153 ETH}
  │                 [VULNERABILITY: 38.60 ETH sent despite only 0.049 kETH burned]
  │
  └─ [depth 1] 0x5a2f → EOA 0xb343  CALL  (ETH transfer)  {value: 38602532887381810523 wei}
        [helper forwards all ETH to attacker EOA]

Key observation: The redeemAllowed call at depth 3 receives redeemTokens = 189868728525 (the uncapped, pre-computed value). The Comptroller approves this because the attacker had exited the collateral market — there are no outstanding borrows to protect. The Comptroller does not independently verify that redeemTokens matches the caller’s actual balance.

Financial Impact

Source: funds_flow.json (primary evidence).

ItemValue
ETH in kETH market before attack38.60053 ETH
Attacker’s ETH deposited for mint0.001 ETH
kETH minted0.04918683 kETH (4,918,683 units, 8 decimals)
ETH withdrawn via exploit38.60153 ETH
kETH burned0.04918683 kETH (entire attacker balance)
Attacker net profit38.6005 ETH (~$97,000 USD at $2,500/ETH)
Market solvency after attackFully drained — kETH market ETH balance reduced to 0

The market was completely drained in a single transaction. All legitimate depositors in the kETH market lost their deposits. The Comptroller contract and the price oracle were not directly involved in enabling the exploit; the flaw is entirely within redeemFresh().

From funds_flow.json attacker_gains:

  • Token: ETH (0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee)
  • Amount: 38.600532887381810523 ETH
  • Attacker EOA (0xb343) pre-attack ETH: 0.005 ETH; post-attack: 38.60552 ETH (verified via trace_prestateTracer.json).

Evidence

On-chain Log Verification

Log indexContractEvent topicSignificance
3kETH (0xee17)Transfer(address(0) → 0x5a2f, 4918683)0xddf252adConfirms kETH minted: 4,918,683 units (0.049 kETH, 8 decimals) for 0.001 ETH
5kETH (0xee17)Transfer(0x5a2f → 0xee17, 4918683)0xddf252adkETH burned = exactly equal to what was minted
6kETH (0xee17)Redeem(0x5a2f, redeemAmount=38601532887381810523, redeemTokens=4918683)0xe5b754fbCritical: 38.60 ETH redeemAmount vs only 4,918,683 kETH burned — mismatch proves the flaw

Storage State Diff (trace_prestateTracer.json)

kETH proxy 0xee17:

  • Before: ETH balance = 0x217b0a4258d2d1d5b = 38.60053 ETH
  • After: ETH balance = 0x0 (fully drained)

Storage slot 0x000...000d (totalSupply):

  • Before: 0x000000306bb4e011 = 207,965,446,161 (kETH total supply including attacker’s freshly minted 4,918,683 tokens)

  • After: 0x000000436f10cdf = 18,101,636,319

    Arithmetic (BUG 1): totalSupplyNew = totalSupply - redeemTokens = 207,965,446,161 − 189,868,728,525 = 18,096,717,636. The +4,918,683 from the attacker’s mint was already baked into the 207B figure, giving a final post-state of 18,101,636,319. No underflow occursredeemTokens (189B) is less than totalSupply (207B), so the subtraction succeeds silently. The supply is simply reduced by ≈189.9B tokens instead of the correct ≈4.9M — a ~38,600× overcorrection that leaves the market’s token accounting permanently corrupted.

Selector Verification

All selectors verified with cast sig:

  • mint()0x1249c58b
  • redeemUnderlying(uint256)0x852a12e3
  • getCash()0x3b1d21a2
  • exitMarket(address)0xede4edd0
  • redeemAllowed(address,address,uint256)0xeabe7d91
  • redeemVerify(address,address,uint256,uint256)0x51dff989

Transaction Details

  • TX: 0x4ccde7fc6b240397228c1a740d15a149d2062ae0c11336ff81ad394603d9dfd8
  • Block: 30488585 (Polygon zkEVM)
  • Status: Success (0x1)
  • Gas used: 900,061
  • Attacker EOA: 0xb343fe12f86f785a88918599b29b690c4a5da6d5
  • Attacker contract: 0x5a2f4151ea961d3dfc4ddf116ca95bfa5865f16f (deployed in this tx)