All incidents

kTAF Dust-Collateral Reuse Drained kDAI

Share
Oct 19, 2023 17:31 UTCAttackLoss: 8,187.51 DAIPending manual check1 exploit txWindow: Atomic
Estimated Impact
8,187.51 DAI
Label
Attack
Exploit Tx
1
Addresses
3
Attack Window
Atomic
Oct 19, 2023 17:31 UTC → Oct 19, 2023 17:31 UTC

Exploit Transactions

TX 1Ethereum
0x325999373f1aae98db2d89662ff1afbe0c842736f7564d16a7b52bf5c777d3a4
Oct 19, 2023 17:31 UTCExplorer

Victim Addresses

0x959fb43ef08f415da0aea39beef92d96f41e41b3Ethereum
0xf5140fc35c6f94d02d7466f793feb0216082d7e5Ethereum
0xe5c6c14f466a4f3a73ecec7f3aaaa15c5ecbc769Ethereum

Loss Breakdown

8,187.51DAI

Similar Incidents

Root Cause Analysis

kTAF Dust-Collateral Reuse Drained kDAI

1. Incident Overview TL;DR

At Ethereum mainnet block 18385886, transaction 0x325999373f1aae98db2d89662ff1afbe0c842736f7564d16a7b52bf5c777d3a4 executed a single-transaction ACT attack against a Compound-style lending deployment composed of kTAF (0xf5140fc35c6f94d02d7466f793feb0216082d7e5), kDAI (0xe5c6c14f466a4f3a73ecec7f3aaaa15c5ecbc769), and Unitroller (0x959fb43ef08f415da0aea39beef92d96f41e41b3). An unprivileged EOA used a zero-fee Balancer flash loan, liquidated a publicly underwater borrower, redeemed the seized kTAF into 3300 TAF, then recycled that same TAF across nine fresh helper borrowers to drain kDAI down to a 1 wei cash balance.

The root cause is a concrete accounting flaw in the collateral market, not a speculative MEV hypothesis. kTAF counted raw donated cash inside exchangeRateStoredInternal() and redeemUnderlying floor-rounded the cTokens to burn, which let a helper keep borrow capacity after almost all underlying collateral had been withdrawn. Comptroller borrow checks trusted that manipulated exchange rate, so the attacker could repeatedly borrow kDAI, remove the apparent backing collateral, self-liquidate the helper, and externalize bad debt while reusing the same 3300 TAF seed collateral.

2. Key Background

kTAF and kDAI are Compound-style CErc20Immutable markets. Their collateral and borrow policy is enforced by Unitroller/Comptroller through getAccountSnapshot, which returns an account's cToken balance, borrow balance, and the market exchange rate that will be used in liquidity checks. In this deployment, kTAF used 18-decimal TAF as underlying, 8-decimal cTokens, and an incident-prestate collateral factor of 0.8e18.

The prestate at block 18385885 already exposed the seed condition needed for the attack. Borrower 0x3cF7e9d9dCfeD77f295CF7A7F5539eC407D9a67d held 16500000000000 kTAF against 3486129908283637633814 DAI debt and had an account shortfall of 846129908283637633814, so anyone could liquidate that position. kTAF held 3300000000000000000000 TAF with exchangeRateStored = 200000000000000000000000000, and kDAI already held 8187514103413431539367 DAI cash.

The relevant kTAF market code makes the vulnerability visible:

function exchangeRateStoredInternal() internal view returns (MathError, uint) {
    uint totalCash = getCashPrior();
    (mathErr, cashPlusBorrowsMinusReserves) = addThenSubUInt(totalCash, totalBorrows, totalReserves);
    (mathErr, exchangeRate) = getExp(cashPlusBorrowsMinusReserves, totalSupply);
    return (MathError.NO_ERROR, exchangeRate.mantissa);
}

function redeemFresh(address payable redeemer, uint redeemTokensIn, uint redeemAmountIn) internal returns (uint) {
    (vars.mathErr, vars.exchangeRateMantissa) = exchangeRateStoredInternal();
    (vars.mathErr, vars.redeemTokens) =
        divScalarByExpTruncate(redeemAmountIn, Exp({mantissa: vars.exchangeRateMantissa}));
    ...
}

