code4rena-2024-01-salty-h02

[H-02] First Liquidity provider can claim all initial pool rewards

보고서

Summary

새로운 풀이 추가되었을 때 부트스트랩 리워드 분배 타임 스탬프를 최신화하지 않는다. 이로 인해 첫 예치자가 부트스트랩 보상의 최대 1%를 가져갈 수 있다.

Keyword

first deposit, crypto theft, reward

Vulnerability

유동성 공급자는 depositCollateralAndIncreaseSharedepositLiquidityAndIncreaseShare를 사용해 프로토콜에 유동성을 추가할 수 있으며, 두 함수 모두 _increaseUserShare 함수를 호출해 사용자에게 유동성을 스테이킹하고 포지션 보상을 계산한다. 이 함수에서 현재 구현에는 풀에 첫번째로 예치했을 때 virtualRewards 을 처리하는 방식에 문제가 있다.

첫번째 유저가 예치했을 때는 이 풀에 발행된 share가 없기 때문에 virtualRewards 계산을 건너뛴다.

// Increase a user's share for the given whitelisted pool.
function _increaseUserShare(address wallet, bytes32 poolID, uint256 increaseShareAmount, bool useCooldown)
  internal
{
    ...
    uint256 existingTotalShares = totalShares[poolID];
 
    if (
@>      existingTotalShares != 0 // prevent / 0
    ) {
        // Round up in favor of the protocol.
        uint256 virtualRewardsToAdd = Math.ceilDiv(totalRewards[poolID] * increaseShareAmount, existingTotalShares);
 
@>      user.virtualRewards += uint128(virtualRewardsToAdd);
@>      totalRewards[poolID] += uint128(virtualRewardsToAdd);
    }
    // Update the deposit balances
    user.userShare += uint128(increaseShareAmount);
    totalShares[poolID] = existingTotalShares + increaseShareAmount;
    ...
}

virtualRewards 는 사용자의 풀 예치 리워드를 계산할 때 이용된다. 유저가 받을 수 있는 리워드를 계산할 때 userRewardForPool 를 호출한다.

  • uint256 rewardsShare = (totalRewards[poolID] * user.userShare) / totalShares[poolID]; 로 유저의 지분에 따라 받을 수 있는 리워드(누적)를 계산한다.
  • 이후 return rewardsShare - user.virtualRewards;로 이미 받았던 리워드(virtualRewards, 누적)을 뺀다.
function claimAllRewards( bytes32[] calldata poolIDs ) external nonReentrant returns (uint256 claimableRewards)
{
    mapping(bytes32=>UserShareInfo) storage userInfo = _userShareInfo[msg.sender];
 
    claimableRewards = 0;
    for( uint256 i = 0; i < poolIDs.length; i++ )
    {
        bytes32 poolID = poolIDs[i];
 
@>      uint256 pendingRewards = userRewardForPool( msg.sender, poolID );
 
        // Increase the virtualRewards balance for the user to account for them receiving the rewards without withdrawing
        userInfo[poolID].virtualRewards += uint128(pendingRewards);
 
        claimableRewards += pendingRewards;
    }
 
    if ( claimableRewards > 0 )
    {
        // Send the actual rewards
        salt.safeTransfer( msg.sender, claimableRewards );
 
        emit RewardsClaimed(msg.sender, claimableRewards);
    }
}
 
function userRewardForPool( address wallet, bytes32 poolID ) public view returns (uint256)
{
    // If there are no shares for the pool, the user can't have any shares either and there can't be any rewards
    if ( totalShares[poolID] == 0 )
        return 0;
 
    UserShareInfo memory user = _userShareInfo[wallet][poolID];
    if ( user.userShare == 0 )
        return 0;
 
    // Determine the share of the rewards for the user based on their deposited share
@>  uint256 rewardsShare = ( totalRewards[poolID] * user.userShare ) / totalShares[poolID];
 
    // Reduce by the virtualRewards - as they were only added to keep the share / rewards ratio the same when the used added their share
 
    // In the event that virtualRewards exceeds rewardsShare due to precision loss - just return zero
    if ( user.virtualRewards > rewardsShare )
        return 0;
 
@>  return rewardsShare - user.virtualRewards;
}

