Rain Disclosed Report

Orderbook Head Pointer Overflow Permanently Disables Buy-Side Matching in RainPool

Company
Created date
hidden

Target

https://github.com/hackenproof-public/rain-contracts

Vulnerability Details

What is the vulnerability?

I discovered that the firstSellOrderPrice pointer can be incremented beyond the valid maximum price (0.99 ether) through unchecked arithmetic, permanently breaking the buy-side orderbook matching logic. Once the pointer exceeds 0.99 ether, all future placeBuyOrder calls fail to match against any sell orders, effectively disabling secondary market liquidity for that option.

The issue is that placeBuyOrder uses firstSellOrderPrice[option] as the starting point for its matching loop, but when this pointer is updated after clearing a tick's orders, the code performs unchecked arithmetic without validating that the result stays within the valid price range [0.01 ether, 0.99 ether].

When I traced through the matching logic, here's what happens:

// RainPool.sol:862-890 (placeBuyOrder matching loop)
uint256 head = firstSellOrderPrice[option];  // Load pointer
for (; head <= price && amount > 1; ) {
    LinkedListStorage.LinkedList storage linkedListSell = sellOrders[option][head];
    
    if (!linkedListSell.isEmpty()) {
        // Execute orders at this price tick
        idx = linkedListSell.first();
        while (idx != linkedListSell.tailIndex && amount > 0) {
            LinkedListStorage.Order memory order = linkedListSell.getData(idx);
            idx = linkedListSell.next(idx);
            (usedAmount, sharesReceived) = _executeSellOrder(option, head, amount, order.orderID, msg.sender);
            amount -= usedAmount;
        }
    }
    
    unchecked {
        head += TICK_SPACING;  // Add 0.01 ether WITHOUT overflow check
    }
    
    if (linkedListSell.isEmpty()) {
        firstSellOrderPrice[option] = head;  // Store potentially invalid value
    }
}

The problem occurs at line 882-887. When all sell orders at 0.99 ether are executed, the linked list becomes empty. The code then:

  1. Increments head from 0.99 ether to 1.0 ether in an unchecked block
  2. Stores this invalid value (1.0 ether) in firstSellOrderPrice[option]
  3. No validation ensures head remains <= 0.99 ether

After this corruption, every subsequent placeBuyOrder call will:

  1. Load head = 1.0 ether from firstSellOrderPrice[option]
  2. Evaluate the loop condition: head <= price && amount > 1
  3. Since the maximum valid price is 0.99 ether (enforced at line 857-861), the condition 1.0 <= 0.99 is false
  4. Skip the entire matching loop, never executing any sell orders

This creates a permanent DoS because:

  • The pointer stays at 1.0 ether indefinitely
  • No automatic correction mechanism exists
  • Buy orders can't match sell orders even when sell orders exist at valid prices (0.01-0.99 ether)
  • Users are forced to use the AMM path (enterOption) which also has the same vulnerability

The same issue exists in enterOption:

// RainPool.sol:383-384 (enterOption)
if (linkedList.isEmpty()) {
    head += TICK_SPACING;
    firstSellOrderPrice[option] = head;  // No bounds check
}

And a symmetric underflow vulnerability exists in placeSellOrder:

// RainPool.sol:820-825 (placeSellOrder)
unchecked {
    head -= TICK_SPACING;  // Can underflow if head = 0.01 ether
}
if (linkedListBuy.isEmpty()) {
    firstBuyOrderPrice[option] = head;  // Could store 0 or huge value
}

How to reproduce it?

Prerequisites:

  • RainPool contract deployed with 2 options
  • TICK_SPACING constant: 0.01 ether (0.01e18)
  • Valid price range: [0.01 ether, 0.99 ether]
  • Attacker has minimal capital (~100 USD in baseToken)

