sherlock-2025-07-mellow-h04

[H-04] Protocol Fee Multiple Accrual in Oracle.submitReports

보고서

Summary

Oracle.submitReports 함수는 단일 트랜잭션으로 여러 종류의 토큰 가격을 등록할 수 있다. 이 과정에서 호출되는 ShareModule.handleReport 함수는 각 가격 리포트 별로 프로토콜 수수료를 청구한다. 마지막으로 가격이 업데이트 된 시점을 기준으로 지난 초만큼 수수료를 청구하는데, base 토큰의 가격을 업데이트할 때만 마지막으로 가격이 업데이트 된 시점을 갱신했다. 따라서 base 자산이 늦게 처리될 경우 수수료가 중복으로 청구된다.

Keyword

fee, business logic vul

Vulnerability

이 취약점은 Oracle.submitReportsShareModule.handleReport 함수의 상호작용에서 비롯된다. Oracle.submitReports 를 호출하여 새로운 가격 정보를 등록할 때, 가격이 정상 범위일 시 볼트의 ShareModule.handleReport 를 호출한다.

    function submitReports(Report[] calldata reports) external nonReentrant onlyRole(SUBMIT_REPORTS_ROLE) {
        OracleStorage storage $ = _oracleStorage();
        SecurityParams memory securityParams_ = $.securityParams;
        uint32 depositTimestamp = uint32(block.timestamp - securityParams_.depositInterval);
        uint32 redeemTimestamp = uint32(block.timestamp - securityParams_.redeemInterval);
        IShareModule vault_ = $.vault;
        EnumerableSet.AddressSet storage supportedAssets_ = $.supportedAssets;
        mapping(address asset => DetailedReport) storage reports_ = $.reports;
        for (uint256 i = 0; i < reports.length; i++) {
            Report calldata report = reports[i];
            if (!supportedAssets_.contains(report.asset)) {
                revert UnsupportedAsset(report.asset);
            }
@>          if (_handleReport(securityParams_, report.priceD18, reports_[report.asset])) {
@>              vault_.handleReport(report.asset, report.priceD18, depositTimestamp, redeemTimestamp);
            }
        }
        emit ReportsSubmitted(reports);
    }

ShareModule.handleReport 함수에서는 떼어야 하는 수수료를 계산한다. 동일 볼트에 여러 토큰이 등록되어 있다면, 그리고 이번 트랜잭션에서 다수의 토큰 가격 정보를 업데이트 했다면 여러번 호출될 것이다.

function handleReport(address asset, uint224 priceD18, uint32 depositTimestamp, uint32 redeemTimestamp)
    external
    nonReentrant
{
    ShareModuleStorage storage $ = _shareModuleStorage();
    if (_msgSender() != $.oracle) {
        revert Forbidden();
    }
    IShareManager shareManager_ = IShareManager($.shareManager);
    IFeeManager feeManager_ = IFeeManager($.feeManager);
@>  uint256 fees = feeManager_.calculateFee(address(this), asset, priceD18, shareManager_.totalShares());
    if (fees != 0) {
@>      shareManager_.mint(feeManager_.feeRecipient(), fees);
    }
    feeManager_.updateState(asset, priceD18);
    ...
}
 

FeeManager.calculateFee 함수는 마지막에 업데이트된 시점 이후로 지난 초에 비례하여 수수료를 계산한다. 이 수수료는 가격 업데이트 중 한번만 처리되어야 한다.(볼트 단위의 수수료이기 때문에 토큰의 종류와는 상관없음. 토큰별로 떼는 것이 아님) 타임스탬프는 FeeManager.updateState 함수에서 업데이트 되는데, 이 함수는 base 토큰의 가격을 업데이트할 때에만 마지막 업데이트 시점을 갱신한다.

function calculateFee(address vault, address asset, uint256 priceD18, uint256 totalShares)
    public
    view
    returns (uint256 shares)
{
    ...
@>  uint256 timestamp = $.timestamps[vault];
    if (timestamp != 0 && block.timestamp > timestamp) {
@>      shares += Math.mulDiv(totalShares, $.protocolFeeD6 * (block.timestamp - timestamp), 365e6 days);
    }
}
 
function updateState(address asset, uint256 priceD18) external {
    FeeManagerStorage storage $ = _feeManagerStorage();
    address vault = _msgSender();
@>  if ($.baseAsset[vault] != asset) {
        return;
    }
    uint256 minPriceD18_ = $.minPriceD18[vault];
    if (minPriceD18_ == 0 || minPriceD18_ > priceD18) {
        $.minPriceD18[vault] = priceD18;
    }
    $.timestamps[vault] = block.timestamp;
    emit UpdateState(vault, asset, priceD18);
}

즉, Oracle.submitReports 로 여러 토큰의 가격 정보를 업데이트할 때, 항상 base 토큰이 배열의 가장 앞쪽에 전달될 것이라고 가정한다. 만약 base 토큰의 가격 정보를 마지막에 업데이트 한다면 base 토큰이 아닌 토큰의 가격을 갱신하면서 중복하여 수수료를 뗄 것이다.

Impact

프로토콜 수수료를 중복으로 뗀다.

Mitigation

base 토큰이 아닌 토큰의 가격을 업데이트 했을 때에도 업데이트 시점을 갱신한다.

    function updateState(address asset, uint256 priceD18) external {  
        FeeManagerStorage storage $ = _feeManagerStorage();  
        address vault = _msgSender();  
+       $.timestamps[vault] = block.timestamp;  
        if ($.baseAsset[vault] != asset) {  
            return;  
        }  
        uint256 minPriceD18_ = $.minPriceD18[vault];  
        if (minPriceD18_ == 0 || minPriceD18_ > priceD18) {  
            $.minPriceD18[vault] = priceD18;  
        }  
-       $.timestamps[vault] = block.timestamp;  
        emit UpdateState(vault, asset, priceD18);  
    }  

tags: bughunting, mellow, smart contract, solidity, severity high, fee, business-logic-vul