https://github.com/hackenproof-public/rain-contracts
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:
head from 0.99 ether to 1.0 ether in an unchecked blockfirstSellOrderPrice[option]head remains <= 0.99 etherAfter this corruption, every subsequent placeBuyOrder call will:
head = 1.0 ether from firstSellOrderPrice[option]head <= price && amount > 1price is 0.99 ether (enforced at line 857-861), the condition 1.0 <= 0.99 is falseThis creates a permanent DoS because:
enterOption) which also has the same vulnerabilityThe 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
}
Prerequisites:
Reproduction Steps:
Establish initial orderbook state at maximum tick
placeSellOrder(option=1, price=0.99 ether, votes=1000)firstSellOrderPrice[1] remains at its current value (e.g., 0.98 ether)Attacker self-matches to clear the 0.99 ether tick
placeBuyOrder(option=1, price=0.99 ether, amount=corresponding_baseToken)placeBuyOrder loopMatching loop reaches 0.99 ether tick and executes order
head increments from 0.98 -> 0.99 etherhead = 0.99 ether, attacker's sell order is found_executeSellOrder executes, consuming all 1000 votessellOrders[1][0.99 ether] becomes emptyPointer corruption occurs (THE VULNERABILITY)
if (linkedListSell.isEmpty()) evaluates TRUEhead += TICK_SPACING0.99 ether + 0.01 ether = 1.0 etherfirstSellOrderPrice[1] = 1.0 etherVerify pointer corruption persists
firstSellOrderPrice[1] from contract storageLegitimate user attempts to place buy order (DoS demonstrated)
placeSellOrder(option=1, price=0.50 ether, votes=5000)placeBuyOrder(option=1, price=0.50 ether, amount=2500)Analyze why matching fails
placeBuyOrder loads: head = firstSellOrderPrice[1] = 1.0 etherhead <= price && amount > 11.0 ether <= 0.50 ether && amount > 1 = FALSEbuyOrders book but never match existing sellsConfirm complete DoS on buy-side matching
placeBuyOrder call can match ANY of themhead = 1.0 etherenterOption AMM (which has same vulnerability)Direct Loss Calculation:
In a pool with 500,000 tokens total value and active orderbook:
Normal operation:
After pointer corruption:
enterOption)Attack economics:
Who Gets Hurt:
placeBuyOrder, must wait for placeSellOrder matchesReal-World Scenario:
A high-volume prediction market with 5M USD pool:
Attack execution:
Cascading effects:
Code References:
placeBuyOrder unchecked block (RainPool.sol:882-884)head <= price always false when head = 1.0 ether (RainPool.sol:865)enterOption lines 383-384, 437placeSellOrder unchecked decrement (RainPool.sol:820-825)Test Environment Setup:
I created a test environment with:
Exploitation Sequence:
Step 1: Establish sell order at maximum valid price
Attacker calls placeSellOrder(option=1, price=0.99 ether, votes=1000)
Result:
sellOrders[1][0.99e18]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.98e18head = 0.98e18, no orders at this tick, increment to 0.99e18head = 0.99e18, attacker's order found_executeSellOrder called: 1000 votes transferred, order consumedif (linkedListSell.isEmpty()) = TRUEStep 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)firstSellOrderPrice[1] = 1.0e18Step 4: Verify pointer corruption in storage
Read contract state: firstSellOrderPrice[1]
Result:
Step 5: Legitimate user creates sell order (normal operation)
Alice calls placeSellOrder(option=1, price=0.50 ether, votes=5000)
Result:
sellOrders[1][0.50e18]placeBuyOrderStep 6: Attempt buy order match (DoS DEMONSTRATED)
Bob calls placeBuyOrder(option=1, price=0.50 ether, amount=2500)
Expected behavior:
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:
buyOrders[1][0.50e18] bookExecuteSellOrder event emittedStep 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]: EmptysellOrders[1][0.30e18]: EmptyAttempt multiple buy orders at different prices:
placeBuyOrder(1, 0.40e18, 2000): NO MATCH (loop skipped)placeBuyOrder(1, 0.30e18, 1500): NO MATCH (loop skipped)placeBuyOrder(1, 0.99e18, 5000): NO MATCH (loop skipped)Result:
head = 1.0 ether exceeds every possible valid price inputVerification Results:
After the attack sequence:
firstSellOrderPrice[1]: 1.0e18 (CORRUPTED, exceeds 0.99e18 maximum)enterOption AMM (also vulnerable to same corruption)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:
Symmetric vulnerability in placeSellOrder:
firstBuyOrderPrice can underflow to 0 or wrap to huge valueTest Command:
forge test --match-test testPointerOverflowDoS -vvvv