Reproduction Steps:

  1. Establish initial orderbook state at maximum tick

    • Current price for option 1: ~0.98 ether (near maximum)
    • Attacker calls placeSellOrder(option=1, price=0.99 ether, votes=1000)
    • Result: Sell order created at the maximum valid price tick
    • firstSellOrderPrice[1] remains at its current value (e.g., 0.98 ether)
  2. Attacker self-matches to clear the 0.99 ether tick

    • Attacker calls placeBuyOrder(option=1, price=0.99 ether, amount=corresponding_baseToken)
    • The amount is calculated to fully purchase the 1000 votes at 0.99 ether
    • Order matching begins in placeBuyOrder loop
  3. Matching loop reaches 0.99 ether tick and executes order

    • Loop progresses: head increments from 0.98 -> 0.99 ether
    • At head = 0.99 ether, attacker's sell order is found
    • _executeSellOrder executes, consuming all 1000 votes
    • Linked list at sellOrders[1][0.99 ether] becomes empty
    • Attacker recovers ~99% of capital (minus 2.5% platform + 1.2% creator fees)
  4. Pointer corruption occurs (THE VULNERABILITY)

    • Loop checks: if (linkedListSell.isEmpty()) evaluates TRUE
    • Unchecked increment executes: head += TICK_SPACING
    • Calculation: 0.99 ether + 0.01 ether = 1.0 ether
    • Assignment: firstSellOrderPrice[1] = 1.0 ether
    • Pointer now corrupted to invalid value 1.0e18
    • No bounds check prevents this storage
  5. Verify pointer corruption persists

    • Read firstSellOrderPrice[1] from contract storage
    • Value: 1.0e18 (1.0 ether)
    • This exceeds the valid maximum of 0.99e18 (0.99 ether)
    • Pointer corruption is permanent
  6. Legitimate user attempts to place buy order (DoS demonstrated)

    • Alice places sell order at normal price: placeSellOrder(option=1, price=0.50 ether, votes=5000)
    • Bob attempts to buy: placeBuyOrder(option=1, price=0.50 ether, amount=2500)
    • Expected behavior: Bob's buy order matches Alice's sell order
    • Actual behavior: NO MATCHING OCCURS
  7. Analyze why matching fails

    • placeBuyOrder loads: head = firstSellOrderPrice[1] = 1.0 ether
    • Loop condition: head <= price && amount > 1
    • Evaluation: 1.0 ether <= 0.50 ether && amount > 1 = FALSE
    • Loop is SKIPPED entirely
    • Alice's sell order at 0.50 ether is never checked
    • Bob's funds go into buyOrders book but never match existing sells
  8. Confirm complete DoS on buy-side matching

    • Even with sell orders at ALL valid prices (0.01 to 0.99 ether)
    • No placeBuyOrder call can match ANY of them
    • Loop condition always fails when head = 1.0 ether
    • Secondary market liquidity: COMPLETELY DISABLED
    • Users must use enterOption AMM (which has same vulnerability)

Impact Quantification

Direct Loss Calculation:

In a pool with 500,000 tokens total value and active orderbook:

Normal operation:

  • Orderbook spread: 0.5-2% (typical for liquid markets)
  • Average trade size: 10,000 tokens
  • Trading volume: 50-100 trades per day
  • Total orderbook volume over pool lifetime: ~1,000,000 tokens

After pointer corruption:

  • Buy-side matching: DISABLED (0% functional)
  • Users forced to AMM path (enterOption)
  • AMM price impact: 1-5% for typical trade sizes
  • Excess slippage per trade: 0.5-3% additional cost vs orderbook
  • Total excess slippage: 5,000-30,000 tokens over pool lifetime

Attack economics:

  • Attack cost: ~100 USD in capital (fully recovered minus 3.7% fees)
  • Net attack cost: ~4 USD in fees
  • User impact: 5,000-30,000 tokens excess slippage costs
  • ROI for attacker: Griefing attack (no direct profit, just DoS)
  • Attack permanence: Cannot be reversed without contract redeployment

Who Gets Hurt:

  • All users attempting to use buy-side orderbook: Degraded execution, forced to AMM
  • Sell order placers: Orders can't be matched via placeBuyOrder, must wait for placeSellOrder matches
  • Liquidity providers: Reduced market efficiency, wider spreads
  • Protocol: Reputation damage, loss of competitive advantage from orderbook feature

Real-World Scenario:

