Reproduced Exploit
Polynomial Protocol — uneven performance-fee deduction shortchanges late KangarooVault holders
1. While a position is open, getTokenPrice values the vault as totalFunds + premiumCollected + … and subtracts usedFunds + markPrice*short — but not the performanceFee that _resetTrade will charge on that premium.
Chain
Other
Category
accounting
Date
Mar 2023
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
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.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 20228-h-05-uneven-deduction-of-performance-fee-causes-some-kangaro. Standalone Foundry PoC and full write-up: 20228-h-05-uneven-deduction-of-performance-fee-causes-some-kangaro_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/accounting/fee-calculation · vuln/logic/price-inconsistency · vuln/mev/frontrun-exposure
Reproduction: a self-contained Foundry PoC that compiles & runs in an isolated project with only
forge-std— no fork, no RPC, noanvil_state. Full trace: output.txt. PoC: test/20228-h-05-uneven-deduction-of-performance-fee-causes-some-kangaro_exp.sol.
Key info#
| Impact | HIGH — getTokenPrice omits the pending performanceFee while a position is open, so a holder who withdraws before _resetTrade escapes the fee at an inflated price and the remaining holders bear it, losing part of their token value |
| Protocol | Polynomial Protocol — KangarooVault (Power-Perp vault) |
| Vulnerable code | KangarooVault.getTokenPrice — totalValue -= (usedFunds + markPrice.mulWadDown(shortAmount)) (no + premiumCollected.mulWadDown(performanceFee)) |
| Bug class | Price/accounting inconsistency: getTokenPrice and _resetTrade disagree on whether the fee is already charged |
| Finding | Code4rena — Polynomial Protocol, 2023-03 · #20228 · reporter peakbolt |
| Report | code4rena.com/reports/2023-03-polynomial |
| Source | AuditVault |
| Status | Audit finding — confirmed by the sponsor, 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 getTokenPrice open-position branch and the _resetTrade fee logic
verbatim, and installs a minimal two-holder open-position state so the uneven
distribution becomes two concrete, comparable payouts.
TL;DR#
- While a position is open,
getTokenPricevalues the vault astotalFunds + premiumCollected + …and subtractsusedFunds + markPrice*short— but not theperformanceFeethat_resetTradewill charge on that premium. - So the open-position share price is over-stated by
premiumCollected * performanceFee / totalSupply. - A holder who withdraws before
_resetTraderedeems at that inflated price and escapes the fee._resetTradethen charges the whole fee, dropping the price, so holders who exit after bear all of it. - In the PoC two holders deposit an equal
10e18. The first-out holder redeems15e18; the last-out holder redeems only13e18. The2e18gap is exactly the performance fee — evaded by one holder, borne entirely by the other.
The vulnerable code#
KangarooVault.getTokenPrice, open-position branch (verbatim):
uint256 totalValue = totalFunds + positionData.premiumCollected + totalMargin + positionData.totalCollateral;
totalValue -= (usedFunds + markPrice.mulWadDown(positionData.shortAmount)); // @> omits premiumCollected.mulWadDown(performanceFee)
return totalValue.divWadDown(totalSupply);
_resetTrade DOES charge the fee (verbatim):
uint256 fees = positionData.premiumCollected.mulWadDown(performanceFee);
if (fees > 0) SUSD.safeTransfer(feeReceipient, fees);
totalFunds += positionData.premiumCollected - fees;
Root cause#
getTokenPrice and _resetTrade disagree about the fee. _resetTrade
subtracts premiumCollected * performanceFee from the value distributed to
holders; getTokenPrice does not. During the open window the price therefore
reflects a fee-free premium. Whoever exits in that window is paid as if no fee
existed; the fee is then charged in full against whoever remains.
Preconditions#
- A profitable open position (non-zero
premiumCollected) and a non-zeroperformanceFee. - Two or more holders, at least one exiting before
_resetTrade(clearPendingCloseOrders) and at least one after — a normal-flow race, and cheaply forced by frontrunning the close.
Attack walkthrough#
From output.txt (1e18 == one unit):
- Holders 2 and 3 each hold
10e18shares (equal deposits). A10e18premium is collected on an open position;performanceFee = 20%. getTokenPricereturns(20e18 + 10e18) / 20e18 = 1.5e18— the fee is not subtracted.- Holder 2 withdraws at
1.5e18, receiving15e18(fee escaped). _resetTradechargesfees = 10e18 * 20% = 2e18, realizes the net premium, and the price drops to13e18 / 10e18 = 1.3e18.- Holder 3 withdraws at
1.3e18, receiving only13e18. - HARM: equal deposits, unequal payouts —
15e18vs13e18. The2e18gap is the entire performance fee; holder 3 was shortchanged by the fee that holder 2 evaded (fair would be14e18each).
Diagrams#
Impact#
Holders who exit after _resetTrade lose part of their token value: they bear a
performance fee that earlier-exiting holders escaped. An attacker can force the
race by frontrunning clearPendingCloseOrders with their own withdrawal,
systematically shifting the fee onto everyone else. Confirmed by the sponsor and
judged HIGH.
Remediation#
Make getTokenPrice subtract the same fee _resetTrade will charge:
- totalValue -= (usedFunds + markPrice.mulWadDown(positionData.shortAmount));
+ totalValue -= (usedFunds + markPrice.mulWadDown(positionData.shortAmount) + positionData.premiumCollected.mulWadDown(performanceFee));
How to reproduce#
cd ~/RustroverProjects/audits/evm-hack-registry/20228-h-05-uneven-deduction-of-performance-fee-causes-some-kangaro_exp
forge test -vvv
# Fully local — no fork, no RPC, no anvil_state required.
# Expected: test_unevenPerformanceFee PASSES (equal deposits, payouts 15e18 vs
# 13e18, gap == the 2e18 fee).
PoC source: test/20228-h-05-uneven-deduction-of-performance-fee-causes-some-kangaro_exp.sol
— drives the verbatim vulnerable getTokenPrice / _resetTrade and re-asserts
the uneven payouts.
Note: the numeric state (deposits, premium, fee, zeroed margin/short) is a reduced-model setup that isolates the fee bug; the vulnerable
getTokenPricesubtraction and the fee-charging_resetTradeare faithful, as is the "exit-before-reset escapes the fee" mechanism.
Sources#
- AuditVault finding: 20228-h-05-uneven-deduction-of-performance-fee-causes-some-kangaro.md
- Contest report: Code4rena — Polynomial Protocol (2023-03)
- Reduced-source provenance: vulnerable
KangarooVault.getTokenPriceand_resetTradereconstructed verbatim from the AuditVault finding (which quotessrc/KangarooVault.sol#L358-L359and#L788-L789) and the merged contest reportcode-423n4/2023-03-polynomial-findings@main. The original contest source repocode-423n4/2023-03-polynomialhas been taken down.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 20228-h-05-uneven-deduction-of-performance-fee-causes-some-kangaro_exp (evm-hack-registry mirror).
- AuditVault finding: 20228-h-05-uneven-deduction-of-performance-fee-causes-some-kangaro.
Alerts & third-party analyses
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.