Reproduced Exploit

Term Finance — 500 excess collateral stays locked after the loan is repaid

After liquidation the repurchase obligation is 0 and 500 collateral is still in the locker, marked for the borrower. externalUnlockCollateral reverts ZeroBorrowerRepurchaseObligation whenever that obligation is 0, so the borrower cannot take the 500. The control skips that check and transfers the 5…

Apr 2023•Other•dos•2 min read

Chain

Other

Category

dos

Date

Apr 2023

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 26296-borrowers-cannot-withdraw-excess-collateral-after-liquidatio. Standalone Foundry PoC and full write-up: 26296-borrowers-cannot-withdraw-excess-collateral-after-liquidatio_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/dos/frozen-funds · vuln/logic/wrong-condition · vuln/logic/liquidation-logic

Key info#

ProtocolTerm Finance
FindingSigma Prime term1, AuditVault 26296
Vulnerable codeTermRepoLocker.externalUnlockCollateral
HarmUnlock reverts and 500 COLL stays in the locker
Chainlocal synthetic

TL;DR#

After liquidation the repurchase obligation is 0 and 500 collateral is still in the locker, marked for the borrower. externalUnlockCollateral reverts ZeroBorrowerRepurchaseObligation whenever that obligation is 0, so the borrower cannot take the 500. The control skips that check and transfers the 500 to the borrower.

The vulnerable code#

SOLIDITY
// @> VULN: a fully repaid borrower still reverts, so leftover collateral cannot move
if (servicer.getBorrowerRepurchaseObligation(borrower) == 0) {
    revert ZeroBorrowerRepurchaseObligation();
}
coll.transfer(borrower, amount);

The control transfers the collateral without that revert.

Root cause#

The zero check was meant to block an unlock when there is no loan, but a liquidation or default can repay the loan without taking every unit of collateral. Zero is then the state in which the excess should be withdrawable, and it is exactly the state the check rejects.

Preconditions#

The borrower's repurchase obligation is 0. The locker holds 500e18 of their collateral. The borrower calls externalUnlockCollateral for 500e18.

Attack walkthrough#

  1. 500e18 is minted into the locker. The obligation is already 0.
  2. The unlock reads the obligation and takes the revert branch.
  3. The call fails. The locker still holds 500e18 and the borrower holds 0.

Diagrams#

flowchart TD A["Loan repaid, 500 collateral left in the locker"] --> B["Borrower calls externalUnlockCollateral"] B --> C["VULN: obligation 0 reverts"] C --> D["500 stays in the locker"] D --> E["Control pays the 500 to the borrower"]

Impact#

The borrower cannot recover 500e18 of excess collateral. It remains in the locker after the debt is gone. The control pays that 500e18 to the borrower and leaves the locker empty.

Remediation#

Allow the withdrawal when the repurchase obligation is 0 because liquidation or default already repaid it. Sigma Prime records the fix in PR 757.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.