|
| 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) |
0 commit comments