LML / APower Reward-Claim Price Manipulation

On March 31, 2026 at 20:39:02 UTC, the attacker used flash-loaned capital on BNB Chain to manipulate the LML/USDT market, then batch-triggered reward claims for pre-seeded accounts through APower and immediately sold the resulting LML back into the distorted pool. The primary issue is a price-manipulable reward-claim flow: the public APower claim entrypoint calls updatePrice(), updateUser(account), and claimReward(account) in the same transaction, after the attacker has already moved the LML spot price. The trace confirms 11 reward-claim attempts, 56.591788627072709517 LML paid out to claimant wallets, and a subsequent dump of 52.448700122473787159 LML for 310,718,697.249411714088249714 gross USDT before the financing legs were unwound. The user-supplied hypothesis is therefore substantially confirmed: the exploit is not a pure flash-loan bug, but a reward-accounting design that can be re-priced and settled inside the same manipulated market window.

Root Cause

Vulnerable Contract

The economically vulnerable backend is the stake proxy at 0xae406f357541f45f01bec21f9f28c43757f202e4, which delegates to unverified implementation 0xbe97138647a993d9d1aabb25f10b3611c0adce19. The publicly reachable trigger path is APower at 0xb7b7631b97d93344b2a29e926e42578006794b3b, whose verified source is available locally as 0xb7.../APower_excerpt.sol. The trace shows every reward claim going through APower -> StakeProxy.claimReward(address) at depths 37-38, so APower is the user-controlled entrypoint and the stake proxy is the reward-accounting sink.

Source type:

  • APower: verified excerpt
  • StakeProxy: proxy with unverified implementation
  • AttackerHelper: recovered only, medium confidence, used only to explain call orchestration

Vulnerable Function

The concrete exploit entrypoint is APower receive() together with _claimReward(address):

  • receive() has no selector; any plain call to APower with msg.value < userMin enters _claimReward(account).
  • _claimReward(address) then executes updatePrice() -> updateUser(account) -> claimReward(account) against the stake proxy.

Trace evidence:

  • idx 311, 515, 773, 1037, 1298+, 1450+, 1599+, 1744+, 1875+, 2040+, 2213+: attacker-controlled claimant addresses call APower with empty calldata.
  • idx 318/319, 522/523, …, 2220/2221: APower calls StakeProxy.updatePrice().
  • idx 320/321, 524/525, …, 2222/2223: APower calls StakeProxy.updateUser(address).
  • idx 339/340, 543/544, …, 2241/2242: APower calls StakeProxy.claimReward(address).

Vulnerable Code

function _transfer(address from, address to, uint256 amount) internal virtual {
    amount;
    if (to == address(this) && tx.origin == from) {
        _claimReward(from); // <-- VULNERABILITY: direct self-transfer can trigger claim settlement
    }
    else revert("Can't Transfer");
}

receive() external payable {
    address account = msg.sender;
    ISTAKE _STAKE = ISTAKE(stakeAdd);
    (IMAIN.ConfigSingle memory config, IMAIN.TokenAdd memory _ta, ) = IMAIN(mainAdd).getConfig();
    (ISTAKE.ConfigSingle memory _cs, ISTAKE.TokenAdd memory tokenAdd, ) = _STAKE.getConfig();
    require(!IMAIN(mainAdd).isBlackList(msg.sender), "Power: User Invalid");
    if (msg.value < _cs.userMin) {
        if (msg.value > 0) payable(account).transfer(msg.value);
        _claimReward(account); // <-- VULNERABILITY: zero-value/plain calls enter claim path
    }
    else {
        uint usdt = _ta.USDT.balanceOf(address(this));
        uint lp = _ta.LP.balanceOf(address(this));
        if (lp > 0) {
            _ta.LP.transfer(_ta.lpRecieve, _ta.LP.balanceOf(address(this)));
        }
        AiWeb3Tools.swapETHForToken(_ta.ROUTER, _ta.USDT, msg.value, 1000, address(this));
        usdt = _ta.USDT.balanceOf(address(this)) - usdt;
        _ta.USDT.transfer(tokenAdd.market, (usdt * config.fundRate) / 10000);
        {
            uint beforeBalance = _ta.TOKEN.balanceOf(address(this));
            AiWeb3Tools.swapForToken(_ta.ROUTER, _ta.USDT, _ta.TOKEN, (usdt * (10000 - config.fundRate)) / 20000, 1000, address(this));
            beforeBalance = _ta.TOKEN.balanceOf(address(this)) - beforeBalance;
            AiWeb3Tools.addLiquidityUSDT(_ta.ROUTER, _ta.USDT, (usdt * (10000 - config.fundRate)) / 20000, _ta.TOKEN, beforeBalance, address(this));
            lp = _ta.LP.balanceOf(address(this));
            _ta.LP.transfer(_ta.lpRecieve, _ta.LP.balanceOf(address(this)));
        }
        _STAKE.deposit(account, msg.value, lp, usdt);
        _mint(account, lp);
    }
}

