All incidents

Mosca double-withdrawal exploit via helper on BNB

Share
Jan 06, 2025 05:19 UTCAttackLoss: 11,366.12 USDC, 8,881 USDTManually checked2 exploit txWindow: 3m 39s
Estimated Impact
11,366.12 USDC, 8,881 USDT
Label
Attack
Exploit Tx
2
Addresses
1
Attack Window
3m 39s
Jan 06, 2025 05:19 UTC → Jan 06, 2025 05:22 UTC

Exploit Transactions

TX 1BSC
0x39467ca3f7eddaa51104b89d840bc718203f9a97a62aaa6b0d66ae2ef0b78fb8
Jan 06, 2025 05:19 UTCExplorer
TX 2BSC
0x4e5bb7e3f552f5ee6ee97db9a9fcf07287aae9a1974e24999690855741121aff
Jan 06, 2025 05:22 UTCExplorer

Victim Addresses

0x1962b3356122d6a56f978e112d14f5e23a25037dBSC

Loss Breakdown

11,366.12USDC
8,881USDT

Similar Incidents

Root Cause Analysis

Mosca double-withdrawal exploit via helper on BNB

1. Incident Overview TL;DR

On BNB Chain, the Mosca subscription/referral protocol (contract 0x1962b3356122d6a56f978e112d14f5e23a25037d) was exploited via a custom helper contract deployed by adversary EOA 0xb7d7240c207e094a9be802c0f370528a9c39fed5. In exploit transaction 0x4e5bb7e3f552f5ee6ee97db9a9fcf07287aae9a1974e24999690855741121aff, the helper used a flash loan and a specific sequence of Mosca buy, join, and exitProgram calls combined with PancakeSwap swaps to extract large quantities of USDT and USDC from Mosca.

The core bug is in Mosca’s withdraw logic. The private function withdrawAll(address) sums three internal accounting fields (balance, balanceUSDT, balanceUSDC) and pays out that combined amount in a single stablecoin without zeroing all of the contributing fields. The public exitProgram() function calls withdrawAll(msg.sender) and only partially resets user state. By carefully setting internal balances and calling exitProgram twice in a single transaction, the helper causes Mosca to pay out the same claim twice, then swaps the duplicated stablecoins to WBNB and sends 27.302344169765420745 WBNB to the adversary EOA, who pays 0.005971938 BNB in gas and nets 27.296372231765420745 BNB profit.

2. Key Background

Mosca is a subscription/referral dApp that tracks per-user balances in an internal ledger. For each user, the contract maintains multiple fields, including:

  • balance: internal balance representing generic rewards.
  • balanceUSDT and balanceUSDC: internal balances representing fiat-denominated rewards in USDT and USDC.
  • referral-related fields such as refCode, collectiveCode, and inviteCount, plus the rewardQueue array of active users.

Verified Mosca source code is available under artifacts/root_cause/data_collector/iter_1/contract/56/0x1962b3...5037d/source/src/Contract.sol. Mosca integrates with standard BEP20 tokens:

  • USDC (0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d, implementation BEP20TokenImplementation at 0xba5fe23f8a3a24bed3236f05f2fcf35fd0bf0b5c),
  • USDT (0x55d398326f99059ff775485246999027b3197955, BEP20USDT),
  • WBNB (0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c).

Mosca users can:

  • buy into the program using USDC,
  • join to enter the reward queue,
  • withdrawFiat and withdrawAll to cash out internal balances in USDT or USDC,
  • exitProgram to leave the queue and withdraw.

The exploit routes value through PancakeSwap:

  • V3-like pool 0x92b7807bf19b7dddf89b706143896d05228f3121 (flash-loan/flash-swap source of USDC),
  • V2 LP 0x16b9a82891338f9ba80e2d6970fdda79d1eb0dae and router 0x10ed43c718714eb63d5aa57b78b54704e256024e,
  • Router 0xd99c7f6c65857ac913a8f880a4cb84032ab2fc5b.