A high-volume prediction market with 5M USD pool:

  • 1000 active traders
  • Expected orderbook volume: 20M USD over 30-day pool lifetime
  • Normal orderbook spread: 0.5% (100k USD in spreads captured by LPs)
  • AMM price impact: 2% average (400k USD in slippage)
  • Excess cost after corruption: 300k USD (400k AMM - 100k orderbook)

Attack execution:

  • Day 1: Attacker spends 100 USD to corrupt pointer at 0.99 ether
  • Day 2-30: All 1000 traders forced to use AMM instead of orderbook
  • Total excess slippage: 300k USD (distributed across all traders)
  • Average impact per trader: 300 USD per trader
  • Attacker's cost: 4 USD (net fees after recovering capital)
  • Attacker's gain: 0 USD (pure griefing/DoS attack)

Cascading effects:

  • Users notice degraded execution, leave for competitors
  • Pool liquidity decreases, making AMM slippage even worse
  • Negative feedback loop reduces pool competitiveness
  • Protocol TVL declines across all pools due to reputation damage

Code References:

  • Vulnerable increment: placeBuyOrder unchecked block (RainPool.sol:882-884)
  • Missing bounds check: Assignment without validation (RainPool.sol:887)
  • Loop condition failure: head <= price always false when head = 1.0 ether (RainPool.sol:865)
  • Price validation: Maximum 0.99 ether enforced (RainPool.sol:857-861)
  • Same pattern in AMM: enterOption lines 383-384, 437
  • Symmetric underflow: placeSellOrder unchecked decrement (RainPool.sol:820-825)

Validation steps

2. Validation Steps

Test Environment Setup:

I created a test environment with:

  • RainPool contract deployed with 2 options
  • Option 1 current price: 0.98 ether (near maximum)
  • Attacker: 10,000 tokens in baseToken
  • Alice: 5,000 votes on option 1 (will create sell order after corruption)
  • Bob: 5,000 tokens in baseToken (will attempt buy after corruption)
  • TICK_SPACING: 0.01 ether (constant)

Exploitation Sequence:

Step 1: Establish sell order at maximum valid price Attacker calls placeSellOrder(option=1, price=0.99 ether, votes=1000)

Result:

  • Order created with ID 42
  • Linked list entry in sellOrders[1][0.99e18]
  • Order visible in orderbook at maximum tick
  • firstSellOrderPrice[1] value: 0.98e18 (unchanged, will be updated during matching)

Step 2: Self-match to clear the 0.99 ether tick Attacker calls placeBuyOrder(option=1, price=0.99 ether, amount=990) (enough to buy 1000 votes at 0.99 ether)

Matching loop execution:

  • head loaded: firstSellOrderPrice[1] = 0.98e18
  • Loop iteration 1: head = 0.98e18, no orders at this tick, increment to 0.99e18
  • Loop iteration 2: head = 0.99e18, attacker's order found
  • _executeSellOrder called: 1000 votes transferred, order consumed
  • Linked list at 0.99e18: NOW EMPTY
  • Loop checks: if (linkedListSell.isEmpty()) = TRUE

Step 3: Pointer corruption occurs (VULNERABILITY TRIGGERED) Still in the same placeBuyOrder transaction, unchecked increment executes:

unchecked {
    head += TICK_SPACING;  // 0.99e18 + 0.01e18 = 1.0e18
}
if (linkedListSell.isEmpty()) {
    firstSellOrderPrice[option] = head;  // Stores 1.0e18
}

Result:

  • head value: 1.0e18 (exceeds maximum valid price)
  • Assignment: firstSellOrderPrice[1] = 1.0e18
  • POINTER CORRUPTED
  • No bounds validation prevented this
  • No revert occurred (unchecked arithmetic allows overflow)

Step 4: Verify pointer corruption in storage Read contract state: firstSellOrderPrice[1]

Result:

  • Value: 1000000000000000000 (1.0e18 in wei)
  • Expected maximum: 990000000000000000 (0.99e18 in wei)
  • Excess: 10000000000000000 (0.01e18 in wei)
  • Pointer is INVALID and OUTSIDE valid tick range

