sherlock-2025-07-mellow-h02

[H-02] RedeemQueue Accounting Mismatch Between Batch Creation and Claim Eligibility

보고서

Summary

출금을 배치로 처리할 때는 최대 가능한 index - 1 까지만 처리하고, 실제로 토큰을 꺼내갈 때는 최대 가능한 index 까지 처리할 수 있다. 전자에서 제외된 index가 토큰을 꺼내가면, 출금을 완료한 share를 추적하는 변수를 차감할 때 언더플로우가 발생한다. 따라서 다른 유저는 언더플로우로 인해 토큰을 꺼내갈 수 없게 된다.

Keyword

integer overflow underflow, logic flaw, off by one error

Vulnerability

가격을 업데이트할 때 출금 큐의 _handleReport 함수에서는 마지막 출금 요청이 아직 출금 대기 기간을 지나지 않은 경우 출금 대기 기간이 지난 인덱스를 찾는다. 그리고 출금 기간이 지난, 가장 최신 인덱스 - 1 까지를 처리한다. 즉, 이번에 처리가 가능할 수도 있던 마지막 상환 요청은 제외한다. 어느 인덱스까지 처리할 지 결정했다면 이 인덱스까지의 출금 요청 share를 합산한다. 출금을 마무리하는 작업에서 이 share 에서 출금된 만큼을 차감할 것이다.

function _handleReport(uint224 priceD18, uint32 timestamp) internal override {
    RedeemQueueStorage storage $ = _redeemQueueStorage();
 
    Checkpoints.Trace224 storage timestamps = _timestamps();
    (, uint32 latestTimestamp, uint224 latestIndex) = timestamps.latestCheckpoint();
    uint256 latestEligibleIndex;
@>  if (latestTimestamp <= timestamp) { // timestamp는 현 시점 - 대기 기간
        latestEligibleIndex = latestIndex;
    } else {
@>      latestEligibleIndex = uint256(timestamps.upperLookupRecent(timestamp));
        if (latestEligibleIndex == 0) {
            return;
        }
@>      latestEligibleIndex--;
    }
    uint256 handledIndices_ = $.handledIndices;
    if (latestEligibleIndex < handledIndices_) {
        return;
    }
 
@>  uint256 shares =
        $.prefixSum[latestEligibleIndex] - (handledIndices_ == 0 ? 0 : $.prefixSum[handledIndices_ - 1]);
    ...
 
@>  $.batches.push(Batch(assets_, shares));
    $.totalDemandAssets += assets_;
}

하지만 출금을 마무리하고 실제로 토큰을 꺼내가는 claim 함수에서는 마지막으로 _handleReport에서 등록된 타임스탬프(즉, timestamp 파라미터)를 기준으로 출금 가능 여부를 판단한다. 즉, _handleReport에서는 제외되어 출금 share 합산에서 제외되었던 인덱스도 claim을 요청할 수 있다. 출금을 완료한 share만큼 batch.shares에서 차감해야 하는데, 제외되었던 인덱스가 claim을 요청한다면 share 금액이 모자라게 된다. 따라서 동일 배치에 출금을 요청한 다른 유저는 언더플로우가 발생해 출금할 수 없게 된다.

function claim(address receiver, uint32[] calldata timestamps) external nonReentrant returns (uint256 assets) {
    RedeemQueueStorage storage $ = _redeemQueueStorage();
    address account = _msgSender();
    EnumerableMap.UintToUintMap storage callerRequests = $.requestsOf[account];
@>  (, uint32 latestReportTimestamp,) = $.prices.latestCheckpoint();
    if (latestReportTimestamp == 0) {
        return 0;
    }
 
    uint256 batchIterator = $.batchIterator;
    for (uint256 i = 0; i < timestamps.length; i++) {
        uint32 timestamp = timestamps[i];
@>      if (timestamp > latestReportTimestamp) {
            continue;
        }
        (bool hasRequest, uint256 shares) = callerRequests.tryGet(timestamp);
        if (!hasRequest) {
            continue;
        }
        if (shares != 0) {
            uint256 index = $.prices.lowerLookup(timestamp);
            if (index >= batchIterator) {
                continue;
            }
            Batch storage batch = $.batches[index];
 
            ...
@>          batch.shares -= shares;
 
            emit RedeemRequestClaimed(account, receiver, assets_, timestamp);
        }
        callerRequests.remove(timestamp);
    }
 
    TransferLibrary.sendAssets(asset(), receiver, assets);
}

Impact

동일 배치에 출금을 요청한 다른 유저는 언더플로우가 발생해 출금할 수 없다.

Mitigation

_handleReport에서 (이번에 처리 가능한)마지막 상환 요청을 제외하지 않는다.


tags: bughunting, mellow, smart contract, solidity, severity high, integer overflow underflow, logic flaw, off by one error