Reproduced Exploit

Licredity — swap-and-pop without index fix-up corrupts Position fungibles

1. Position tracks fungibles in an array and a fungibleStates mapping (1-based index + balance). 2. On full remove, the code swap-and-pops the array but never updates the moved tail element's cached index. 3. Remove A from [A,B,C] → array becomes [C,B] but index(C) stays 3. 4. Remove C with stale i…

Sep 2025Otherdata-structure3 min read

Chain

Other

Category

data-structure

Date

Sep 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: 62348-swap-and-pop-without-index-fix-up-corrupts-positions-fungibl. Standalone Foundry PoC and full write-up: 62348-swap-and-pop-without-index-fix-up-corrupts-positions-fungibl_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/data-structure/stale-index · vuln/accounting/desync · impact/incorrect-health

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/62348-swap-and-pop-without-index-fix-up-corrupts-positions-fungibl_exp.sol.


Key info#

ImpactHIGH — removing one fungible can silently drop another from the position's enumeration array while leaving a ghost balance in the mapping; appraisal that walks fungibles[] under-counts collateral
ProtocolLicredity v1/v2 — position collateral accounting
Vulnerable codePosition::removeFungible swap-and-pop assembly (missing moved-element index fix-up)
Bug classStale cached index after swap-and-pop
FindingCyfrin — Licredity v2.0, 2025-09 · #62348 · reporter Immeas
Report2025-09-01-cyfrin-licredity-v2.0.md
SourceAuditVault
StatusAudit finding — fixed in PR#64. Reproduced here as a standalone local PoC.
Compiler^0.8.24 (PoC)

TL;DR#

  1. Position tracks fungibles in an array and a fungibleStates mapping (1-based index + balance).
  2. On full remove, the code swap-and-pops the array but never updates the moved tail element's cached index.
  3. Remove A from [A,B,C] → array becomes [C,B] but index(C) stays 3.
  4. Remove C with stale index=3 while len=2 → B is written past the array end and popped from the live range.
  5. HARM: B has a ghost mapping balance of 1 ether but is missing from fungibles[]; appraisal under-counts.

The vulnerable code#

SOLIDITY
// remove a fungible from the fungibles array
assembly ("memory-safe") {
    let slot := add(self.slot, FUNGIBLES_OFFSET)
    let len := sload(slot)
    mstore(0x00, slot)
    let dataSlot := keccak256(0x00, 0x20)

    if iszero(eq(index, len)) {
        sstore(add(dataSlot, sub(index, 1)), sload(add(dataSlot, sub(len, 1))))
    }

    sstore(add(dataSlot, sub(len, 1)), 0)
    sstore(slot, sub(len, 1)) // @> VULN: no fungibleStates[moved].index = index
}

Fix: after the swap, set fungibleStates[moved].index = index (1-based).


Root cause#

Two structures must stay in sync. The array move is performed; the mapping index is not. Later removals trust the stale index and corrupt neighboring slots.

Preconditions#

  • A position holds ≥2 fungibles.
  • A non-tail asset is fully removed (triggers swap-and-pop).
  • A subsequent remove uses the moved asset's stale index.

Attack walkthrough#

  1. Deposit A, B, C → indexes 1, 2, 3.
  2. Fully remove A → C moves to slot 0; index(C) stays 3.
  3. Fully remove C → assembly uses index=3, len=2; B vanishes from the array.
  4. fungibleBalance(B) == 1 ether but B ∉ fungibles[]; appraise() under-counts.

Diagrams#

flowchart TD A["fungibles = [A, B, C]<br/>idx A=1 B=2 C=3"] --> B["remove A fully"] B --> C["array = [C, B]<br/>idx C still 3 STALE"] C --> D["remove C with idx=3, len=2"] D --> E["B written past end + popped"] E --> F["B ghost in mapping<br/>appraisal under-counts"]

Impact#

  • Inconsistent fungibles[] vs fungibleStates.
  • Removing X can drop Y from the array.
  • Valuation/health that iterates the array under- or over-counts positions.

Sources#

Taxonomy (AuditVault)#

  • severity/high · sector/lending · sector/dex · platform/cyfrin
  • genome: lockup · permanent · fot-slippage · liquidation-underwater · oracle-freshness

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.