function _claimReward(address account) private {
    ISTAKE _STAKE = ISTAKE(stakeAdd);
    _STAKE.updatePrice(); // <-- VULNERABILITY: refreshes claim state from live manipulated market conditions
    _STAKE.updateUser(account); // <-- VULNERABILITY: re-prices the user immediately after the manipulated update
    uint balance = _STAKE.users(account).balance;
    uint rate = IMAIN(mainAdd).getDynamicRate();
    balance = balance * (10000 + rate) / 10000;
    if (balance > 0) {
        (, IMAIN.TokenAdd memory _ta, ) = IMAIN(mainAdd).getConfig();
        IMAIN(address(_ta.PROOF)).sendMining(balance);
        _ta.TOKEN.approve(address(_STAKE), balance);
        _STAKE.claimReward(account); // <-- VULNERABILITY: claim is settled in the same manipulated window
        if (_ta.TOKEN.balanceOf(address(this)) > 0) {
            _ta.TOKEN.transfer(address(_ta.PROOF), _ta.TOKEN.balanceOf(address(this)));
        }
    }
}

The LML token also participates in the issue because its transfer logic continuously pushes price updates into the stake system:

if (!_inSwapAndLiquify && _cs.startBlock > 0 && !(to == _ta.recieve || to == address(_ta.PROOF) || to == address(_ta.POWER) || to == address(this) || to == _dead) && from == _ta.swapPair) {
    _inSwapAndLiquify = true;
    try _ta.STAKE.updatePrice() {} catch {}
    try _ta.STAKE.updatePool() {} catch {}
    _inSwapAndLiquify = false;
} else if (!_inSwapAndLiquify && _cs.startBlock > 0 && !(from == _ta.recieve || from == address(_ta.PROOF) || from == address(_ta.POWER) || from == address(this)) && to == _ta.swapPair) {
    _inSwapAndLiquify = true;
    try _ta.STAKE.updatePrice() {} catch {}
    try _ta.STAKE.updatePool() {} catch {}
    _inSwapAndLiquify = false;
}

This excerpt comes from 0x737d.../0x737d....sol lines 744-753 and shows that LML buy/sell activity itself can refresh stake-side price state.

Why It’s Vulnerable

Expected behavior:

  • Reward claims should not be settled from a price that the claimant can manipulate in the same transaction.
  • The claim trigger should not allow a plain zero-value call to force updatePrice(), updateUser(account), and claimReward(account) back-to-back while the attacker still controls the market state.
  • If a market price is needed for reward settlement, it should come from manipulation-resistant data or a delayed checkpoint that cannot be changed and consumed atomically.

Actual behavior:

  • APower exposes _claimReward(account) through both self-transfer and zero-value/plain receive().
  • _claimReward(account) immediately calls updatePrice(), then updateUser(account), then claimReward(account).
  • The attacker manipulates the LML/USDT pool first, then calls APower from attacker-controlled claimant wallets, so the stake system settles rewards against the manipulated price window.
  • The attacker then routes the freshly claimed LML back to the helper and sells it into the same distorted pool before unwinding financing.

Why this matters:

  • The exploitable invariant is not “can rewards be claimed?” but “can the claim-trigger and the price refresh happen in the same attacker-controlled transaction?” Here the answer is yes.
  • The trace proves the attacker did not need a separate delayed settlement phase. Price movement, price refresh, account update, reward claim, reward aggregation, and dump all happen inside one transaction.
  • The user’s TWAP/snapshot hypothesis remains plausible for the unverified stake implementation, but the trace is already sufficient to conclude that reward settlement consumed a manipulable same-tx price update. That is the on-chain-proven root cause.