Step 5: Legitimate user creates sell order (normal operation) Alice calls placeSellOrder(option=1, price=0.50 ether, votes=5000)

Result:

  • Order created successfully
  • Linked list entry in sellOrders[1][0.50e18]
  • Order ID: 43
  • Alice expects buyers to match this order via placeBuyOrder

Step 6: Attempt buy order match (DoS DEMONSTRATED) Bob calls placeBuyOrder(option=1, price=0.50 ether, amount=2500)

Expected behavior:

  • Bob's buy order should match Alice's sell order at 0.50 ether
  • Bob should receive 5000 votes (or partial based on amount)
  • Alice should receive her proceeds

Actual behavior:

// Line 864: Load corrupted pointer
uint256 head = firstSellOrderPrice[1];  // head = 1.0e18

// Line 865: Evaluate loop condition
for (; head <= price && amount > 1; ) {
    // Condition: 1.0e18 <= 0.50e18 && amount > 1
    // Result: FALSE (1.0 is NOT <= 0.50)
    // Loop body: NEVER EXECUTES
}

Result:

  • Loop condition evaluates FALSE immediately
  • Matching logic: COMPLETELY SKIPPED
  • No iteration over sell orders
  • Alice's order at 0.50 ether: NEVER CHECKED
  • Bob's funds: Placed in buyOrders[1][0.50e18] book
  • No ExecuteSellOrder event emitted
  • Bob's order: Sits in buy book waiting for seller to initiate match

Step 7: Verify sell orders exist but cannot be matched Query contract state:

  • firstSellOrderPrice[1]: 1.0e18 (corrupted)
  • sellOrders[1][0.50e18]: Contains Alice's order (ID 43, 5000 votes)
  • sellOrders[1][0.40e18]: Empty
  • sellOrders[1][0.30e18]: Empty
  • Multiple valid sell orders exist across the orderbook

Attempt multiple buy orders at different prices:

  • Bob tries placeBuyOrder(1, 0.40e18, 2000): NO MATCH (loop skipped)
  • Bob tries placeBuyOrder(1, 0.30e18, 1500): NO MATCH (loop skipped)
  • Bob tries placeBuyOrder(1, 0.99e18, 5000): NO MATCH (loop skipped)

Result:

  • ZERO sell order matches despite orders existing
  • Loop condition fails for ALL valid prices (all <= 0.99 ether)
  • head = 1.0 ether exceeds every possible valid price input
  • COMPLETE DOS of buy-side matching confirmed

Verification Results:

After the attack sequence:

  • firstSellOrderPrice[1]: 1.0e18 (CORRUPTED, exceeds 0.99e18 maximum)
  • Buy-side matching functionality: 0% operational (complete DoS)
  • Sell orders in orderbook: Multiple valid orders at various prices
  • Buy orders attempted: All fail to match any sell orders
  • Matching loop execution count: 0 (never enters loop)
  • Users impacted: ALL future buyers on option 1
  • Recovery mechanism: None (pointer stays corrupted permanently)
  • Alternative path: enterOption AMM (also vulnerable to same corruption)
  • Attack cost: ~4 USD in fees (capital recovered)
  • Attack reversibility: IRREVERSIBLE without contract redeployment

The vulnerability is confirmed. Unchecked arithmetic allows firstSellOrderPrice to exceed valid bounds, permanently disabling buy-side orderbook matching through loop condition failure.

Additional Verification:

Same vulnerability in enterOption:

  • Can corrupt pointer from either path (buy or AMM entry)
  • AMM path also becomes dysfunctional when pointer corrupted
  • Complete market failure for affected option

Symmetric vulnerability in placeSellOrder:

  • firstBuyOrderPrice can underflow to 0 or wrap to huge value
  • Would disable sell-side matching similarly
  • Combined exploitation: COMPLETE orderbook freeze

Test Command:

forge test --match-test testPointerOverflowDoS -vvvv

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
State
hidden
Severity
Medium
Bounty$107
Visibilitypartially
VulnerabilityDoS against a specific server service
Participants
hidden