Movie Token (MT) — Deflationary Burn Manipulation via extractFromPoolForLpMining

On 2026-02-28 (BSC block 85677691), the Movie Token ($MT) project was exploited for approximately 381.75 WBNB (~$242K USD) in a single transaction. The attacker abused the MT token’s extractFromPoolForLpMining function, which burns tokens directly from the PancakeSwap MT/WBNB LP pair’s balance without the pair’s knowledge (i.e., without a prior reserve-matching transfer). By pairing this with a PancakeSwap flash swap callback (pancakeCall), the attacker caused the pair to sync() after the pool’s MT balance had been drastically reduced by the burn, collapsing the MT reserve from ~6.76M MT to 21,000 MT and allowing a subsequent sell of MT tokens at a massively inflated effective price.

Root Cause

Vulnerable Contract

  • Name: Movie Token (MT)
  • Address: 0xb32979f3a5b426a4a6ae920f2b391d885abf4c18
  • Source type: Verified (Etherscan)
  • Proxy: No

Vulnerable Function

  • Name: extractFromPoolForLpMining
  • Signature: extractFromPoolForLpMining(uint256 amount)
  • Selector: 0x962b2809
  • File: contracts/core/MT.sol

Vulnerable Code

// contracts/core/MT.sol (verified)

function extractFromPoolForLpMining(uint256 amount) external returns (uint256) {
    require(msg.sender == lpMiningAddr, "Only LP mining");  // <-- VULNERABILITY (1)
    // ... day-limit and amount-cap checks ...

    // Extract from pool balance (similar to BTdaoToken)
    balanceOf[extractionPool] -= amount;          // <-- VULNERABILITY (2)
    balanceOf[address(this)] += amount;
    emit Transfer(extractionPool, address(this), amount);

    // Transfer to LP mining contract
    balanceOf[address(this)] -= amount;
    balanceOf[lpMiningAddr] += amount;
    emit Transfer(address(this), lpMiningAddr, amount);

    // Sync pair reserves
    try IUniswapV2Pair(extractionPool).sync() {} catch {}  // <-- VULNERABILITY (3)

    lastPoolExtractionTime = todayMidnight;
    return amount;
}

And the closely related internal burn called within the same function path:

function _executePendingBurn(address extractionPool) internal {
    // ...
    // Burns tokens from pool balance directly, then calls sync()
    balanceOf[extractionPool] -= burnAmount;          // <-- VULNERABILITY (2)
    balanceOf[DEAD_ADDRESS] += burnAmount;
    emit Transfer(extractionPool, DEAD_ADDRESS, burnAmount);
    // ...
    try IUniswapV2Pair(extractionPool).sync() {} catch {}  // <-- VULNERABILITY (3)
}

Caller access guard (MT_LP_RewardDistributor, distributeDailyRewards, selector 0x501788af):

// MT_LP_RewardDistributor (0x139bd2ecfde76f5311d7beeb2e05cba6fede26d6) [recovered — approximation]
// distributeDailyRewards() is callable by anyone with no access control guard.
// It reads pair reserves, computes a 1% extraction amount, then calls
// MT.extractFromPoolForLpMining(amount), which is gated only on msg.sender == lpMiningAddr.
// The MT token has lpMiningAddr set to this distributor contract — so calling
// distributeDailyRewards() from inside a PancakeSwap flash swap callback is permitted.

Why It’s Vulnerable

Expected behavior: extractFromPoolForLpMining SHOULD only be triggerable under safe conditions where the pair’s reserves are known to be accurate (e.g., outside an active swap or after a sync initiated by the pair itself). The pair’s swap() function holds a reentrancy lock (Pancake: LOCKED) to prevent reserve manipulation during a swap.

Actual behavior: The function directly subtracts tokens from balanceOf[extractionPool] (the pair’s token balance as tracked in the MT token contract’s own mapping), and then calls IUniswapV2Pair(extractionPool).sync(). This sync() call updates the pair’s reserve state to match its actual token balances, meaning that any reduction in balanceOf[pair] — whether by a legitimate transfer or by this direct subtraction — is absorbed by sync() as a real reserve decrease.

