sherlock-audit / 2024-05-pooltogether-judging

13 stars 8 forks source link

MiloTruck - Inconsistent result from `DrawAccumulator.binarySearch()` causes prize pool calculations to be incorrect #32

Closed sherlock-admin2 closed 4 months ago

sherlock-admin2 commented 4 months ago

MiloTruck

high

Inconsistent result from DrawAccumulator.binarySearch() causes prize pool calculations to be incorrect

Summary

The result returned from DrawAccumulator.binarySearch() is not consistent, causing DrawAccumulator.getDisbursedBetween() to wrongly exclude the start and/or end draw ID under certain conditions. This causes all prize calculation in the prize pool to be incorrect.

Vulnerability Detail

In DrawAccumulator.binarySearch(), the terminating condition (ie. the condition at which the binary search stops) is as shown:

DrawAccumulatorLib.sol#L257-L262

      bool targetAtOrAfter = beforeOrAtDrawId <= _targetDrawId;

      // Check if we've found the corresponding Observation.
      if (targetAtOrAfter && _targetDrawId <= afterOrAtDrawId) {
        break;
      }

As seen from above, the terminating condition is beforeOrAtDrawId <= targetId <= afterOrAtDrawId. However, when targetId is in the draw ring buffer, the result returned by binarySearch() may be inconsistent. For example:

The result returned depends on the length of the draw ring buffer, more specifically, the difference between _oldestIndex and _newestIndex. This is demonstrated in the POC below.

binarySearch() is used in DrawAccumulator.getDisbursedBetween() to find the total balance between a start and end draw, inclusive of both draws:

DrawAccumulatorLib.sol#L183-L190

        (, , , uint24 afterOrAtDrawId) = binarySearch(
          _accumulator.drawRingBuffer,
          oldestIndex,
          newestIndex,
          ringBufferInfo.cardinality,
          _startDrawId
        );
        atOrAfterStart = _accumulator.observations[afterOrAtDrawId];

Since afterOrAtDrawId is taken as the start draw without checking if beforeOrAtDrawId is equal to _startDrawId, it is possible for afterOrAtDrawId to be greater than _startDrawId when _startDrawId is present in drawRingBuffer. For example:

Similarly, the ending draw returned could wrongly exclude _endDrawId even when it is present in drawRingBuffer, since beforeOrAtDrawId is always used:

DrawAccumulatorLib.sol#L201-L208

        (, uint24 beforeOrAtDrawId, , ) = binarySearch(
          _accumulator.drawRingBuffer,
          oldestIndex,
          newestIndex,
          ringBufferInfo.cardinality,
          _endDrawId
        );
        atOrBeforeEnd = _accumulator.observations[beforeOrAtDrawId];

As a result, getDisbursedBetween() could return a balance far smaller than the actual disbursed balance between _startDrawId and _endDrawId.

The following POC demonstrates how the results returned by binarySearch() are inconsistent:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import "src/libraries/DrawAccumulatorLib.sol";

contract BinarySearchTest is Test { 
    uint24[MAX_OBSERVATION_CARDINALITY] drawRingBuffer;

    function testBinarySearchInconsistent() public {
        // Buffer of length 3
        drawRingBuffer[0] = 1;
        drawRingBuffer[1] = 2;
        drawRingBuffer[2] = 3;

        // BInary search returns (2, 3)
        (, uint24 beforeOrAtDrawId,, uint24 afterOrAtDrawId) = DrawAccumulatorLib.binarySearch(
            drawRingBuffer, 
            0, // _oldestIndex
            2, // _newestIndex
            MAX_OBSERVATION_CARDINALITY, 
            2  // _targetDrawId
        );
        console2.log(beforeOrAtDrawId, afterOrAtDrawId);

        // Buffer of length 5
        drawRingBuffer[3] = 4;
        drawRingBuffer[4] = 5;

        // Binary search returns (1, 2)
        (, beforeOrAtDrawId,, afterOrAtDrawId) = DrawAccumulatorLib.binarySearch(
            drawRingBuffer, 
            0, // _oldestIndex
            4, // _newestIndex
            MAX_OBSERVATION_CARDINALITY, 
            2  // _targetDrawId
        );
        console2.log(beforeOrAtDrawId, afterOrAtDrawId);
    }
}

