Reproduced Exploit
Mosca Exploit — `exitProgram()` Pays Out Internal Credit That Was Never Backed by a Deposit
Mosca is an MLM-style "citizenship" program that keeps per-user internal balances in three fields — balance (MOSCA credit), balanceUSDT, balanceUSDC (Mosca.sol:175-187). Users top up these internal balances via join() and buy(), and cash out via exitProgram().
Loss
~$19K combined — both stablecoin sides of the contract were drained. The PoC's USDC-only balance log shows 30…
Chain
BNB Chain
Category
Logic / State
Date
Jan 2025
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: 2025-01-Mosca_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Mosca_exp.sol.
Vulnerability classes: vuln/logic/state-update · vuln/logic/incorrect-state-transition
One-liner: Mosca's
buy()credits the caller a large internalbalanceUSDC,exitProgram()/withdrawAll()pays that credit out in real USDC/USDT, butexitProgram()only zeroesuser.balance— it never clearsbalanceUSDC/balanceUSDT— so the same credit can be withdrawn over and over, draining both stablecoin reserves of the contract.
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: sources/Mosca_1962b3/Mosca.sol.
Key info#
| Loss | ~$19K combined — both stablecoin sides of the contract were drained. The PoC's USDC-only balance log shows 30 → 11,228 USDC (≈ +11,198 USDC) and the contract also bled ~7.9K of USDT in the same transaction. |
| Vulnerable contract | Mosca — 0x1962b3356122d6A56f978e112d14f5E23a25037D |
| Victim / drained reserves | Mosca's own USDC (0x8AC7…580d) and USDT/BSC-USD (0x55d398…7955) balances |
| Flash-loan source | PancakeSwap V3 pool 0x92b7807bF19b7DDdf89b706143896d05228f3121 (USDC side) |
| Attacker EOA | 0xb7d7240c207e094a9be802c0f370528a9c39fed5 |
| Attacker contract | 0x851288dcfb39330291015c82a5a93721cc92507a |
| Attack tx | 0x4e5bb7e3f552f5ee6ee97db9a9fcf07287aae9a1974e24999690855741121aff |
| Chain / block / date | BSC / 45,519,929 / 2025-01-06 (Joined event timestamp 1736140963) |
| Compiler | Solidity v0.8.20, optimizer 200 runs |
| Bug class | Broken accounting — withdrawal does not clear the credited balance (double/infinite withdrawal of unbacked internal credit) |
TL;DR#
Mosca is an MLM-style "citizenship" program that keeps per-user internal balances in three fields
— balance (MOSCA credit), balanceUSDT, balanceUSDC
(Mosca.sol:175-187). Users top up these internal balances
via join() and buy(), and cash out via exitProgram().
The exit path is broken:
exitProgram()→withdrawAll(addr)pays the useruser.balance + user.balanceUSDT + user.balanceUSDCin real USDC (or USDT if USDC is short) (Mosca.sol:918-934).- After paying,
exitProgram()resets onlyuser.balance = 0anduser.enterprise = false(Mosca.sol:823-824). It never zeroesbalanceUSDCorbalanceUSDT.
So the attacker calls buy(1000 USDC, fiat) once to mint a balanceUSDC credit of 985.22e18
(Mosca.sol:493-496), then loops join() + exitProgram().
Each exitProgram() keeps paying out the same ~986.78 USDC because the 985.22 balanceUSDC credit
is never deleted, while each cheap join() just re-tops the small balance field. The attacker pays
30 USDC per join (≈21 stays in the contract, 9 goes to the owner) and receives ~986.78 stablecoin each
exit — a ~32× return per cycle — repeated until the contract's USDC and USDT reserves are empty.
Background — what Mosca does#
Mosca (source) is a referral / matrix ("citizenship") program
denominated in BSC stablecoins. Each user has a User struct
(Mosca.sol:175-187):
struct User {
uint256 balance; // "MOSCA" credit (tier rewards, join leftovers, transfer fees)
uint256 balanceUSDT; // internal USDT credit
uint256 balanceUSDC; // internal USDC credit
uint256 nextDeadline; // subscription renewal deadline
...
address walletAddress;
bool enterprise;
}
The two real tokens the contract custodies:
| Symbol in code | BSC address | Real identity |
|---|---|---|
usdc | 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d | Binance-Peg USDC |
usdt | 0x55d398326f99059fF775485246999027B3197955 | BSC-USD / USDT |
The relevant entry points:
join(amount, refCode, fiat, enterpriseJoin)— pays aJOIN_FEE(28e18) to activate citizenship, pulling stablecoins in and crediting the leftover to the user'sbalance(Mosca.sol:349-475).buy(amount, buyFiat, fiat)— top up a fiat internal balance: pullsamountof the chosen stablecoin and creditsbaseAmount = amount·1000/1015tobalanceUSDC/balanceUSDT(Mosca.sol:476-512).exitProgram()— leave the program and withdraw all internal balances (Mosca.sol:805-832).
The on-chain economic constants at the fork block:
| Constant | Value |
|---|---|
JOIN_FEE | 28e18 |
TAX | 3e18 (non-enterprise join sends TAX*3 = 9e18 to owner) |
TRANSFER_FEE | 50 (bps, used by distributeFees) |
buy/join base divisor | ×1000/1015 (≈ a 1.5% intake skim) |
The vulnerable code#
1. buy() mints a large internal balanceUSDC credit#
function buy(uint256 amount, bool buyFiat, uint8 fiat) external nonReentrant{
require(refByAddr[msg.sender] != 0, "Cannot buy before activating citizenship");
User storage user = users[msg.sender];
uint256 baseAmount = (amount * 1000)/1015; // 1000e18 → 985.2216e18
...
if(!buyFiat){ user.balance += baseAmount; ... }
else {
if(fiat == 1) { user.balanceUSDT += baseAmount; ... }
else { user.balanceUSDC += baseAmount; // ← credit recorded here
emit BoughtUSDC(msg.sender, block.timestamp, baseAmount); }
}
...
require(usdc.transferFrom(msg.sender, address(this), amount), "Transfer failed"); // pulls 1000 USDC
distributeFees(msg.sender, amount);
}
2. exitProgram() pays everything out — but only resets balance#
function exitProgram() external nonReentrant {
require(!isBlacklisted[msg.sender], "Blacklisted user");
User storage user = users[msg.sender];
...
for (uint256 i = 0; i < rewardQueue.length; i++) {
address userAddr = rewardQueue[i];
if (userAddr == msg.sender) {
withdrawAll(msg.sender); // ← pays balance + balanceUSDT + balanceUSDC
refByAddr[userAddr] = 0;
referrers[user.refCode] = 0x...dEaD;
user.balance = 0; // ⚠️ ONLY balance is cleared
user.enterprise = false; // ⚠️ balanceUSDC / balanceUSDT NOT cleared
rewardQueue[i] = rewardQueue[rewardQueue.length - 1];
rewardQueue.pop();
emit ExitProgram(msg.sender, block.timestamp);
}
}
}
3. withdrawAll() sums all three balances and pays real tokens#
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; // ← sum of all credits
if(usdc.balanceOf(address(this)) >= balance){
usdc.transfer(user.walletAddress, balance); // pay in USDC if enough...
emit WithdrawAll(user.walletAddress, block.timestamp, balance, 2);
} else {
usdt.transfer(user.walletAddress, balance); // ...else pay in USDT
emit WithdrawAll(user.walletAddress, block.timestamp, balance, 1);
}
}
Root cause — why it was possible#
exitProgram() is supposed to be a one-time, fully-settling withdrawal: pay out everything, then zero
the account. But the settlement is incomplete.
withdrawAll()paysbalance + balanceUSDT + balanceUSDC, yetexitProgram()only writes backuser.balance = 0. ThebalanceUSDC/balanceUSDTfields keep their pre-withdrawal values forever.
That single missing reset turns a withdrawal into a reusable credit voucher:
- Unbacked persistence. The 985.22e18
balanceUSDCcredit minted by onebuy()survives everyexitProgram(). Each exit re-pays it out of the contract's shared reserves (other users' money), even though the attacker only ever deposited 1000 USDC once. exitProgram()is replayable. It is not gated to "must currently be in the queue" in a way that blocks re-entry —join()simply pushes the caller back ontorewardQueue(Mosca.sol:463), so the attacker can re-join → re-exit indefinitely.- Cheap re-qualification. A non-enterprise
join(30e18)costs only 30 USDC (21 to the contract, 9 to the owner) yet re-establishes citizenship and re-topsbalance(Mosca.sol:422-447). The persistentbalanceUSDCdoes the heavy lifting on every exit. - USDC→USDT fallback widens the blast radius. When the contract's USDC runs out,
withdrawAllsilently pays the same credit in USDT (Mosca.sol:923-928), so the bug drains both reserves, not just the one the attacker deposited.
The exact arithmetic per exit:
baseAmount(buy 1000) = 1000e18 * 1000/1015 = 985.221674876847290640e18 (→ balanceUSDC, persistent)
balance(after join 30)= 30e18*1000/1015 - 28e18 = 1.556650246305418719e18 (re-added every join)
withdrawAll payout = 1.556650246305418719e18 + 985.221674876847290640e18
= 986.778325123152709359e18 (matches the on-chain transfer to the wei)
Preconditions#
- Caller must hold a referral code (
refByAddr[msg.sender] != 0) before callingbuy()(Mosca.sol:477). The firstjoin()establishes this viagenerateRefCode()(Mosca.sol:465-467). - The contract must hold enough USDC/USDT reserves to satisfy the inflated payouts — it did (≈12.9K USDC + a USDT cushion at the fork block).
- Working capital to fund the single
buy(1000 USDC)and the repeatedjoin(30 USDC)calls. This is trivially flash-loanable: the PoC borrows 1000 USDC from a PancakeSwap V3 pool insideflash()/pancakeV3FlashCallbackand repays it (1000 + 0.1 fee) at the end (test/Mosca_exp.sol:44-73).
Attack walkthrough (with on-chain numbers from the trace)#
All figures are pulled directly from output.txt. Token decimals = 18 throughout.
| # | Step | Attacker pays | Mosca credits / pays | Net effect |
|---|---|---|---|---|
| 0 | Fund + first join(30, ref=0, fiat=2, ent=false) (outside flash) | 30 USDC (21 → contract, 9 → owner 0x2fE7…74a) | balance += 1.5567; gets a referral code | Citizenship activated; enables later buy(). |
| 1 | Flash-borrow 1000 USDC from Pancake V3 pool 0x92b7…3121 | — | 1000 USDC sent to attacker contract | Working capital for the cycle. |
| 2 | buy(1000, buyFiat=true, fiat=2) | 1000 USDC pulled into Mosca | balanceUSDC += 985.2217 (event BoughtUSDC) | The persistent, unbacked credit is minted. |
| 3 | exitProgram() #1 | — | pays 986.778325 USDC to attacker; resets only balance | balanceUSDC survives → 985.22 still credited. |
| 4 | Loop ×20: join(30) then exitProgram() | 30 USDC per join | pays 986.778325 stablecoin per exit | Each cycle nets ≈ +956.78 to the attacker. |
| 5 | Repay flash | 1000 + 0.1 USDC to pool | — | Loan closed. |
Trace facts confirming the mechanism:
- 21
joins, 1buy, 21exitProgrampayouts of exactly986778325123152709359each. - The contract's USDC ran dry mid-loop, so 13 exits paid in USDC (
0x8AC7…580d) and the remaining 8 exits paid in USDT (0x55d398…7955) via thewithdrawAllfallback — draining both reserves. - Final attacker USDC balance (from the PoC's
balanceLog): 11,228.018226600985221667 USDC, up from the 30 USDC starting stake.
Profit / loss accounting#
The PoC's
balanceLogmodifier only tracks the funding token (USDC), so it reports the USDC leg. The contract additionally lost7.9K USDT (8 exits × 986.78) paid out through the fallback branch, which is why the publicly reported total loss is **$19K** across both stablecoins.
| Item | USDC |
|---|---|
| Attacker USDC before | 30.000000 |
| Attacker USDC after | 11,228.018227 |
| USDC profit (measured) | +11,198.018227 |
| USDT additionally drained (8 × 986.78, fallback branch) | ≈ 7,894 USDT |
| Combined reported loss | ≈ $19K |
The flash loan of 1000 USDC is fully repaid (1000 + 0.1 fee), so the attacker's only real cost is the gas plus the net of joins vs. payouts — which is hugely positive.
Diagrams#
Sequence of the attack#
Account-state evolution (why each exit keeps paying)#
The flaw inside the exit path#
Why each magic number#
buy(1000 USDC): mintsbalanceUSDC = 1000·1000/1015 = 985.2217e18. This is the persistent, unbacked credit that every subsequentexitProgram()re-pays. Onebuyis enough — it is never consumed.join(30 USDC): the minimum cheap way to re-activate citizenship after each exit.JOIN_FEEis28e18; with the×1000/1015intake skim,30e18leavesbalance += 1.5567e18and pushes the attacker back ontorewardQueueso the nextexitProgram()finds them. Cost: 30 USDC (9 of which goes to the owner), payout: 986.78 stablecoin — a ~32× per-cycle return.- 20 loop iterations (21 exits total): enough cycles to exhaust the contract's USDC reserve and spill into the USDT reserve via the fallback, maximizing extraction in one transaction.
- Flash loan of 1000 USDC: only needed so the attacker can fund the single
buy()without committing its own capital; repaid (1000 + 0.1 fee) before the transaction ends.
Remediation#
- Clear all balances on exit. In
exitProgram(), afterwithdrawAll, setuser.balanceUSDT = 0anduser.balanceUSDC = 0in addition touser.balance = 0. This single fix eliminates the reusable-credit bug. (Equivalently, havewithdrawAllitself zero each field as it pays it.) - Settle from per-user funds, not shared reserves. Internal credits must be backed 1:1 by the
user's own deposits and accounted globally (e.g., a
totalLiabilitiesfigure) so a single user can never withdraw more than they put in. Right nowbuy()credits985.22for a1000deposit but the payout is repeatable, so the realized liability is unbounded. - Make
exitProgram()non-replayable. Require the user to be currently active before paying (e.g., arequire(user.walletAddress == msg.sender && !exited[msg.sender])latch), and do not allowjoin()to silently re-qualify an account that still holds stale fiat credits. - Don't silently switch payout token. The USDC→USDT fallback in
withdrawAll(Mosca.sol:923-928) lets one accounting error drain a second, unrelated reserve. Pay each credit only in its own token, and revert if the matching reserve is insufficient instead of substituting another asset. - Add a global invariant check. Assert that the sum of all outstanding internal balances never exceeds the contract's actual token holdings; any operation that would break it should revert.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to whole-compile under forge test):
_shared/run_poc.sh 2025-01-Mosca_exp -vvvvv
- RPC: a BSC archive endpoint is required (fork block 45,519,929).
foundry.tomluseshttps://bsc-mainnet.public.blastapi.io, which serves historical state at that block; most public BSC RPCs prune it and fail withheader not found/missing trie node. - Result:
[PASS] testExploit()with the attacker's USDC balance growing 30 → 11,228.
Expected tail:
[PASS] testExploit() (gas: 3612361)
Attacker Before exploit USDC Balance: 30.000000000000000000
Attacker After exploit USDC Balance: 11228.018226600985221667
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: DeFiHackLabs — Mosca, BSC, ~$19K. Vulnerable source verified on BscScan at
0x1962b3356122d6A56f978e112d14f5E23a25037D.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2025-01-Mosca_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Mosca_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Mosca Exploit".
- 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.