The adversary-related accounts are:

  • EOA 0xb7d7240c207e094a9be802c0f370528a9c39fed5 (controls the exploit),
  • Helper/orchestrator contract 0x851288dcfb39330291015c82a5a93721cc92507a, deployed by the EOA in tx 0x39467ca3f7eddaa51104b89d840bc718203f9a97a62aaa6b0d66ae2ef0b78fb8.

The opportunity is permissionless (an ACT opportunity): any EOA can deploy an equivalent helper and execute the same buy/join/exitProgram sequence while Mosca and Pancake liquidity are in a similar state.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability is a state-accounting bug in Mosca’s withdraw logic. Conceptually, Mosca intends that each user’s total withdrawals across USDT/USDC never exceed their net deposits plus earned rewards. The contract however maintains three separate internal balance fields (balance, balanceUSDT, balanceUSDC) and uses them inconsistently across withdrawal paths.

The private withdrawAll(address) function computes:

function withdrawAll(address addr) private {
    User storage user = users[addr];
    require(msg.sender == user.walletAddress, "Wallet addresses do not match");
    uint balance = user.balance + user.balanceUSDT + user.balanceUSDC;

    if (usdc.balanceOf(address(this)) >= balance) {
        usdc.transfer(user.walletAddress, balance);
        emit WithdrawAll(user.walletAddress, block.timestamp, balance, 2);
    } else {
        usdt.transfer(user.walletAddress, balance);
        emit WithdrawAll(user.walletAddress, block.timestamp, balance, 1);
    }
}

The public exitProgram() function calls withdrawAll(msg.sender) and only partially clears internal state:

function exitProgram() external nonReentrant {
    require(!isBlacklisted[msg.sender], "Blacklisted user");
    User storage user = users[msg.sender];

    address referrer = referrers[user.collectiveCode];
    if (referrer != address(0) && users[referrer].inviteCount > 0) {
        users[referrer].inviteCount--;
    }

    for (uint256 i = 0; i < rewardQueue.length; i++) {
        address userAddr = rewardQueue[i];
        if (userAddr == msg.sender) {
            // Perform withdrawal before modifying user state
            withdrawAll(msg.sender);

            // Remove user from reward queue and reset state
            refByAddr[userAddr] = 0;
            referrers[user.refCode] = 0x000000000000000000000000000000000000dEaD;
            user.balance = 0;
            user.enterprise = false;

            rewardQueue[i] = rewardQueue[rewardQueue.length - 1];
            rewardQueue.pop();

            emit ExitProgram(msg.sender, block.timestamp);
        }
    }
}

Crucially, withdrawAll never zeroes balance, balanceUSDT, or balanceUSDC, and exitProgram only zeroes user.balance, leaving balanceUSDT and balanceUSDC untouched. Combined with other flows that credit these fields, this allows a user (or helper contract) to engineer repeated full cash-outs of the same underlying claim via multiple exitProgram calls in a single transaction.

4. Detailed Root Cause Analysis

This section walks through how the Mosca accounting bug is exploited in seed transaction 0x4e5b... using concrete on-chain evidence.

4.1 Setup and Flash Loan

In transaction 0x3946... (block 45519930), the adversary EOA 0xb7d7... deploys helper contract 0x8512... with constructor arguments tying it to Mosca, USDT/USDC, Pancake routers, and a Pancake V3 pool. This helper is used exactly once in the exploit.

In the exploit transaction 0x4e5b... (block 45519931), the EOA calls the helper with:

  • from = 0xb7d7...,
  • to = 0x8512...,
  • value = 0,
  • calldata encoding Mosca address 0x1962... and an amount 1000000000000000000000 (1e21 units).

From the seed tx_receipt.json and trace.cast.log, the helper obtains USDC via an interaction with the Pancake V3-like pool 0x92b7...:

