Reproduced Exploit
Perennial: maxRedeem pays 1 of a 2000000 deposit
maxRedeem mins the vault's assets by each market's current maker position. Market 1's maker is near its limit, so the quoted collateral is 1 even though the target for that market on a 2000000 vault is 1000000. Redeeming does not need to shrink market 1. It needs to grow it. The cap is still 1, and…
Chain
Other
Category
logic
Date
Oct 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, 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.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 28950-h-3-vault-max-redeem-calculations-limit-redeem-amount-to-the. Standalone Foundry PoC and full write-up: 28950-h-3-vault-max-redeem-calculations-limit-redeem-amount-to-the_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/price-calculation · vuln/logic/wrong-condition
Key info#
| Protocol | Perennial |
| Finding | Sherlock 2023-10-perennial H-3, AuditVault 28950 |
| Vulnerable code | StrategyLib maxRedeem collateral cap |
| Harm | A depositor of 2000000 can redeem 1. 1999999 stays in the vault. The control redeems 1999998 |
| Chain | local synthetic |
TL;DR#
maxRedeem mins the vault's assets by each market's current maker position. Market 1's maker is near its limit, so the quoted collateral is 1 even though the target for that market on a 2000000 vault is 1000000. Redeeming does not need to shrink market 1. It needs to grow it. The cap is still 1, and the depositor receives 1. The control ignores a market whose current collateral is below its target and redeems 1999998.
The vulnerable code#
The audited expression, with the cap that uses it:
UFixed6 collateral = marketContext.currentPosition.maker
.sub(marketContext.currentPosition.net().min(marketContext.currentPosition.maker))
.min(marketContext.closable.mul(StrategyLib.LEVERAGE_BUFFER))
.muldiv(marketContext.latestPrice.abs(), registration.leverage)
.muldiv(totalWeight, registration.weight);
// @> VULN: caps redeem by the smallest current position, here $1
stepCap = UFixed6.unwrap(redemptionAssets.min(collateral));
Market 1: maker 0.5, price 1, leverage 1, weight 1, totalWeight 2. Collateral is 1.
Market 2: maker 500000, same scale. Collateral is 1000000.
redemptionAssets starts at 2000000 and becomes min(2000000, 1, 1000000) = 1.
The control computes how much can be redeemed before an under-target market would have to shrink. Market 1's current collateral is 1 and its target is 1000000, so that room is 1999998. Market 2 is exactly at its target and does not tighten the under-target room. redeemFair pays 1999998.
Root cause#
The cap uses only the current position. It does not compare that position with the target the vault would hold after the redeem. A market that is already far below its target still sets the vault-wide maximum.
Preconditions#
The vault holds two markets, weights 1 and 1, totalWeight 2. Market 1's current collateral is 1 against a 1000000 target. Market 2's current collateral is 1000000, equal to its target. One depositor has 2000000 in the vault. No time change.
Attack walkthrough#
- Deposit 2000000. See output.txt.
maxRedeemquotes market 1 at 1 and market 2 at 1000000.- The min stores 1.
redeem(2000000)transfers 1. The vault balance is 1999999.- The control on a fresh vault deposits 2000000 and
redeemFairtransfers 1999998. The vault keeps 2, which is the collateral market 1 still has to sit above.
Diagrams#
Impact#
The depositor of 2000000 receives 1. 1999999 of the deposit cannot be redeemed in this call. Repeating the call does not help: the same position still quotes 1. The fair redeem is 1999998.
Remediation#
Do not cap by a market whose current collateral is below the target that market would have after the redeem. Cap only by the room that keeps every under-target market from being forced down. That room is 1999998 here.
How to reproduce#
cd 28950-h-3-vault-max-redeem-calculations-limit-redeem-amount-to-the_exp
forge test -vv
Sources#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/28950-h-3-vault-max-redeem-calculations-limit-redeem-amount-to-the.md
- Sherlock issue: https://github.com/sherlock-audit/2023-10-perennial-judging/issues/29
- Formula: https://github.com/sherlock-audit/2023-10-perennial/blob/main/perennial-v2/packages/perennial-vault/contracts/lib/StrategyLib.sol#L94-L98
- Repo: sherlock-audit/2023-10-perennial (perennial-v2 StrategyLib)
- Synthetic: test/28950-h-3-vault-max-redeem-calculations-limit-redeem-amount-to-the.sol
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 28950-h-3-vault-max-redeem-calculations-limit-redeem-amount-to-the_exp (evm-hack-registry mirror).
- AuditVault finding: 28950-h-3-vault-max-redeem-calculations-limit-redeem-amount-to-the.
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.