This creates a two-part flaw:

  1. The authorization check is insufficient. msg.sender == lpMiningAddr only restricts direct calls; it does not prevent calling distributeDailyRewards() on 0x139bd2ecfde76f5311d7beeb2e05cba6fede26d6 (the LP reward distributor, which is the lpMiningAddr) from inside a flash swap callback. The pair’s reentrancy lock blocks swap(), mint(), and burn() on the pair itself, but does NOT block calls to the MT token contract or the reward distributor. There is no lock on extractFromPoolForLpMining preventing it from being called while a PancakeSwap swap is in progress.

  2. Reserve manipulation via direct balance subtraction + sync. When extractFromPoolForLpMining reduces balanceOf[pair] directly in the MT token’s storage (not via a transfer that the pair can observe) and subsequently calls pair.sync(), the pair sets reserve0 (MT) to the new, lower balance. The key effect: after the attacker had already used flash-loan capital to buy ~10M MT twice from the pool (depleting MT reserves to ~6.76M MT and pushing WBNB reserves to ~1,201 WBNB), calling distributeDailyRewards() inside pancakeCall triggers the burn of ~6.74M MT from balanceOf[pair], followed by sync(). The pair’s MT reserve collapses from ~6.76M MT to ~21,000 MT (the MIN_LP_SUPPLY floor), while WBNB reserves remain at ~1,201 WBNB. This artificial skew — 1,201 WBNB backing only 21,000 MT — is what yields the profit when the attacker sells MT back into the pool.

Normal vs. Attack flow (reserves in the MT/WBNB pair, reserve0=MT as token0, reserve1=WBNB as token1):

ScenarioMT reserve (reserve0)WBNB reserve (reserve1)MT price
Pre-attack initial~17,756,517 MT~384.27 WBNBfair (~21.6 MT per WBNB)
After attacker buys 10M MT (first swap)~7,756,517 MT~880.93 WBNBMT price up
After attacker buys 10M MT (second swap)~6,756,517 MT~1,201.15 WBNBMT price up further
After extractFromPoolForLpMining + sync()~21,000 MT~1,201.15 WBNB (unchanged)MT “worth” ~0.057 WBNB each — 46× distortion
Attacker sells 10M MT into depleted pooldrains remaining WBNBattacker profits massively

On-chain confirmation from SYNC events in receipt.json:

  • Log #42: reserve0(MT)=17,756,516.74, reserve1(WBNB)=384.27 — initial state before attack ops
  • Log #79: reserve0(MT)=6,756,516.94, reserve1(WBNB)=1,201.15 — after both 10M MT purchases
  • Log #86: reserve0(MT)=6,756,516.90, reserve1(WBNB)=1,201.15 — just before distributeDailyRewards call (tiny LP burn)
  • Log #92: reserve0(MT)=21,000.00, reserve1(WBNB)=1,201.15 — after extractFromPoolForLpMining + sync(): burn confirmed

Attack Execution

High-Level Flow

  1. Attacker EOA (0xdb0901a3) calls exploit(MT, PancakeRouter, MT_WBNB_Pair, LPRewardDistributor) on pre-deployed contract 0xdf7ed22d.
  2. Attacker checks the WBNB balance of the Moolah flash loan pool, then takes a flash loan of the full amount (~358,681 WBNB) from Moolah (0x8f73b65b).
  3. Inside onMoolahFlashLoan, the attacker seeds the pair with a tiny WBNB amount (~0.000004 WBNB) and 0.2 MT, mints a small LP position, then uses the flash-loan capital to buy ~10M MT from the pool via PancakeRouter.
  4. A small LP removal is performed. The attacker then initiates a PancakeSwap flash swap requesting WBNB out, triggering the pancakeCall callback.
  5. Inside pancakeCall, the attacker sends ~10M MT back to the pair to partially settle the flash swap (triggering MT fee logic), then buys a second batch of ~10M MT from the pool, depleting MT reserves to ~6.76M MT and raising WBNB reserves to ~1,201 WBNB.
  6. The attacker calls distributeDailyRewards() on the LP reward distributor. This reads the current reserves, computes a ~1% daily extraction amount (~6.74M MT), and calls MT.extractFromPoolForLpMining(~6.74M MT). This burns ~6.74M MT directly from balanceOf[pair] and calls pair.sync() — collapsing the MT reserve to the MIN_LP_SUPPLY floor of 21,000 MT while WBNB stays at ~1,201 WBNB.
  7. The attacker sells 10M MT into the now-depleted pool, receiving ~1,198.6 WBNB (at the artificially distorted price).
  8. The attacker performs additional LP removals to redeem remaining WBNB from LP positions.
  9. The flash swap callback returns. The attacker repays the Moolah flash loan (358,681 WBNB) and transfers ~381.75 WBNB profit to the EOA.

Detailed Call Trace

