Reproduced Exploit

Polynomial Protocol — KangarooVault.removeCollateral updates storage without removing collateral

1. addCollateral transfers collateral to the Exchange and increments usedFunds / positionData.totalCollateral — real assets move. 2. removeCollateral decrements the same accounting but never calls EXCHANGE.removeCollateral, so no collateral is pulled back. The vault's

Mar 2023Otherlogic5 min read

Chain

Other

Category

logic

Date

Mar 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, 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: 20227-h-04-kangaroovaultremovecollateral-updates-storage-without-a. Standalone Foundry PoC and full write-up: 20227-h-04-kangaroovaultremovecollateral-updates-storage-without-a_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/missing-external-call · vuln/loss-of-funds/locked-funds · vuln/accounting/state-desync

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/20227-h-04-kangaroovaultremovecollateral-updates-storage-without-a_exp.sol.


Key info#

ImpactHIGH — removeCollateral decrements the vault's collateral accounting but never pulls collateral back from the Exchange; the "removed" collateral is stranded in the Exchange and permanently lost to the vault's LPs on close
ProtocolPolynomial Protocol — KangarooVault (Power-Perp short-position vault)
Vulnerable codeKangarooVault.removeCollateral — updates usedFunds / positionData.totalCollateral but omits EXCHANGE.removeCollateral(...)
Bug classMissing external call: state updated as if an effect happened, but the effect (asset movement) never does
FindingCode4rena — Polynomial Protocol, 2023-03 · #20227 · reporter Bauer (severity raised to High by the judge)
Reportcode4rena.com/reports/2023-03-polynomial
SourceAuditVault
StatusAudit finding — confirmed via duplicate, judged HIGH (not exploited on-chain). Reproduced here as a standalone local PoC.
Compiler^0.8.24 (PoC)

This is an audit finding, not a historical on-chain incident. The upstream Code4rena source repo has been taken down, so the reduced PoC preserves the vulnerable removeCollateral verbatim from the finding and models a minimal Exchange that actually custodies collateral, so the "lost collateral" harm becomes a mechanically-checkable balance shortfall.


TL;DR#

  1. addCollateral transfers collateral to the Exchange and increments usedFunds / positionData.totalCollateral — real assets move.
  2. removeCollateral decrements the same accounting but never calls EXCHANGE.removeCollateral, so no collateral is pulled back. The vault's books say the collateral is gone; the collateral is still in the Exchange.
  3. On close, _closePosition retrieves only positionData.totalCollateral (already decremented), so the removed slice is never requested back.
  4. Result: the removed collateral is stranded in the Exchange with no path to recover it. In the PoC the vault posts 3e18, "removes" 1e18, and closes — recovering only 2e18. The 1e18 is permanently lost.

The vulnerable code#

KangarooVault.removeCollateral (verbatim):

SOLIDITY
function removeCollateral(uint256 collateralToRemove) external requiresAuth nonReentrant {
    (uint256 markPrice,) = LIQUIDITY_POOL.getMarkPrice();
    uint256 minColl = positionData.shortAmount.mulWadDown(markPrice);
    minColl = minColl.mulWadDown(collRatio);

    require(positionData.totalCollateral >= minColl + collateralToRemove);

    usedFunds -= collateralToRemove;
    positionData.totalCollateral -= collateralToRemove; // @> EXCHANGE.removeCollateral is NEVER called

    emit RemoveCollateral(positionData.positionId, collateralToRemove);
}

Compare addCollateral, which does move assets (EXCHANGE.addCollateral):

SOLIDITY
function addCollateral(uint256 additionalCollateral) external requiresAuth nonReentrant {
    SUSD.safeApprove(address(EXCHANGE), additionalCollateral);
    EXCHANGE.addCollateral(positionData.positionId, additionalCollateral);
    usedFunds += additionalCollateral;
    positionData.totalCollateral += additionalCollateral;
    ...
}

Root cause#

removeCollateral performs the accounting half of a collateral withdrawal without the asset-movement half. Because collateral is only ever returned by the Exchange, and the vault never asks for it, the decrement silently desyncs the books from reality. On close the vault asks the Exchange for only what its (decremented) books claim, so the difference is orphaned in the Exchange.

Preconditions#

  • A position with posted collateral exists and is healthy enough to pass the totalCollateral >= minColl + collateralToRemove check.
  • The admin (or any requiresAuth caller) invokes removeCollateral — a normal operation.

Attack walkthrough#

From output.txt:

  1. Vault posts 3e18 collateral to the Exchange (addCollateral); books: totalCollateral = 3e18, usedFunds = 3e18; Exchange holds 3e18.
  2. Admin calls removeCollateral(1e18) — the health check passes (3e18 >= 1.2e18 + 1e18). usedFunds and totalCollateral drop to 2e18, but no sUSD returns. Exchange still holds 3e18.
  3. closePosition retrieves totalCollateral = 2e18 from the Exchange.
  4. HARM: the vault ends with 2e18 — it deposited 3e18 and got nothing for the removal. The remaining 1e18 is stranded in the Exchange; the position is closed, so no path remains to recover it. Loss = removed amount.

Diagrams#

flowchart TD A[Vault: addCollateral 3e18 -> Exchange holds 3e18] --> B[removeCollateral 1e18] B --> C[health check passes: 3e18 >= 1.2e18 + 1e18] C --> D[usedFunds & totalCollateral -= 1e18] D --> E{EXCHANGE.removeCollateral called?} E -- No: missing call --> F[no sUSD returned; Exchange still holds 3e18] F --> G[closePosition retrieves totalCollateral = 2e18 only] G --> H[Vault recovers 2e18 of 3e18] H --> I[1e18 stranded in Exchange, unrecoverable = LOSS]

Impact#

The vault (its LP token holders) permanently loses whatever collateral is "removed": the removed slice is neither returned at removal time nor recovered on close. The finding also notes a secondary effect — usedFunds is understated so processWithdrawalQueue can revert (availableFunds exceeds the real balance). The judge raised the severity to HIGH as a direct loss of funds.

Remediation#

Actually pull the collateral back:

DIFF
     usedFunds -= collateralToRemove;
     positionData.totalCollateral -= collateralToRemove;
+    EXCHANGE.removeCollateral(positionData.positionId, collateralToRemove);
     emit RemoveCollateral(positionData.positionId, collateralToRemove);

How to reproduce#

BASH
cd ~/RustroverProjects/audits/evm-hack-registry/20227-h-04-kangaroovaultremovecollateral-updates-storage-without-a_exp
forge test -vvv
# Fully local — no fork, no RPC, no anvil_state required.
# Expected: test_removeCollateralLosesFunds PASSES (vault recovers 2e18 of 3e18;
# 1e18 stranded in the Exchange).

PoC source: test/20227-h-04-kangaroovaultremovecollateral-updates-storage-without-a_exp.sol — drives the verbatim vulnerable removeCollateral and re-asserts the loss.

Note: the 1:1 collateral model, fixed markPrice/collRatio, and the minimal Exchange plumbing are reduced-model assumptions (the real Polynomial system is out of scope); the vulnerable missing-call and the "removed collateral is lost on close" mechanism are faithful.


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.