Impact

DrawAccumulatorLib.getDisbursedBetween() could return a balance much smaller than the actual balance between two draws. It is used in many crucial calculations of the prize pool:

Due to the bug in getDisbursedBetween(), all calculations in the prize pool involving the allocation/distribution of prize liquidity will be incorrect, causing a loss of funds as users will receive incorrect prize amounts.

Code Snippet

https://github.com/sherlock-audit/2024-05-pooltogether/blob/1aa1b8c028b659585e4c7a6b9b652fb075f86db3/pt-v5-prize-pool/src/libraries/DrawAccumulatorLib.sol#L257-L262

https://github.com/sherlock-audit/2024-05-pooltogether/blob/1aa1b8c028b659585e4c7a6b9b652fb075f86db3/pt-v5-prize-pool/src/libraries/DrawAccumulatorLib.sol#L183-L190

https://github.com/sherlock-audit/2024-05-pooltogether/blob/1aa1b8c028b659585e4c7a6b9b652fb075f86db3/pt-v5-prize-pool/src/libraries/DrawAccumulatorLib.sol#L201-L208

Tool used

Manual Review

Recommendation

When finding the start draw in binarySearch(), consider checking if beforeOrAtDrawId equals to _startDrawId before using afterOrAtDrawId:

- (, , , uint24 afterOrAtDrawId) = binarySearch(
+ (, uint24 beforeOrAtDrawId, , uint24 afterOrAtDrawId) = binarySearch(
    _accumulator.drawRingBuffer,
    oldestIndex,
    newestIndex,
    ringBufferInfo.cardinality,
    _startDrawId
  );
- atOrAfterStart = _accumulator.observations[afterOrAtDrawId];
+ atOrAfterStart = _accumulator.observations[
+   beforeOrAtDrawId == _startDrawId ? beforeOrAtDrawId : afterOrAtDrawId
+ ];

Similarly, when finding the end draw, check if afterOrAtDrawId equals to _endDrawId before using beforeOrAtDrawId:

- (, uint24 beforeOrAtDrawId, , ) = binarySearch(
+ (, uint24 beforeOrAtDrawId, , uint24 afterOrAtDrawId) = binarySearch(
    _accumulator.drawRingBuffer,
    oldestIndex,
    newestIndex,
    ringBufferInfo.cardinality,
    _endDrawId
  );
- atOrBeforeEnd = _accumulator.observations[beforeOrAtDrawId];
+ atOrBeforeEnd = _accumulator.observations[
+   afterOrAtDrawId == _endDrawId ? afterOrAtDrawId : beforeOrAtDrawId
+ ];
nevillehuang commented 4 months ago

Request PoC to faciilitate discussion

Likely Invalid, sponsor comments:

  • invalid, the binary search is only used if there is no observation available at the draw ID in question
  • see these two checks:
  1. observation at startDrawId not available: https://github.com/sherlock-audit/2024-05-pooltogether/blob/1aa1b8c028b659585e4c7a6b9b652fb075f86db3/pt-v5-prize-pool/src/libraries/DrawAccumulatorLib.sol#L182
  2. observation at endDrawId not available: https://github.com/sherlock-audit/2024-05-pooltogether/blob/1aa1b8c028b659585e4c7a6b9b652fb075f86db3/pt-v5-prize-pool/src/libraries/DrawAccumulatorLib.sol#L200
sherlock-admin4 commented 4 months ago

PoC requested from @MiloTruck

Requests remaining: 7

trmid commented 4 months ago

The binary search is only used if an observation for the draw in question is not available in the mapping (see the links in the comment above).