GithubHelp home page GithubHelp logo

2024-04-ai-arena-mitigation-findings's Introduction

AI Arena Mitigation Review

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.


Audit findings are submitted to this repo

Sponsors have three critical tasks in the audit process:

  1. Respond to issues.
  2. Weigh in on severity.
  3. Share your mitigation of findings.

Let's walk through each of these.

High and Medium Risk Issues

Wardens submit issues without seeing each other's submissions, so keep in mind that there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

Judges have the ultimate discretion in determining validity and severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criterion.

Respond to issues

For each High or Medium risk finding that appears in the dropdown at the top of the chrome extension, please label as one of these:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

Add any necessary comments explaining your rationale for your evaluation of the issue.

Note that when the repo is public, after all issues are mitigated, wardens will read these comments; they may also be included in your C4 audit report.

Weigh in on severity

If you believe a finding is technically correct but disagree with the listed severity, select the disagree with severity option, along with a comment indicating your reasoning for the judge to review. You may also add questions for the judge in the comments. (Note: even if you disagree with severity, please still choose one of the sponsor confirmed or sponsor acknowledged options as well.)

For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

QA reports, Gas reports, and Analyses

All warden submissions in these three categories are submitted as bulk listings of issues and recommendations:

  • QA reports include all low severity and non-critical findings from an individual warden.
  • Gas reports include all gas optimization recommendations from an individual warden.
  • Analyses contain high-level advice and review of the code: the "forest" to individual findings' "trees.”

For QA reports, Gas reports, and Analyses, sponsors are not required to weigh in on severity or risk level. We ask that sponsors:

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • For QA and Gas reports only: add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once labelling is complete

When you have finished labelling findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.

Share your mitigation of findings

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2024-04-ai-arena-mitigation-findings's People

Contributors

c4-bot-1 avatar c4-bot-10 avatar c4-bot-2 avatar c4-bot-3 avatar c4-bot-4 avatar c4-bot-5 avatar c4-bot-6 avatar c4-bot-7 avatar c4-bot-8 avatar c4-bot-9 avatar c4-judge avatar code4rena-id[bot] avatar knownfactc4 avatar

Watchers

 avatar  avatar

2024-04-ai-arena-mitigation-findings's Issues

H-06 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L370

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/FighterFarm.sol#L415

Vulnerability details

The issue was reported in #68.

The function FighterFarm.reRoll() permits player recomputing fighter traits, i.e., computing the AiArenaHelper.createPhysicalAttributes() function on a different dna computed according to msg.sender, tokenId which represents fighter ID and numRerolls[tokenId], i.e., the number of times the player tried to recompute that fighter traits. During reRoll() calls, only the numRerolls[tokenId] value changes.

The vulnerability relies on line #370:

FighterFarm.sol#L370

    function reRoll(uint8 tokenId, uint8 fighterType) public {

As we said above, tokenId which represents fighter ID. In general, the fighter ID is a uint256 and so, it can assume every value in [0, 2^255 - 1]. However, the reRoll() function uses a uint8 to represent tokenId. This means that reRoll() can not be successfully called for tokenId values in [2^8, 2^255 - 1]. This has a huge impact on players because they can not recompute fighters' traits if their fighters have an ID greater than 255.

Recommended Mitigation proposed by wardens

The proposed mitigation step is to use uint256 to represent tokenId inside FighterFarm.reRoll() method:

    /// @notice Rolls a new fighter with random traits.
    /// @param tokenId ID of the fighter being re-rolled.
    /// @param fighterType The fighter type.
-   function reRoll(uint8 tokenId, uint8 fighterType) public {
+   function reRoll(uint256 tokenId, uint8 fighterType) public {
        require(msg.sender == ownerOf(tokenId));
        require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
        require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
    
        _neuronInstance.approveSpender(msg.sender, rerollCost);
        bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
        if (success) {
            numRerolls[tokenId] += 1;
            uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
            (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
            fighters[tokenId].element = element;
            fighters[tokenId].weight = weight;
            fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
                newDna,
                generation[fighterType],
                fighters[tokenId].iconsType,
                fighters[tokenId].dendroidBool
            );
            _tokenURIs[tokenId] = "";
        }
    }  

This solution was implemented by the Ai Arena team

Comment about the Mitigation Proposal

We think this is a valid mitigation proposal. In this way, it is possible to call the FighterFarm.reRoll() method on any tokenId until the maxRerollsAllowed value is reached.

We want to underline that the numRerolls value is not reset when a fighter is transferred from one player to another. So, numRerolls represents a general limit for a specific tokenId. Furthermore, a player can forecast how much reRolls are needed to obtain the rare fighter. Developers tried to mitigate this problem in #16:

    /// @notice Rolls a new fighter with random traits.
    /// @param tokenId ID of the fighter being re-rolled.
    /// @param fighterType The fighter type.
    function reRoll(uint256 tokenId, uint8 fighterType) public {
        require(msg.sender == ownerOf(tokenId));
        require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
        require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
    
        _neuronInstance.approveSpender(msg.sender, rerollCost);
        bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
        if (success) {
            numRerolls[tokenId] += 1;
-           uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
+           uint256 dna = uint256(keccak256(abi.encode(tokenId, numRerolls[tokenId])));
            (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
            fighters[tokenId].element = element;
            fighters[tokenId].weight = weight;
            fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
                newDna,
                generation[fighterType],
                fighters[tokenId].iconsType,
                fighters[tokenId].dendroidBool
            );
            _tokenURIs[tokenId] = "";
        }
    }  

In this way, they removed the possibility to manipulate the FighterFarm.reRoll() outcome. However, it is still possible to forecast the next reRolls and decide when to stop calling it.

H-07 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

H-07: Fighters cannot be minted after the initial generation due to uninitialized numElements mapping

Comments

The previous implementation didn't have a method to set numElements for new generations. This made it impossible to create fighters for generations > 0, since only generation 0 had hardcoded numElements during construction.

Mitigation

PR #7
A function setNumElements has been added, which allows the contract owner to set or update the number of elements of a given generation:

/// @notice Updates the number of elements for a given generation.
/// @dev Only the owner address is authorized to call this function.
/// @param newNumElements number of elements for the generation.
/// @param generation_ generation to be updated.
function setNumElements(uint8 newNumElements, uint8 generation_) external {
    require(msg.sender == _ownerAddress);
    numElements[generation_] = newNumElements;
}

Suggestion

The function allows updating an existing numElements[generation] value to a smaller value. If this is a possibility, beware that previously created fighters could have an element value greater than the one stored in numElements[generation]. This has no consequence in the smart contract itself, but could affect the game.

Conclusion

LGTM

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

H-03 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L233-L263

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-47/src/FighterFarm.sol#L261-L304

Vulnerability description

The issue was reported in #366.

FighterFarm implements a method to redeem mint passes and obtain NFT fighters. This method is redeemMintPass:

233      function redeemMintPass(
234          uint256[] calldata mintpassIdsToBurn,
235          uint8[] calldata fighterTypes,
236          uint8[] calldata iconsTypes,
237          string[] calldata mintPassDnas,
238          string[] calldata modelHashes,
239          string[] calldata modelTypes
240      ) 
241          external 
242      {
243          require(
244              mintpassIdsToBurn.length == mintPassDnas.length && 
245              mintPassDnas.length == fighterTypes.length && 
246              fighterTypes.length == modelHashes.length &&
247              modelHashes.length == modelTypes.length
248          );
249          for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) {
250              require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i]));
251              _mintpassInstance.burn(mintpassIdsToBurn[i]);
252              _createNewFighter(
253                  msg.sender, 
254                  uint256(keccak256(abi.encode(mintPassDnas[i]))), 
255                  modelHashes[i], 
256                  modelTypes[i],
257                  fighterTypes[i],
258                  iconsTypes[i],
259                  [uint256(100), uint256(100)]
260              );
261          }
262      }

This method can be directly called by a player, who could create new fighters according to passed parameters. This means that a player can find and provide dna to mint fighters with very rare physical attributes and can easily mint fighters of type Dendroid. This issue is due to a lack of validation of redeem passes.

Recommended Mitigation proposed by wardens

The proposed mitigation is to apply the same validation check that is in claimFighters. However, the signature is applied on different parameters:

FighterFarm.sol#L280-L289

280           bytes32 msgHash = bytes32(keccak256(abi.encode(
281            mintpassIdsToBurn,
282            fighterTypes,
283            iconsTypes,
284            mintPassDnas,
285            modelHashes,
286            modelTypes
287          )));
288  
289          require(Verification.verify(_delegatedAddress, msgHash, signature));

According to what developers explain to me, the backend server assigns metadata to a specific mintPassId. This means that a specific tuple of (mintpassIdsToBurn, fighterTypes, iconsTypes, mintPassDnas, modelHashes, modelTypes) is valid only if the player has the signature of that tuple obtained by the backend server.

This solution was implemented by the Ai Arena team

Comment about the Mitigation Proposal

We think this approach could lead to several issues. It mitigates the issue described in issue 366, but strongly modifies the nature and usage of mint passes.

We report our comments below.

The signature is the same for several mint passes

Let's think Bob has several mint passes. To create fighters, he has to ask to backend server to obtain the signature. If he asks for the signature for a subset of those mint passes, he has to redeem those mint passes together. In other words, the signature is not one for each mintpassIdToBurn. We suggest having a signature for each mintpassIdToBurn.
There is another issue. We don't know how the signature is generated by the backend. However, let's think a player asks the signature to redeem 11 mint passes he/she owns. This signature will never work:

  • The player calls redeemMintPass with the right signature, passing mintpassIdsToBurn with 11 elements.
  • redeemMintPass tries to call _createNewFighter 11 times.
  • Assuming at the beginning the player doesn't own any fighter, _createNewFighter will fail at the 11th iteration, making the transaction revert, because the line FighterFarm.sol#L540

So, even if the player obtained a valid signature, he/she can not use it.

The dna is part of mint pass metadata. So, it is possible to forecast the attributes of the redeemed fighters

Mint passes can be bought, for example, on OpenSea.
The Ai Champion Mint Pass 245 can be bought for a price of 2,888 ETH, to date. We can see that its dna is 0x34d5c19afa611134f0c27b7b6d09e34c2d579f48d7b92e8c8f997af4981469fd. We can forecast the physical attributes of the fighter that can be redeemed using this mint pass.
Using this code:

function testGetAttributes() public {
    uint256 newDna = uint256(keccak256(abi.encode("0x34d5c19afa611134f0c27b7b6d09e34c2d579f48d7b92e8c8f997af4981469fd")));
    
    // Assuming generation = 0, iconsType=1, fighterType = 0
    uint256 generation = 0;
    uint256 iconsType = 1;
    uint256 fighterType = 0;
    bool dendroidBool = fighterType == 1;

    FighterOps.FighterPhysicalAttributes memory attrs = _helperContract.createPhysicalAttributes(
        newDna,
        0,
        1,
        false
    );
    console.log(attrs.head); // head = 1
    console.log(attrs.eyes); // eyes = 50
    console.log(attrs.mouth); // mouth = 3
    console.log(attrs.body); // body = 3
    console.log(attrs.hands); // hands = 2
    console.log(attrs.feet); // feet = 2
}

We asked to sponsor and it seems the wanted behavior. They want the outcoming physical attributes to be foreseeable. Furthermore, they wanted that the mechanism to compute these attributes from dna is on-chain. However, we propose to move this mechanism off-chain. Because of the way the dna is computed by the backend server, the fact physical attributes are computed on-chain doesn't add transparency to the operation. It could be better if the mint pass already shows the physical attributes of the outcoming fighter.
We want to add that also the outcomes of the reRoll operation are foreseeable. In other words, players can forecast before buying what physical attributes could be reached using the reRoll operation.

Conclusion

We think this mitigation works to prevent players from creating fighters with wanted attributes. Now, the traits are bound to dna written in the mintPass, which is defined by the backend server.
This value is known and permits forecasting future attributes of a fighter before buying the mintPass. This issue could be related to other medium issues reported during the contest, like issue #1017 - Users can get benefited from DNA pseudorandomly calculation. However, according to my conversation with the sponsor during this mitigation review, it seems that the possibility to forecast fighter traits is a wanted behavior.

M-04 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/MergingPool.sol#L134-L167

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

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/MergingPool.sol#L137-L175

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L134-L167

Vulnerability details

The issue was reported in #868.

The function MergingPool.claimRewards() aims to claim rewards for winner players. When a player wins during a round, it is added to the winnerAddresses list by the admin, using MergingPool.pickWinner(). Then, he/she can claim a reward, i.e., he/she can obtain a new fighter calling MergingPool.claimRewards().

A player can claim rewards obtained in old rounds. Let's analyze the original MergingPool.claimRewards() function:

134      /// @notice Allows the user to batch claim rewards for multiple rounds.
135      /// @dev The user can only claim rewards once for each round.
136      /// @param modelURIs The array of model URIs corresponding to each round and winner address.
137      /// @param modelTypes The array of model types corresponding to each round and winner address.
138      /// @param customAttributes Array with [element, weight] of the newly created fighter.
139      function claimRewards(
140          string[] calldata modelURIs, 
141          string[] calldata modelTypes,
142          uint256[2][] calldata customAttributes
143      ) 
144          external 
145      {
146          uint256 winnersLength;
147          uint32 claimIndex = 0;
148          uint32 lowerBound = numRoundsClaimed[msg.sender];
149          for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
150              numRoundsClaimed[msg.sender] += 1;
151              winnersLength = winnerAddresses[currentRound].length;
152              for (uint32 j = 0; j < winnersLength; j++) {
153                  if (msg.sender == winnerAddresses[currentRound][j]) {
154                      _fighterFarmInstance.mintFromMergingPool(
155                          msg.sender,
156                          modelURIs[claimIndex],
157                          modelTypes[claimIndex],
158                          customAttributes[claimIndex]
159                      );
160                      claimIndex += 1;
161                  }
162              }
163          }
164          if (claimIndex > 0) {
165              emit Claimed(msg.sender, claimIndex);
166          }
167      }

This function first iterates over all round IDs from the last one claimed by msg.sender to the current one, roundId. Then for each round iterates over all winnerAddresses to find if there is msg.sender, i.e., if msg.sender is among winnerAddresses.

The issue is that if a player doesn't claim a reward for many rounds, this function becomes very expensive and could reach the block gas limit. In this case, the player will not have access to its rewards anymore: in other words, he/she suffers a DoS of the MergingPool.claimRewards() function.

A very similar issue was reported in RankedBattle.claimNRN(). However, in this case is harder to reach the block gas limit because the iteration over rounds doesn't have a nested loop.

Recommended Mitigation proposed by wardens

The mitigation step proposed by wardens is to use a variable to limit the maximum number of iterations:

MergingPool.sol#L137-L175

    /// @notice Allows the user to batch claim rewards for multiple rounds.
    /// @dev The user can only claim rewards once for each round.
    /// @param modelURIs The array of model URIs corresponding to each round and winner address.
    /// @param modelTypes The array of model types corresponding to each round and winner address.
    /// @param customAttributes Array with [element, weight] of the newly created fighter.
    function claimRewards(
        string[] calldata modelURIs, 
        string[] calldata modelTypes,
-       uint256[2][] calldata customAttributes
+       uint256[2][] calldata customAttributes,
+       uint32 totalRoundsToConsider
    ) 
        external nonReentrant
    {
        uint256 winnersLength;
        uint32 claimIndex = 0;
        uint32 lowerBound = numRoundsClaimed[msg.sender];
+       require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
        uint8 generation = _fighterFarmInstance.generation(0);
-       for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
+       for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; currentRound++) {
            numRoundsClaimed[msg.sender] += 1;
            winnersLength = winnerAddresses[currentRound].length;
            for (uint32 j = 0; j < winnersLength; j++) {
                require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
                require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of bounds");
                if (msg.sender == winnerAddresses[currentRound][j]) {
                    _fighterFarmInstance.mintFromMergingPool(
                        msg.sender,
                        modelURIs[claimIndex],
                        modelTypes[claimIndex],
                        customAttributes[claimIndex]
                    );
                    claimIndex += 1;
                }
            }
        }
        if (claimIndex > 0) {
            emit Claimed(msg.sender, claimIndex);
        }
    }

In this way, there is a fixed number of rounds that can be considered: it should not be possible to reach the block gas limit.
This solution was implemented by the Ai Arena team

Comment about the Mitigation Proposal

We think the proposed mitigation solves the issue. In this way, players can call MergingPool.claimRewards() on a specific number of rounds and avoid reaching the block gas limit. Even if in the selected round range the player isn't among the winners, the value of numRoundsClaimed[msg.sender] increases and it is always possible to reach the condition numRoundsClaimed[msg.sender] == roundId - 1.

We want to report two test scripts. The initial condition is the same: user2 won at roundId = 8000 for the very first time. So numRoundsClaimed[msg.sender] = 0. Then, user2 decides to not claim the reward until roundId = 10000.

The first script tries to claim the reward using a single call to MergingPool.claimRewards(): it reaches the block gas limit:

This test can be added to MergingPool.t.sol

