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