92b7... (Pancake V3 pool)
  ... flash-like Swap/Flash event involving 0x8512...

The resulting USDC is then sent into Mosca through its buy and join functions, crediting the helper’s internal balances (in particular, users[0x8512].balanceUSDC) to a large value on the order of 9.8e20 units.

4.2 Double Withdraw via exitProgram / withdrawAll

Once the helper has inflated its Mosca internal balances, it calls exitProgram() twice in the same transaction. Each call traverses the reward queue, finds msg.sender, and executes:

  1. withdrawAll(msg.sender), computing balance = balance + balanceUSDT + balanceUSDC.
  2. Transfers balance worth of USDT or USDC from Mosca’s on-chain token holdings to user.walletAddress (the helper).
  3. Emits a WithdrawAll event.
  4. Zeroes only user.balance and some referral data; balanceUSDT and balanceUSDC remain unchanged.

Because the internal fields are not all zeroed, the second exitProgram call sees the same non-zero total and pays it out again. This behavior is observable in Mosca’s events and ERC20 balance diffs.

From balance_diff.json for the seed tx:

{
  "erc20_balance_deltas": [
    {
      "token": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d",
      "holder": "0x1962b3356122d6a56f978e112d14f5e23a25037d",
      "delta": "-11366118226600985221667"
    },
    {
      "token": "0x55d398326f99059ff775485246999027b3197955",
      "holder": "0x1962b3356122d6a56f978e112d14f5e23a25037d",
      "delta": "-8881004926108374384231"
    }
  ]
}

These raw 18-decimal deltas show Mosca losing:

  • 11,366,118,226,600,985,221,667 units of USDC,
  • 8,881,004,926,108,374,384,231 units of USDT.

The matching positive deltas are observed on Pancake router and LP addresses, indicating that these stablecoins are forwarded out of Mosca and into the DEX path orchestrated by the helper.

4.3 Conversion to WBNB and Profit Realization

The helper routes the duplicated stablecoin withdrawals through PancakeSwap, ultimately accumulating WBNB at its own address and then forwarding it to the EOA. The critical transfer is visible in the seed trace:

WBNB::transfer(0xb7D7240c207e094a9Be802C0f370528A9C39Fed5, 27302344169765420745 [2.73e19])

This corresponds to 27.302344169765420745 WBNB being transferred from the helper 0x8512... to the adversary EOA 0xb7d7.... The transaction receipt tx_receipt.json contains the matching WBNB Transfer event from 0x8512... to 0xb7d7... with the same value.

From balance_diff.json, the adversary’s native BNB balance changes by:

{
  "address": "0xb7d7240c207e094a9be802c0f370528a9c39fed5",
  "delta_wei": "-5971938000000000"
}

-5,971,938,000,000,000 wei is -0.005971938 BNB, representing gas cost for the exploit transaction. Treating WBNB as the reference asset, the opportunity-specific portfolio for the adversary is:

  • value_before_in_reference_asset = 0 (by definition of the opportunity-specific portfolio),
  • value_after_in_reference_asset = 27.302344169765420745 BNB (WBNB received),
  • fees_paid_in_reference_asset = 0.005971938 BNB (gas),
  • value_delta_in_reference_asset = 27.296372231765420745 BNB (after - fees).

These figures are fully determined from on-chain balances and traces with no remaining placeholders.

4.4 Invariant and Breakpoint

An appropriate invariant for Mosca is:

For every user, the sum of all withdrawals from Mosca (across USDT and USDC) must never exceed their net deposits plus earned rewards recorded in internal accounting.

At the code level, the breakpoint is:

  • withdrawAll(address) computes balance = user.balance + user.balanceUSDT + user.balanceUSDC and transfers that entire amount from Mosca’s stablecoin balances to the user, without resetting these internal fields.
  • exitProgram() calls withdrawAll(msg.sender) and then sets user.balance = 0 but does not reset balanceUSDT or balanceUSDC.