function testClaimRewardsDOS() public {
    address user1 = vm.addr(1);
    address user2 = vm.addr(2);
    address user3 = vm.addr(3);

    _mintFromMergingPool(user1);
    _mintFromMergingPool(user2);
    _mintFromMergingPool(user3);

    uint firstUser1Win = 8000;
    uint totalWinUser2 = 1;
    uint totalRound = 10000;

    uint256[] memory _winnersGeneral = new uint256[](2);
    _winnersGeneral[0] = 0;
    _winnersGeneral[1] = 2;

    uint256[] memory _winnersUser = new uint256[](2);
    _winnersUser[0] = 0;
    _winnersUser[1] = 1;
    
    for (uint i = 0; i < totalRound; i++) {
        if (i >= firstUser1Win && i < firstUser1Win + totalWinUser2) {
            _mergingPoolContract.pickWinners(_winnersUser);
        } else {
            _mergingPoolContract.pickWinners(_winnersGeneral);
        }
    }

    string[] memory _modelURIs = new string[](totalWinUser2);
    string[] memory _modelTypes = new string[](totalWinUser2);
    uint256[2][] memory _customAttributes = new uint256[2][](totalWinUser2);
    for (uint i = 0; i < totalWinUser2; i++) {
        _modelURIs[
            i
        ] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelTypes[i] = "original";
        _customAttributes[i][0] = uint256(1);
        _customAttributes[i][1] = uint256(80);
    }

    // numRoundsClaimed(user2) = 0
    console.log(_mergingPoolContract.numRoundsClaimed(user2));

    // user2 tries to claim one fighter (it is the only way to change )
    vm.prank(user2);
    uint gasBefore = gasleft();
    _mergingPoolContract.claimRewards(
        _modelURIs,
        _modelTypes,
        _customAttributes,
        uint32(firstUser1Win + 1)
    );
    
    // numRoundsClaimed(user2) = 8001

    uint gasAfter = gasleft();
    uint gasDiff = gasBefore - gasAfter;
    emit log_uint(gasDiff);
    uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(user2);
    assertEq(numRewards, 0);
    assertGt(gasDiff, 4_000_000);
}

Output:
emit log_uint(val: 18097345 [1.809e7]) //gasDiff
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.44s (1.44s CPU time)

The second script tries to claim reward using a many calls to MergingPool.claimRewards(). Each of them uses totalRoundsToConsider = 1000.

function testClaimRewardsDOS() public {
    address user1 = vm.addr(1);
    address user2 = vm.addr(2);
    address user3 = vm.addr(3);

    _mintFromMergingPool(user1);
    _mintFromMergingPool(user2);
    _mintFromMergingPool(user3);

    uint firstUser1Win = 8000;
    uint totalWinUser2 = 1;
    uint totalRound = 10000;

    uint256[] memory _winnersGeneral = new uint256[](2);
    _winnersGeneral[0] = 0;
    _winnersGeneral[1] = 2;

    uint256[] memory _winnersUser = new uint256[](2);
    _winnersUser[0] = 0;
    _winnersUser[1] = 1;
    
    for (uint i = 0; i < totalRound; i++) {
        if (i >= firstUser1Win && i < firstUser1Win + totalWinUser2) {
            _mergingPoolContract.pickWinners(_winnersUser);
        } else {
            _mergingPoolContract.pickWinners(_winnersGeneral);
        }
    }

    string[] memory _modelURIs = new string[](totalWinUser2);
    string[] memory _modelTypes = new string[](totalWinUser2);
    uint256[2][] memory _customAttributes = new uint256[2][](totalWinUser2);
    for (uint i = 0; i < totalWinUser2; i++) {
        _modelURIs[
            i
        ] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelTypes[i] = "original";
        _customAttributes[i][0] = uint256(1);
        _customAttributes[i][1] = uint256(80);
    }

    // numRoundsClaimed(user2) = 0
    console.log(_mergingPoolContract.numRoundsClaimed(user2));

    // user2 tries to claim one fighter (it is the only way to change )
    vm.startPrank(user2);
    for (uint i = 0; i < 9; i++) {
        uint gasBefore = gasleft();
        _mergingPoolContract.claimRewards(
            _modelURIs,
            _modelTypes,
            _customAttributes,
            1000
        );

        uint gasAfter = gasleft();
        uint gasDiff = gasBefore - gasAfter;
        emit log_uint(gasDiff);
        assertLt(gasDiff, 4_000_000); // In this case, the gas block limit isn't reached.
    }

    uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(user2);
    assertEq(numRewards, 0);
}

Output:
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.44s (1.44s CPU time)

In this case, the player manages to claim all his/her rewards using multiple calls to MergingPool.claimRewards() and a small value of totalRoundsToConsider.

Even if this approach solves the vulnerability, it is gas-expensive. To mitigate the gas cost, we propose to update the numRoundsClaimed value when pickWinners is called by the admin. In this way, when a new player tries to use MergingPool.claimRewards() and roundId is high, he hasn't made multiple calls to MergingPool.claimRewards() and waste gas.

    function pickWinners(uint256[] calldata winners) external {
        require(isAdmin[msg.sender]);
        require(winners.length == winnersPerPeriod, "Incorrect number of winners");
        require(!isSelectionComplete[roundId], "Winners are already selected");
        uint256 winnersLength = winners.length;
        address[] memory currentWinnerAddresses = new address[](winnersLength);
        for (uint256 i = 0; i < winnersLength; i++) {
            currentWinnerAddresses[i] = _fighterFarmInstance.ownerOf(winners[i]);
            totalPoints -= fighterPoints[winners[i]];
            fighterPoints[winners[i]] = 0;
+           if(numRoundsClaimed(currentWinnerAddresses[i]) == 0){
+               numRoundsClaimed(currentWinnerAddresses[i]) = roundId;
+           }
        }
        winnerAddresses[roundId] = currentWinnerAddresses;
        isSelectionComplete[roundId] = true;
        roundId += 1;
    }

Similar consideration can be applied to RandedBattle.claimNRN()

H-08 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

H-08: Player can mint more fighter NFTs during claim of rewards by leveraging reentrancy on the claimRewards() function

Comments

Players who own fighters using smart contract wallets could reenter Merging::claimRewards when a minting occurred, because FighterFarm, being an ERC721 contract, would call onERC721Received on the smart contract owner. Because of this, Merging::claimRewards was vulnerable to reentrancy: players could mint more NFTs than their were supposed to.

Mitigation

PR #6
The MergingPool contract now inherits the OpenZeppelin's ReentrancyGuard, and its nonReentrant modifier is correctly used in Merging::claimRewards. This protects Merging::claimRewards against the attack described in H-08.

Suggestion

None

Conclusion

LGTM

[ADD-02] Mitigation Error - `toEthSignedMessageHash()` is not in ECDSA.sol

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/d81beee0df9c5465fe3ae954ce41300a9dd60b7f/src/Verification.sol#L20

Vulnerability details

Impact

Verification.verify() reverts. This breaks AAMintPass.claimMintPass(), FighterFarm.claimFighters() and FighterFarm.redeemMintPass().

Proof of Concept

Verification.sol#L20:

bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();

will revert because toEthSignedMessageHash() is located in MessageHashUtils.sol.

This breaks AAMintPass.claimMintPass(), FighterFarm.claimFighters() and FighterFarm.redeemMintPass(), which call Verification.verify().

Recommended Mitigation Steps

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
+ import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

...

- bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
+ bytes32 ethSignedMessageHash = MessageHashUtils.toEthSignedMessageHash(messageHash);

Assessed type

Library

M-04 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

M-04: DoS in MergingPool::claimRewards function and potential DoS in RankedBattle::claimNRN function if called after a significant amount of rounds passed

Comments

Previously, the MergingPool::claimRewards function loop could exceed the block gas limit, potentially causing a DoS. This would happen if a user tried claiming their rewards after too many rounds had passed. Similarly, there was a risk of DoS in RankedBattle::claimNRN for the same reason.

Mitigation

PR #12
Now the issue in both functions is fixed thanks to an additional input uint32 totalRoundsToConsider which allows users to loop fewer rounds per call. So, even if the amount of rounds is huge, it's possible to loop a fixed amount of rounds per call without exceeding the block gas limit.

Suggestion

Even though DoS is now avoided, users will still have to pay a high price for claiming rewards and could suffer from bad UX, since claiming rewards could take several transactions.

Consider using a claiming strategy that doesn't depend on loops. For example, in the case of claimRewards, the function could receive a round id array and a winner address index array and then mark the (id, index) pairs as claimed. This claimed state variable could be packed together with the winner address within the winnerAddresses array.

Conclusion

The mitigation prevents the DoS issue, but could do a better job at preventing users from spending a lot of gas in claiming transactions.

M-05A Unmitigated

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/FighterFarm.sol#L366
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/MergingPool.sol#L142-L175
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/FighterFarm.sol#L425
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/FighterFarm.sol#L241

Vulnerability details

C4 issue

M-05A: Users can get benefited from DNA pseudorandomly calculation

Comments

Before, the pseudorandom derivation of new fighters' DNA was vulnerable to manipulation in several ways:

  1. owner address manipulation in FighterFarm::claimFighters and FighterFarm::reRoll.
  2. users could freely choose mintPassDnas values to pass to FighterFarm::redeemMintPass.
  3. users could delay the minting of fighters till fighters.length resulted in a beneficial DNA in FighterFarm::mintFromMergingPool in FighterFarm::claimFighters.
  4. it was possible to know in advance which re-roll would result in the best DNA in FighterFarm::reRoll, because of numRerolls[tokenId] being used to generate the DNA hash.

Mitigation

PR #11
This issue has been only partially mitigated.

FighterFarm::redeemMintPass

There's one instance in which the mitigation works. FighterFarm::redeemMintPass now relies on a trusted _delegatedAddress which controls what parameters are being used, including the mintPassDnas values which are keccak256 hashed into DNAs. This fix was introduced as mitigation of H-03.

FighterFarm::claimFighters

On the other hand, FighterFarm::claimFighters now relies as well on a trusted _delegatedAddress, but it signs:

bytes32 msgHash = bytes32(keccak256(abi.encode(
    msg.sender, 
    numToMint[0], 
    numToMint[1],
    nftsClaimed[msg.sender][0],
    nftsClaimed[msg.sender][1]
)));

and then msg.sender, nftsClaimed[msg.sender][0] and nftsClaimed[msg.sender][1] are used to generate the DNA hash. In all scenarios in which msg.sender could be manipulated, for example if promotional campaigns are run in which users could precompute and use the address resulting in the best DNA, the issue remains unmitigated. Note that nftsClaimed[msg.sender] values of newly generated addresses are always 0 and numToMint could probably be known or assumed in advance.

Another consequence of the fix that seems incorrect is that all fighters minted within a FighterFarm::claimFighters call will have the same DNA, because msg.sender, nftsClaimed[msg.sender][0] and nftsClaimed[msg.sender][1] don't change among iterations in:

for (uint16 i = 0; i < totalToMint; i++) {
    _createNewFighter(
        msg.sender, 
        uint256(keccak256(abi.encode(msg.sender, nftsClaimed[msg.sender][0], nftsClaimed[msg.sender][1]))),
        modelHashes[i], 
        modelTypes[i],
        i < numToMint[0] ? 0 : 1,
        0,
        [uint256(100), uint256(100)]
    );
}

Note that previously fighter.length would increase as each fighter was minted, and therefore each fighter would have a different DNA.

FighterFarm::mintFromMergingPool

FighterFarm::mintFromMergingPool now sets the new dna to uint256(keccak256(abi.encode(to, fighters.length))), making the dna dependent on the current amount of fighters and the user address. Using to is relatively safe, taking into account that winners of NFTs in the MergingPool raffle would have to play the game for probably a long time before they win, so manipulating the address in advance would require a lot of speculation on several variables they don't control.

However, users calling this function have full control regarding when to call MergingPool::claimRewards. This means that users can delay the claim till fighters.length results in a DNA they like, which can be accomplished by pre-computing the expected DNAs for all the following fighters.length values.

FighterFarm::reRoll

In this case msg.sender is no longer used for the DNA generation, but it is vulnerable since it still uses tokenId and numRerolls[tokenId]. tokenId is to some extent manipulable, because ids are incremental and users claiming new fighters have full control of when they execute the claim. Additionally, all possible re-rolling DNAs could be calculated in order to know which one will be the best one.

With this information, players could choose to some degree the token ID of their new fighter and re-roll it up to maxRerollsAllowed times until they get their desired DNA. There could even be a front-running madness scenario for claiming new fighters with exceptionally good token IDs, since re-rolled DNAs depend on token ID and amount of re-rolls.

Suggestion

  1. Don't use the current amount of minted fighters (fighters.length) as a randomness source, since users could take advantage of it and speculate on dnas they could get if they wait for minting.
  2. Speculating on fighters.length also means that user can speculate on token IDs. Avoid using token IDs for DNA re-rolling, since it can be manipulated to some degree.
  3. Avoid using msg.sender in FighterFarm::claimFighters.

Conclusion

The mitigation does not fully solve the DNA generation issues.

Assessed type

Other

M-05A Unmitigated

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/FighterFarm.sol#L241
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/FighterFarm.sol#L424

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L214
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L379

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/FighterFarm.sol#L241
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/FighterFarm.sol#L424

Vulnerability details

The issue was reported in #1017.

It describes 4 impacts:

  1. DNA malipulation in FighterFarm.reRoll(). It was mitigated in #16.
  2. DNA malipulation in FighterFarm.mintFromMergingPool(). It was mitigate in #3.
  3. DNA malipulation in FighterFarm.redeemMintPass(). It was mitigated in #10.
  4. DNA malipulation in FighterFarm.claimFighters(). It was mitigated in #11.

The M-05A mitigation aims to mitigate 1) and 4).

The vulnerabilities rely on the fact that dna depends on an external input that can be used by a malicious
player to obtain rare fighters. In detail, in both of them the computation of dna depends on msg.sender
and other parameters that can foreseen. A malicious player can create many wallet, or could use Create2
to create a contract at wanted address.

Mitigation applied by developers

FighterFarm.claimFighters()