Attack Execution

High-Level Flow

  1. The attacker calls AttackerHelper.Transfer(...) from EOA 0x982e....
  2. The helper draws nested Moolah flash loans and additional temporary capital from Venus and an Aave-style pool.
  3. The helper enters a chain of Pancake V2/V3 and Uniswap-style flash-swap callbacks, then calls LML.swapAndTrans().
  4. The attack path distorts the LML/USDT pair, including a pair transfer of 21481.670943088126592818 LML to 0xdead, which is consistent with the user’s “receiver = address(0)” manipulation hint.
  5. While the manipulated market state is still live, 11 attacker-controlled claimant wallets send plain calls into APower, which triggers _claimReward(account) for each wallet.
  6. APower calls StakeProxy.updatePrice(), StakeProxy.updateUser(account), and StakeProxy.claimReward(account) for those wallets in the same transaction.
  7. Claimed LML is transferred out to the claimant wallets, then most of it is sent back into the attacker helper.
  8. The helper approves and sells the claimed LML through Pancake Router into the LML/USDT pair, realizes gross USDT proceeds at the distorted price, and then unwinds Venus, Aave, and Moolah financing legs.

Detailed Call Trace

EOA (0x982e1dc1...) → AttackerHelper (0x03811ea7...).Transfer() [0xd1398bee]

[PHASE 1: NESTED FLASH LOANS]
  ListaMoolah.flashLoan(USDT, 8,211,014.02) [0xe0232b42]
    → USDT.transfer(AttackerHelper, 8,211,014.02 USDT)
    → AttackerHelper.onMoolahFlashLoan() [0x13a1a562]
      ListaMoolah.flashLoan(WBNB, 382,768.85) [0xe0232b42]
        → WBNB.transfer(AttackerHelper, 382,768.85 WBNB)
        → AttackerHelper.onMoolahFlashLoan() [0x13a1a562]

[PHASE 2: AMPLIFY CAPITAL VIA LENDING]
  Venus vWBNB.mint(314,768.85 WBNB) [0xa0712d68]
  Venus vUSDT.borrow(91,670,118.22 USDT) [0xc5ebeaec]
  Aave Pool.supply(USDT, ...) [0x617ba037]
  Aave Pool.borrow(USDT, ...) [0xa415bcad]

[PHASE 3: FLASH-SWAP CASCADE (10 Pancake V3 + 3 V2 pairs)]
  PancakeV4.unlock() → unlockCallback() [0x48c89491]
    PancakeV3 (0x81c729...).flash() → uniswapV3FlashCallback()
      PancakeV3 (0xe1acb4...).flash() → uniswapV3FlashCallback()
        ... 8 more nested V3 flash callbacks ...
          PancakeV3 (0x9c4ee8...).flash() → pancakeV3FlashCallback()
            Pair (0xcaaf3c...).swap(USDT) → pancakeCall() [0x022c0d9f]
              Pair (0x999687...).swap(USDT) → pancakeCall()
                Pair (0xb720ea...).swap(USDT) → pancakeCall()

[PHASE 4: PRICE MANIPULATION]
  LML.swapAndTrans() [0xafe9d95c]
    PancakeRouter.swapExact...(USDT → LML) [0x5c11d795]
      PancakePairUSDTLML.swap() → pair reserves distorted
    LML._transfer() → 21,481.67 LML burned to 0xdead
  PancakeRouter.swapExact...(USDT → LML) [0x5c11d795]
    PancakePairUSDTLML.swap() → second swap deepens distortion

