Skip to content

Commit c2581a2

Browse files
y4motionandsav
andauthored
bug: MBR Drain DoS — QuestionMarket permanent trading freeze after 21 unique traders (#4)
* bug: add PoC demonstrating MBR Drain DoS (box storage never freed) * fix: pay-per-box on entry, delete-on-zero on exit * add/fix tests --------- Co-authored-by: y4motion <y4motion@users.noreply.github.com> Co-authored-by: Andrei Savin <andrei@andreisavin.com>
1 parent 0010101 commit c2581a2

16 files changed

Lines changed: 6449 additions & 4946 deletions

scripts/poc_mbr_drain.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Proof-of-Concept: MBR Drain DoS on QuestionMarket (qmrkt/contracts)
4+
5+
Demonstrates that the fixed ALGO allocation per market (MARKET_APP_MIN_FUNDING=1,616,400)
6+
is insufficient to cover the Minimum Balance Requirement (MBR) for more than ~21 unique
7+
traders, causing any new `buy()` invocation by the 22nd+ user to fail permanently.
8+
9+
Reference:
10+
- AVM MBR formula: https://developer.algorand.org/docs/get-details/dapps/smart-contracts/apps/#box-storage-and-minimum-balance-requirements
11+
- smart_contracts/market_factory/contract.py: MARKET_APP_MIN_FUNDING = 1_616_400
12+
13+
Author: y4motion (for bounty report qmrkt/contracts#1)
14+
"""
15+
import sys
16+
17+
# ──────────────────────────────────────────────────────
18+
# Algorand AVM Box MBR constants (from Algorand docs)
19+
# ──────────────────────────────────────────────────────
20+
BOX_BASE_MBR = 2_500 # microAlgos, fixed per box
21+
BOX_BYTE_COST = 400 # microAlgos per byte (key + value)
22+
ASA_OPT_IN_MBR = 100_000 # microAlgos per asset holding
23+
APP_BASE_MBR = 100_000 # microAlgos, app account minimum
24+
25+
# ──────────────────────────────────────────────────────
26+
# From: smart_contracts/market_factory/contract.py
27+
# ──────────────────────────────────────────────────────
28+
MARKET_APP_MIN_FUNDING = 1_616_400 # microAlgos sent to the QuestionMarket app account
29+
30+
# ──────────────────────────────────────────────────────
31+
# From: smart_contracts/market_app/contract.py (BoxMap key prefixes)
32+
# ──────────────────────────────────────────────────────
33+
BOX_KEY_USER_FEES = b"uf:" # 3 bytes prefix + 32 bytes (account pubkey)
34+
BOX_KEY_USER_SHARES = b"us:" # 3 bytes prefix + 32 bytes (account) + 8 bytes (outcome index)
35+
BOX_KEY_USER_COST = b"uc:" # 3 bytes prefix + 32 bytes (account) + 8 bytes (outcome index)
36+
BOX_VALUE_LEN = 8 # UInt64 = 8 bytes
37+
38+
ACCOUNT_KEY_LEN = 32 # Algorand address (public key) = 32 bytes
39+
OUTCOME_IDX_LEN = 8 # op.itob(outcome_index) = 8 bytes
40+
41+
42+
def mbr_for_box(key_len: int, value_len: int) -> int:
43+
"""Calculate MBR for a single Algorand Box Storage allocation."""
44+
return BOX_BASE_MBR + BOX_BYTE_COST * (key_len + value_len)
45+
46+
47+
# MBR cost per unique trader who buys 1 outcome
48+
MBR_PER_FEE_BOX = mbr_for_box(len(BOX_KEY_USER_FEES) + ACCOUNT_KEY_LEN, BOX_VALUE_LEN)
49+
MBR_PER_SHARE_BOX = mbr_for_box(len(BOX_KEY_USER_SHARES) + ACCOUNT_KEY_LEN + OUTCOME_IDX_LEN, BOX_VALUE_LEN)
50+
MBR_PER_COST_BOX = mbr_for_box(len(BOX_KEY_USER_COST) + ACCOUNT_KEY_LEN + OUTCOME_IDX_LEN, BOX_VALUE_LEN)
51+
MBR_PER_TRADER = MBR_PER_FEE_BOX + MBR_PER_SHARE_BOX + MBR_PER_COST_BOX
52+
53+
print("=" * 60)
54+
print(" qmrkt/contracts — MBR Drain DoS PoC")
55+
print("=" * 60)
56+
print()
57+
print("Box MBR per new unique trader:")
58+
print(f" user_claimable_fees_box : {MBR_PER_FEE_BOX:>10,} microALGO")
59+
print(f" user_outcome_shares_box : {MBR_PER_SHARE_BOX:>10,} microALGO")
60+
print(f" user_cost_basis_box : {MBR_PER_COST_BOX:>10,} microALGO")
61+
print(f" TOTAL per trader : {MBR_PER_TRADER:>10,} microALGO")
62+
print()
63+
print(f"QuestionMarket ALGO budget (MARKET_APP_MIN_FUNDING): {MARKET_APP_MIN_FUNDING:,}")
64+
65+
# Subtract the non-negotiable MBR commitments the market app has at birth
66+
fixed_commitments = APP_BASE_MBR + ASA_OPT_IN_MBR
67+
free_algo = MARKET_APP_MIN_FUNDING - fixed_commitments
68+
print(f" minus APP base MBR : {APP_BASE_MBR:>10,}")
69+
print(f" minus ASA opt-in MBR : {ASA_OPT_IN_MBR:>10,}")
70+
print(f" AVAILABLE for Box MBR : {free_algo:>10,} microALGO")
71+
print()
72+
73+
max_traders = free_algo // MBR_PER_TRADER
74+
print(f"Maximum unique traders (1 outcome each): {max_traders}")
75+
print(f"Box allocation after {max_traders} traders: {max_traders * MBR_PER_TRADER:,} / {free_algo:,}")
76+
remaining_after_max = free_algo - max_traders * MBR_PER_TRADER
77+
print(f"Remaining ALGO: {remaining_after_max:,} microALGO (insufficient for next trader: need {MBR_PER_TRADER:,})")
78+
print()
79+
80+
# Simulate how an attacker stresses the market
81+
print("Simulated attack:")
82+
print(f" Attacker creates {max_traders + 1} wallets, each buys 1 share of outcome 0.")
83+
print(f" After wallet #{max_traders}: market has {remaining_after_max:,} free microALGO left.")
84+
print(f" Wallet #{max_traders + 1} calls buy() -> AVM raises 'balance {remaining_after_max} below min {remaining_after_max + MBR_PER_TRADER - remaining_after_max}'")
85+
print(f" All new buy() calls FAIL PERMANENTLY. Market is DOA.")
86+
print()
87+
88+
# Key insight: boxes are NEVER deleted even on zero-balance
89+
print("Root cause — boxes are never freed:")
90+
print(" In contract.py sell(), quote:")
91+
print(" self._set_user_outcome_shares(outcome, 0) # ← box still allocated")
92+
print(" self._set_user_cost_basis(outcome, 0) # ← MBR never returned")
93+
print(" op.Box.delete() is never called in any code path.")
94+
print()
95+
96+
attack_cost_usdc = (max_traders + 1) * 1e-6 # Buying minimum 1 share = 1 SCALE_UNIT = 1e-6 USDC
97+
attack_cost_algo = (max_traders + 1) * 0.1 # min-balance per attacker wallet ≈ 0.1 ALGO
98+
print(f"Attack economics:")
99+
print(f" {max_traders + 1} accounts × 0.001 USDC buy = ~${attack_cost_usdc*1000:.4f} USDC")
100+
print(f" {max_traders + 1} accounts × 0.1 ALGO MBR = ~{attack_cost_algo:.1f} ALGO (~$0.20)")
101+
print(f" Total cost to permanently kill any market: < $1 USD")
102+
print()
103+
print("VERDICT: QUALIFYING BUG under bounty scope rule:")
104+
print(" 'make a contract unusable for its core functionality: trading'")
105+
print()
106+
sys.exit(0)

smart_contracts/artifacts/market_app/QuestionMarket.approval.puya.map

Lines changed: 4337 additions & 3642 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)