@@ -220,7 +220,7 @@ contract FighterFarm is ERC721, ERC721Enumerable {
        for (uint16 i = 0; i < totalToMint; i++) {
            _createNewFighter(
                msg.sender, 
-               uint256(keccak256(abi.encode(msg.sender, fighters.length))),
+               uint256(keccak256(abi.encode(msg.sender, nftsClaimed[msg.sender][0], nftsClaimed[msg.sender][1]))),
                modelHashes[i], 
                modelTypes[i],
                i < numToMint[0] ? 0 : 1,
FighterFarm.reRoll()

@@ -403,7 +403,7 @@ contract FighterFarm is ERC721, ERC721Enumerable {
        bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
        if (success) {
            numRerolls[tokenId] += 1;
-           uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
+           uint256 dna = uint256(keccak256(abi.encode(tokenId, numRerolls[tokenId])));
            (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
            fighters[tokenId].element = element;
            fighters[tokenId].weight = weight;

Comment about the Mitigation Proposal

We think this mitigation doesn't solve the initial issue.

FighterFarm.claimFighters()

It is still possible to forecast the dna outcome. When a player uses FighterFarm.claimFighters() for the
first time, the values of nftsClaimed[msg.sender][0] and nftsClaimed[msg.sender][1] are 0. Now,
the malicious player has to build a wallet, i.e., has to find the correct msg.sender for the tuple (msg.sender, 0, 0)
to obtain dna for a rare fighter.
Before the mitigation, the malicious player should obtain the right fighter.length. This value could be forced,
but it was a value that didn't depend on player input and it is not fixed. After mitigation, the issue isn't solve.
The situation is even worse.

FighterFarm.reRoll()

It is still possible to forecast the dna outcome. Furthermore, a malicious player could wait the right
tokenId, i.e., he/she could wait to mint a redeem pass until he/she knows it can be obtained a good tokenId,
which can be used with a specific numRerolls in range [0, maxRerollsAllowed[fighterType]] to obtain a rare
fighter. Now, dna didn't rely anymore on a value supplied by an external source. However, it is still
possible to force FighterFarm.reRoll() to obtain a rare fighter

Conclusion

Thanks to reason above, we think the issue is unmitigated. It is still possible to forecast fighter attributes
and force protocol to obtain rare fighters. We propose to use an external oracle or to add block.timestamp
to dna computation to make harder to obtain wanted dna

Assessed type

Other

H-01 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/70b73ce5acaf10bc331cf388d35e4edff88d4541/src/FighterFarm.sol#L338-L348

https://github.com/code-423n4/2024-02-ai-arena/blob/70b73ce5acaf10bc331cf388d35e4edff88d4541/src/FighterFarm.sol#L355-L365

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/d81beee0df9c5465fe3ae954ce41300a9dd60b7f/src/FighterFarm.sol#L398-L410

Vulnerability details

The issue was reported in #739 and #1709. Even if the former is indicated in the mitigation contest Github repository, the latter was selected for the report.

FighterFarm inherits from ERC721. This means that it inherits ERC721's methods. In particular, it inherits three methods which aim to transfer tokens ownership:

Ai Arena developers wrote two FighterFarm's methods to overwrite the ERC721 ones:

transferFrom(address from, address to, uint256 tokenId):

FighterFarm.sol#L338-L348

338      function transferFrom(
339          address from, 
340          address to, 
341          uint256 tokenId
342      ) 
343          public 
344          override(ERC721, IERC721)
345      {
346          require(_ableToTransfer(tokenId, to));
347          _transfer(from, to, tokenId);
348      }

and function safeTransferFrom(address from, address to, uint256 tokenId)

FighterFarm.sol#L355-L365

355      function safeTransferFrom(
356          address from, 
357          address to, 
358          uint256 tokenId
359      ) 
360          public 
361          override(ERC721, IERC721)
362      {
363          require(_ableToTransfer(tokenId, to));
364          _safeTransfer(from, to, tokenId, "");
365      }

In both of these methods, developers use require(_ableToTransfer(tokenId, to)) to check if it is allowed to transfer that specific fighter NFT.
However, the ERC721's method safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) is not overwritten, and so it can be called by the owner of an NFT to make the transfer without the _ableToTransfer(tokenId, to) check.
This leads to several issues, as described in Issue #1709. There are mainly two impacts:

  • Impact 1: a fighter becomes unstoppable, game server unable to commit
  • Impact 2: another fighter can't win, game server unable to commit

Recommended Mitigation proposed by wardens

The recommended mitigation step is to overwrite the ERC721's method safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) instead of safeTransferFrom(address from, address to, uint256 tokenId):

function safeTransferFrom(
    address from, 
    address to, 
    uint256 tokenId,
    bytes memory data
) 
    public 
    override(ERC721, IERC721)
{
    require(_ableToTransfer(tokenId, to));
    _safeTransfer(from, to, tokenId, data);
}

In this way, the ERC721's default method is no longer available, and this method checks if to can transfer tokenId.
The ERC721 default method safeTransferFrom(address from, address to, uint256 tokenId) isn't overridden; so, it is possible to use it:

ERC721.sol#L152-L154

152      function safeTransferFrom(address from, address to, uint256 tokenId) public {
153          safeTransferFrom(from, to, tokenId, "");
154      }

However, it calls safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data): this means that also safeTransferFrom(address from, address to, uint256 tokenId) checks if to can transfer tokenId.

This solution was implemented by the Ai Arena team

Comment about the Mitigation Proposal

We think the mitigation is enough to solve the issue. Using it, there is no way to transfer ownership of a Fighter bypassing the _ableToTransfer(tokenId, to) check. _ableToTransfer(tokenId, to) is used by all three ERC721 methods that aim to transfer the ownership of an NFT.
If developers think that the parameter data is useless, they could avoid passing it to _safeTransfer.
So, we propose a bit cleaner version of the mitigation:

@@ -406,7 +406,7 @@ contract FighterFarm is ERC721, ERC721Enumerable {
         override(ERC721, IERC721)
     {
         require(_ableToTransfer(tokenId, to));
-        _safeTransfer(from, to, tokenId, data);
+        _safeTransfer(from, to, tokenId, "");
     }

     /// @notice Rolls a new fighter with random traits.

Furthermore, we think it would be safer and more player-friendly if it wasn't the possibility to use transferFrom(address from, address to, uint256 tokenId). In this way, players can transfer their fighters only to another EOA or to a contract that can manage it.
We wrote a simple test to show how it is possible to transfer the ownership of a fighter to a contract that is not able to receive it:

function testTransferFromToNoERC721Receiver() public {
    _mintFromMergingPool(_ownerAddress2);
    assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress2);

    vm.startPrank(_ownerAddress2);

    // This reverts because _helperContract is not able to receive ERC721
    vm.expectRevert();
    _fighterFarmContract.safeTransferFrom(_ownerAddress2, address(_helperContract), 0);

    // This transfers token ownership successfully. Now, the token 0 is stuck inside the _helperContract
    _fighterFarmContract.transferFrom(_ownerAddress2, address(_helperContract), 0);
    vm.stopPrank();

    assertEq(_fighterFarmContract.ownerOf(0), address(_helperContract));
}

For example, we propose to make transferFrom(address from, address to, uint256 tokenId) unusable:

@@ -385,8 +385,7 @@ contract FighterFarm is ERC721, ERC721Enumerable {
         public
         override(ERC721, IERC721)
     {
-        require(_ableToTransfer(tokenId, to));
-        _transfer(from, to, tokenId);
+        require(false, "Please use safeTransferFrom()");
     }

     /// @notice Safely transfers an NFT from one address to another.

FighterFarm.reRoll() method works wrong and allows minting wanted attributes

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/FighterFarm.sol#L412-L436

Vulnerability details

Impact

Each fighter has a specific number of max reRolls, according to fighterType. This means that the owner of the fighter can call FighterFarm.reRoll() at most maxRerollsAllowed times, in order to obtain better fighter's attributes.

We want to underline three aspects:

  • FighterFarm.reRoll() operation has a cost in NRN
  • numRerolls of a specific fighter is not reset when it is transfered
  • dna is the only value used to compute fighter's traits. After mitigation, dna depends just on tokenId and numRerolls[tokenId]

Because the reasons above, the owner of a fighter can always forecast what is the best number of reRolls.
So, a cunning player should always improve its fighter's traits using the right number of reRolls. For this reason, we could expect that all fighters will be reRolled until they reach the best number of reRolls.
Furthermore, a malicious player can wait for the right tokenId which can be used in reRoll operation to obtain a very rare fighter.

Remaining reRolls of a fighter don't increase its value

We asked in a private thread a clarification on the FighterFarm.reRoll() mechanism. This was the answer:

I: I've another question on reRoll method. A fighter can be reRolled several times. 
The limit is the MasRerollsAllowed value. When a fighter is transferred from a player to other,
the numRerolls value is not reset. This means that the onwer can't reRoll the receive fighter, if
the previous owner used all roll changes. Also this is a wanted behavior?

Dev: Yes this is intended. You can think of this as someone potentially paying a premium for an NFT that has 
more reRolls remaining. They should be intrinsically more valuable since they have more optionality.

While this was true before the mitigation, now the dna doesn't depend on msg.sender anymore. This means that the outcomes of reRoll operations made by the seller is the same of the outcomes obtained by the buyer. If they are both cunning players, they know before the transfer if that fighter would improve using FighterFarm.reRoll() operation or not.
We want to underline that this mechanism strongly changes after the mitigation. As long as the outcome of reRoll operation depended on msg.sender, buying a fighter with remaining reRoll operations made sense, because buyer reRoll operations would have different outcome then the seller ones.

After mitigation, seller and buyer can reach the same rare attributes. They both should be aware on the best outcome of reRoll operation. If not, it could happen because one or both of them are not cunning: the transfer could be not fair, impacting the game and the market.

So, after mitigation, the reRoll operation appears as a pretense to pay NRNs. Its initial aim is lost.

The only right thing to do is to reach the best numRerolls for each fighter

As we said above, remaining reRolls of a fighter don't increase its market value. So, why a player should decide to not perform reRoll operations until reaching the best attributes combination? In this way, each player is forced to use NRNs to reach the best combination, because it doens't make sense to have a non-optimal fighter: it hasn't more market value and it can become a better fighter for sure.

A malicious player could wait the right tokenId to mint its fighter and reRoll it

A malicious player could wait to redeem his/her mint pass until he can obtain the wanted tokenId. This can be done because that player has foreseen that the wanted tokenId combined with a specific numRerolls permit obtaining a very rare fighter.

Conclusion

Now, the reRoll operation seems useless. It can be used only to have different battle traits (weight and element). So we propose reRoll modifies just the battle attributes.

If somebody would sell his/her fighter, remaining numRerolls doesn't influence the fighter value. Before mitigation, there was the possibility that a new player with the right msg.sender would obtain better attrbutes for a fighter. Now, all players can obtain the same and foreseenable outcome from reRoll operations. Furthermore, now malicious players have the possibility to precompute dna despite their msg.sender and thus they can create fighter with wanted rare attributes.

Proof of concept

This is the mitigated FighterFarm.reRoll() operation:

/// @notice Rolls a new fighter with random traits.
/// @param tokenId ID of the fighter being re-rolled.
/// @param fighterType The fighter type.
function reRoll(uint256 tokenId, uint8 fighterType) public {
    require(msg.sender == ownerOf(tokenId));
    require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
    require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");


    _neuronInstance.approveSpender(msg.sender, rerollCost);
    bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
    if (success) {
        numRerolls[tokenId] += 1;
        uint256 dna = uint256(keccak256(abi.encode(tokenId, numRerolls[tokenId])));
        (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
        fighters[tokenId].element = element;
        fighters[tokenId].weight = weight;
        fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
            newDna,
            generation[fighterType],
            fighters[tokenId].iconsType,
            fighters[tokenId].dendroidBool
        );
        _tokenURIs[tokenId] = "";
    }
}    

The dna is computed at line FighterFarm.sol#L424:

            uint256 dna = uint256(keccak256(abi.encode(tokenId, numRerolls[tokenId])));

It depends just on tokenId, which never change, and numRerolls[tokenId], which can change only using FighterFarm.reRoll() operation.

Let's maxRerollsAllowed[fighterType] = 10. This means this fighter can be rerolled 10 times. We can forecast the dna results for each of 10 reroll operations. For example, we forecast that the 5th reRolled operation will reach the best attributes combination. So, we know before the first reRoll operation that it not makes sense to perform more than 5 reRolls.

If this fighter can not reach a good attributes combination, we could try to sell it, hoping we find a naive buyer, with all numRerolls available. On the other hand, if this fighter can reach a good attributes combination, we could make 5 reRolls, obtain the optimal attributes combination and use it in battles; or we could sell it, even before use 5 reRolls, because the reachable optimal attributes combination can be forecast by everyone, and it becomes an intrinsic value of the fighter

Tools Used

Visual inspection

Recommended Mitigation Steps

The issue relies on the pseudorandom mechanism. It is impossible to build a pseudorandom public mechanism that can't be forecasted. Before the mitigation, an external source could be exploited to obtain the wanted reRoll outcomes. Now, the best outcome is intrinsic in fighter. Furthermore, a malicious player could still exploit the tokenId to obtain the wanted reRoll. We strongly suggest to use an external oracle or to add block.timestamp to the computation of dna, to make a bit harder to forecast outcome attributes.

Assessed type

Other

M-08 MitigationConfirmed

Lines of code

Vulnerability details

C4 issue

M-08: Burner role can not be revoked

Comments

Burner roles in the GameItems contract could be set but not revoked by the contract admins.

Mitigation

PR #18
The function setAllowedBurningAddresses:

/// @notice Sets the allowed burning addresses.
/// @dev Only the admins are authorized to call this function.
/// @param newBurningAddress The address to allow for burning.
function setAllowedBurningAddresses(address newBurningAddress) public {
    require(isAdmin[msg.sender]);
    allowedBurningAddresses[newBurningAddress] = true;
}

has been replace with the function adjustBurningAccess, which accepts an additional bool access parameter that determines whether the burner role will be set or revoked for the given address:

/// @notice Adjusts the allowed burning addresses.
/// @dev Only the admins are authorized to call this function.
/// @param burningAddress The address to adjust for burning.
function adjustBurningAccess(address burningAddress, bool access) public {
    require(isAdmin[msg.sender]);
    allowedBurningAddresses[burningAddress] = access;
}

Suggestion

Add the new access parameter to the NatSpec's @param variables.

Conclusion

LGTM

H-03 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

H-03: Players have complete freedom to customize the fighter NFT when calling redeemMintPass and can redeem fighters of types Dendroid and with rare attributes

Comments

Players redeeming mint passes with redeemMintPass() had freedom to choose their new fighter's type and DNA. This should be a random process, not a deterministic one.

Mitigation

PR #10
redeemMintPass() was changed to accept an additional input signature, which makes sure that the input data has been approved or generated by a trusted party, a.k.a. _delegatedAddress:

bytes32 msgHash = bytes32(keccak256(abi.encode(
    mintpassIdsToBurn,
    fighterTypes,
    iconsTypes,
    mintPassDnas,
    modelHashes,
    modelTypes
)));

require(Verification.verify(_delegatedAddress, msgHash, signature));

Suggestion

Compile the contracts with via-ir enabled to avoid stack too deep errors.

Conclusion

LGTM

H-03 Unmitigated

Lines of code

Vulnerability details

Mitigation of H-03: NOT fully mitigated

Mitigated issue

H-03: Players have complete freedom to customize the fighter NFT when calling redeemMintPass and can redeem fighters of types Dendroid and with rare attributes

The issue was that FighterFarm.redeemMintPass() allowed for user controlled input to customize their new fighter.

Mitigation review - "re-requesting of randomness"

A delegated signer scheme like the one in claimFighters() has been implemented in redeemMintPass(). This means that the delegated signer has to approve and sign the input values.

There is a likely issue in the randomness of the signed input. At least the fighter DNA is supposed to be random. Since it is still the user who calls redeemMintPass(), this means the signed message has to be requested by the user from the delegated signer. This is an off-chain process. Since it must be taken into account that the user might lose this signature and need a new one, it should be possible for the user to re-request a signature, which suggests that the DNA is regenerated anew. This is the equivalent of the issue of re-requesting randomness from a randomness oracle.
It cannot be guaranteed that the delegated signer will store the signature indefinitely for the user, and if the DNA is deterministically generated from on-chain data it does not make sense to mitigate the issue in this way.

This means that this mitigation still leaves this an issue of the same kind as M-05: Can mint NFT with the desired attributes by reverting transaction.

Suggested further mitigation

A simple way to amend this would be to have the delegated signer itself call redeemMintPass(). That is, the delegated signer then acts like a random oracle. This is of course more centralized than using a decentralized VRF, but may be acceptable in this case. Or if the delegated singer calls another function to just set the DNA and other values, which are then retrieved in redeemMintPass().
If the delegated signer is used to set some randomness on-chain, it seems a good and transparent solution is to use blockhash(block.number - 1). Since the user has no control over when the delegated signer calls the contract, he cannot influence the randomness. This reduces the centralization to only giving the delegated signer the choice of when to call the contract (to wait for another blockhash).

M-03 Unmitigated

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/d81beee0df9c5465fe3ae954ce41300a9dd60b7f/src/MergingPool.sol#L158-L169

Vulnerability details

C4 issue

M-03: Fighter created by mintFromMergingPool can have arbitrary weight and element

Comments

When users minted new fighters through the MergingPool, validation for weight and element values, passed as input in the customAttributes array, was missing. Therefore, winners of MergingPool NFTs could arbitrarily assign any weight and element to their new fighters.

Mitigation

PR #16
The fixed implemented introduces input validation for the custom attributes provided in MergingPool::claimRewards. element is required to be smaller than the total number of possible elements for the current generation, while weight is required to be in the [65, 95] range:

require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of bounds");

Users calling claimRewards provide one pair of customAttribute per claimable NFT. However, the customAttributes index checked is not the appropriate index claimIndex but the winnerAddresses index j.

The most concerning consequence of this bug is that the custom attribute values could bypass the element and weight requirements if the custom attribute index for a given claim is greater than the max number of winners of the rounds iterated. For example, if there is 1 winner per round for several rounds in a row and a player wins two rounds, customAttributes[1] would not be checked and customAttributes[0] would be checked repeatedly.

Suggestion

Change

for (uint32 j = 0; j < winnersLength; j++) {
    require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
    require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of bounds");
    if (msg.sender == winnerAddresses[currentRound][j]) {
        _fighterFarmInstance.mintFromMergingPool(
            msg.sender,
            modelURIs[claimIndex],
            modelTypes[claimIndex],
            customAttributes[claimIndex]
        );
        claimIndex += 1;
    }
}

to:

for (uint32 j = 0; j < winnersLength; j++) {
    if (msg.sender == winnerAddresses[currentRound][j]) {
        require(customAttributes[claimIndex][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
        require(customAttributes[claimIndex][1] >= 65 && customAttributes[claimIndex][1] <= 95, "MergingPool: weight out of bounds");
        _fighterFarmInstance.mintFromMergingPool(
            msg.sender,
            modelURIs[claimIndex],
            modelTypes[claimIndex],
            customAttributes[claimIndex]
        );
        claimIndex += 1;
    }
}

Conclusion

The implemented mitigation is incorrect. In some scenarios, the element and weight values input in MergingPool::claimRewards could still be arbitrarily set, ignoring the intended input validation.

Assessed type

Invalid Validation

ADD-03 Unmitigated

Lines of code

Vulnerability details

Mitigation of ADD-03: 6/18 mitigated, one error.

Mitigated issue

QA #704

Mitigation review

Only [8], [9], [10], [13], [15] and [18] are mitigated.
[12] contains an error.

[1] No mitigation attempt.
[2] No mitigation attempt.
[3] No mitigation attempt.
[4] No mitigation attempt.
[5] No mitigation attempt.
[6] No mitigation attempt.
[7] No mitigation attempt. (But the issue seems invalid.)
[8] Fixed.
[9] Fixed.
[10] Fixed.
[11] No mitigation attempt. (But the issue seems invalid.)
[12] Fixed here, but with error here. This can only be used to set the next game item. Change tokenId == _itemCount to tokenId < _itemCount, and in CreateGameItem() replace setTokenURI(_itemCount, tokenURI); by _tokenURIs[tokenId] = _tokenURI;.
[13] All fixed: delagated, nft, whos points, recieves.
[14] No mitigation attempt.
[15] All fixed.
[16] No mitigation attempt.
[17] No mitigation attempt.
[18] Fixed.

H-04 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation of H-04: Mitigated

Mitigated issue

H-04: Since you can reroll with a different fighterType than the NFT you own, you can reroll bypassing maxRerollsAllowed and reroll attributes based on a different fighterType

The issue was that FighterFarm.reRoll() allows any fighterType input.

Mitigation review

A check has been added which requires fighterType to correspond to the type indicated by fighters[tokenId].dendroidBool. This fixes the issue.
But since this means that there is precisely only one valid input value of fighterType it seems a better solution would be to simply read this value from fighters[tokenId].dendroidBool, i.e. fighterType = fighters[tokenId].dendroidBool ? 1 : 0;, and remove fighterType as an input.

M-08 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/GameItems.sol#L185-L188

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-47/src/GameItems.sol#L191-L197

Vulnerability details

The issue was reported in #47.

GameItems defines a mapping to track addresses allowed to burn game items, called GameItems.allowedBurningAddresses:

GameItems.sol#L79-L80

79      /// @notice Mapping tracking addresses allowed to burn game items.
80      mapping(address => bool) public allowedBurningAddresses;

allowedBurningAddresses is used only inside from the GameItems.burn() method to permit some addresses burning GameItems:

GameItems.sol#L237-L245

237      /// @notice Burns a specified amount of game items from an account.
238      /// @dev Only addresses listed in allowedBurningAddresses are authorized to call this function.
239      /// @param account The account from which the game items will be burned.
240      /// @param tokenId The ID of the game item.
241      /// @param amount The amount of game items to burn.
242      function burn(address account, uint256 tokenId, uint256 amount) public {
243          require(allowedBurningAddresses[msg.sender]);
244          _burn(account, tokenId, amount);
245      }

allowedBurningAddresses is set by the GameItems.setAllowedBurningAddresses() method:

182      /// @notice Sets the allowed burning addresses.
183      /// @dev Only the admins are authorized to call this function.
184      /// @param newBurningAddress The address to allow for burning.
185      function setAllowedBurningAddresses(address newBurningAddress) public {
186          require(isAdmin[msg.sender]);
187          allowedBurningAddresses[newBurningAddress] = true;
188      }

which permits setting an address, but doens't permit removing an address. In other words, once an address is allowed burning, this authorization can't be removed, neither by the admin.
Even if admin is a trusted address which will set only other trusted addresses to be allowed burning GameItems, it still remains a vulnerability: if there are reasons to set an address, there could be reasons to revoke authorization to the same address. According to this motivation, this issue was judged as "Medium Severity".

Recommended Mitigation proposed by wardens

The mitigation proposed by wardens is to add a method to set or revoke burning access to an address:

function adjustBurningAccess(address burningAddress, bool access) public {
    require(isAdmin[msg.sender]);
    allowedBurningAddresses[burningAddress] = access;
}

This solution was implemented by the Ai Arena team. Furthermore, they removed the GameItems.setAllowedBurningAddresses() method.

Comment about the Mitigation Proposal

We think this is a valid mitigation proposal. In this way, admin role has the completely freedom to modificate allowedBurningAddresses mapping for each address he wants. We suggest to have a limitation on the number of addresses which are allowed at the same moment. Furthermore, we think it could be useful to have a list of allowed addresses: admin could forgot to remove some authorization, leaves some unwanted addresses having permission to burn GameItems.
For this reason, we propose this improvement:

+   uint256 allowedBurningAddressesAmount;
+   uint256 maxAllowedBurningAddressesAmount;
+   address[] public allowedBurningAddressesList;

95      constructor(address ownerAddress, address treasuryAddress_) ERC1155("https://ipfs.io/ipfs/") {
96          _ownerAddress = ownerAddress;
97          treasuryAddress = treasuryAddress_;
98          isAdmin[_ownerAddress] = true;
+           maxAllowedBurningAddressesAmount = 10;
+           allowedBurningAddressesList = new address[]();
99      }

+       function setMaxAllowedBurningAddressesAmount(uint256 newMaxAllowedBurningAddressesAmount) public {
+           require(isAdmin[msg.sender]);
+           maxAllowedBurningAddressesAmount = newMaxAllowedBurningAddressesAmount;
+       }

194     function adjustBurningAccess(address burningAddress, bool access) public {
195         require(isAdmin[msg.sender]);
+           if(access && !allowedBurningAddresses[burningAddress]){
+                allowedBurningAddressesAmount++;
+                allowedBurningAddressesList.push(burningAddress);
+           }
196         allowedBurningAddresses[burningAddress] = access;
197     }

H-01 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

H-01: A locked fighter can be transferred; leads to game server unable to commit transactions, and unstoppable fighters

Comments

The FighterFarm contract was not overriding every ERC721 transfer function and therefore users could bypass transfer restrictions given by _ableToTransfer(). More specifically, safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) was not overriden.

Mitigation

PR #2
Now safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) is correctly overriden and it's checked whether the token id can be transferred. The safeTransferFrom(address from, address to, uint256 tokenId) override was correctly removed, since it calls the former internally, which already calls _ableToTransfer().

Suggestion

None

Conclusion

LGTM

A player who want to call `MergingPool.claimRewards()` have to pass parameters with at least `winnersLength` elements

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/MergingPool.sol#L159-L160

Vulnerability details

Vulnerability description

M-03 finding reports that Fighter created by mintFromMergingPool can have arbitrary weight and element.
To mitigate this issue, developers added two lines to MergingPool.claimRewards() method, that is the only one which can call FighterFarm.mintFromMergingPool():

MergingPool.sol#L142-L175

    function claimRewards(
        string[] calldata modelURIs, 
        string[] calldata modelTypes,
        uint256[2][] calldata customAttributes,
        uint32 totalRoundsToConsider
    ) 
        external nonReentrant
    {
        uint256 winnersLength;
        uint32 claimIndex = 0;
        uint32 lowerBound = numRoundsClaimed[msg.sender];
        require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
        uint8 generation = _fighterFarmInstance.generation(0);
        for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; currentRound++) {
            numRoundsClaimed[msg.sender] += 1;
            winnersLength = winnerAddresses[currentRound].length;
            for (uint32 j = 0; j < winnersLength; j++) {
+               require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
+               require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of bounds");
                if (msg.sender == winnerAddresses[currentRound][j]) {
                    _fighterFarmInstance.mintFromMergingPool(
                        msg.sender,
                        modelURIs[claimIndex],
                        modelTypes[claimIndex],
                        customAttributes[claimIndex]
                    );
                    claimIndex += 1;
                }
            }
        }
        if (claimIndex > 0) {
            emit Claimed(msg.sender, claimIndex);
        }
    }

These two lines check the values of customAttributes passed by the player who is claiming the reward.
We want to report two issues:

Unmitigated risk

It is still possible to pass customAttributes out of the bound. For example, if the maximum value of winnersLength is 5 and a player tries to claim 6 rewards, the 6th will be not checked. The followind is a coded POC:

function testClaimRewardsCustomAttributesOutOfBound() public {
        address user0 = vm.addr(1);
        address user1 = vm.addr(2);
        address user2 = vm.addr(3);

        _mintFromMergingPool(user0);
        _mintFromMergingPool(user1);
        _mintFromMergingPool(user2);

        uint256 totalRound = 6;


        uint256[] memory _winners013 = new uint256[](2);
        _winners013[0] = 0;
        _winners013[1] = 1;

        uint256[] memory _winners023 = new uint256[](2);
        _winners023[0] = 0;
        _winners023[1] = 2;

        uint256 totalWinUser1 = 0;
        
        for (uint i = 0; i < totalRound; i++) {
            if (i%2 == 0) {
                _mergingPoolContract.pickWinners(_winners013);
                totalWinUser1 += 1;
            } else {
                _mergingPoolContract.pickWinners(_winners023);
            }
        }

        string[] memory _modelURIs = new string[](totalWinUser1);
        string[] memory _modelTypes = new string[](totalWinUser1);
        uint256[2][] memory _customAttributes = new uint256[2][](totalWinUser1);

        _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelTypes[0] = "original";
        _customAttributes[0][0] = uint256(1);
        _customAttributes[0][1] = uint256(80);

        _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelTypes[1] = "original";
        _customAttributes[1][0] = uint256(1);
        _customAttributes[1][1] = uint256(80);

        _modelURIs[2] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelTypes[2] = "original";
        _customAttributes[2][0] = uint256(10);
        _customAttributes[2][1] = uint256(99);

        vm.startPrank(user1);
        _mergingPoolContract.claimRewards(
            _modelURIs,
            _modelTypes,
            _customAttributes,
            uint32(totalRound - 1)
        );

        uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(user1);
        assertEq(numRewards, 0);

        assertEq(_fighterFarmContract.balanceOf(user1), 4);
    }

Output:
emit FighterCreated(id: 5, weight: 99, element: 10, generation: 0)

However, M-03 is out of scope. I would just report that this risk is not mitigated.

The wrong usage of index forces players to create and pass an array of customAttributes

Even if a player want to claim one reward, he/she has to pass to MergingPool.claimRewards() arrays with at least winnersLength elements, where winnersLength is the maximum value among winnerAddresses[round].length for analyzed rounds (i.e. rounds in the selected interval).
We suggest to change these two lines:

@@ -156,9 +156,9 @@ contract MergingPool is ReentrancyGuard{
             numRoundsClaimed[msg.sender] += 1;
             winnersLength = winnerAddresses[currentRound].length;
             for (uint32 j = 0; j < winnersLength; j++) {
-                require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: elemen
t out of bounds");
-                require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of b
ounds");
                 if (msg.sender == winnerAddresses[currentRound][j]) {
+                    require(customAttributes[claimIndex][0] < _fighterFarmInstance.numElements(generation), "
MergingPool: element out of bounds");
+                    require(customAttributes[claimIndex][1] >= 65 && customAttributes[claimIndex][1] <= 95, "
MergingPool: weight out of bounds");
                     _fighterFarmInstance.mintFromMergingPool(
                         msg.sender,
                         modelURIs[claimIndex],

Assessed type

Invalid Validation

Review of ADD-05: Issues found

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/RankedBattle.sol#L278
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/RankedBattle.sol#L206-L210

Vulnerability details

Mitigation of ADD-05: Issues found

Reviewed changes

ArenaX-Labs/2024-02-ai-arena-mitigation#15

Low issues

Consider emitting events when rankedOpen and allowedStakingDuringRanked are set, since this affects what users can do.

!rankedOpen || (rankedOpen && allowedStakingDuringRanked) can be simplified to !rankedOpen || allowedStakingDuringRanked.

"UnStaking" should be "Unstaking".

Medium issue 1

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/RankedBattle.sol#L278
The intention behind rankedOpen and allowedStakingDuringRanked is not clear, but it seems it should not be possible to call updateBattleRecord() while !rankedOpen. updateBattleRecord() can only be called by _gameServerAddress, whereas rankedOpen can only be set by isAdmin[msg.sender], so there is bound to be some syncing difficulties between these, and it should be ensured that updateBattleRecord() cannot be called before the ranked battle has opened.

Medium issue 2

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/RankedBattle.sol#L206-L210
Staking and unstaking is controlled in unison. If staking is possible during the battles, the only way to halt staking it is therefore to also halt unstaking. Users may thus stake during the battles and then suddenly not be able to unstake. It seems that if it was possible to stake during the battles it should always be possible to unstake again.
Contrast this with staking and unstaking outside of battles where users can be expected to make up their minds about how much they want to stake before the battles start, and then be committed to this stake (i.e. allowedStakingDuringRanked remains false).
Being allowed to stake during battles should be interpreted as an added opportunity for the user, with an added risk. The opportunity should not be possible to revoke without also allowing the user to withdraw from the added risk.

Consider not allowing a true allowedStakingDuringRanked be set to false while rankedOpen == true. This way the admin's decision to allow staking during battles will always allow users to unstake until the end of the battles.

Assessed type

Timing

M-05B Unmitigated

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/FighterFarm.sol#L366

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L324

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/FighterFarm.sol#L366

Vulnerability details

The issue was reported in #578 and #1017.

The vulnerability is inside FighterFarm.mintFromMergingPool():

FighterFarm.sol#L307-L331

307      /// @notice Mints a new fighter from the merging pool.
308      /// @dev Only the merging pool contract address is authorized to call this function.
309      /// @param to The address that the new fighter will be assigned to.
310      /// @param modelHash The hash of the ML model associated with the fighter.
311      /// @param modelType The type of the ML model associated with the fighter.
312      /// @param customAttributes Array with [element, weight] of the newly created fighter.
313      function mintFromMergingPool(
314          address to, 
315          string calldata modelHash, 
316          string calldata modelType, 
317          uint256[2] calldata customAttributes
318      ) 
319          public 
320      {
321          require(msg.sender == _mergingPoolAddress);
322          _createNewFighter(
323              to, 
324              uint256(keccak256(abi.encode(msg.sender, fighters.length))), 
325              modelHash, 
326              modelType,
327              0,
328              0,
329              customAttributes
330          );
331      }

This function can be called only by the merging pool contract. This means that msg.sender must be _mergingPoolAddress.
However, msg.sender is also used as dna parameter of _createNewFighter() (line L324).

Impact: Even if an attacker can't exploit this bad randomness to obtain better fighters, players could forecast the attributes of fighters that will be created in the future, due to the bad randomness caused by this issue.

Recommended Mitigation proposed by wardens

The mitigation proposal is to replace msg.sender in line L324 with the address to, that should represents the player who calls MergingPool.claimRewards():

FighterFarm.sol#L307-L331

/// @notice Mints a new fighter from the merging pool.
/// @dev Only the merging pool contract address is authorized to call this function.
/// @param to The address that the new fighter will be assigned to.
/// @param modelHash The hash of the ML model associated with the fighter.
/// @param modelType The type of the ML model associated with the fighter.
/// @param customAttributes Array with [element, weight] of the newly created fighter.
function mintFromMergingPool(
    address to, 
    string calldata modelHash, 
    string calldata modelType, 
    uint256[2] calldata customAttributes
) 
    public 
{
    require(msg.sender == _mergingPoolAddress);
    _createNewFighter(
        to, 
-       uint256(keccak256(abi.encode(msg.sender, fighters.length))),
+       uint256(keccak256(abi.encode(to, fighters.length))), 
        modelHash, 
        modelType,
        0,
        0,
        customAttributes
    );
}

This solution was implemented by the Ai Arena team

Comment about the Mitigation Proposal

This finding belongs to a group of issues that reported the low randomness of dna. Two of the most important are #53 and #519. The root cause is that msg.sender can be a bad source of randomness because an attacker could create a malicious contract at the wanted address using, for example, Create2.

In the case above, before the mitigation, nobody could exploit the vulnerability, because the msg.sender value was always the _mergingPoolAddress. After the mitigation, the dna of the new fighter relies on the caller's address. So, the mitigation could introduce the possibility of manipulating the msg.sender address and obtaining wanted attributes.

Attack vector

Let's think that before the current round, fighters.length into FighterFarm.sol is 0. Eve locally tries many combinations and finds that the tuple (address_E, fighters.length=3) permits creating a very rare fighter: Eve can forecast this is because she can exploit the line FighterFarm.sol#214:

FighterFarm.sol#214

214                  uint256(keccak256(abi.encode(msg.sender, fighters.length))),

to obtain dna and then use its value to precompute the new fighter's physical attributes:

FighterFarm.sol#L510-L515

510          FighterOps.FighterPhysicalAttributes memory attrs = _aiArenaHelperInstance.createPhysicalAttributes(
511              newDna,
512              generation[fighterType],
513              iconsType,
514              dendroidBool
515          );

Now, Eve creates a new contract at address_E and tries to win the current round (for example using a new fighter redeemed with a mint pass).
If she wins, she could call MergingPool.claimRewards() just after someone else has obtained the fighter number 3 (in other words, when fighters.length=3).

We know this attack vector could be hard to perform, but we have to report the consequences of mitigation. To solve the bad randomness introduced by this mitigation, we propose to implement something like FighterFarm.redeemMintPass() where the dna is obtained from an external source (for example, from a backend server) and cannot be manipulated by the caller.

Conclusions

In conclusion, the proposed mitigation solves the initial bad randomness due to too little dna combinations space. However, it doesn't solve the issue reported by #1017:

In this function a user can not manipulate the output hash, however, he can compute the hash for the upcoming fighters, 
because when a new fighter is created, the fighters.length will change along with the output hash. As a result, 
a user can claim the MergingPool reward to mint and NFT when the output hash will be benefitial for him.

The malicious user can still forecast the outcome fighter attributes and claim the MergingPool reward when it is more convenient for him/her. Furthermore, it introduces the possibility to manipulate the msg.sender value to obtain a wanted dna and, so, a fighter with wanted valuable and rare attributes. We are going to report this comment as "unmitigated" and the attack vector above as a "new finding".

Assessed type

Other

ADD-04 Unmitigated

Lines of code

Vulnerability details

Mitigation of ADD-04: 4/13 mitigated, High unmitigated

Mitigated issues

#1490

Mitigation review

Only [L-0], [L-5], [L-6] and [L-10] are mitigated.
[L-8] is a High severity issue that has not been mitigated.
Note that [N-0] is similar to M-02 and unmitigated.

[L-0] Fixed. But consider comparing all to mintpassIdsToBurn.length, instead of in a chain, for improved readability.
[L-1] No mitigation attempt.
[L-2] No mitigation attempt.
[L-3] No mitigation attempt.
[L-4] No mitigation attempt.
[L-5] Fixed.
[L-6] Fixed.
[L-7] No mitigation attempt. (Issue is of dubious validity.)
[L-8] Not mitigated. Note that this was upgraded to High.
[L-9] No mitigation attempted.
[L-10] This M-04, which is mitigated (see separate review).
[L-11] No mitigation attempt. The issue is of dubious validity, but do consider whether modelHash should not be bytes32 rather than string.
[N-0] No mitigation attempt. Note that this is similar to M-02.
[N-1] No mitigation attempt.

ADD-01 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/MergingPool.sol#L206

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-48/src/MergingPool.sol#L208

The mitigation proposed has three purposes:

  1. Mitigated claimRewards reentrancy (this mitigation is related with H-8 and we reported our comments in that mitigation review)
  2. fixed uninitialized numElements (this mitigation is related to h-7 and we reported our comments in that mitigation review)
  3. fixed points initialization to match maxId (this is the scope of this mitigation review).

Vulnerability details

The issue was reported in #48 - The implementation of getFighterPoints(uint256 maxId) has an issue, causing it to only access fighterPoints with an ID of 0..
The issue is in MergingPool.sol#L205-L211:

MergingPool.sol#L205-L211

205      function getFighterPoints(uint256 maxId) public view returns(uint256[] memory) {
206          uint256[] memory points = new uint256[](1);
207          for (uint256 i = 0; i < maxId; i++) {
208              points[i] = fighterPoints[i];
209          }
210          return points;
211      }

The array points is initialized with a fixed length of 1, but it is accessed using the range [0, maxId]. In other words, if maxId > 1, this function goes to the Out of bound exception.

Recommended Mitigation proposed by wardens

The proposed mitigation is to correctly initialize the points array:

function getFighterPoints(uint256 maxId) public view returns(uint256[] memory) {
-       uint256[] memory points = new uint256[](1);
+       uint256[] memory points = new uint256[](maxId);
        for (uint256 i = 0; i < maxId; i++) {
            points[i] = fighterPoints[i];
        }
        return points;
    }

This solution was implemented by the Ai Arena team.
However, it is not present in the fix-47 branch, which should be the final one (see here).

Comment about the Mitigation Proposal

This issue was clearly due to a coding mistake. After mitigation, the function can not raise the Out of bound exception, because points.length = maxId and fighterPoints is a mapping between uint256.

We just propose to use pre-increment to save gas:

function getFighterPoints(uint256 maxId) public view returns(uint256[] memory) {
        uint256[] memory points = new uint256[](maxId);
-       for (uint256 i = 0; i < maxId; i++) {
+       for (uint256 i = 0; i < maxId; ++i) {
            points[i] = fighterPoints[i];
        }
        return points;
    }

We consider this issue mitigated.

H-01 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation of H-01: Mitigated

Mitigated issue

H-01: A locked fighter can be transferred; leads to game server unable to commit transactions, and unstoppable fighters

The issue was a missing override of safeTransferFrom() with data in FighterFarm.sol.

Mitigation review - Mitigated

The override of safeTransferFrom(address from, address to, uint256 tokenId) has been replaced by an override of safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data). This works because the former internally calls the latter.

This may not be the neatest mitigation, however. Where it not for a separate check in _createNewFighter() we would have the same issue here with _safeMint().
transferFrom(), both safeTransferFrom(), as well as _safeMint() could all have been covered by a single override of _beforeTokenTransfer(), or _update() in the latest version (v5.0) of OpenZeppelin's ERC721.sol. Then make sure to exclude the case of burning, i.e. when to == address(0), if you want to allow this by means of a transferFrom() to address(0) or in case you implement a burn().
This also clears up the code from the overrides of transferFrom() and safeTransferFrom().

M-06 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/RankedBattle.sol#L145-L146
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/RankedBattle.sol#L375-L380

Vulnerability details

The issue was reported in #137 and #1795.

In each round, a player can decide to assign an amount of NRN to each of his/her fighters: it is called stakeAtRisk. When a battle finishes, the RankedBattle._addResultPoints() is called. There are 5 scenarios:

/// @dev 1) Win + no stake-at-risk = Increase points balance
/// @dev 2) Win + stake-at-risk = Reclaim some of the stake that is at risk
/// @dev 3) Lose + positive point balance = Deduct from the point balance
/// @dev 4) Lose + no points = Move some of their NRN staked to the Stake At Risk contract
/// @dev 5) Tie = no consequence

The vulnerability relies on RankedBattle.sol#L486:

472          } else if (battleResult == 2) {
473              /// If the user lost the match
474  
475              /// Do not allow users to lose more NRNs than they have in their staking pool
476              if (curStakeAtRisk > amountStaked[tokenId]) {
477                  curStakeAtRisk = amountStaked[tokenId];
478              }
479              if (accumulatedPointsPerFighter[tokenId][roundId] > 0) {
480                  /// If the fighter has a positive point balance for this round, deduct points 
481                  points = stakingFactor[tokenId] * eloFactor;
482                  if (points > accumulatedPointsPerFighter[tokenId][roundId]) {
483                      points = accumulatedPointsPerFighter[tokenId][roundId];
484                  }
485                  accumulatedPointsPerFighter[tokenId][roundId] -= points;
486                  accumulatedPointsPerAddress[fighterOwner][roundId] -= points;
487                  totalAccumulatedPoints[roundId] -= points;
488                  if (points > 0) {
489                      emit PointsChanged(tokenId, points, false);
490                  }
491              } else {

If it was possible to reach a state where accumulatedPointsPerAddress[fighterOwner][roundId] is less than points, line #486 would always revert. In this way, when the player loses during roundId, he/she will not lose points. In other words, the player could start earning risk-free rewards.

To reach the above situation, the malicious player Bob has to execute some steps.

  1. Let's assume Bob has two accounts, i.e., two addresses, Bob1 and Bob2.
  2. Bob1 owns the fighter0, i.e., the fighter with tokenId = 0. He decides to stake an amount of 1 NRN on fighter0 calling RankedBattle.stakeNRN()
amountStaked[0] = 1;
fighterStaked[0] = true;
  1. Then, Bob1 loses a battle using fighter0. Then the _gameServerAddress calls RankedBattle.updateBattleRecord() which calls RankedBattle._addResultPoints():
RankedBattle.sol#L491-L497

491              } else {
492                  /// If the fighter does not have any points for this round, NRNs become at risk of being lost
493                  bool success = _neuronInstance.transfer(_stakeAtRiskAddress, curStakeAtRisk);
494                  if (success) {
495                      _stakeAtRiskInstance.updateAtRiskRecords(curStakeAtRisk, tokenId, fighterOwner);
496                      amountStaked[tokenId] -= curStakeAtRisk;
497                  }
amountStaked[0] = 0;
fighterStaked[0] = true;
stakeAtRisk[roundId][0] = 1;
  1. Now, Bob1 calls RankedBattle.unstakeNRN()
amountStaked[0] = 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 1;
  1. Once Bob1 has reached this state, he uses fighter0 to win a battle. He aims to have something to claim. If he win, the amountStaked[0] increases:
RankedBattle.sol#L439-L471

439          curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
440          if (battleResult == 0) {
441              /// If the user won the match
442  
443              /// If the user has no NRNs at risk, then they can earn points
444              if (stakeAtRisk == 0) {
445                  points = stakingFactor[tokenId] * eloFactor;
446              }
447  
448              /// Divert a portion of the points to the merging pool
449              uint256 mergingPoints = (points * mergingPortion) / 100;
450              points -= mergingPoints;
451              _mergingPoolInstance.addPoints(tokenId, mergingPoints);
452  
453              /// Do not allow users to reclaim more NRNs than they have at risk
454              if (curStakeAtRisk > stakeAtRisk) {
455                  curStakeAtRisk = stakeAtRisk;
456              }
457  
458              /// If the user has stake-at-risk for their fighter, reclaim a portion
459              /// Reclaiming stake-at-risk puts the NRN back into their staking pool
460              if (curStakeAtRisk > 0) {
461                  _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner);
462                  amountStaked[tokenId] += curStakeAtRisk;
463              }
464  
465              /// Add points to the fighter for this round
466              accumulatedPointsPerFighter[tokenId][roundId] += points;
467              accumulatedPointsPerAddress[fighterOwner][roundId] += points;
468              totalAccumulatedPoints[roundId] += points;
469              if (points > 0) {
470                  emit PointsChanged(tokenId, points, true);
471              }
amountStaked[0] = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4 = bpsLostPerLoss/ 10**4 = X > 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 1;
  1. Now, let's start a new round: RankedBattle.setNewRound():
RankedBattle.sol#L233-L239

233      function setNewRound() external {
234          require(isAdmin[msg.sender]);
235          require(totalAccumulatedPoints[roundId] > 0);
236          roundId += 1;
237          _stakeAtRiskInstance.setNewRound(roundId);
238          rankedNrnDistribution[roundId] = rankedNrnDistribution[roundId - 1];
239      }
amountStaked[0] = X > 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 0;

Now Bob has obtained the critical situation. When a fighter token has an amount staked on it, it should be not possible to transfer it. However,
the transfer method just checks the value of fighterStaked[tokenId]:

FighterFarm.sol#L539-L545

539      function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) {
540          return (
541            _isApprovedOrOwner(msg.sender, tokenId) &&
542            balanceOf(to) < MAX_FIGHTERS_ALLOWED &&
543            !fighterStaked[tokenId]
544          );
545      }
  1. Bob uses fighter0 to win a battle:
amountStaked[0] = Y > 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 0;

accumulatedPointsPerFighter(tokenId, roundId) = Z;
accumulatedPointsPerAddress(Bob1, roundId)  = Z;
  1. Then Bob transfer fighter0 from Bob1 to Bob2. As we said above, he is allowed to do it.
amountStaked[0] = Y > 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 0;

accumulatedPointsPerFighter(tokenId, roundId) = Z;
accumulatedPointsPerAddress(Bob2, roundId)  = 0;

In this situation, when fighter0 loses, it is not possible to call RankedBattle.updateBattleRecord(), because line #486 would always revert:

RankedBattle.sol#L486

486                  accumulatedPointsPerAddress[fighterOwner][roundId] -= points;

In other words, during the current round, Bob earns risk-free rewards.

Recommended Mitigation proposed by wardens

The mitigation proposed by #137 is to update RankedBattle.unstakeNRN():

function unstakeNRN(uint256 amount, uint256 tokenId) external {
    require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter");
    if (amount > amountStaked[tokenId]) {
        amount = amountStaked[tokenId];
    }
    amountStaked[tokenId] -= amount;
    globalStakedAmount -= amount;
    stakingFactor[tokenId] = _getStakingFactor(
        tokenId, 
        _stakeAtRiskInstance.getStakeAtRisk(tokenId)
    );
    _calculatedStakingFactor[tokenId][roundId] = true;
    hasUnstaked[tokenId][roundId] = true;
    bool success = _neuronInstance.transfer(msg.sender, amount);
    if (success) {
-       if (amountStaked[tokenId] == 0) {
+       if (amountStaked[tokenId] == 0 && _stakeAtRiskInstance.getStakeAtRisk(tokenId) == 0) {
            _fighterFarmInstance.updateFighterStaking(tokenId, false);
        }
        emit Unstaked(msg.sender, amount);
    }
}

In this way, it should not be possible to reach a state like this:

amountStaked[0] = 0;
fighterStaked[0] = false;
stakeAtRisk[roundId][0] = 1;

which breakes an invariant: fighterStaked[tokenId] = false iff stakeAtRisk[roundId][tokenId] == 0.

This solution was implemented by the Ai Arena team.

Mitigation applied by developers

Developers have applied the mitigation proposed by #137.
Furthermore, they decided to add a specific mapping, called addressStartedRound, to check whether the owner of a fighter
changes during a round:

@@ -133,6 +133,9 @@ contract RankedBattle {
    /// @notice Indicates whether we have calculated the staking factor for a given round and token.
    mapping(uint256 => mapping(uint256 => bool)) _calculatedStakingFactor;

+   /// @notice Maps token ID to round ID to starting address.
+   mapping(uint256 => mapping(uint256 => address)) public addressStartedRound;
+
    /*//////////////////////////////////////////////////////////////
                               CONSTRUCTOR
    //////////////////////////////////////////////////////////////*/
@@ -337,6 +340,13 @@ contract RankedBattle {
            _voltageManagerInstance.ownerVoltage(fighterOwner) >= VOLTAGE_COST
        );

+       if (addressStartedRound[tokenId][roundId] == address(0)) {
+         addressStartedRound[tokenId][roundId] = fighterOwner;
+       }
+       else {
+         require(addressStartedRound[tokenId][roundId] == fighterOwner);
+       }
+
        _updateRecord(tokenId, battleResult);
        uint256 stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId);
        if (amountStaked[tokenId] + stakeAtRisk > 0) {

This solution was implemented by the Ai Arena team.

Comment about the Mitigation Proposal

As we said above, thanks to mitigation proposed by wardens, it should not be possible to break the invariant

fighterStaked[tokenId] = false iff stakeAtRisk[roundId][tokenId] == 0

amountStaked[tokenId] can be modified only by 4 events:

  • A call to stakeNRN (which increases it)
  • A call to unstakeNRN (which decreases it)
  • A win in a battle (which increases it only if the previous amountStaked[tokenId]>0 or stakeAtRisk>0)
  • A loss in a battle (which decreases it)

Among them, only unstakeNRN() sets fighterStaked[tokenId] = false

Before mitigation, the state that should never be reached is the following:

amountStaked[0] = 0;
fighterStaked[0] = true;
stakeAtRisk[roundId][0] = Y>0;

stakeAtRisk can increase only when the player loses a battle having amountStaked[tokenId]>0 or stakeAtRisk>0. In other words, the player can have stakeAtRisk>0 during this round iff the player has called stakeNRN with amount > 0 during this round, i.e., iff fighterStaked[0] = true.
In the state above, it was possible to call unstakeNRN() successfully.

After mitigation, the state above is not critical anymore: it is not possible to call unstakeNRN() successfully. Now, to obtain fighterStaked[0] = false, unstakeNRN(amount, tokenId) can be called only from the following state:

amountStaked[tokenId] = amount;
fighterStaked[tokenId] = true/false;
stakeAtRisk[roundId][tokenId] = 0;

After this call, the only state reachable is

amountStaked[tokenId] = 0;
fighterStaked[tokenId] = false;
stakeAtRisk[roundId][tokenId] = 0;

which is the right one.
In conclusion, thanks to the mitigation proposed by wardens, the vector attack above is no longer feasible. The part of mitigation proposed by developers is not used yet. addressStartedRound is not used in any check. However, we think that it would be unnecessary in any case, and it can be deleted.

H-07 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-47/src/FighterFarm.sol#L137-L144

Vulnerability description

The issue was reported in #45.

This issue is due to the missing initialization of numElements mapping with an index greater than 0.
numElements describe the number of elements for each generation and it is defined in FighterFarm.sol#L84-L85:

FighterFarm.sol#L84-L85

84      /// @notice Mapping of number elements by generation.
85      mapping(uint8 => uint8) public numElements;

This means that each index of numElements is initialized with value 0. Then, inside FighterFarm.constructor(), numElements[0] is defined in FighterFarm.sol#L110:

FighterFarm.sol#L104-L111

104      constructor(address ownerAddress, address delegatedAddress, address treasuryAddress_)
105          ERC721("AI Arena Fighter", "FTR")
106      {
107          _ownerAddress = ownerAddress;
108          _delegatedAddress = delegatedAddress;
109          treasuryAddress = treasuryAddress_;
110          numElements[0] = 3;
111      } 

There is no other function that can set numElements for generations > 0. This means that numElements[i]=0 for i > 0.
numElements is used only inside FighterFarm._createFighterBase() at FighterFarm.sol#L470:

FighterFarm.sol#L462-L474

462      function _createFighterBase(
463          uint256 dna, 
464          uint8 fighterType
465      ) 
466          private 
467          view 
468          returns (uint256, uint256, uint256) 
469      {
470          uint256 element = dna % numElements[generation[fighterType]];
471          uint256 weight = dna % 31 + 65;
472          uint256 newDna = fighterType == 0 ? dna : uint256(fighterType);
473          return (element, weight, newDna);
474      }

So, when generations > 0, numElements=0, and the formula at FighterFarm.sol#L470 tries to apply modulo 0, which reverts. This cause a DoS of two core functions: FighterFarm._createNewFighter() (when it is used with param customAttributes[0] = 100), and FighterFarm.reRoll()

Recommended Mitigation proposed by wardens

The mitigation proposal is to add a function usable just by the admin to update the numElements mapping.

@@ -133,6 +133,15 @@ contract FighterFarm is ERC721, ERC721Enumerable {
        return generation[fighterType];
    }

+   /// @notice Updates the number of elements for a given generation.
+   /// @dev Only the owner address is authorized to call this function.
+   /// @param newNumElements number of elements for the generation.
+   /// @param generation_ generation to be updated.
+   function setNumElements(uint8 newNumElements, uint8 generation_) external {
+       require(msg.sender == _ownerAddress);
+       numElements[generation_] = newNumElements;
+   }
+
    /// @notice Adds a new address that is allowed to stake fighters on behalf of users.
    /// @dev Only the owner address is authorized to call this function.
    /// @param newStaker The address of the new staker

This solution was implemented by the Ai Arena team

Comment about the Mitigation Proposal

The solution above mitigates the issue. Now, the admin can set numElements for a specific generation. However, we suggest some improvements.

  1. It should not be possible to update numElements for the current generation because otherwise, we could have fighter of the same generation with different numElements. Because the generation is defined for each fighterType and can only increase, we propose to make updatable only numElements for generations that have not been reached yet by both fighterType:
FighterFarm.sol#L137-L144

137      /// @notice Updates the number of elements for a given generation.
138      /// @dev Only the owner address is authorized to call this function.
139      /// @param newNumElements number of elements for the generation.
140      /// @param generation_ generation to be updated.
141      function setNumElements(uint8 newNumElements, uint8 generation_) external {
142          require(msg.sender == _ownerAddress);
+            uint256 maxGeneration = generation[0] > generation[1] ? generation[0] : generation[1]
+            require(generation_ > maxGeneration)
143          numElements[generation_] = newNumElements;
144      }
  1. It is still possible that admin sets numElements=0. We propose to avoid this, and to add a safe mechanism to the FighterFarm._createFighterBase() function:
FighterFarm.sol#L137-L144, FighterFarm.sol#L508-L520

137      /// @notice Updates the number of elements for a given generation.
138      /// @dev Only the owner address is authorized to call this function.
139      /// @param newNumElements number of elements for the generation.
140      /// @param generation_ generation to be updated.
141      function setNumElements(uint8 newNumElements, uint8 generation_) external {
142          require(msg.sender == _ownerAddress);
+            require(newNumElements >= 1);
             uint256 maxGeneration = generation[0] > generation[1] ? generation[0] : generation[1]
             require(generation_ > maxGeneration)
143          numElements[generation_] = newNumElements;
144      }
[...]
508      function _createFighterBase(
509          uint256 dna, 
510          uint8 fighterType
511      ) 
512          private 
513          view 
514          returns (uint256, uint256, uint256) 
515      {
+            uint256 element = dna % (numElements[generation[fighterType]]+1);
-516         uint256 element = dna % numElements[generation[fighterType]];
517          uint256 weight = dna % 31 + 65;
518          uint256 newDna = fighterType == 0 ? dna : uint256(fighterType);
519          return (element, weight, newDna);
520      }

The proposed safe mechanism above is to be sure numElements>0, if it doesn't care about the modulo used on dna.

  1. The proposed mitigation forces the admin to remember to update numElements for future generations. To be safer against centralization risk and not depend on manual mechanism, we suggest updating numElements>0 when a generation is increased, in this way:
FighterFarm.sol#L125-L135

125      /// @notice Increase the generation of the specified fighter type.
126      /// @dev Only the owner address is authorized to call this function.
127      /// @param fighterType Type of fighter either 0 or 1 (champion or dendroid).
128      /// @return Generation count of the fighter type.
129      function incrementGeneration(uint8 fighterType) external returns (uint8) {
130          require(msg.sender == _ownerAddress);
131          require(fighterType == 0 || fighterType == 1);
132          generation[fighterType] += 1;
133          maxRerollsAllowed[fighterType] += 1;
+            if(numElements[generation[fighterType] + 1] == 0) numElements[generation[fighterType] + 1] = numElements[generation[fighterType]]
134          return generation[fighterType];
135      }

In this way, numElements for the next generation is always initialized.

H-04 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

H-04: Since you can reroll with a different fighterType than the NFT you own, you can reroll bypassing maxRerollsAllowed and reroll attributes based on a different fighterType

Comments

Previously, users could FighterFarm::reRoll their fighter using a corrupted fighter type, because the fighter type was provided as input and not validated. For this reason, it was possible to re-roll attributes based on a different fighter type and maxRerollsAllowed could be bypassed in some scenarios.

Mitigation

PR #17
Now it is correctly checked whether the fighterType value passed as input corresponds to the type of the fighter being re-rolled:

    function reRoll(uint256 tokenId, uint8 fighterType) public {
        require((fighterType == 1) == fighters[tokenId].dendroidBool, "Type mismatch");

Note that fighterType cannot be greater than 1, because otherwise the call will revert at L418 due to an index out of bounds error trying to access maxRerollsAllowed[fighterType]. Therefore, fighterType is either 1 (dendroid) or 0.

Suggestion

Although the fix solves issue H-04, the fix could have been simpler. The fighter state variable dendroidBool already contains the fighter type information needed, so it is redundant to ask callers of reRoll() for the fighter type. The function could have been changed to:

/// @notice Rolls a new fighter with random traits.
/// @param tokenId ID of the fighter being re-rolled.
function reRoll(uint256 tokenId) public {
    uint8 fighterType = fighters[tokenId].dendroidBool ? 1 : 0;
    require(msg.sender == ownerOf(tokenId));
    require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
    require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");

    _neuronInstance.approveSpender(msg.sender, rerollCost);
    bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
    if (success) {
        numRerolls[tokenId] += 1;
        uint256 dna = uint256(keccak256(abi.encode(tokenId, numRerolls[tokenId])));
        (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
        fighters[tokenId].element = element;
        fighters[tokenId].weight = weight;
        fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
            newDna,
            generation[fighterType],
            fighters[tokenId].iconsType,
            fighters[tokenId].dendroidBool
        );
        _tokenURIs[tokenId] = "";
    }
}   

Conclusion

LGTM

ADD-01 Unmitigated

Lines of code

Vulnerability details

Mitigation of ADD-01: NOT mitigated

Mitigated issue

#48

The issue was that getFighterPoints() initializes the return array to a length of 1, and thus cannot fill the array.

Mitigation review - mitigation not committed

In the PR it is now initialized to maxId which solves the issue. However, for some reason this change is not applied to the setUpAirdrop-mitigation branch. Make sure it is included.
Also consider changing the visibility of getFighterPoints() to external since it is not called within MergingPool.sol.

M-05 Unmitigated

Lines of code

Vulnerability details

Mitigation of M-05: NOT mitigated, with error, see comments

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/d81beee0df9c5465fe3ae954ce41300a9dd60b7f/src/FighterFarm.sol#L574

Mitigated issue(s)

M-05: Can mint NFT with the desired attributes by reverting transaction
(Also see duplicated reports #1017, #578, #1991)

There were four issues with the randomness of the DNA in FighterFarm.sol:

  1. The user can retry the DNA set in _createNewFighter() by reverting the onERC721Received() hook.
  2. The user can brute force his msg.sender which seeds the DNA in reRoll() and claimFighters().
  3. The user can wait for an appropriate fighters.length which seeds the DNA in mintFromMergingPool() and claimFighters().
  4. The user can select the best out of all rerolls, which can be calculated in advance.

(The issue of freely setting DNA in mintFromMergingPool() is part of H-03: Players have complete freedom to customize the fighter NFT when calling redeemMintPass and can redeem fighters of types Dendroid and with rare attributes.)

Additionally, a mitigation of #578, which was duplicated to M-05 (#376), has been attempted. This issue was about the DNA simply being differently generated, due to a likely mistake, but with low impact.

M-05: Can mint NFT with the desired attributes by reverting transaction is only 1. above, and I consider the other issues (including #578) to have been incorrectly or misleadingly duplicated to M-05, and I have to deal with them as separate/new issues/errors.
I have submitted reports on 2-4 as three new issues, as well as a report on the error from the mitigation of #578. I will make use of this submission as the report of an unmitigated 1., the details on which and recommended mitigation steps follow at the end of this report, after an overview of the review of these issues and the error.
The mitigation review of the part of the scope which is "M-05" thus consists of a total of 5 reports:

  1. This report.

  2. "User influence on DNA in claimFighters() (unmitigated issue 2. grouped under M-05)"

  3. "User influence on DNA via fighters.length in mintFromMergingPool() (unmitigated issue 3. grouped under M-05)"

  4. "The user can select the best out of all rerolls (unmitigated issue 4. grouped under M-05)"

    Error. "[M-05/#578] Mitigation error: user influence on DNA via to"

Mitigation re- and overview

  1. Nothing has been done to mitigate this.

  2. has been mitigated in reRoll() by replacing msg.sender with tokenId. But it has NOT been mitigated in claimFighters().

  3. Nothing has been done about this in mintFromMergingPool().
    In claimFighters() fighters.length has been replaced by nftsClaimed[msg.sender][0], nftsClaimed[msg.sender][1]. This is only slightly better. fighters.length will be updated by all other users so the attacker would get a steady stream of new values. nftsClaimed[msg.sender][] is changed only by the user. However, by transferring the token to another address this value will change, so the user can still influence the DNA.

  4. Nothing has been done to mitigate this.

In summary, none of the issues 1-4 have been fully mitigated.

#578 lead to replacing msg.sender by to. This introduced the error discussed below, also reported separately.

Mitigation error - introduction of issue 2. in mintFromMergingPool()

In the mitigation of #578, having changed the DNA in mintFromMergingPool() from uint256(keccak256(abi.encode(msg.sender, fighters.length))) to uint256(keccak256(abi.encode(to, fighters.length))) has introduced issue 2. for this function as well. to is set to msg.sender from MergingPool.claimRewards(). This is checked to be a winner, which is set by the admin in pickWinners() as the owner of the token. The user can transfer his token to an appropriate address, such that if he is picked as the winner this address generates the desired DNA. A complication for the user is that fighters.length might change, so he needs to take the few possible such values into account, and try to limit them by acting quickly and hoping not many fighters will be minted in between. In any case he can significantly influence his DNA.

Overview of mitigation steps for 1-4 and the error

  1. Do not set the DNA in the same transaction as the user receives the fighter.

  2. Do not use msg.sender to set the DNA.

  3. Do not use fighters.length to set the DNA.

  4. Do not use currently known values to set the DNA.

    Error: Do not use to (here equivalent to msg.sender in 2.) to set the DNA.

If a third party decentralized VRF is not an option, you can act as your own centralized randomness provider, otherwise equivalent to the typical VRF oracles. The only consideration then is whether users will trust that your server is unbiased and uninfluenceable.
Otherwise it seems the best solution is to set the DNA as blockhash(block.number - 1) in a call from the admin, and then in a second call by the user assign the fighter to the user, whether this is by direct minting to the user, by transferring the fighter from an escrow (e.g. FighterFarm itself), or as a finalizing of the reroll.

M-05: Can mint NFT with the desired attributes by reverting transaction

To clarify the original report:
This issue makes no claim about the direct influence of the DNA calculated in _createNewFighter(). The DNA is set in the same transaction as the the fighter is minted. The issue is that the user can let the DNA be calculated and the fighter minted to him, and then, based on what he is to receive, revert the transaction. This is enabled by the onERC721Received() hook.
_createNewFighter() calls _safeMint(to, newId). This calls ERC721Utils.checkOnERC721Received(_msgSender(), address(0), to, newId, ""), which on a contract executes

try IERC721Receiver(to).onERC721Received(msg.sender, address(0), newId, "") returns (bytes4 retval) {
    if (retval != IERC721Receiver.onERC721Received.selector) {
        // Token rejected
        revert IERC721Errors.ERC721InvalidReceiver(to);
    }

The user (his contract) may thus implement his onERC721Received() to reject unwanted fighters, e.g.

function onERC721Received(
    address operator,
    address from,
    uint256 tokenId,
    bytes calldata data
) public returns (bytes4){
    
    (,,uint256 weight,,,,) = fighterFarm.getAllFighterInfo(tokenId);
    require(weight == 95, "I don't want this attribute");

    return bytes4(keccak256(bytes("onERC721Received(address,address,uint256,bytes)")));
}

Recommended mitigation steps

There is no way to avoid this issue if the randomness is set in the same transaction as the minting/onERC721Received() call. The DNA must be committed first, and then, in a separate transaction, should the user be able to get his fighter. One way is to mint the fighter to the FighterFarm contract, acting as an escrow, and implementing a function for the user to claim it by having it transferred to him.

M-03 Unmitigated

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-47/src/MergingPool.sol#L137-L175

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L134-L167

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-47/src/MergingPool.sol#L137-L175

Vulnerability details

M-03 (#932) reports that Fighter created by mintFromMergingPool can have arbitrary weight and element.

FighterFarm.mintFromMergingPool() can be called only by the MergingPool.claimRewards() method:

MergingPool.sol#L134-L167

134      /// @notice Allows the user to batch claim rewards for multiple rounds.
135      /// @dev The user can only claim rewards once for each round.
136      /// @param modelURIs The array of model URIs corresponding to each round and winner address.
137      /// @param modelTypes The array of model types corresponding to each round and winner address.
138      /// @param customAttributes Array with [element, weight] of the newly created fighter.
139      function claimRewards(
140          string[] calldata modelURIs, 
141          string[] calldata modelTypes,
142          uint256[2][] calldata customAttributes
143      ) 
144          external 
145      {
146          uint256 winnersLength;
147          uint32 claimIndex = 0;
148          uint32 lowerBound = numRoundsClaimed[msg.sender];
149          for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
150              numRoundsClaimed[msg.sender] += 1;
151              winnersLength = winnerAddresses[currentRound].length;
152              for (uint32 j = 0; j < winnersLength; j++) {
153                  if (msg.sender == winnerAddresses[currentRound][j]) {
154                      _fighterFarmInstance.mintFromMergingPool(
155                          msg.sender,
156                          modelURIs[claimIndex],
157                          modelTypes[claimIndex],
158                          customAttributes[claimIndex]
159                      );
160                      claimIndex += 1;
161                  }
162              }
163          }
164          if (claimIndex > 0) {
165              emit Claimed(msg.sender, claimIndex);
166          }
167      }

MergingPool.claimRewards() is used to claim rewards, i.e., to mint a new fighter from the merging pool for each round where the player wins. When a player calls MergingPool.claimRewards(), he/she can pass the customAttributes parameter, which is not checked anywhere. This array is passed to FighterFarm.mintFromMergingPool():

FighterFarm.sol#L307-L331

307      /// @notice Mints a new fighter from the merging pool.
308      /// @dev Only the merging pool contract address is authorized to call this function.
309      /// @param to The address that the new fighter will be assigned to.
310      /// @param modelHash The hash of the ML model associated with the fighter.
311      /// @param modelType The type of the ML model associated with the fighter.
312      /// @param customAttributes Array with [element, weight] of the newly created fighter.
313      function mintFromMergingPool(
314          address to, 
315          string calldata modelHash, 
316          string calldata modelType, 
317          uint256[2] calldata customAttributes
318      ) 
319          public 
320      {
321          require(msg.sender == _mergingPoolAddress);
322          _createNewFighter(
323              to, 
324              uint256(keccak256(abi.encode(msg.sender, fighters.length))), 
325              modelHash, 
326              modelType,
327              0,
328              0,
329              customAttributes
330          );
331      }

This method also doesn't check customAttributes parameter and pass it directly to FighterFarm._createNewFighter() which use it to mint a fighter with customAttributes values for weight and element:

FighterFarm.sol#L484-L506

484      function _createNewFighter(
485          address to, 
486          uint256 dna, 
487          string memory modelHash,
488          string memory modelType, 
489          uint8 fighterType,
490          uint8 iconsType,
491          uint256[2] memory customAttributes
492      ) 
493          private 
494      {  
495          require(balanceOf(to) < MAX_FIGHTERS_ALLOWED);
496          uint256 element; 
497          uint256 weight;
498          uint256 newDna;
499          if (customAttributes[0] == 100) {
500              (element, weight, newDna) = _createFighterBase(dna, fighterType);
501          }
502          else {
503              element = customAttributes[0];
504              weight = customAttributes[1];
505              newDna = dna;
506          }

In this way, the player can set directly weight and element values. This behavior is wanted by developers. However, weight and element must be bound within the same limit described in FighterFarm._createFighterBase():

FighterFarm.sol#L462-L474

470          uint256 element = dna % numElements[generation[fighterType]];
471          uint256 weight = dna % 31 + 65;

In other words, element must be in the range [0, numElements[generation[fighterType]]-1], and weight must be in the range [65, 95], extremes included. This vulnerability allows the user to set weight and element out from this ranges.

Recommended Mitigation proposed by wardens

Wardens proposed to restrict weight and element using a check inside FighterFarm.mintFromMergingPool(). Developers decided to apply a different mitigation.

Mitigation applied by developers

To mitigate this issue, developers added two lines to MergingPool.claimRewards() method, that is the only one which can call FighterFarm.mintFromMergingPool():

MergingPool.sol#L142-L175

    function claimRewards(
        string[] calldata modelURIs, 
        string[] calldata modelTypes,
        uint256[2][] calldata customAttributes,
        uint32 totalRoundsToConsider
    ) 
        external nonReentrant
    {
        uint256 winnersLength;
        uint32 claimIndex = 0;
        uint32 lowerBound = numRoundsClaimed[msg.sender];
        require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
        uint8 generation = _fighterFarmInstance.generation(0);
        for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; currentRound++) {
            numRoundsClaimed[msg.sender] += 1;
            winnersLength = winnerAddresses[currentRound].length;
            for (uint32 j = 0; j < winnersLength; j++) {
+               require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: element out of bounds");
+               require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of bounds");
                if (msg.sender == winnerAddresses[currentRound][j]) {
                    _fighterFarmInstance.mintFromMergingPool(
                        msg.sender,
                        modelURIs[claimIndex],
                        modelTypes[claimIndex],
                        customAttributes[claimIndex]
                    );
                    claimIndex += 1;
                }
            }
        }
        if (claimIndex > 0) {
            emit Claimed(msg.sender, claimIndex);
        }
    }

These two lines check the values of customAttributes passed by the player who is claiming the reward.
This solution was implemented by the Ai Arena team

Comment about the Mitigation Proposal

We want to report two issues:

Unmitigated risk

It is still possible to pass customAttributes out of bounds. For example, if the maximum value of winnersLength is 2 and a player tries to claim 3 rewards, the 3rd will be not checked. The following is a coded POC:

function testClaimRewardsCustomAttributesOutOfBound() public {
        address user0 = vm.addr(1);
        address user1 = vm.addr(2);
        address user2 = vm.addr(3);

        _mintFromMergingPool(user0);
        _mintFromMergingPool(user1);
        _mintFromMergingPool(user2);

        uint256 totalRound = 6;


        uint256[] memory _winners013 = new uint256[](2);
        _winners013[0] = 0;
        _winners013[1] = 1;

        uint256[] memory _winners023 = new uint256[](2);
        _winners023[0] = 0;
        _winners023[1] = 2;

        uint256 totalWinUser1 = 0;
        
        for (uint i = 0; i < totalRound; i++) {
            if (i%2 == 0) {
                _mergingPoolContract.pickWinners(_winners013);
                totalWinUser1 += 1;
            } else {
                _mergingPoolContract.pickWinners(_winners023);
            }
        }

        string[] memory _modelURIs = new string[](totalWinUser1);
        string[] memory _modelTypes = new string[](totalWinUser1);
        uint256[2][] memory _customAttributes = new uint256[2][](totalWinUser1);

        _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelTypes[0] = "original";
        _customAttributes[0][0] = uint256(1);
        _customAttributes[0][1] = uint256(80);

        _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelTypes[1] = "original";
        _customAttributes[1][0] = uint256(1);
        _customAttributes[1][1] = uint256(80);

        _modelURIs[2] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelTypes[2] = "original";
        _customAttributes[2][0] = uint256(10);
        _customAttributes[2][1] = uint256(99);

        vm.startPrank(user1);
        _mergingPoolContract.claimRewards(
            _modelURIs,
            _modelTypes,
            _customAttributes,
            uint32(totalRound - 1)
        );

        uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(user1);
        assertEq(numRewards, 0);

        assertEq(_fighterFarmContract.balanceOf(user1), 4);
    }

Output:
emit FighterCreated(id: 5, weight: 99, element: 10, generation: 0)

This happens because a player can claim more rewards than the winnerLenght. Furthermore winnerLenght changes round by round and is completely unbounded from the number of rewards a player can claim.

The wrong usage of index forces players to create and pass an array of customAttributes

Even if a player wants to claim one reward, he/she has to pass to MergingPool.claimRewards() arrays with at least winnersLength elements, where winnersLength is the maximum value among winnerAddresses[round].length for analyzed rounds (i.e. rounds in the selected interval). We reported this issue with Low severity.

Our mitigation proposal

We suggest to change these two lines:

@@ -156,9 +156,9 @@ contract MergingPool is ReentrancyGuard{
             numRoundsClaimed[msg.sender] += 1;
             winnersLength = winnerAddresses[currentRound].length;
             for (uint32 j = 0; j < winnersLength; j++) {
-                require(customAttributes[j][0] < _fighterFarmInstance.numElements(generation), "MergingPool: elemen
t out of bounds");
-                require(customAttributes[j][1] >= 65 && customAttributes[j][1] <= 95, "MergingPool: weight out of b
ounds");
                 if (msg.sender == winnerAddresses[currentRound][j]) {
+                    require(customAttributes[claimIndex][0] < _fighterFarmInstance.numElements(generation), "
MergingPool: element out of bounds");
+                    require(customAttributes[claimIndex][1] >= 65 && customAttributes[claimIndex][1] <= 95, "
MergingPool: weight out of bounds");
                     _fighterFarmInstance.mintFromMergingPool(
                         msg.sender,
                         modelURIs[claimIndex],

We also suggest to implement a public function to obtain the number of rewards a player can claim within a specified interval of rounds:

    function getClaimRewardsAmount(
        uint32 totalRoundsToConsider
    ) 
        external nonReentrant returns (uint32 claimAmount)
    {
        uint256 winnersLength;
        uint32 lowerBound = numRoundsClaimed[msg.sender];
        require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
        for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; currentRound++) {
            numRoundsClaimed[msg.sender] += 1;
            winnersLength = winnerAddresses[currentRound].length;
            for (uint32 j = 0; j < winnersLength; j++) {
                if (msg.sender == winnerAddresses[currentRound][j]) {
                    claimAmount += 1;
                }
            }
        }
    }

In this way, a player can easily know the length of parameters he/she should pass to the MergingPool.claimRewards() method.

Conclusion

We consider this issue unmitigated because it is still possible to create a fighter with weight and element values out of bounds.

Assessed type

Invalid Validation

M-04 Unmitigated

Lines of code

Vulnerability details

Mitigation of M-04: NOT fully mitigated

Mitigated issue

M-04: DoS in MergingPool::claimRewards function and potential DoS in RankedBattle::claimNRN function if called after a significant amount of rounds passed.

The issue was that MergingPool.claimRewards() and RankedBattle.claimNRN() loop over all hitherto unclaimed rounds, leading to either OOG or revert due to exceeding MAX_FIGHTERS_ALLOWED.

Mitigation review - no definite DoS but may still be prohibitively costly

A variable totalRoundsToConsider has been added to claimRewards() and claimNRN() which lets the user loop over only part of claimable rounds at a time. Appropriate checks are also made that this does not lead to exceeding roundId.

However, the user is still forced to loop through all previous rounds until the user's numRoundsClaimed is up to speed. This is especially a problem if a user joins AI Arena after many rounds have already been played, in which case the user has loop all the way starting from 0. Since the user can split this loop in arbitrarily many parts, it is theoretically possible, but would still cost as much gas as previously caused the DoS.
This applies to both MergingPool.claimRewards() and RankedBattle.claimNRN().

Consider using a mapping instead so the user can choose any round from which to claim. Or set numRoundsClaimed[msg.sender] = roundId - 1; when the user first joins or plays, e.g. when a fighter is first minted/transferred to the staker.

Assessed type

Loop

M-05B MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

M-05B: Incorrect DNA Generation in FighterFarm::mintFromMergingPool

Comments

Previously, FighterFarm::mintFromMergingPool incorrectly used the MergingPool address for generating the new fighter's DNA, when it should actually have used the player's address.

Mitigation

PR #3
The DNA calculation in FighterFarm::mintFromMergingPool was correctly changed from:
uint256(keccak256(abi.encode(msg.sender, fighters.length)))
to
uint256(keccak256(abi.encode(to, fighters.length)))
where msg.sender was the MergingPool address and now to is equal to the caller of MergingPool::claimRewards, i.e. the player's address.

Suggestion

None

Conclusion

LGTM

M-06 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

M-06: NFTs can be transferred even if StakeAtRisk remains, so the user's win cannot be recorded on the chain due to underflow, and can recover past losses that can't be recovered(steal protocol's token)

Comments

Previously, RankedBattle::unstakeNRN allowed users to fully unstake fighters even if they had stake at risk for the ongoing round. This would unlock fighters, meaning they could be transferred. Some of the consequences were: (1) potential underflow when attempting to record a win in some scenarios and (2) the possibility of recovering stake at risk from older rounds in exchange for someone not being able to recover stake at risk form an ongoing round.

Mitigation

PR #9 & commit 02b3752ac5d3ed3ec406cb2d6410d9adc40fd1fa
PR #9 still allows fighters with stake at risk (1) to be transferred and (2) to be staked again in the same round by the new owner. However, it's not possible to input a battle update of a fighter in said state to the contract. Note that the game server would still have to be careful: if it mistakenly runs a battle for this fighter, the rival's update could still be recorded.

Additionally taking commit 02b3752ac5d3ed3ec406cb2d6410d9adc40fd1fa into account, now RankedBattle::unstakeNRN checks whether the fighter currently has stake at risk before unlocking it. If it has, unstaking is still allowed, but the user would have to wait until the next round before it can be unlocked by calling RankedBattle::unstakeNRN again.

Suggestion

The implemented mitigation might have the unintended consequence of blocking transferred fighters without stake at risk from participating in battles. Note that such fighters can be staked, but updateBattleRecord() would revert if a battle is attempted to be recorded while it won't for the opponent.

To improve code clarity and to avoid potential mistakes from the game server at interpreting the contract's state and interacting with it, block staking instead of blocking battle updates. In other words, remove the following from updateBattleRecord():

else {
    require(addressStartedRound[tokenId][roundId] == fighterOwner);
}

and block staking in stakeNRN() if the fighter has battles recorded in the current round by another owner.

Conclusion

While the fix solves the issues mentioned in M-06, it might allow problematic uses of the contract by the game server account. Either beware of this when interacting with the contract or implement some changes to be on the safe side.

H-06 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

H-06: FighterFarm:: reroll won't work for nft id greator than 255 due to input limited to uint8

Comments

The reRoll() function couldn't take token ids greater than 255, because the input type was uint8 tokenId.

Mitigation

PR #1
Now the reRoll() input type was changed to uint256 tokenId and therefore it correctly accepts any possible token id:

function reRoll(uint256 tokenId, uint8 fighterType) public {

Suggestion

None

Conclusion

LGTM

ADD-02 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/Verification.sol

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/setUpAirdrop-mitigation/src/Verification.sol

Vulnerability details

The issue was reported in rspadi-Q. The issue is in Verification.verify():

Verification.sol#L6-L41

6      function verify(
7          bytes32 msgHash, 
8          bytes memory signature,
9          address signer
10      ) 
11          public 
12          pure 
13          returns(bool) 
14      {
15          require(signature.length == 65, "invalid signature length");
16  
17          bytes32 r;
18          bytes32 s;
19          uint8 v;
20  
21          assembly {
22              /*
23              First 32 bytes stores the length of the signature
24  
25              add(sig, 32) = pointer of sig + 32
26              effectively, skips first 32 bytes of signature
27  
28              mload(p) loads next 32 bytes starting at the memory address p into memory
29              */
30  
31              // first 32 bytes, after the length prefix
32              r := mload(add(signature, 32))
33              // second 32 bytes
34              s := mload(add(signature, 64))
35              // final byte (first byte of the next 32 bytes)
36              v := byte(0, mload(add(signature, 96)))
37          }
38          bytes memory prefix = "\x19Ethereum Signed Message:\n32";
39          bytes32 prefixedHash = keccak256(abi.encodePacked(prefix, msgHash));
40          return ecrecover(prefixedHash, v, r, s) == signer;
41      }

This method uses ecrecover (line #40), which is known to be vulnerable to signature malleability. An explanation of this vulnerability is here. The problem is that we have two valid signatures to obtain the address signer: (r, s) and (r, -s mod n). In other words, it is possible to create, starting from a valid signature from the address Bob, a fake signature that is different from the first one but is valid, without having the Bob private key.

Recommended Mitigation proposed by wardens

The proposed mitigation is to use the OpenZeppelin ECDSA library.
This is a well-known solution that is also suggested by AuditBase.
This is the Verification library after mitigation:

Verification.sol#L4-L28

4  import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
5  
6  
7  library Verification {
8      using ECDSA for bytes32;
9  
10      function verify(
11          address signer, 
12          bytes32 messageHash, 
13          bytes memory signature
14      ) 
15          public 
16          pure 
17          returns (bool) 
18      {
19          // Since the message is already a hash, directly prepare it for signature verification
20          bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
21  
22          // Recover the signer's address from the signature
23          address recoveredSigner = ethSignedMessageHash.recover(signature);
24  
25          // Check if the recovered address is the same as the expected signer
26          return recoveredSigner == signer;
27      }
28  }

This solution was implemented by the Ai Arena team.

Comment about the Mitigation Proposal

The solution proposed above was built according to Openzeppelin documentation. After mitigation, the Verification library exploits Openzeppelin ECDSA to recover the signer of a messageHash.
We suggest splitting the two parts of the library: the recover part and the verification part.

 4  import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
 5  
 6  
 7  library Verification {
 8      using ECDSA for bytes32;
 9  
 10      function verify(
 11          address signer, 
 12          bytes32 messageHash, 
 13          bytes memory signature
 14      ) 
 15          public 
 16          pure 
 17          returns (bool) 
 18      {
-19          // Since the message is already a hash, directly prepare it for signature verification
-20          bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
-21  
-22          // Recover the signer's address from the signature
-23          address recoveredSigner = ethSignedMessageHash.recover(signature);
-24  
 25          // Check if the recovered address is the same as the expected signer
-26          return recoveredSigner == signer;
+            return recover(messageHash, signature) == signer;
 27      }
+ 
+        function recover(
+            bytes32 messageHash, 
+            bytes memory signature
+        )
+            public
+            pure
+            returns (address recoveredSigner)
+        {
+            // Since the message is already a hash, directly prepare it for signature verification
+            bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
+     
+            // Recover the signer's address from the signature
+            recoveredSigner = ethSignedMessageHash.recover(signature);
+        }
 28  }

We consider this issue mitigated because it entirely relies on the OpenZeppelin ECDSA library.

H-02 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/d81beee0df9c5465fe3ae954ce41300a9dd60b7f/src/GameItems.sol#L315-L331

Vulnerability details

The issue was reported in #575

GameItems inherits from ERC1155. It implements the adjustTransferability function to set if a game item can be transferred or not:

GameItems.sol#L122-L134

122      /// @notice Adjusts whether the game item can be transferred or not
123      /// @dev Only the owner address is authorized to call this function.
124      /// @param tokenId The token id for the specific game item being adjusted.
125      /// @param transferable Whether the game item is transferable or not
126      function adjustTransferability(uint256 tokenId, bool transferable) external {
127          require(msg.sender == _ownerAddress);
128          allGameItemAttributes[tokenId].transferable = transferable;
129          if (transferable) {
130            emit Unlocked(tokenId);
131          } else {
132            emit Locked(tokenId);
133          }
134      }

It uses the allGameItemAttributes list based on tokenId to set whether a gameItem can be transferred or not. The functions described in GameItems to transfer a gameItem object is:

GameItems.sol#L299-L313

299      /// @notice Safely transfers an NFT from one address to another.
300      /// @dev Added a check to see if the game item is transferable.
301      function safeTransferFrom(
302          address from, 
303          address to, 
304          uint256 tokenId,
305          uint256 amount,
306          bytes memory data
307      ) 
308          public 
309          override(ERC1155)
310      {
311          require(allGameItemAttributes[tokenId].transferable);
312          super.safeTransferFrom(from, to, tokenId, amount, data);
313      }

This function checks if allGameItemAttributes[tokenId].transferable is true, and revert otherwise.
Furthermore, it overrides ERC1155.safeTransferFrom(address from, address to, uint256 tokenId, uint256 amount, bytes memory data).

However, there is another function in ERC1155 that allows transferring, and this function is inherited by GameItems contract:

ERC1155.sol#L131-L146

131      /**
132       * @dev See {IERC1155-safeBatchTransferFrom}.
133       */
134      function safeBatchTransferFrom(
135          address from,
136          address to,
137          uint256[] memory ids,
138          uint256[] memory amounts,
139          bytes memory data
140      ) public virtual override {
141          require(
142              from == _msgSender() || isApprovedForAll(from, _msgSender()),
143              "ERC1155: caller is not token owner nor approved"
144          );
145          _safeBatchTransferFrom(from, to, ids, amounts, data);
146      }

ERC1155.safeBatchTransferFrom is a simple and less gas-intensive function that aims to transfer multiple tokens (according to documentation)
Due to the fact that this function is not overridden in the GameItems contract, a user could call it to transfer a game item that is not transferable.

Recommended Mitigation proposed by wardens

In the selected report, it is proposed to override the ERC1155.safeBatchTransferFrom function inside the GameItems contract:

+    function safeBatchTransferFrom(
+        address from,
+        address to,
+        uint256[] memory ids,
+        uint256[] memory amounts,
+        bytes memory data
+    ) public override(ERC1155) {
+        for(uint256 i; i < ids.length; i++{
+            require(allGameItemAttributes[ids[i]].transferable);
+        }
+        super.safeBatchTransferFrom(from, to, ids, amounts, data);
+    }

This solution was implemented by the Ai Arena team

Comment about the Mitigation Proposal

We think that this is a valid mitigation proposal. It avoids using safeBatchTransferFrom without checking if an item is transferable or not. However, the proposed mitigation seems to have a problem. What if a single game item is not transferable in a batch of hundreds of transferable items?
The execution of safeBatchTransferFrom will revert. For this reason, we propose a different approach:

+    function safeBatchTransferFrom(
+        address from,
+        address to,
+        uint256[] memory ids,
+        uint256[] memory amounts,
+        bytes memory data
+    ) public  {
+        uint256[] memory transferableIds = new uint256[](ids.length);
+        uint256[] memory transferableAmounts = new uint256[](amounts.length);
+        uint256 n = 0;
+        for(uint256 i; i < ids.length; i++){
+            if(allGameItemAttributes[ids[i]].transferable){
+                transferableIds[n] = ids[i];
+                transferableAmounts[n] = amounts[i];
+                n++;
+            }            
+        }
+        uint256 transferableItemsAmount = ids.length - n;
+        assembly{
+            mstore(transferableIds, sub(mload(transferableIds),transferableItemsAmount)) // Increase the size of the array in transferableItemsAmount
+            mstore(transferableAmounts, sub(mload(transferableAmounts),transferableItemsAmount)) // Increase the size of the array in transferableItemsAmount
+        }
+        super.safeBatchTransferFrom(from, to, transferableIds, transferableAmounts, data);
+    }

In our proposal, we create and fill two local arrays, resizing them according to the transferable amount of items.
This is useful to perform super.safeBatchTransferFrom just on transferable items.

H-08 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/MergingPool.sol#L9

https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/MergingPool.sol#L139-L145

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-47/src/MergingPool.sol#L5
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-47/src/MergingPool.sol#L11
https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-47/src/MergingPool.sol#L142-L149

Vulnerability details

The issue was reported in #37.

The issue is because MergingPool.claimRewards() is vulnerable to reentrancy.

MergingPool.sol#L139-L167

139      function claimRewards(
140          string[] calldata modelURIs, 
141          string[] calldata modelTypes,
142          uint256[2][] calldata customAttributes
143      ) 
144          external 
145      {
146          uint256 winnersLength;
147          uint32 claimIndex = 0;
148          uint32 lowerBound = numRoundsClaimed[msg.sender];
149          for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
150              numRoundsClaimed[msg.sender] += 1;
151              winnersLength = winnerAddresses[currentRound].length;
152              for (uint32 j = 0; j < winnersLength; j++) {
153                  if (msg.sender == winnerAddresses[currentRound][j]) {
154                      _fighterFarmInstance.mintFromMergingPool(
155                          msg.sender,
156                          modelURIs[claimIndex],
157                          modelTypes[claimIndex],
158                          customAttributes[claimIndex]
159                      );
160                      claimIndex += 1;
161                  }
162              }
163          }
164          if (claimIndex > 0) {
165              emit Claimed(msg.sender, claimIndex);
166          }
167      }

A malicious player could call MergingPool.claimRewards() using a contract. This function mints multiple fighter using FighterFarm.mintFromMergingPool(). A malicious user can build an attacker contract that implements Openzeppelin.IERC721Receiver overriding the onERC721Received() which is triggered by _mint() action. This malicious method could reenter calling MergingPool.claimRewards() again, before the first call is finished, obtaining more fighters than he should have.

Recommended Mitigation proposed by wardens

The mitigation proposal is to use the nonReentrant modifier on MergingPool.claimRewards():

    @@ -2,11 +2,13 @@
    pragma solidity >=0.8.0 <0.9.0;

    import { FighterFarm } from "./FighterFarm.sol";
+   import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
+

    /// @title MergingPool
    /// @author ArenaX Labs Inc.
    /// @notice This contract allows users to potentially earn a new fighter NFT.
-   contract MergingPool {
+   contract MergingPool is ReentrancyGuard{

        /*//////////////////////////////////////////////////////////////
                                    EVENTS
    @@ -141,7 +143,7 @@ contract MergingPool {
            string[] calldata modelTypes,
            uint256[2][] calldata customAttributes
        ) 
-           external 
+           external nonReentrant
        {
            uint256 winnersLength;
            uint32 claimIndex = 0;

This solution was implemented by the Ai Arena team.

Comment about the Mitigation Proposal

This mitigation is commonly used to avoid Reentrancy attacks. According to OpenZeppelin Documentation:

/**
* @dev Prevents a contract from calling itself, directly or indirectly.
* Calling a `nonReentrant` function from another `nonReentrant`
* function is not supported. It is possible to prevent this from happening
* by making the `nonReentrant` function external, and making it call a
* `private` function that does the actual work.
*/

So, it will not be possible to call

`MergingPool.claimRewards()` -> `FighterFarm.mintFromMergingPool()` -> malicious contract -> `MergingPool.claimRewards()`

We consider this issue mitigated. We want to add the we found another core functions that should be protected against reentrancy: FighterFarm.claimFighters() and FighterFarm.redeemMintPass()

FighterFarm.sol#L218-L249

    218      function claimFighters(
    219          uint8[2] calldata numToMint,
    220          bytes calldata signature,
    221          string[] calldata modelHashes,
    222          string[] calldata modelTypes
    223      ) 
-   224          external 
+                external nonReentrant
    225      {
    226          bytes32 msgHash = bytes32(keccak256(abi.encode(
    227              msg.sender, 
    228              numToMint[0], 
    229              numToMint[1],
    230              nftsClaimed[msg.sender][0],
    231              nftsClaimed[msg.sender][1]
    232          )));
    233          require(Verification.verify(_delegatedAddress, msgHash, signature));
    234          uint16 totalToMint = uint16(numToMint[0] + numToMint[1]);
    235          require(modelHashes.length == totalToMint && modelTypes.length == totalToMint);
    236          nftsClaimed[msg.sender][0] += numToMint[0];
    237          nftsClaimed[msg.sender][1] += numToMint[1];
    238          for (uint16 i = 0; i < totalToMint; i++) {
    239              _createNewFighter(
    240                  msg.sender, 
    241                  uint256(keccak256(abi.encode(msg.sender, nftsClaimed[msg.sender][0], nftsClaimed[msg.sender][1]))),
    242                  modelHashes[i], 
    243                  modelTypes[i],
    244                  i < numToMint[0] ? 0 : 1,
    245                  0,
    246                  [uint256(100), uint256(100)]
    247              );
    248          }
    249      }
FighterFarm.sol#L261-L304

    261      function redeemMintPass(
    262          uint256[] calldata mintpassIdsToBurn,
    263          uint8[] calldata fighterTypes,
    264          uint8[] calldata iconsTypes,
    265          string[] calldata mintPassDnas,
    266          string[] calldata modelHashes,
    267          string[] calldata modelTypes,
    268          bytes calldata signature
    269      ) 
-   270          external 
+                external nonReentrant
    271      {
    272          require(
    273              mintpassIdsToBurn.length == mintPassDnas.length && 
    274              mintPassDnas.length == fighterTypes.length && 
    275              fighterTypes.length == modelHashes.length &&
    276              modelHashes.length == modelTypes.length &&
    277              modelTypes.length == iconsTypes.length
    278          );
    279  
    280           bytes32 msgHash = bytes32(keccak256(abi.encode(
    281            mintpassIdsToBurn,
    282            fighterTypes,
    283            iconsTypes,
    284            mintPassDnas,
    285            modelHashes,
    286            modelTypes
    287          )));
    288  
    289          require(Verification.verify(_delegatedAddress, msgHash, signature));
    290  
    291          for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) {
    292              require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i]));
    293              _mintpassInstance.burn(mintpassIdsToBurn[i]);
    294              _createNewFighter(
    295                  msg.sender, 
    296                  uint256(keccak256(abi.encode(mintPassDnas[i]))), 
    297                  modelHashes[i], 
    298                  modelTypes[i],
    299                  fighterTypes[i],
    300                  iconsTypes[i],
    301                  [uint256(100), uint256(100)]
    302              );
    303          }
    304      }

They are both protected against reentrancy: FighterFarm.claimFighters() thanks to [L230-L231] which changes at every call and avoids using the same signature twice, FighterFarm.redeemMintPass() thanks to the fact that the used mintPass is burned. However, we suggest protecting both using OpenZeppelin ReentrancyGuard, because they mint a new ERC721 token and so they both could trigger the onERC721Received method of a malicious contract. In future development, without this protection, some vulnerabilities could be introduced.

H-07 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation of H-07: Mitigated, with new issue or error

Mitigated issue

H-07: Fighters cannot be minted after the initial generation due to uninitialized numElements mapping

The issue was that numElements[] were not and could not be set.

Mitigation review - setter added

A function setNumElements() has been added.

New issue

Since numElements[] can now map to an arbitrary uint8 there is an issue if the number of elements for a generation is a multiple of 31. This is reported separately in "Element and weight correlation when numElements is multiple of 31".

H-04 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Old lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L370-L391

Mitigated lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/fix-47/src/FighterFarm.sol#L416

Vulnerability description

The issue was reported in #306.

The FighterFarm.reRoll() can be used to recompute fighter traits according to a new dna value:

FighterFarm.sol#L370-L391

370      function reRoll(uint8 tokenId, uint8 fighterType) public {
371          require(msg.sender == ownerOf(tokenId));
372          require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
373          require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
374  
375          _neuronInstance.approveSpender(msg.sender, rerollCost);
376          bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
377          if (success) {
378              numRerolls[tokenId] += 1;
379              uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
380              (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
381              fighters[tokenId].element = element;
382              fighters[tokenId].weight = weight;
383              fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
384                  newDna,
385                  generation[fighterType],
386                  fighters[tokenId].iconsType,
387                  fighters[tokenId].dendroidBool
388              );
389              _tokenURIs[tokenId] = "";
390          }
391      }    

This function must be called passing two parameters: tokenId and fighterType. The number of maxRerollsAllowed depends on the fighterType.
There can be two kinds of fighter: Champion (fighterType = 0) and Dendroid (fighterType = 1).
When FighterFarm.reRoll() is called, the player can use any value for fighterType, because there is no check that the passed tokenId belongs
to the fighterType group. For example, if fighter0 is Champion, i.e., its fighterType=0, it is possible to call the reRoll function with fighterType=1.
A player can exploit this fact to obtain more reRoll operation than expected for fighter0's fighterType.

Recommended Mitigation proposed by wardens

The mitigations proposed by #306 and 212 are quite similar, but the latter seems more elegant:

@@ -413,6 +413,7 @@ contract FighterFarm is ERC721, ERC721Enumerable {
    /// @param tokenId ID of the fighter being re-rolled.
    /// @param fighterType The fighter type.
    function reRoll(uint256 tokenId, uint8 fighterType) public {
+       require((fighterType == 1) == fighters[tokenId].dendroidBool, "Type mismatch");
        require(msg.sender == ownerOf(tokenId));
        require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
        require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");

This solution was implemented by the Ai Arena team

Comment about the Mitigation Proposal

The mitigation proposed by wardens works. This is the truth table:

FighterType fighters.dendroidBool fighterType == 1 (fighterType == 1) == fighters[tokenId].dendroidBool
0 (Champion) false (Champion) false true
0 (Champion) true (Dendroid) false false
1 (Dendroid) false (Champion) true false
1 (Dendroid) true (Dendroid) true true

So, the require is satisfied only when FighterType matches with fighters.dendroidBool.
However, we suggest to remove the FighterType parameter from FighterFarm.reRoll() at all.
The following is the new FighterFarm.reRoll() after all mitigations applied:

    412      /// @notice Rolls a new fighter with random traits.
    413      /// @param tokenId ID of the fighter being re-rolled.
-   414      /// @param fighterType The fighter type.
-   415      function reRoll(uint256 tokenId, uint8 fighterType) public {
-   416          require((fighterType == 1) == fighters[tokenId].dendroidBool, "Type mismatch");
+            function reRoll(uint256 tokenId) public {
    417          require(msg.sender == ownerOf(tokenId));
+                uint8 fighterType = fighters[tokenId].dendroidBool ? 1 : 0;
    418          require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
    419          require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
    420  
    421          _neuronInstance.approveSpender(msg.sender, rerollCost);
    422          bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
    423          if (success) {
    424              numRerolls[tokenId] += 1;
    425              uint256 dna = uint256(keccak256(abi.encode(tokenId, numRerolls[tokenId])));
    426              (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
    427              fighters[tokenId].element = element;
    428              fighters[tokenId].weight = weight;
    429              fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
    430                  newDna,
    431                  generation[fighterType],
    432                  fighters[tokenId].iconsType,
    433                  fighters[tokenId].dendroidBool
    434              );
    435              _tokenURIs[tokenId] = "";
    436          }
    437      }    

In this way, we removed the possibility of passing the wrong FighterType: it is computed at runtime using fighters.dendroidBool.
If you need to maintain the same function signature, we suggest to create a new function:

+            function reRoll(uint256 tokenId, uint8 fighterType) public {
+               _reRoll(tokenId);
+            }

    412      /// @notice Rolls a new fighter with random traits.
    413      /// @param tokenId ID of the fighter being re-rolled.
-   414      /// @param fighterType The fighter type.
-   415      function reRoll(uint256 tokenId, uint8 fighterType) public {
-   416          require((fighterType == 1) == fighters[tokenId].dendroidBool, "Type mismatch");
+            function _reRoll(uint256 tokenId) public {
    417          require(msg.sender == ownerOf(tokenId));
+                uint8 fighterType = fighters[tokenId].dendroidBool ? 1 : 0;
    418          require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
    419          require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll");
    420  
    421          _neuronInstance.approveSpender(msg.sender, rerollCost);
    422          bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost);
    423          if (success) {
    424              numRerolls[tokenId] += 1;
    425              uint256 dna = uint256(keccak256(abi.encode(tokenId, numRerolls[tokenId])));
    426              (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType);
    427              fighters[tokenId].element = element;
    428              fighters[tokenId].weight = weight;
    429              fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes(
    430                  newDna,
    431                  generation[fighterType],
    432                  fighters[tokenId].iconsType,
    433                  fighters[tokenId].dendroidBool
    434              );
    435              _tokenURIs[tokenId] = "";
    436          }
    437      }  

Review of ADD-06: Issue found

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/d81beee0df9c5465fe3ae954ce41300a9dd60b7f/src/Neuron.sol#L168-L169

Vulnerability details

Reviewed changes

New airdrop mechanism in ArenaX-Labs/2024-02-ai-arena-mitigation@d81beee.

Review

QA issues

Missing @param for root

Note that rootClaimedAirdrop is keyed by the user address rather than by leaves (address and amount). This means that you have to make sure that an address appears in at most one leaf, since it can only claim one amount. If this is undesirable rootClaimedAirdrop should be keyed by the leaf(hash).

High issue

These lines in Neuron.claim() are wrong.

_approve(treasuryAddress, msg.sender, amount);
transferFrom(treasuryAddress, msg.sender, amount);

Approval is given to msg.sender to spend from treasuryAddress in addition to a transfer from treasuryAddress to msg.sender. This leads either to a revert in transferFrom() if Neuron has not been given approval by the treasury, or a double claim by the user spending his approved amount in addition to the same amount transferred.
What is intended is probably

- _approve(treasuryAddress, msg.sender, amount);
+ _approve(treasuryAddress, address(this), amount);
transferFrom(treasuryAddress, msg.sender, amount);

I'm not sure why airdropped token have to pass via the treasury, but otherwise they could instead just be minted directly to the user, i.e. _mint(msg.sender, amount).

Assessed type

ERC20

ADD-02 MitigationConfirmed

Lines of code

Vulnerability details

Mitigation of ADD-02: Mitigated with Error

Mitigated issue

L-02 in #507

The issue was a missing check for ECDSA signature malleability in Verification.verify().

Mitigation review - mitigated with error

Verification.sol has been overhauled and now uses OpenZeppelin's ECDSA library, which only allows s in the lower half order.
The order of parameters of verify() was changed, and the corresponding changes have been made in calls to verify().

This mitigation includes the Error below.

Related to this mitigation is the mitigation of H-03 which added a signature check in FighterFarm.redeemMintPass(). This is also impacted by the Error of this mitigation.

Mitigation Error - toEthSignedMessageHash() is not in ECDSA.sol.

L20:

bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();

will revert because toEthSignedMessageHash() is located in MessageHashUtils.sol.

This breaks AAMintPass.claimMintPass(), FighterFarm.claimFighters() and FighterFarm.redeemMintPass(), which call Verification.verify().

Recommended Mitigation of Error

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
+ import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

...

- bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
+ bytes32 ethSignedMessageHash = MessageHashUtils.toEthSignedMessageHash(messageHash);

Remaining Low risk issue - no chainId in hash

The chainId is not included in the message hash, so cross-chain replay could be possible, if this were to be also deployed on other chains.

It's not possible to claim MergingPool rewards for the last round, only for rounds previous to it

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/1192a55963c92fb4bd9ca8e0453c96af09731235/src/MergingPool.sol#L153

Vulnerability details

C4 issue

M-04: DoS in MergingPool::claimRewards function and potential DoS in RankedBattle::claimNRN function if called after a significant amount of rounds passed

Comments

Previously, The MergingPool::claimRewards function loop could exceed the block gas limit, potentially causing a DoS. This would happen if a user tried claiming their rewards after too many rounds had passed. Similarly, there was a risk of DoS in RankedBattle::claimNRN for the same reason.

In both cases, rounds were iterated up to the current round ID non inclusively.

Mitigation

PR #18
Now the issue in both functions is fixed thanks to an additional input uint32 totalRoundsToConsider which allows users to loop fewer rounds per call. However, the input validation of totalRoundsToConsider in MergingPool::claimRewards is incorrect:

    require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");
    uint8 generation = _fighterFarmInstance.generation(0);
    for (uint32 currentRound = lowerBound; currentRound < lowerBound + totalRoundsToConsider; currentRound++) {

lowerBound + totalRoundsToConsider can at most be equal to roundId - 1, which means that claimRewards() will loop, at most, till roundId - 2. Therefore, it's not possible to claim MergingPool rewards for the last round, only for rounds previous to it.

Note that this was correctly implemented in the RankedBattle::claimNRN fix.

Suggestion

Change

    require(lowerBound + totalRoundsToConsider < roundId, "MergingPool: totalRoundsToConsider exceeds the limit");

to

    require(lowerBound + totalRoundsToConsider <= roundId, "MergingPool: totalRoundsToConsider exceeds the limit");

Conclusion

The mitigation works well but introduces an issue because the new function input is wrongly validated.

Assessed type

Invalid Validation

H-02 MitigationConfirmed

Lines of code

Vulnerability details

Lines of code

Vulnerability details

C4 issue

H-02: Non-transferable GameItems can be transferred with GameItems::safeBatchTransferFrom(...)

Comments

The GameItems contract inherits ERC1155 and should prevent non-transferable token from being transferred. safeTransferFrom was correctly overriden according to this behavior, but safeBatchTransferFrom was left as is. Users could therefore bypass this feature by using safeBatchTransferFrom.

Mitigation

PR #4
Now safeBatchTransferFrom has been implemented and all ids are checked. If any id is non-transferable, the call correctly reverts:

/// @notice Safely transfers an batch of NFTs from one address to another.
/// @dev Added a check to see if the game item is transferable.
function safeBatchTransferFrom(
    address from,
    address to,
    uint256[] memory ids,
    uint256[] memory amounts,
    bytes memory data
) 
    public 
    override(ERC1155) 
{
    for (uint256 i; i < ids.length; i++) {
        require(allGameItemAttributes[ids[i]].transferable);
    }
    super.safeBatchTransferFrom(from, to, ids, amounts, data);
}

Suggestion

None

Conclusion

LGTM

[ADD-03 [12]] Mitigation Error: `GameItems.setTokenURI()` can only set the next game item.

Lines of code

https://github.com/ArenaX-Labs/2024-02-ai-arena-mitigation/blob/d81beee0df9c5465fe3ae954ce41300a9dd60b7f/src/GameItems.sol#L205

Vulnerability details

Impact

GameItems.setTokenURI() can only set the next game item.

Proof of Concept

function setTokenURI(uint256 tokenId, string memory _tokenURI) public {
    require(isAdmin[msg.sender]);
    require(tokenId == _itemCount, "Token ID does not exist");
    _tokenURIs[tokenId] = _tokenURI;
}

Recommended Mitigation Steps

In GameItems.setTokenURI()

function setTokenURI(uint256 tokenId, string memory _tokenURI) public {
    require(isAdmin[msg.sender]);
-   require(tokenId == _itemCount, "Token ID does not exist");
+   require(tokenId < _itemCount, "Token ID does not exist");
    _tokenURIs[tokenId] = _tokenURI;
}

and in CreateGameItem() on L243

- setTokenURI(_itemCount, tokenURI);
+ _tokenURIs[tokenId] = _tokenURI;

Assessed type

Invalid Validation

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.