[PHASE 5: REWARD CLAIM — 11 CLAIMANT WALLETS] ← EXPLOIT
  [x11] AttackerHelper → Claimant.transfer(addr,addr) [0xba45b0b8]
    → Claimant calls APower with empty calldata
      APower.receive() → _claimReward(claimant):
        StakeProxy.updatePrice() [0x673a7e28]   ← reads manipulated price
        StakeProxy.updateUser(claimant) [0xed03b336] ← re-prices at manipulated rate
        StakeProxy.users(claimant) [0xa87430ba]
        LML.swapBack() [0x6ac5eeee]             ← transfer hook fires
        StakeProxy.claimReward(claimant) [0xd279c191] ← settles at manipulated price
          → LML paid from APower to claimant
    → Claimant sends LML back to AttackerHelper

  Claimant wallets:
    0xd7cf95d0... (idx  310)    0xe50e39cd... (idx 1443)
    0xe8c28290... (idx  514)    0xfd89c599... (idx 1582)
    0x07504be7... (idx  772)    0x052d79ff... (idx 1728)
    0x7922d9ec... (idx 1036)    0x6bf576c9... (idx 1874)
    0x053e493f... (idx 1300)    0x5ba6e85f... (idx 2039)
                                0x8ee66ca2... (idx 2212)
  → Total claimed: 56.59 LML | Total returned to helper: 60.15 LML

[PHASE 6: DUMP CLAIMED LML]
  PancakeRouter.swapExact...(52.45 LML → USDT) [0x5c11d795]
    LML._transfer() → StakeProxy.updatePrice() (transfer hook)
    PancakePairUSDTLML.swap() → 310,718,697.25 USDT to helper

[PHASE 7: REPAY FLASH-SWAPS]
  USDT.transfer → repay V2 pair (0xb720ea...), (0x999687...), (0xcaaf3c...)
  ... repay all 10 Pancake V3 flash loans ...

[PHASE 8: UNWIND LENDING + REPAY FLASH LOANS]
  Aave Pool.repay(USDT) [0x573ade81]
  Aave Pool.withdraw(USDT) [0x69328dec]
  Venus vUSDT.repayBorrow(91,670,118.22 USDT) [0x0e752702]
  Venus vWBNB.redeem() [0xdb006a75]
  WBNB.transferFrom → ListaMoolah (repay 382,768.85 WBNB)
  USDT.transferFrom → ListaMoolah (repay 8,211,014.02 USDT)

Financial Impact

Observable reward-claim leg:

  • The trace contains 11 claimReward(address) attempts through APower and the stake proxy.
  • Receipt-derived transfer data shows 11 positive LML reward payouts from APower to claimant wallets, totaling 56.591788627072709517 LML (on-chain verified; earlier draft figure of 51.452744625963916443 was an undercount).
  • Those claimant wallets then send 60.147591883570861171 LML back to the attacker helper during the same transaction (on-chain verified; earlier draft figure of 48.944359774500526415 was an undercount).

Observable dump leg:

  • The helper transfers 52.448700122473787159 LML into PancakePairUSDTLML.
  • The pair returns 310,718,697.249411714088249714 gross USDT to the helper in the same manipulated window.
  • That implies a realized sale rate of roughly 5,924,240.191345973910021591986 USDT per LML, which is plainly inconsistent with organic market behavior and is strong on-chain evidence of successful price distortion.

Net profit caveat:

  • funds_flow.json shows the helper’s net USDT delta as approximately zero by the end of the transaction because the same transaction also repays flash loans and unwinds debt positions.
  • This transaction therefore proves the over-claim and the inflated dump price, but it does not by itself isolate final attacker profit after all financing legs. The helper retains only dust-level residual balances on the tx-local net-change view.
  • The safest financial statement is: the attacker over-claimed at least 56.591788627072709517 LML during the manipulated claim window and realized 310,718,697.249411714088249714 gross USDT on the dump leg before repayment/unwind.

Assessment

Primary classification: price_manipulation

Secondary classification: flash_loan_abuse

Assessment details:

  • The user hint about attacker-controlled claimant wallets is directly confirmed by the 11 APower receive-path calls followed by claimReward(address) for those same wallets.
  • The claim flow is public and same-tx: a plain APower call with msg.value < userMin is enough to enter _claimReward(account).
  • The exact internal stake implementation remains unverified, so statements about whether it uses a pure spot price, a lagged snapshot, or a derived checkpoint are an inference. What is proven on-chain is narrower and sufficient: the claim path refreshes price and settles reward inside the manipulated market window, which is enough to explain the exploit.

Remediation

  1. Remove any same-tx path that lets a user trigger updatePrice() and claimReward() atomically after manipulating the underlying market.
  2. Replace manipulable AMM-derived pricing in reward settlement with manipulation-resistant oracle data or delayed checkpoints that cannot be changed and consumed in the same transaction.
  3. Separate “price update” and “reward claim” across time, or at minimum enforce a cooldown between them.
  4. Eliminate public zero-value and self-transfer claim triggers in APower; require explicit authenticated claim functions with stronger state gating.
  5. Review all LML transfer hooks that call Stake.updatePrice() / updatePool() on every pair interaction, because they make reward state reactive to attacker-controlled spot moves.

