Reproduced Exploit

Perennial: a negative Pyth exponent records 5e-5 as 5e5 and unlocks a 400,000 USDC borrow

Jul 2023•Other•arithmetic•3 min read

Chain

Other

Category

arithmetic

Date

Jul 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, 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: 24302-h-4-pythoracleif-priceexpo-is-less-than-0-wrong-prices-will. Standalone Foundry PoC and full write-up: 24302-h-4-pythoracleif-priceexpo-is-less-than-0-wrong-prices-will_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/arithmetic/decimal-mismatch · vuln/logic/price-calculation

Reproduction: local synthetic, no fork. _recordPrice is the audited body. A feed of 5 with exponent -5 is stored as 5e5, and one collateral token borrows 400,000 USDC. Trace: output.txt.

Key info#

ImpactHIGH — 1 collateral token, fairly worth $0.00005, borrows 400,000 USDC
ProtocolPerennial V2 — PythOracle._recordPrice
Vulnerable codemultiply by 10^abs(expo) even when expo is negative
Bug classExponent sign dropped → price inflated 10^10
FindingSherlock 2023-07 Perennial · #24302 · panprog
Reportsherlock-audit/2023-07-perennial-judging

Root cause#

_recordPrice always multiplies the Pyth price by 10 raised to the absolute exponent:

SOLIDITY
_prices[oracleVersion] = Fixed6Lib.from(price.price).mul(
    Fixed6Lib.from(SafeCast.toInt256(10 ** SafeCast.toUint256(price.expo > 0 ? price.expo : -price.expo)))
);

Fixed6Lib.from scales an integer by 1e6, and mul divides by 1e6, so the human value is price * 10^|expo|. For price = 5 and expo = -5 the true value is 5e-5. The function stores 5e5. That is a factor of 10^10.

Fixed6 rawHuman price
Buggy record500,000e6500,000
Fair record500.00005

A lending pool that prices 1 collateral token at the recorded Fixed6, with an 80% LTV, authorizes 500,000 * 0.8 = 400,000 USDC. The fair cap is 0.00005 * 0.8 = 0.000040 USDC, which is 40 base units of a 6-decimal stablecoin.

Attack path#

flowchart TD A["Pool holds 400,000 USDC"] --> B["Oracle receives price 5 with exponent -5"] B --> C["abs expo multiplies, so 5e-5 is stored as 5e5"] C --> D["Recorded Fixed6 is 500,000e6"] D --> E["Fair Fixed6 of the same feed is 50"] E --> F["80 percent LTV of the inflated price allows 400,000 USDC"] F --> G["Borrower receives 400,000 USDC for 1 collateral token"]

PoC#

BASH
cd 24302-h-4-pythoracleif-priceexpo-is-less-than-0-wrong-prices-will_exp
forge test -vv

The exploit asserts the recorded raw price is 500_000e6, the fair raw price is 50, the ratio is 10_000_000_000, and the attacker balance is 400_000e6 USDC. The control oracle divides when the exponent is negative. The same 400,000 USDC borrow reverts with ltv, and the only borrow that lands is 40 base units.

Remediation#

Keep the exponent. When it is negative, divide by 10^|expo| instead of multiplying. The audited recommendation stores price and expo together and scales at the use site. Fixed in equilibria-xyz/perennial-v2#53.

References#


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.