code4rena-2025-01-liquid-ron-h01

[H-01] The calculation of totalAssets() could be wrong if operatorFeeAmount > 0, this can cause potential loss for the new depositors

보고서

Summary

관리자 수수료가 totalAssets 조회량에서 제외되지 않아 실제보다 크게 잡힌다. 이로 인해 잘못된 양의 리워드가 분배될 수 있다.

Keyword

logic flaw, arithmetic error

Vulnerability

harvest 를 호출하면 스테이킹 리워드를 수확하여 WRON 토큰으로 래핑 후 Vault 에 전송한다. 그리고 수수료 n% 만큼을 계산하여 operatorFeeAmount 에 누적한다. 추후 수수료 수신자가 operatorFeeAmount 에 누적된 만큼 WRON을 출금해갈 수 있다.

harvestAndDelegateRewards 를 호출한다면 수확된 리워드는 Vault 컨트랙트로 전송되지 않고, _consensusAddrDst 에게 위임된다. 즉, 스테이킹된 토큰의 양이 증가한다.

function harvest(uint256 _proxyIndex, address[] calldata _consensusAddrs) external onlyOperator whenNotPaused {
    uint256 harvestedAmount = ILiquidProxy(stakingProxies[_proxyIndex]).harvest(_consensusAddrs);
@>  operatorFeeAmount += (harvestedAmount * operatorFee) / BIPS;
    emit Harvest(_proxyIndex, harvestedAmount);
}
 
function harvestAndDelegateRewards(
    uint256 _proxyIndex,
    address[] calldata _consensusAddrs,
    address _consensusAddrDst
) external onlyOperator whenNotPaused {
    _tryPushValidator(_consensusAddrDst);
    uint256 harvestedAmount = ILiquidProxy(stakingProxies[_proxyIndex]).harvestAndDelegateRewards(
        _consensusAddrs,
        _consensusAddrDst
    );
@>  operatorFeeAmount += (harvestedAmount * operatorFee) / BIPS;
    emit Harvest(_proxyIndex, harvestedAmount);
}
 
function fetchOperatorFee() external {
    if (msg.sender != feeRecipient) revert ErrNotFeeRecipient();
    uint256 amount = operatorFeeAmount;
    operatorFeeAmount = 0;
@>  _withdrawRONTo(feeRecipient, amount);
}

그런데, 유저가 출금할 수 있는 WRON의 양을 계산할 때 사용되는 totalAssets 함수에서는 Vault에 예치된 WRON 양 + 스테이킹된 RON 양 + 아직 수확하지 않은 리워드 양 을 리턴한다. 즉, operatorFeeAmount 는 예치된 WRON으로 볼 수 없지만, harvest 의 경우 super.totalAssets 에, harvestAndDelegateRewards 의 경우 getTotalStakedoperatorFeeAmount 가 포함되어버린다.

function totalAssets() public view override returns (uint256) {
@>  return super.totalAssets() + getTotalStaked() + getTotalRewards();
}
 
function getTotalRewards() public view returns (uint256) {
    address[] memory consensusAddrs = _getValidators();
    uint256 proxyCount = stakingProxyCount;
    uint256 totalRewards;
    uint256 totalFees;
 
    for (uint256 i = 0; i < proxyCount; i++) totalRewards += _getTotalRewardsInProxy(i, consensusAddrs);
@>  totalFees = (totalRewards * operatorFee) / BIPS;
    return totalRewards - totalFees;
}
 
function getTotalStaked() public view returns (uint256) {
    address[] memory consensusAddrs = _getValidators();
    uint256 proxyCount = stakingProxyCount;
    uint256 totalStaked;
 
    for (uint256 i = 0; i < proxyCount; i++) totalStaked += _getTotalStakedInProxy(i, consensusAddrs);
    return totalStaked;
}

Impact

이로 인해 다음 문제가 발생할 수 있다.

  1. 리워드를 수확하기 전 totalAssets 에서는 getTotalRewards 에서 수수료만큼을 제외한다. 하지만 harvest 호출 후에는 수수료가 super.totalAssets (balanceOf 호출값)에 포함되고, harvestAndDelegateRewards 호출 후에는 getTotalStaked 에 포함된다.
    1. 즉, 리워드를 수확하고 수수료를 아직 출금하기 전에는 수수료만큼 totalAssets 이 증가한다. 따라서 리워드 수확 이후에 출금을 하면 원래 받아야 하는 것보다 더 많은 토큰을 받을 수 있다.
  2. 이는 fetchOperatorFee 의 호출 전후의 출금양에도 차이를 만든다. fetchOperatorFee 보다 먼저 출금 요청을 한다면 totalAssets 가 원래보다 크게 간주되어 동일한 share로 더 많은 양의 WRON을 출금할 수 있다.

Mitigation

function totalAssets() public view override returns (uint256) {
-   return super.totalAssets() + getTotalStaked() + getTotalRewards();
+   return super.totalAssets() + getTotalStaked() + getTotalRewards() - operatorFeeAmount;
}

tags: bughunting, liquid ron, smart contract, solidity, logic flaw, arithmetic error, severity high