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
유동성 공급자는 depositCollateralAndIncreaseShare 와 depositLiquidityAndIncreaseShare를 사용해 프로토콜에 유동성을 추가할 수 있으며, 두 함수 모두 _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