EOA (0xdb0901a3)
└── CALL AttackerContract (0xdf7ed22d).exploit(0x279f9e46)
    ├── STATICCALL WBNB (0xbb4cdb9c).balanceOf(Moolah) → 358681.54 WBNB
    └── CALL Moolah Proxy (0x8f73b65b).flashLoan(WBNB, 358681.54e18, calldata) (0xe0232b42)
        └── DELEGATECALL Moolah Impl (0x9321587e).flashLoan(...)
            ├── CALL WBNB.transfer(AttackerContract, 358681.54e18) → flash loan disbursed
            └── CALL AttackerContract.onMoolahFlashLoan(358681.54e18, data) (0x13a1a562)
                ├── CALL WBNB.transfer(Pair, ~0.000004 WBNB)   [seed WBNB for LP mint]
                ├── CALL MT.transfer(Pair, 0.2 MT)             [seed MT for LP mint]
                ├── CALL Pair (0x037e6eb2).mint(AttackerContract) → receive LP tokens
                │   (SYNC log#42: MT=17,756,516.74, WBNB=384.27 — initial state)
                │   (SYNC log#49: MT=17,756,516.94, WBNB=384.27 — after mint)
                ├── CALL WBNB.approve(PancakeRouter, ~497 WBNB)
                ├── CALL PancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
                │       ~497 WBNB, 0, [WBNB, MT], AttackerContract, deadline)
                │   [BUY ~10M MT from pool; pair sends MT to router then to AttackerContract]
                │   ├── CALL WBNB.transferFrom(AttackerContract, Pair, ~497 WBNB)
                │   └── CALL Pair.swap(10M MT out, 0 WBNB out, PancakeRouter, "")
                │       └── CALL MT.transfer(PancakeRouter, 10M MT) → log#53
                │   (SYNC log#54: MT=7,756,516.94, WBNB=880.93)
                ├── [AttackerContract receives 10M MT from router — log#64]
                ├── CALL Pair.approve(PancakeRouter, LP_small)
                ├── CALL PancakeRouter.removeLiquidityETHSupportingFeeOnTransferTokens(MT, LP_small, ...)
                │   └── CALL Pair.burn(PancakeRouter) → returns tiny MT + WBNB
                │   (SYNC log#62: MT=7,756,516.90, WBNB=880.93 — minor LP burn)
                │
                ├── CALL Pair.swap(0, ~158.8 WBNB, AttackerContract, calldata) (0x022c0d9f)
                │   [Flash swap: request WBNB out, pass callback data]
                │   ├── CALL WBNB.transfer(AttackerContract, ~158.8 WBNB) → log#66
                │   └── CALL AttackerContract.pancakeCall(sender, 0, ~158.8 WBNB, data) (0x84800812)
                │       │
                │       ├── CALL MT.transfer(Pair, ~9M MT)  [settle flash swap partially]
                │       │   └── [MT._transfer triggers fee → CALL MT_FeeDistributor.distributeFees()
                │       │        ├── swapExact... → REVERTS "Pancake: LOCKED"
                │       │        └── CALL MT.transfer(0x9edce171, eco_fee_MT)]
                │       │   (SYNC log#74: MT=16,756,516.94, WBNB=483.60)
                │       │
                │       ├── CALL WBNB.approve(PancakeRouter, ~718 WBNB)
                │       ├── CALL PancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
                │       │       ~718 WBNB, 0, [WBNB, MT], AttackerContract, deadline)
                │       │   [BUY second batch of ~10M MT from pool]
                │       │   (SYNC log#79: MT=6,756,516.94, WBNB=1,201.15)
                │       │
                │       ├── CALL Pair.approve(PancakeRouter, LP_small)
                │       ├── CALL PancakeRouter.removeLiquidityETHSupportingFeeOnTransferTokens(...)
                │       │   [small LP removal, burns LP → tiny MT + WBNB]
                │       │   (SYNC log#86: MT=6,756,516.90, WBNB=1,201.15)
                │       │
                │       ├── CALL LPRewardDistributor (0x139bd2ec).distributeDailyRewards() (0x501788af)
                │       │   [*** CORE EXPLOIT STEP ***]
                │       │   ├── STATICCALL Pair.getReserves() → MT=6,756,516.90, WBNB=1,201.15
                │       │   ├── STATICCALL Pair.token0() → MT address (confirms reserve0=MT)
                │       │   └── CALL MT.extractFromPoolForLpMining(~6,735,516 MT) (0x962b2809)
                │       │       ├── [_executePendingBurn: burns ~6,735,517 MT from balanceOf[Pair]
                │       │       │    Transfer(Pair, 0xdead, ~6,735,517 MT) — log#90]
                │       │       └── CALL Pair.sync() (0xfff6cae9)
                │       │           [Pair reserves updated to actual balances]
                │       │           ├── STATICCALL MT.balanceOf(Pair) → ~21,000 MT
                │       │           └── STATICCALL WBNB.balanceOf(Pair) → 1,201.15 WBNB
                │       │   (SYNC log#92: MT=21,000.00, WBNB=1,201.15 — CONFIRMED)
                │       │
                │       ├── CALL MT.approve(PancakeRouter, ~10M MT)
                │       ├── CALL PancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
                │       │       ~10M MT, 0, [MT, WBNB], AttackerContract, deadline)
                │       │   [SELL 10M MT into depleted pool → receives ~1,198.6 WBNB — log#96]
                │       │   (SYNC log#97: MT=10,021,000.04, WBNB=2.52 — most WBNB drained)
                │       │
                │       └── [additional LP removal for remaining WBNB; WBNB deposit/withdraw]
                │
                ├── CALL WBNB.approve(Moolah, 358681.54 WBNB)  [prepare flash loan repayment]
                ├── CALL WBNB.transfer(Moolah, 358681.54 WBNB)  [repay flash loan — log#100]
                └── CALL WBNB.transfer(EOA, 381.75 WBNB)        [profit — log#102]

Financial Impact

Based on funds_flow.json (primary evidence):

PartyChange
Attacker EOA (0xdb0901a3)+381.75 WBNB profit
Attacker Contract (0xdf7ed22d)net ~0 WBNB (flash loan borrowed and repaid), net 0 MT
MT/WBNB PancakeSwap Pair (0x037e6eb2)-381.75 WBNB, -7,735,517 MT (reserves drained)
0xdead (burn address)+6,735,517 MT permanently burned by extractFromPoolForLpMining
Fee Distributor (0x048f012b)+250,000 MT (accumulated fee MT, illiquid)
Ecosystem address (0x9edce171)+750,000 MT (eco fee MT, illiquid)
Moolah flash loan poolnet 0 (repaid in full)

Total attacker profit: ~381.75 WBNB.

At ~$635/WBNB (approximate market rate at time of attack), this equates to approximately $242,100 USD.

Protocol solvency impact: The MT/WBNB PancakeSwap V2 pair was severely damaged. The MT reserve was burned from ~6.76M MT (at the moment of extraction) down to the MIN_LP_SUPPLY floor of 21,000 MT — a reduction of ~6.74M MT tokens, permanently burned to 0xdead. The pair’s WBNB reserve of ~1,201 WBNB was then drained to near zero (~2.52 WBNB) by the attacker’s final sell. Legitimate LP holders suffered near-total loss of WBNB liquidity. The initial pre-attack pool state (17.76M MT, 384 WBNB) had been inflated to 1,201 WBNB by the attacker’s own WBNB injections before the burn, which is how the profit ratio was achieved.

Evidence

Key Log Entries (from receipt.json / funds_flow.json)

Log IndexEventFromToAmount
38WBNB TransferMoolah (0x8f73b6)AttackerContract358,681.54 WBNB
53MT TransferPairPancakeRouter10,000,000 MT (first buy)
78MT TransferPairPancakeRouter10,000,000 MT (second buy)
90MT TransferPair (0x037e6e)0xdead6,735,516.9 MT (burn)
96WBNB TransferPairAttackerContract1,198.63 WBNB (sell proceeds)
100WBNB TransferAttackerContractMoolah358,681.54 WBNB (repayment)
102WBNB TransferAttackerContractEOA381.75 WBNB (profit)

SYNC Event Reserve Progression (from receipt.json)

Log IndexEventMT Reserve (reserve0)WBNB Reserve (reserve1)Stage
42SYNC17,756,516.74 MT384.27 WBNBPre-attack initial state
49SYNC17,756,516.94 MT384.27 WBNBAfter LP mint (tiny seed)
54SYNC7,756,516.94 MT880.93 WBNBAfter first 10M MT purchase
62SYNC7,756,516.90 MT880.93 WBNBAfter first small LP burn
74SYNC16,756,516.94 MT483.60 WBNBAfter ~9M MT transferred back to pair
79SYNC6,756,516.94 MT1,201.15 WBNBAfter second 10M MT purchase
86SYNC6,756,516.90 MT1,201.15 WBNBAfter second small LP burn
92SYNC21,000.00 MT1,201.15 WBNBAfter extractFromPoolForLpMining + sync()
97SYNC10,021,000.04 MT2.52 WBNBAfter attacker sells 10M MT

Note: MT address (0xb32979f3...) < WBNB address (0xbb4cdb9c...), therefore MT = token0 = reserve0 and WBNB = token1 = reserve1 in the PancakeSwap V2 pair. The pre-attack slot8 prestate value 0x69aec27d000000000014d4d22a569a015cf80000000eb016b65c79cf077f5585 decodes to: reserve0(MT) = 0xeb016b65c79cf077f5585 = 17,756,516.94 MT; reserve1(WBNB) = 0x14d4d22a569a015cf8 = 384.27 WBNB.

Selector Verification

All selectors verified with cast sig:

  • extractFromPoolForLpMining(uint256)0x962b2809
  • distributeDailyRewards()0x501788af
  • pancakeCall(address,uint256,uint256,bytes)0x84800812
  • onMoolahFlashLoan(uint256,bytes)0x13a1a562
  • exploit(address,address,address,address)0x279f9e46
  • flashLoan(address,uint256,bytes)0xe0232b42