Reproduced Exploit
MOKE LP Dividend Drain — Stale `userLPRecord` Double-Claim via Unsynced LP Transfers
1. MokeLPDividend pays BNB dividends proportional to MOKE/WBNB LP holdings, using a MasterChef-style debt model: userLPRecord, userDividendDebt, and global totalDividendPerLP (MokeLPDividend.sol:43-46).
Loss
~$907.7K (~1,546.5 BNB net to the attacker in the live tx; PoC drains ~1,639 BNB of a 1,664.6 BNB pot via sta…
Chain
BNB Chain
Category
logic
Date
Aug 2026
Source
Crypto Training
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. Crypto Training original detection and analysis (live Twitter/X security-alert intake — not from DeFiHackLabs). Standalone Foundry PoC, offline
anvil_state.json, and full write-up: 2026-08-MOKE_exp in theevm-hack-registrymirror.
Vulnerability classes: vuln/logic/reward-calculation · vuln/logic/incorrect-state-transition · vuln/logic/missing-check · vuln/governance/flash-loan-attack
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source: MokeLPDividend.sol and the fee-on-transfer token MokeToken.sol.
Key info#
| Loss | ~$907.7K (~1,546.5 BNB net to the attacker in the live tx; PoC drains ~1,639 BNB of a 1,664.6 BNB pot via stale LP records — output.txt:456) |
| Vulnerable contract | MokeLPDividend — 0x5ae569d8a0539a6a603e96a26ac8caea7ceba377 |
| Token / LP | MOKE — 0x1a35c16ce21903bc17fd020c4ed73fedc70c1b2a · MOKE/WBNB LP — 0xBA6a49A97Cc725B3C39d6C5ea6dEfFddb64fe6b8 |
| Attacker EOA | 0xE454a9BAC1a44868e4A9Cbe1a4B5ac231D0DCF8a |
| Claim clones | ~100 EIP-7702 delegated EOAs (designator 0xef0100… → helper 0x8dbbedc9…), each pre-synced with the same stale userLPRecord |
| Flash-loan source | Venus vBNB — 0xA07c5B74C9B40447A954e1466938B865b6BBea36 (~230,000 BNB) |
| Attack tx | 0x0776048b1b58064fb31b6513721811e7b44d6bdbe7bf5833158b241ca6756a8f |
| Chain / block / date | BSC (chainId 56) / 113,652,609 (fork PoC at 113,652,608) / 2026-08-02 ~20:50 UTC |
| Compiler | Solidity v0.8.28+commit.7893614a (MokeLPDividend, verified) |
| Bug class | LP-dividend tracker credits claims from stale userLPRecord after LP is transferred without re-sync — same LP share is claimed many times |
TL;DR#
-
MokeLPDividendpays BNB dividends proportional to MOKE/WBNB LP holdings, using a MasterChef-style debt model:userLPRecord,userDividendDebt, and globaltotalDividendPerLP(MokeLPDividend.sol:43-46). -
Records are updated only in
_syncUserLP(MokeLPDividend.sol:131-149). Pancake LP ERC-20 transfers never call sync. MokeToken only syncs on MOKE transfers that touch a market pair — not on LP moves. -
Before the attack, the attacker held ~1,334.18 LP and walked that balance through ~100 EIP-7702 clones, calling
syncUserLPon each whiletotalDividendPerLP == 0. Every clone storeduserLPRecord = 1.334e21anddebt = 0, then sent the LP away without re-syncing. On the fork block each clone still has that stale record and zero LP balance. -
The live attack flash-loaned ~230k BNB from Venus, forced a large taxed MOKE volume so ~1,664.6 BNB landed in the dividend contract, then called
distributeDividend(). Accrual used the reallpToken.totalSupply()as the denominator (MokeLPDividend.sol:168-175). -
Each clone then
claimDividend()→_syncUserLPcreditspending += recordedLP * totalDividendPerLP / 1e18from the stale record (MokeLPDividend.sol:135-139) and pays ~25.32 BNB per clone — as if each still held the full LP. Sum of stale records ≫ real total supply → the pot is drained. -
PoC offline result (output.txt:456-457): 1,639.29 BNB claimed via clones; 1,665.62 BNB attacker balance (includes returned seed capital). Live incident net
1,546.5 BNB ($907.7K).
Background#
MOKE is a tax token on BSC. Sell/buy taxes (2% LP dividend + 2% NFT + 1% marketing) accumulate on MokeToken, are swapped to BNB, and the LP share is forwarded to MokeLPDividend via receive() → unprocessedBNB. Anyone may call distributeDividend() to convert pending BNB (and any MOKE sitting on the dividend contract) into an increase of totalDividendPerLP. LP holders then claimDividend().
The design assumes userLPRecord[user] always tracks lpToken.balanceOf(user). That invariant is not enforced on LP transfers.
The vulnerable code#
1. Accrual uses stale recordedLP before refreshing the record#
// sources/MokeLPDividend_5ae569/project_contracts_MokeLPDividend.sol:131-149
function _syncUserLP(address user) internal {
uint256 currentLP = lpToken.balanceOf(user);
uint256 recordedLP = userLPRecord[user];
if (recordedLP > 0) {
uint256 accumulated = recordedLP * totalDividendPerLP / 1e18;
uint256 debt = userDividendDebt[user];
if (accumulated > debt) {
userPendingDividend[user] += accumulated - debt; // credits STALE share
}
}
// ... then finally:
userLPRecord[user] = currentLP;
userDividendDebt[user] = currentLP * totalDividendPerLP / 1e18;
}
If recordedLP was set when the user briefly held LP, and they later transferred it away without calling syncUserLP, the next sync/claim still pays out as if they held recordedLP for the entire distribution window.
2. Distribution uses real total supply (fair denominator, unfair numerators)#
// sources/MokeLPDividend_5ae569/project_contracts_MokeLPDividend.sol:168-175
uint256 totalLP = lpToken.totalSupply();
uint256 perLP = bnbAmount * 1e18 / totalLP;
totalDividendPerLP += perLP;
perLP is correct for honest holders. Multiplied by N copies of the same historical LP amount across clones, total claim rights become N * attackerLP / totalSupply — here N ≈ 100 and attackerLP / totalSupply ≈ 1.52%, so claim rights exceed 100% of the pot. First claimers win; the contract's BNB balance is the only cap.
3. claimDividend always syncs then pays#
// sources/MokeLPDividend_5ae569/project_contracts_MokeLPDividend.sol:180-191
function claimDividend() external override nonReentrant whenNotPaused {
_syncUserLP(msg.sender);
uint256 pending = userPendingDividend[msg.sender];
require(pending > 0, "No dividend");
userPendingDividend[msg.sender] = 0;
(bool sent,) = payable(msg.sender).call{value: pending}("");
require(sent, "Transfer failed");
}
No check that pending is consistent with current LP or with sum of all records ≤ totalSupply.
Root cause#
Missing LP-transfer hooks + debt accrual on stale records.
Eligibility is stored in userLPRecord / userDividendDebt but is never updated when Pancake LP tokens move. An attacker can:
- Hold LP amount
R, withtotalDividendPerLP = 0sodebt = 0after sync. - Transfer
Rto clone B,syncUserLP(B), transfer back — repeat for many clones. - Trigger (or wait for) a large
distributeDividend. - Have every clone claim
R / totalSupplyof the pot.
This is a classic share-token reward double-dip / stale snapshot bug, amplified by EIP-7702 claim automation and flash-loan-sized tax volume.
Preconditions#
- Attacker (or prep txs) can obtain MOKE/WBNB LP and call
syncUserLPfreely (permissionless). totalDividendPerLPis still low/zero at prep time sodebtstays 0 after each sync.- A non-trivial BNB pot will enter
MokeLPDividend(protocol tax, donated MOKE swap, or — as in the live tx — flash-loan-driven volume). - Many addresses (EOAs / EIP-7702 delegates) available to hold stale records and call
claimDividend.
On block 113,652,608 those prep conditions were already on-chain: 100 clones each with userLPRecord = 1334183807563777105758 and debt = 0, while only the attacker EOA still held the real LP.
Attack walkthrough#
Numbers from the offline PoC (output.txt) unless noted as live-tx.
-
Fork pre-attack at block
113,652,608. Confirm clone #0:userLPRecord == attacker LP,debt == 0,lp.balanceOf(clone) == 0;totalDividendPerLP == 0. -
Seed the dividend pot with the 1,664.616… BNB observed entering
MokeLPDividendin the live attack (stand-in for Venus flash-loan → taxed MOKE flow + internal MOKE→BNB swap).receive()creditsunprocessedBNB. -
distributeDividend()(output.txt:521):bnbAmount = 1664616180024096154954totalLP = 87707736520595878692798perLP ≈ 1.897e16(emitted asDividendDistributed)
-
Claim loop over 65 poisoned clones (subset of the 100 in the attack calldata). Each:
_syncUserLP:recordedLP = 1.334e21,currentLP = 0→UserLPSyncedthen pending ≈ 25.3216 BNB- Pays clone, test forwards to attacker EOA
-
Attacker EOA claim (still holds live LP) takes another ~25.32 BNB share.
-
Result (output.txt:456-459):
- Claimed via clones: 1,639.294828337604995436 BNB
- Attacker profit (incl. returned seed dust): 1,665.616470889346105615 BNB
- Dividend leftover: 49339 wei (~empty)
Live tx net to attacker was ~1,546.5 BNB after flash-loan fees / path differences; same bug, same drain class.
Diagrams#
Remediation#
-
Hook LP transfers — use a custom LP wrapper, or require claims/sync only through a vault that updates debt on every balance change. Plain Pancake LP cannot callback on transfer; design must not trust external LP balances without continuous sync.
-
Accrue from
min(recorded, current)or always set record frombalanceOfbefore multiplying — never pay on a recorded amount higher than current balance for the period after the last sync. Safer pattern: snapshot at distribution time into a claimable merkle/bitmap, or use a pull-based share token minted 1:1 with deposited LP. -
Cap claims — track
totalClaimable/totalDistributedand requiresum(pending) ≤ contract.balancewith pro-rata scaling, or maintaintotalRecordedLPinvariant==eligible supply. -
Force sync on claim paths only is not enough if sync itself trusts stale
recordedLPfor the accrual step — fix the accrual order/math (credit based on LP held during the epoch, not the pre-transfer snapshot after exit). -
Operational — pause
claimDividend/distributeDividendifsum(userLPRecord)diverges fromlp.totalSupply()by a threshold.
How to reproduce#
# Offline (anvil loads anvil_state.json — no RPC required)
cd /path/to/evm-hack-registry
_shared/run_poc.sh 2026-08-MOKE_exp -vvvvv
# Expect: [PASS] testExploit — ~1639 BNB claimed via clones
PoC entry: test/MOKE_exp.sol. Fork block 113_652_608, chain port 8546 (BSC).
Reference: https://x.com/TenArmorAlert/status/2084102947500368164
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-08-MOKE_exp (evm-hack-registry mirror).
- Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- 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.