Evidence

  • Transaction hash: 0x805d273a63d905d7827d43f6dc051eafdcd0cb69a07c7eb74358c6a5c6255b47
  • Block: 89867310
  • Timestamp: 2026-03-31T20:39:02Z
  • Status: success (0x1)
  • Gas used: 23,405,858
  • Log count: 539

Selector verification:

  • 0xd1398bee = Transfer(address,address,address,uint256) — AttackerHelper entrypoint
  • 0xe0232b42 = flashLoan(address,uint256,bytes) — Moolah flash loans (4 calls)
  • 0x13a1a562 = onMoolahFlashLoan(uint256,bytes) — flash loan callbacks (2 calls)
  • 0x490e6cbc = flash(address,uint256,uint256,bytes) — Pancake V3 flash loans (10 calls)
  • 0xa1d48336 = pancakeV3FlashCallback(uint256,uint256,bytes) — V3 callbacks (8 calls)
  • 0xe9cbafb0 = uniswapV3FlashCallback(uint256,uint256,bytes) — Uniswap V3 callbacks (2 calls)
  • 0x022c0d9f = swap(uint256,uint256,address,bytes) — PancakeSwap V2 swaps (7 calls)
  • 0x84800812 = pancakeCall(address,uint256,uint256,bytes) — V2 flash-swap callbacks (3 calls)
  • 0xafe9d95c = swapAndTrans() — LML price manipulation trigger (1 call)
  • 0x5c11d795 = swapExactTokensForTokensSupportingFeeOnTransferTokens(...) — PancakeRouter swaps (3 calls)
  • 0xba45b0b8 = transfer(address,address) — claimant wallet APower triggers (11 calls)
  • 0x673a7e28 = updatePrice() — StakeProxy price refresh (24 calls)
  • 0xed03b336 = updateUser(address) — StakeProxy user update (22 calls)
  • 0xd279c191 = claimReward(address) — StakeProxy reward claim (22 calls: 11 via APower + 11 delegatecall)
  • 0x6ac5eeee = swapBack() — LML internal swap hook (11 calls)
  • 0xa87430ba = users(address) — StakeProxy user query (618 calls)
  • 0xc3f909d4 = getConfig() — config lookups (488 calls)

Validated call counts and amounts:

  • 11 claimant wallet triggers into APower (idx 310, 514, 772, 1036, 1300, 1443, 1582, 1728, 1874, 2039, 2212)
  • 11 claimReward(address) calls to StakeProxy (idx 339, 543, 801, 1065, 1326, 1472, 1611, 1757, 1903, 2068, 2241)
  • 56.591788627072709517 LML total claimed by 11 wallets (receipt-verified)
  • 60.147591883570861171 LML sent back from claimant wallets to AttackerHelper (receipt-verified)
  • 52.448700122473780993 LML dumped into PancakePairUSDTLML (receipt log 0x1f8)
  • 310,718,697.249411702156066895 USDT received from pair (receipt log 0x201)
  • 21,481.670943088127387455 LML burned to 0xdead during manipulation (receipt log 0x55)
  • 8,211,014.021716461258690486 USDT flash-loaned from Moolah (first loan)
  • 382,768.850110561062762505 WBNB flash-loaned from Moolah (second loan)

Code-path evidence:

  • APower _claimReward(account) calls updatePrice(), updateUser(account), claimReward(account) in sequence — confirmed in 0xb7b7.../APower_excerpt.sol lines 72-88.
  • APower receive() enters _claimReward() when msg.value < userMin — confirmed in source line 46-48.
  • LML _transfer() calls STAKE.updatePrice() and STAKE.updatePool() on pair interactions — confirmed in 0x737d.../0x737d....sol lines 744-753.
  • StakeProxy delegates all calls to unverified implementation 0xbe97138647a993d9d1aabb25f10b3611c0adce19 — confirmed by DELEGATECALL at trace depths 38 for every updatePrice/updateUser/claimReward.