Reproduced Exploit

VII Finance — more value extracted by liquidations than expected (`normalizedToFull`)

1. Liquidation / value transfer walks every enabled tokenId and converts a unit-of-account amount into an ERC-6909 amount via normalizedToFull. 2. normalizedToFull multiplies by totalSupply(tokenId) instead of the sender's own ERC-6909 balance of that tokenId.

Jul 2025Otherlogic3 min read

Chain

Other

Category

logic

Date

Jul 2025

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: 61329-more-value-can-be-extracted-by-liquidations-than-expected-du. Standalone Foundry PoC and full write-up: 61329-more-value-can-be-extracted-by-liquidations-than-expected-du_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/liquidation-logic · liquidation-manipulation · integer-bounds

Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only forge-std — no fork, no RPC, no anvil_state. Full trace: output.txt. PoC: test/61329-more-value-can-be-extracted-by-liquidations-than-expected-du_exp.sol.

AuditVault taxonomylang/solidity · platform/cyfrin · has/poc · severity/high · sector/dex · sector/lending · sector/nft · genome: liquidation-logic · liquidation-manipulation · variant · integer-bounds · liquidation-underwater


Key info#

ImpactHIGH — value-denominated collateral transfers (used in liquidations) seize more unit-of-account value than requested when the violator owns less than 100% of any enabled ERC-6909 tokenId
ProtocolVII Finance — Uniswap V3/V4 position wrappers as EVK collateral
Vulnerable codeERC721WrapperBase.normalizedToFull — multiplies by totalSupply(tokenId) instead of the sender's balance
Bug classIncorrect normalization in liquidation share transfer
FindingCyfrin 2025-07-15 vii-v2.0 · AuditVault #61329 · reporter Giovanni Di Siena
ReportCyfrin vii-v2.0
SourceAuditVault
StatusFixed in commit b7549f2
Compiler^0.8.24 (PoC)

TL;DR#

  1. Liquidation / value transfer walks every enabled tokenId and converts a unit-of-account amount into an ERC-6909 amount via normalizedToFull.
  2. normalizedToFull multiplies by totalSupply(tokenId) instead of the sender's own ERC-6909 balance of that tokenId.
  3. When the violator owns less than 100% of any tokenId, each leg of the transfer is inflated; the liquidator receives more value than requested.
  4. Fix: multiply by balanceOf(sender, tokenId).

The vulnerable code#

SOLIDITY
function normalizedToFull(uint256 tokenId, uint256 amount, uint256 currentBalance) public view returns (uint256) {
    // @audit => multiplying by the total ERC-6909 supply of the specified tokenId is incorrect
    return Math.mulDiv(amount, totalSupply(tokenId), currentBalance); // @> VULN
}

Recommended fix:

DIFF
-       return Math.mulDiv(amount, totalSupply(tokenId), currentBalance);
+       return Math.mulDiv(amount, balanceOf(_msgSender(), tokenId), currentBalance);

Root cause#

balanceOf(sender) is the sum of unit-of-account values across enabled tokenIds. For each tokenId the code must convert amount / currentBalance into a fraction of the sender's claim on that tokenId. Using totalSupply treats the sender as if they owned 100% of every tokenId, so partial ownership inflates the ERC-6909 amount transferred on every other leg as well.

Preconditions#

  • Sender has enabled multiple tokenIds (or one partially owned tokenId).
  • Sender owns less than 100% of at least one enabled tokenId (prior partial transfer / partial liquidation / shared position).
  • A value-denominated transfer(to, amount) is executed (liquidation path).

Attack walkthrough#

  1. Borrower wraps two positions (tokenId1, tokenId2), full supply 100 each.
  2. Borrower transfers half of tokenId1 to another account → owns 50 + 100 = 150 value.
  3. Liquidator requests transfer of 75 (half of remaining value).
  4. Bug: tokenId1 transfers 75 * 100 / 150 = 50 (all remaining); tokenId2 transfers 75 * 100 / 150 = 50100 value seized vs 75 requested (+25 surplus).
  5. Correct formula would transfer 25 + 50 = 75.

Diagrams#

flowchart TD A["Borrower owns tokenId1:50 and tokenId2:100"] --> B["balanceOf = 150"] B --> C["transfer liquidator amount=75"] C --> D{"normalizedToFull uses totalSupply?"} D -->|bug| E["tokenId1: 75*100/150=50<br/>tokenId2: 75*100/150=50"] E --> F["liquidator value = 100 > 75"] D -->|fix| G["tokenId1: 75*50/150=25<br/>tokenId2: 75*100/150=50"] G --> H["liquidator value = 75 exact"]

Impact#

Liquidated accounts incur larger losses than the liquidation parameters specify. Repeated partial liquidations amplify the surplus extraction.

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.