Reproduced Exploit
LPBonus (MSN LP-reward pool) — reserve-inconsistency between reward accrual and withdrawal lets a flash-manipulated reserve inflate an LP's FIST claim
1. LPBonus is an LP-reward pool for the MSN token. Its reward callbacks are all gated require(msg.sender == MSN) — they are hooks the MSN token fires from inside its own transfer/transferFrom. So every reward state change is reachable permissionlessly through ordinary MSN swaps / add- / remove-liqu…
Loss
Final attacker USDT balance 92,633.859396071632955691 (≈ $92.6k). The exploit's net new USDT, realized by sel…
Chain
BNB Chain
Category
logic
Date
Sep 2026
Source
DeFiHackLabs
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-09-LPBonus_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/LPBonus_exp.sol.
Vulnerability classes: vuln/logic/reward-calculation · vuln/oracle/price-manipulation · vuln/defi/fee-manipulation · vuln/defi/flash-loan Reproduction: isolated Foundry project at this folder. Full passing trace: output.txt. LPBonus and MSN are unverified (reversed from bytecode);
sources/is empty, so the code below is RECONSTRUCTED-FROM-BYTECODE and corroborated by the execution trace, not by verified source.
Key info#
| Loss (this PoC) | Final attacker USDT balance 92,633.859396071632955691 (≈ $92.6k). The exploit's net new USDT, realized by selling the leftover stolen FIST, is 92,607.317234 (output.txt:3846) — matching the reported figure to the wei; the remaining 26.542161 was the test contract's pre-existing balance (output.txt:1539). |
| Loss (reported) | |
| Vulnerable contract | LPBonus 0x52272524…bb054b — unverified, reversed from bytecode |
| MSN token | 0xd8b3ef86…97c1 — 18 dec, 2% transfer fee, fires the LPBonus reward hooks from its own transfer/transferFrom |
| FIST token (reward) | 0xc9882dEF…0Bc6A — 6 dec |
| USDT | 0x55d39832…7955 |
| FIST/MSN pair | 0xdD95c8a5…e6e5 — Cake-LP, token0 = FIST, token1 = MSN; the LP position LPBonus tracks and the reserve source it reads |
| FstSwap USDT/FIST pair | 0xB4Ec801a…D8C9 — token0 = USDT, token1 = FIST; flash-swap capital source |
| PCS Router | 0x10ED43C7…024E |
| Attacker EOA | 0xb6fff29D…6f7A |
| Attack contract | 0xa8607269…43c7 |
| Attack tx / block | 0xecac1563bbb76fb8fefb4a7da4592260a8c1ddde21d7da62b78a9e3769808e6b @ block 124,759,921 |
| Chain / block / date | BSC (chainId 56) / fork 124,759,920 (parent of the attack block, pre-manipulation) / 2026-09 |
| Compiler | Test harness solc ^0.8.10, evm_version = cancun. Target contracts unverified (no source). |
| Bug class | Reward index is accrued against one AMM reserve read and paid against a different one, both from a freely-manipulable pair.getReserves(). A flash-loan crush of the MSN reserve inflates the per-share index; a dust withdrawal collects the inflated claim. |
TL;DR#
LPBonusis an LP-reward pool for the MSN token. Its reward callbacks are all gatedrequire(msg.sender == MSN)— they are hooks the MSN token fires from inside its owntransfer/transferFrom. So every reward state change is reachable permissionlessly through ordinary MSN swaps / add- / remove-liquidity. No admin, owner, or signer is in the attacker's path.- Accrual (
BonusSEND) swaps LPBonus's accumulated MSN fees to FIST and bumps a per-share index withoneshareFIST += fistIn * 1e18 / reserveMSN, wherereserveMSNis read frompair.getReserves()at accrual time. Withdrawal (UserRemoveLp→CalcPendingUser) paysuser_weight * indexusing the reserve read at withdrawal time. Both reads come from the same manipulable pair; neither is a TWAP or a consistent snapshot. - In one atomic tx the attacker flash-borrows 13,481,395.302739 FIST (output.txt:1564), registers a position while the MSN reserve is ~361–501, crushes the MSN reserve to 89.327789 (output.txt:2511) so
BonusSENDfunds a huge FIST amount and divides the index by a tiny reserve, restores the reserve to 491.113095 (output.txt:2864), then burns a dust LP amount to triggerUserRemoveLp, which pays the full inflated claim 1,442,165.713011 FIST (output.txt:2922). - That single dust position claims 1,442,165.713011 FIST against an accrual that funded only 908,449.642947 FIST net — a 533,716.070067 FIST over-claim (output.txt:3861–output.txt:3863). The excess is paid out of FIST that belonged to the reward pool / other LPs.
- Flash loan repaid (output.txt:3814), leftover FIST sold for 92,607.317234 USDT (output.txt:3846).
[PASS] testExploit()(output.txt:1537, output.txt:3884).
The crush is a flash-loanable spot manipulation; there is no cross-block exposure and no capital at risk once the tx reverts-or-profits atomically.
Background#
MSN is a fee-on-transfer token (2% fee) on BSC whose liquidity lives in the PancakeSwap-style Cake-LP pair FIST/MSN (0xdD95c8a5…e6e5, token0 = FIST, token1 = MSN). FIST is the reward token (6 decimals). The LPBonus contract (0x52272524…bb054b) is a side accounting contract that distributes FIST rewards to holders of that LP position.
Crucially, LPBonus has no public user entry points of its own in the attack path. Its three relevant functions — BonusSEND, userAddLP, UserRemoveLp — are callbacks the MSN token invokes from inside its transfer hooks (each gated require(msg.sender == MSN)). Any MSN movement on the pair (a buy, a sell, a mint, a burn) causes MSN's hook to call back into LPBonus. The reserve that LPBonus uses to price rewards is whatever pair.getReserves() returns at the instant the hook fires — and getReserves() is read 24 times across the trace, always live, never snapshotted (grep -c "getReserves() [staticcall]" = 24).
Because MSN's own hooks perform the accrual and payout, the attacker never needs a privileged role: they drive the entire reward lifecycle by operating on the public AMM pair, funded by a flash loan from an unrelated FstSwap USDT/FIST pool.
LPBonus and MSN are unverified contracts; the mechanism below is reconstructed from the on-chain bytecode behaviour visible in the trace (the BonusSEND / userAddLP / UserRemoveLp / AddFistFee calls and their exact reserve arguments), not from verified source.
The vulnerable code#
RECONSTRUCTED-FROM-BYTECODE — LPBonus and MSN are unverified. The following reflects the behaviour proven by the trace; names come from the dispatched selectors.
Accrual — BonusSEND(reserveMSN, isRemove) (fires on every MSN movement, from the MSN transfer hook):
// RECONSTRUCTED. reserveMSN = pair.getReserves().reserve1 at the moment the hook fires.
function BonusSEND(uint256 reserveMSN, bool isRemove) external {
require(msg.sender == MSN); // permissionless via MSN's own transfer hook
uint256 fee = accumulatedMSNFee; // MSN fees LPBonus has collected
uint256 fistIn = _swapMSNforFIST(fee); // sells `fee` MSN -> FIST on the SAME pair (AddFistFee)
// per-share reward index, 1e18-scaled, divided by the LIVE reserve:
oneshareFIST += fistIn * 1e18 / reserveMSN; // <-- both fistIn and the divisor are reserve-dependent
// ... redistributes a slice to other LP holders in the same call ...
}
Two things make this explosive when reserveMSN is small:
fistInis the proceeds of sellingfeeMSN into a pool where MSN is scarce (reserve crushed), so MSN is priced high andfistInis large. In the trace the fee swapAddFistFee(6.057 MSN, reserve 89.327789)returns 940,041.612768 FIST (output.txt:2528, output.txt:2553).- The divisor
reserveMSNis the same crushed value (89.327789), so the per-share index is bumped as if the entire stake were priced against ~89 MSN (output.txt:2578:BonusSEND(89327788899759978606, true)).
Registration — userAddLP(user, amount, reserveMSN) snapshots the user's weight and the current index (takedFIST) at the reserve prevailing when liquidity is added — 361.373021 at the userAddLP call (output.txt:1888), ~501 once the mint re-adds the 140 MSN (output.txt:2219). Either way it is far above the ~89 accrual reserve the attacker is about to create.
Withdrawal — UserRemoveLp(user, amount, reserveMSN_now) → CalcPendingUser pays:
// RECONSTRUCTED. reserveMSN_now = pair.getReserves().reserve1 at WITHDRAWAL time (491.113095).
function UserRemoveLp(address user, uint256 amount, uint256 reserveMSN_now) external {
require(msg.sender == MSN);
uint256 pending = CalcPendingUser(user, amount); // = weight * (oneshareFIST - takedFIST[user]) / 1e18
FIST.transfer(user, pending); // pays the INFLATED index, restored-reserve path
// ...
}
The index oneshareFIST was inflated under the 89.327789 reserve, but it is consumed by a withdrawal that executes under the 491.113095 reserve (output.txt:2913). The two reserve reads are inconsistent, and both are instantaneous pair.getReserves() values — there is no stored snapshot tying the payout to the reserve under which rewards were actually funded, and no manipulation-resistant oracle. CalcPendingUser pays 1,442,165.713011 FIST to the dust position (output.txt:2922).
Root cause#
- The reward index is priced on an instantaneous, manipulable AMM reserve.
oneshareFIST += fistIn * 1e18 / reserveMSNreadsreserveMSNfrompair.getReserves()with no TWAP and no bound. A single swap moves it from 504.230164 (output.txt:1562) to 89.327789 (output.txt:2511) and back to 491.113095 (output.txt:2864). - Accrual and withdrawal use different reserve reads. The index is bumped under the crushed reserve (89.3) during
BonusSEND, then paid out under the restored reserve (491.1) duringUserRemoveLp. Nothing requires the two to be consistent; the attacker chooses one reserve for accrual and another for payout within the same transaction. - Double amplification at the crush. With MSN scarce, selling the accumulated MSN fee returns an outsized FIST amount (large numerator) and the index divides by the small reserve (small denominator). Both push
oneshareFISTup together. - No cap tying a claim to actually-funded rewards. The dust position claims 1,442,165.713011 FIST while the accrual it triggered funded only 908,449.642947 FIST net / 940,041.612768 gross; the pool pays the ~533,716 FIST gap out of pre-existing reward balance (output.txt:3861–output.txt:3863).
- Fully permissionless. Every callback is gated
require(msg.sender == MSN)and fired from MSN's transfer hooks, so the whole sequence is reachable by anyone operating the public pair — no admin, keeper, or signer step gates it.
The purchased position is dust and is held for microseconds inside one atomic tx; this is a flash-loan spot-manipulation, not a governance or multi-block attack.
Preconditions#
- A reward index priced on a live AMM reserve that an attacker can move with a swap (here
pair.getReserves().reserve1, the MSN side of FIST/MSN). Met: 24 livegetReserves()reads, no TWAP. - Reward accrual and withdrawal both callable within one transaction, with no same-block / reentrancy guard linking the reserve under which rewards accrue to the reserve under which they are paid. Met: MSN's transfer hooks fire
BonusSENDandUserRemoveLpon demand. - A flash-loan source for the FIST needed to crush the reserve. Met: FstSwap USDT/FIST pair lends 13,481,395.302739 FIST via
fstswapCall(output.txt:1564, output.txt:1571). - Accumulated MSN fees inside LPBonus so that
BonusSEND's fee swap produces a largefistInat the crushed reserve. Met:AddFistFee(6.057 MSN, 89.327789)→ 940,041.612768 FIST (output.txt:2528, output.txt:2553). - A pre-existing FIST reward balance in LPBonus large enough to pay the over-claim. Met: the 1,442,165.71 FIST claim settles in full (output.txt:2922).
Attack walkthrough#
Fork 124,759,920 (parent of the attack block), test contract 0x7FA9…1496. Trace: output.txt. Start state: attacker USDT 26.542161622221038197 (output.txt:1539), FIST/MSN reserves (1,361,845.456121 FIST, 504.230164 MSN) (output.txt:1562).
- Flash-borrow FIST.
FSTSWAP.swap(0, 13481395302739, …, 0x01)lends 13,481,395.302739 FIST and calls back intofstswapCall(output.txt:1564, output.txt:1571). - Buy ~140 MSN. Send 539,710.857353 FIST to the pair (output.txt:1572); the pair returns 142.857 MSN gross, and MSN's 2% transfer fee delivers 140.000000000115325628 MSN net to the attacker (output.txt:1592). The MSN movement fires
BonusSEND(504.230164, true)— an ordinary accrual at the normal reserve (output.txt:1593). - Add liquidity → register the position. Transfer 140 MSN (output.txt:1881) plus 736,684.445385 FIST to the pair and
mint(output.txt:2188). MSN's hook firesuserAddLP(attacker, 140e18, 361.373021…)(output.txt:1888), snapshotting the attacker's weight /takedFISTwhile the reserve is ~501 (BonusSEND(501.373021, true), output.txt:2219). - Crush the MSN reserve. Dump 12,200,000 FIST into the pair (output.txt:2513:
amount0In 12200000000000), driving the MSN reserve down to 89.327788899759978606 (output.txt:2511–output.txt:2512). - Accrue at the crushed reserve. The same swap's MSN movement fires the fee machinery:
AddFistFee(6.057064918535115840 MSN, 89.327789)(output.txt:2528) sells the accumulated MSN fee for 940,041.612768 FIST (output.txt:2553), thenBonusSEND(89.327788899759978606, true)bumpsoneshareFISTagainst the tiny reserve (output.txt:2578). This is the index inflation. - Restore the reserve. Sell the held MSN back into the pair, raising the MSN reserve to 491.113095162589329323 (output.txt:2864, output.txt:2876).
- Dust withdrawal collects the inflated claim. Burn a dust 40,372 LP (output.txt:2899); the tiny 558,183,177 wei MSN leaving the pair to the attacker (output.txt:2906) fires
UserRemoveLp(attacker, 558183177, 491.113095)(output.txt:2913), whoseCalcPendingUsertransfers 1,442,165.713011 FIST to the attacker (output.txt:2922). The remaining position is burned (output.txt:3220), paying a second smallUserRemoveLp(output.txt:3252). - Repay and cash out. Repay the flash loan: transfer 13,521,961.186298 FIST (principal + 0.3% fee) back to FstSwap (output.txt:3814, output.txt:3829). Sell the leftover 391,849.853206 FIST for 92,607.317234 USDT (output.txt:3845–output.txt:3846).
- Result.
claimedReward1,442,165.713014 FIST vsfundedReward(net) 908,449.642947 FIST → over-claim 533,716.070067 FIST (output.txt:3861–output.txt:3863); final USDT 92,633.859396 (output.txt:3866). Assertions: profit ≈ 92,607.317234 (output.txt:3869), claimed ≈ 1,442,165.713011 (output.txt:3871), claimed > funded (output.txt:3873) all pass —1 passed; 0 failed(output.txt:3884).
| FIST (6 dec) | |
|---|---|
Claimed by the dust position (UserRemoveLp) | 1,442,165.713014 |
| Funded by the accrual (net, retained in LPBonus) | 908,449.642947 |
Funded by the accrual (gross BonusSEND swap output) | 940,041.612768 |
| Over-claim (claimed − funded net) | 533,716.070067 |
| Realized profit, converted to USDT | 92,607.317234 USDT (~$92.6k) |
Diagrams#
Remediation#
- Do not price the reward index on an instantaneous AMM reserve. Replace
pair.getReserves()with a manipulation-resistant source — a TWAP, or an off-chain/oracle price — for both the fee-swap valuation and the per-share division. - Use one consistent reserve snapshot across a reward lifecycle. If a reserve must be used, the reserve under which rewards accrue must be the same reserve used when they are paid; store it with the accrual rather than re-reading
getReserves()at withdrawal. - Add same-block / reentrancy protection so reward accrual and withdrawal cannot be composed within a single manipulated-reserve window. Rewards earned in block N should not be claimable until a later block.
- Cap per-position claims to actually-funded rewards. A claim should never exceed the FIST the position's accrual genuinely contributed;
CalcPendingUserpaying out of pre-existing pool balance beyond funded accrual is the drain. - Treat token-hook callbacks as untrusted entry points.
require(msg.sender == MSN)does not makeBonusSEND/UserRemoveLpsafe when MSN fires them on every permissionless transfer; guard the economic invariants, not just the caller.
How to reproduce#
Offline, from the committed anvil_state.json (no live RPC). The harness forks BSC at block 124,759,920:
_shared/run-poc/run_poc.sh 2026-09-LPBonus_exp -vvvvv
Expected tail:
[PASS] testExploit() (gas: 5049376)
FIST claimed by UserRemoveLp : 1442165.713014
FIST funded into LPBonus (net): 908449.642947
over-claim (claimed - funded): 533716.070067
realized USDT profit : 92633.859396071632955691
Suite result: ok. 1 passed; 0 failed; 0 skipped
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-09-LPBonus_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
LPBonus_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "LPBonus (MSN LP-reward pool)".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.