The helper exploits this by:

  1. Inflating the internal balances via buy/join.
  2. Calling exitProgram() twice in the same transaction, each time triggering withdrawAll to pay the full sum.

The result is that Mosca’s actual token holdings decrease by more than any legitimate combination of deposits and rewards would justify, violating the invariant.

5. Adversary Flow Analysis

The end-to-end adversary flow consists of a short transaction sequence on BNB Chain:

  1. Helper deployment (tx 0x3946..., block 45519930):

    • From 0xb7d7... to a contract-creation address.
    • Deploys 0x8512... with parameters linking it to Mosca (0x1962...), USDT/USDC, Pancake routers, and a Pancake V3 pool.
    • This prepares a programmable entry point for the exploit.
  2. Exploit transaction (tx 0x4e5b..., block 45519931):

    • from = 0xb7d7..., to = 0x8512..., value = 0.
    • The helper:
      • Obtains USDC via an interaction with Pancake V3 pool 0x92b7... (flash swap/loan).
      • Calls Mosca buy/join to record a large internal USDC balance for itself.
      • Calls exitProgram() twice, each time triggering withdrawAll(msg.sender) and causing Mosca to pay out the same combined internal claim twice in stablecoins.
      • Routes the duplicated stablecoins through PancakeSwap routers and LPs (0xd99c7..., 0x16b9...) to swap into WBNB.
      • Sends 27.302344169765420745 WBNB to 0xb7d7... via WBNB::transfer.
  3. Net result for the adversary:

    • Receives 27.302344169765420745 WBNB.
    • Pays 0.005971938 BNB in gas.
    • Nets 27.296372231765420745 BNB profit in this opportunity-specific portfolio.

All steps use only public contracts and functions (Mosca public entry points, Pancake routers/pools, a standard WBNB token) and a single adversary-controlled helper. No privileged roles or non-public infrastructure are required.

6. Impact & Losses

The primary victim is the Mosca protocol, which suffers large outflows of USDC and USDT from its contract balances. From balance_diff.json for the exploit tx:

  • Mosca USDC (0x8ac76...) balance decreases by 11366118226600985221667 units.
  • Mosca USDT (0x55d398...) balance decreases by 8881004926108374384231 units.

These amounts correspond to user/protocol funds that are effectively diverted into the adversary’s DEX route and ultimately converted to WBNB. The adversary EOA ends the transaction with a net profit of 27.296372231765420745 BNB in WBNB terms, while Mosca’s internal accounting no longer matches its on-chain token holdings, undermining the integrity of its balances and payouts.

7. References

  • Seed transaction trace and metadata for 0x4e5b7e3f552f5ee6ee97db9a9fcf07287aae9a1974e24999690855741121aff (BNB Chain, block 45519931), including trace.cast.log, balance_diff.json, and metadata.json under artifacts/root_cause/seed/56/0x4e5b.../.
  • Mosca contract source at 0x1962b3356122d6a56f978e112d14f5e23a25037d, stored under artifacts/root_cause/data_collector/iter_1/contract/56/0x1962b3...5037d/source/src/Contract.sol.
  • Stablecoin and WBNB token sources: USDC (BEP20TokenImplementation), USDT (BEP20USDT), WBNB (WBNB) under artifacts/root_cause/seed/56/*/src/Contract.sol.
  • PancakeSwap contracts involved in the exploit: V3 pool 0x92b7807bf19b7dddf89b706143896d05228f3121, LP 0x16b9a82891338f9ba80e2d6970fdda79d1eb0dae, and router 0xd99c7f6c65857ac913a8f880a4cb84032ab2fc5b.
  • Helper/orchestrator contract 0x851288dcfb39330291015c82a5a93721cc92507a deployment and usage traces under artifacts/root_cause/data_collector/iter_1/contract/56/0x8512.../ and address txlists under artifacts/root_cause/data_collector/iter_1/address/56/.