즉 첫번째 예치자가 빈 풀에 예치하는 경우, 풀의 총 share 와 동일한 수의 share 를 가지게 되지만, virtualRewards 의 수는 초기화되지 않아 0으로 남는다. 첫번째 예치자는 스테이킹 컨트랙트에 모든 리워드(totalRewards[poolID])를 꺼내갈 수 있다.

초기 설정으로는 풀을 새로 만들었을 때 풀 하나당 부트스트랩 보상을 할당하며, 이는 하루에 1% 비율로 풀린다. 부트스트랩 보상은 liquidityRewardsEmitter 에게 전송되어 하루 1% 비율로 풀린다. liquidityRewardsEmitter.performUpkeep 이 호출될 때마다 조금씩 풀린 리워드를 이동하여 유저가 가져갈 수 있도록 한다. 이 때 timeSinceLastUpkeep(마지막으로 Upkeep을 호출한 시점으로부터 지난 초)을 기준으로 리워드가 전달된다. 즉 Upkeep이 호출된 지 오래되었다면, 새로운 풀이 추가된 후 Upkeep을 호출했을 때 즉시 최대 24시간동안의 리워드가 풀린다.

즉, 풀을 새로 추가하기 전 또는 첫 예치자가 나타나기 직전에 Upkeep을 호출한다면 많은 리워드가 풀리지 않아 첫 예치자가 공격을 해도 적은 양의 리워드만 가져갈 수 있을 것이다. Upkeep을 먼저 호출하지 않는다면, Upkeep이 24시간 이상 호출되지 않았다면 첫번째 예치자는 최대 부트스트랩 보상의 1%만큼(24시간동안 풀리는 양)의 SALT를 받아갈 수 있다.

function performUpkeep( uint256 timeSinceLastUpkeep ) external
{
    ...
 
    // Rewards to emit = pendingRewards * timeSinceLastUpkeep * rewardsEmitterDailyPercent / oneDay
@>  uint256 numeratorMult = timeSinceLastUpkeep * rewardsConfig.rewardsEmitterDailyPercentTimes1000();
    uint256 denominatorMult = 1 days * 100000; // simplification of numberSecondsInOneDay * (100 percent) * 1000
 
    uint256 sum = 0;
    for( uint256 i = 0; i < poolIDs.length; i++ )
    {
        bytes32 poolID = poolIDs[i];
 
        // Each pool will send a percentage of the pending rewards based on the time elapsed since the last send
        uint256 amountToAddForPool = ( pendingRewards[poolID] * numeratorMult ) / denominatorMult;
 
        // Reduce the pending rewards so they are not sent again
        if ( amountToAddForPool != 0 )
        {
            pendingRewards[poolID] -= amountToAddForPool;
 
            sum += amountToAddForPool;
        }
 
        // Specify the rewards that will be added for the specific pool
@>      addedRewards[i] = AddedReward( poolID, amountToAddForPool );
    }
 
    // Add the rewards so that they can later be claimed by the users proportional to their share of the StakingRewards derived contract.
    stakingRewards.addSALTRewards( addedRewards );
}

Impact

새로운 풀이 추가되었을 때, 첫 예치자가 부트스트랩 보상의 최대 1%를 가져갈 수 있다.

Mitigation

새 풀 등록 직전에 performUpkeep 를 호출하여 부트스트랩 리워드 분배 타임 스탬프를 최신화한다. 이후 배포된 새로운 풀에는 performUpkeep 를 다시 호출하더라도 아주 작은 양(풀 런칭 후 지난 초만큼의 리워드)의 리워드만 풀릴 것이다.


tags: bughunting, saltyio, smart contract, solidity, crypto theft, severity high