That combination is dangerous in a dust-supply state: donated underlying raises the exchange rate, and divScalarByExpTruncate lets redeemUnderlying burn fewer cTokens than a full-precision calculation would require.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability is an accounting and invariant failure in the collateral market. kTAF allowed user-donated TAF to increase the per-share exchange rate seen by Comptroller even though that donated TAF was not economically locked once the attacker reached a dust-supply state. At the same time, redeemUnderlying truncated redeemAmount / exchangeRate downward, so a helper account could redeem nearly all underlying while burning only 1 cToken. Unitroller then continued to value the remaining dust kTAF at the attacker-inflated exchange rate during borrowAllowed.

The violated invariant is straightforward: a borrower must not be able to increase borrow capacity with cToken collateral unless the corresponding underlying remains locked for the life of the debt. Here, that invariant breaks when the helper leaves only 2 kTAF outstanding, donates the redeemed TAF back into kTAF, borrows kDAI against the inflated snapshot, and then uses redeemUnderlying to pull almost all TAF back out while burning only 1 kTAF. The policy layer trusts a collateral value that is no longer present in the market.

This is an ACT attack because every ingredient is permissionless and publicly observable: the underwater borrower was already liquidatable, the Balancer flash loan was public, the attacker deployed fresh helper contracts in the transaction, and no privileged keys, private orderflow, or special access were required.

4. Detailed Root Cause Analysis

The attack began by turning a public liquidation opportunity into reusable seed collateral. The seed trace shows the attacker contract 0xa6d35c97bd00b99a962393408aaa9eb275a45c5e receiving a 4000000000000000000000 DAI Balancer flash loan, repeatedly calling kDAI.liquidateBorrow against the underwater borrower, and then redeeming the seized 16500000000000 kTAF for 3300000000000000000000 TAF.

The helper-borrower loop is the critical root-cause realization. Each fresh helper:

  1. Enters kTAF as collateral.
  2. Sends 1 wei TAF directly to kTAF, then mints with 3299999999999999999999 TAF.
  3. Redeems all but 2 kTAF, recovering 3299999999999599999999 TAF.
  4. Donates the recovered TAF back into kTAF, which restores cash while leaving total supply at 2.
  5. Borrows kDAI against the inflated snapshot.
  6. Calls redeemUnderlying(kTAF_cash - 1) to withdraw nearly all TAF while burning only 1 kTAF.
  7. Transfers both DAI and TAF back to the attacker contract.

The seed trace captures that exact sequence:

emit Mint(minter: 0x8619984AAc2e25829FAD1f002E416ba942C1dFec, mintAmount: 3299999999999999999999, mintTokens: 16499999999999)
emit Redeem(redeemer: 0x8619984AAc2e25829FAD1f002E416ba942C1dFec, redeemAmount: 3299999999999599999999, redeemTokens: 16499999999997)
CErc20Immutable::getAccountSnapshot(0x8619984AAc2e25829FAD1f002E416ba942C1dFec) -> (0, 2, 0, 1650000000000000000000000000000000000000)
emit Borrow(borrower: 0x8619984AAc2e25829FAD1f002E416ba942C1dFec, borrowAmount: 1320000000000000000000, accountBorrows: 1320000000000000000000)
emit Redeem(redeemer: 0x8619984AAc2e25829FAD1f002E416ba942C1dFec, redeemAmount: 3299999999999999999999, redeemTokens: 1)

Those trace values line up exactly with the code-level breakpoint. After the helper has only 2 kTAF left, exchangeRateStoredInternal() values each remaining share at 1650 TAF-equivalent because totalCash has been pushed back to 3300 TAF while totalSupply is only 2. borrowAllowed then accepts a kTAF snapshot that looks massively overcollateralized. When the helper calls redeemUnderlying, divScalarByExpTruncate rounds down and burns only 1 kTAF to withdraw 3299999999999999999999 TAF, so the apparent backing collateral disappears even though the borrow remains.

The attacker then closes the loop with deterministic self-liquidation. A separate attacker-controlled path repays 1 wei DAI to kDAI, seizes the helper's last 1 kTAF, redeems that dust share for the final 1 wei TAF, and leaves the helper with positive kDAI debt and zero kTAF collateral:

emit RepayBorrow(payer: 0xA6D35c97Bd00B99a962393408aAa9EB275a45c5e, borrower: 0x547d35b7079b002346E2896A79Cdb711098A4BC6, repayAmount: 1)
emit LiquidateBorrow(liquidator: 0xA6D35c97Bd00B99a962393408aAa9EB275a45c5e, borrower: 0x547d35b7079b002346E2896A79Cdb711098A4BC6, repayAmount: 1, cTokenCollateral: 0xf5140fC35C6f94D02d7466f793fEB0216082d7E5, seizeTokens: 1)
emit Redeem(redeemer: 0xA6D35c97Bd00B99a962393408aAa9EB275a45c5e, redeemAmount: 1, redeemTokens: 1)

The trace contains nine helper ::start(Unitroller, kTAF, kDAI) invocations, so the same 3300 TAF was recycled across nine disposable borrowers. The final state proves the exploit predicate: kDAI cash is reduced to 1, the helpers retain bad debt with no kTAF collateral, the attacker still holds the full 3300000000000000000000 TAF seed collateral, and the EOA profit recipient ends the block with 8187514103413431539366 DAI after flash-loan repayment.

5. Adversary Flow Analysis

The attacker strategy had three stages inside one transaction.

First, the attacker EOA 0x9b99d7ce9e39c68ab93348fd31fd4c99f79e4b19 called its own contract 0xa6d35c97bd00b99a962393408aaa9eb275a45c5e, which borrowed 4000 DAI from Balancer Vault 0xBA12222222228d8Ba445958a75a0704d566BF2C8. That flash liquidity was immediately routed into kDAI liquidations against the public underwater borrower until the attacker seized the borrower's entire kTAF position and redeemed it into 3300 TAF.

Second, the attacker deployed nine helper borrowers and ran the same borrow factory repeatedly. Each helper accepted the full 3300 TAF seed from the attacker, entered kTAF as collateral, created the dust-supply condition, borrowed as much kDAI cash as the manipulated liquidity calculation allowed, redeemed almost all TAF back out, and returned both the borrowed DAI and recovered TAF to the attacker contract. The helpers were intentionally disposable and were never expected to remain solvent.

Third, after each helper sent out its assets, the attacker repaid 1 wei DAI to liquidate the helper, seized the final 1 kTAF dust share, redeemed the last 1 wei TAF, and moved to the next helper. After the ninth helper, the attacker repaid Balancer exactly 4000000000000000000000 DAI and transferred the residual 8187514103413431539366 DAI to the originating EOA. The Balancer fee was zero in the trace, and the profit-recipient EOA balance moved from 0 DAI before block 18385886 to 8187514103413431539366 DAI after the block.

6. Impact & Losses

The measurable post-transaction attacker profit was 8187514103413431539366 DAI units (8187.514103413431539366 DAI), which is the amount recorded in the profit-recipient EOA after the flash loan was fully repaid. kDAI's cash balance fell from 8187514103413431539367 DAI at the start of the forked prestate to 1 wei at the end of the exploit.

The gross borrow volume extracted from kDAI during the helper loop exceeded the net profit because the attack first injected flash-loaned DAI into kDAI through liquidation of the public borrower. That liquidation step converted the borrower's public collateral into reusable TAF seed while also replenishing kDAI's cash, which the attacker then borrowed back out through the dust-collateral loop. The end result was a drained kDAI market, nine helper accounts left with bad debt and no meaningful collateral, and complete recovery of the original 3300 TAF seed by the attacker.

7. References

  • Seed transaction: 0x325999373f1aae98db2d89662ff1afbe0c842736f7564d16a7b52bf5c777d3a4
  • Balancer Vault: 0xBA12222222228d8Ba445958a75a0704d566BF2C8
  • Unitroller / Comptroller: 0x959fb43ef08f415da0aea39beef92d96f41e41b3
  • kTAF market: 0xf5140fc35c6f94d02d7466f793feb0216082d7e5
  • kDAI market: 0xe5c6c14f466a4f3a73ecec7f3aaaa15c5ecbc769
  • TAF underlying: 0xf573e6740045b5387f6d36a26b102c2adf639af5
  • DAI underlying: 0x6b175474e89094c44da98b954eedeac495271d0f
  • Public underwater borrower: 0x3cF7e9d9dCfeD77f295CF7A7F5539eC407D9a67d
  • Evidence artifacts used in validation:
    • prestate observations for block 18385885
    • seed transaction opcode trace and metadata
    • verified kTAF CErc20Immutable source
    • determinism-resolution observations for DAI before/after balances, zero DAI fee evidence, and contract verification status