AI Arena - peter'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: 53/283

Findings: 7

Award: $125.19

🌟 Selected for report: 0

πŸš€ Solo Findings: 0

Lines of code

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

Vulnerability details

Impact

Specific game items can be locked/unlocked by the contract owner, with the intention of preventing transfers of these game items during periods when they are locked. This constraint is correctly added to the overwritten (inherited) ERC1155 function safeTransferFrom, which requires the item be unlocked in order to allow a transfer:

    /// @dev Added a check to see if the game item is transferable.
    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);
    }

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/GameItems.sol#L290-L303

However the contract does not also overwrite the inherited ERC1155 function safeBatchTransferFrom, as it should. Therefore the entire locking/unlocking logic is rendered defunct, as locked items can be as freely transferred as unlocked items. This may have significant ramifications for the protocol, as core game item logic doesn't work as intended.

Proof of Concept

Add the following test to the GameItemsTest test suite and run:

forge test --match-test testCircumventLockWithBatchTransfer -vvv
    /// @notice Test cirumventing the lock by using a batch transfer
    function testCircumventLockWithBatchTransfer() public {
        _fundUserWith4kNeuronByTreasury(_ownerAddress);
        _gameItemsContract.mint(0, 1);
        console.log("make the game item non transferable");
        _gameItemsContract.adjustTransferability(0, false);     // make the game item non transferable
        (,, bool transferable,,,) = _gameItemsContract.allGameItemAttributes(0);
        assertEq(transferable, false);
        console.log("expect revert since the game item is non transferable");
        vm.expectRevert();       // expect revert since the game item is non transferable
        _gameItemsContract.safeTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, 0, 1, "");
        console.log("the game item was not transferred as safe transfer lock check works as expected");
        assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 0);   // the game item was not transferred
        assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 1);

        console.log("transfer the same locked item with a batch transfer instead");
        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, "");
        console.log("the game item was transferred successfully as no lock check is performed for ERC1155 batch transfers");
        assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 1);   // the game item was transferred
        assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 0);
    }

Output:

Running 1 test for test/GameItems.t.sol:GameItemsTest
[PASS] testCircumventLockWithBatchTransfer() (gas: 201580)
Logs:
  make the game item non-transferable
  expect revert since the game item is non-transferable
  the game item was not transferred as safe transfer lock check works as expected
  transfer the same locked item with a batch transfer instead
  the game item was transferred successfully as no lock check is performed for ERC1155 batch transfers

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.78ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools Used

Manual inspection, Foundry

Add the same transferability lock/unlock logic to safeBatchTransferFrom in GameItems.sol:

diff --git a/src/GameItems.sol b/src/GameItems.sol index 4c810a8..900466d 100644 --- a/src/GameItems.sol +++ b/src/GameItems.sol @@ -302,6 +302,25 @@ contract GameItems is ERC1155 { super.safeTransferFrom(from, to, tokenId, amount, data); } + /// @notice Safely transfers an NFT from one address to another. + /// @dev Added a check to see if the game item is transferable. + function safeBatchTransferFrom( + address from, + address to, + uint256[] memory tokenIds, + uint256[] memory amounts, + bytes memory data + ) + public + override(ERC1155) + { + for (uint256 i = 0; i < tokenIds.length;) { + require(allGameItemAttributes[tokenIds[i]].transferable); + unchecked {++i;} + } + super.safeBatchTransferFrom(from, to, tokenIds, amounts, data); + } + /*////////////////////////////////////////////////////////////// PRIVATE FUNCTIONS //////////////////////////////////////////////////////////////*/

Assessed type

Token-Transfer

#0 - c4-pre-sort

2024-02-22T04:43:35Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-22T04:43:41Z

raymondfam marked the issue as duplicate of #18

#2 - c4-pre-sort

2024-02-26T00:29:54Z

raymondfam marked the issue as duplicate of #575

#3 - c4-judge

2024-03-05T04:58:32Z

HickupHH3 marked the issue as satisfactory

Awards

1.2667 USDC - $1.27

Labels

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

External Links

Lines of code

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

Vulnerability details

Impact

As long as a user has a valid mint pass, they can give any arbitrary values for modelHashes, modelTypes, fighterTypes, iconsTypes and mintPassDnas. During protocol launch, FighterFarm::redeemMintPass will be the only mechanism to get a fighter and play the game, so this function will be used extensively.

With arbitrary fighterTypes, users can mint themselves any fighter type they desire, including a Dendroid, which the protocol intends to be:

"very rare and more difficult to obtain"

Furthermore, arbitrary iconsTypes allow users to acquire unique custom icon attributes that they shouldn't be allowed through the FighterFarm::redeemMintPass user flow. And arbitrary mintPassDnas allows users to give themselves custom rarity attributes, as these become deterministic if one is able to set the dna value (see AiArenaHelper::createPhysicalAttributes).

Beyond simply exploiting the FighterFarm::redeemMintPass intended functionality and giving oneself especially rare fighters, this exploit has 2 downstream financial consequences:

  1. It lowers the market value of rare NFTs owned by users that derived them legitimately, thus causing financial losses for honest users
  2. It effectively allows users to dictate the value of the NFT they mint, thus allowing malicious users to indirectly financially exploit the protocol

In summary, users can set any values for key parameters within FighterFarm::redeemMintPass. This allows malicious actors to exploit the intended functionality of the protocol by giving themselves arbitrarily rare fighter NFTs, which has significant negative consequences for the protocol.

Tools Used

Manual inspection, Foundry

Incorporate the following diff file instead of the original FigherFarm::redeemMintPass. The changes are as follows:

  1. Add public state mapping to track number of redemptions a given address has made
  2. Require a signature from the delegated address for each redemption, similar to the logic in FigherFarm::claimFighters
  3. Each signature message should include: msg.sender, fighterType, iconsType and how many redemptions the address has already made. This ensures no same-chain replay, and ensures the integrity of the fighter and icons type, as this has to be approved by the delegator.
diff --git a/src/FighterFarm.sol b/src/FighterFarm.sol index 06ee3e6..b924a23 100644 --- a/src/FighterFarm.sol +++ b/src/FighterFarm.sol @@ -87,6 +87,9 @@ contract FighterFarm is ERC721, ERC721Enumerable { /// @notice Maps address to fighter type to return the number of NFTs claimed. mapping(address => mapping(uint8 => uint8)) public nftsClaimed; + /// @notice Mapping of address to the number of NFTs redeemed. + mapping(address => uint8) public nftsRedeemed; + /// @notice Mapping of tokenId to number of times trained. mapping(uint256 => uint32) public numTrained; @@ -230,28 +233,37 @@ contract FighterFarm is ERC721, ERC721Enumerable { /// @param fighterTypes Array of fighter types corresponding to the fighters being minted. /// @param modelHashes Array of ML model hashes corresponding to the fighters being minted. /// @param modelTypes Array of ML model types corresponding to the fighters being minted. + /// @param signatures Array of signatures corresponding to the mint pass IDs to be burned. function redeemMintPass( uint256[] calldata mintpassIdsToBurn, uint8[] calldata fighterTypes, uint8[] calldata iconsTypes, - string[] calldata mintPassDnas, string[] calldata modelHashes, string[] calldata modelTypes, + bytes[] calldata signatures ) external { require( - mintpassIdsToBurn.length == mintPassDnas.length && - mintPassDnas.length == fighterTypes.length && + mintpassIdsToBurn.length == fighterTypes.length && fighterTypes.length == modelHashes.length && modelHashes.length == modelTypes.length ); + for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) { + bytes32 msgHash = bytes32(keccak256(abi.encode( + msg.sender, + fighterTypes[i], + iconsTypes[i], + nftsRedeemed[msg.sender] + ))); + require(Verification.verify(msgHash, signatures[i], _delegatedAddress)); + nftsRedeemed[msg.sender] += 1; require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i])); _mintpassInstance.burn(mintpassIdsToBurn[i]); _createNewFighter( msg.sender, - uint256(keccak256(abi.encode(mintPassDnas[i]))), + uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHashes[i], modelTypes[i], fighterTypes[i],

Assessed type

Other

#0 - c4-pre-sort

2024-02-22T07:37:22Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-22T07:37:28Z

raymondfam marked the issue as duplicate of #33

#2 - c4-pre-sort

2024-02-26T00:53:11Z

raymondfam marked the issue as duplicate of #1626

#3 - c4-judge

2024-03-06T03:08:59Z

HickupHH3 marked the issue as satisfactory

Lines of code

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

Vulnerability details

Impact

FighterFarm.sol provides _ownerAddress the ability to increment fighter generations, meaning the contract is designed to handle generations > 0. However nowhere in the code can the owner or any other user actually add elements for future generations ('Elemental Powers' are a key component in each fighter NFT). Only generation 0 elements are defined as they are hard-coded in the constructor:

    constructor(address ownerAddress, address delegatedAddress, address treasuryAddress_)
        ERC721("AI Arena Fighter", "FTR")
    {
        _ownerAddress = ownerAddress;
        _delegatedAddress = delegatedAddress;
        treasuryAddress = treasuryAddress_;
        numElements[0] = 3;
    } 

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

This means that as fighter type generations are incremented in FighterFarm.sol (as intended), the key NFT fighter minting functions will always revert. This is because both FighterFarm::claimFighters and FighterFarm::redeemMintPass use the internal FighterFarm::_createFighterBase function. When the elements in a generation > 0 are not defined (as they can't be), the line uint256 element = dna % numElements[generation[fighterType]]; will always revert, as num % 0 will always give EVM divide by 0 error.

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

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

In summary, essential fighter NFT parameters can't be defined as non-zero in a contract that is clearly intended to handle generations > 0, causing core minting functionality to always fail for generations > 0.

Tools Used

Manual inspection

Add the following function to FighterFarm.sol to add elements for future generations:

    /// @notice Adds a new element number to the specified generation.
    function addElements(uint8 generation, uint8 _numElements) external {
        require(msg.sender == _ownerAddress);
        numElements[generation] = _numElements;
    }

Assessed type

Other

#0 - c4-pre-sort

2024-02-22T18:33:51Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-22T18:34:00Z

raymondfam marked the issue as duplicate of #45

#2 - c4-judge

2024-03-07T06:59:43Z

HickupHH3 marked the issue as satisfactory

Awards

1.876 USDC - $1.88

Labels

bug
2 (Med Risk)
downgraded by judge
insufficient quality report
satisfactory
edited-by-warden
:robot:_30_group
duplicate-932

External Links

Lines of code

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

Vulnerability details

Impact

NFT fighters in generation 0 are explicitly limited to 3 elements: fire, water and electricity. Further there are 3 weight classes: striker, scrapper and slugger. Elements are strictly bounded between uint values [0,2] and weight classes [65,95], where each fighter weight class increases in increments of 10 (i.e. striker is 65-74). These limitations are clearly laid out in FighterFarm. From the protocol documentation, a fighter’s weight:

"is the primary determinant of its other relative strength and weaknesses"

"Additionally, it is used to calculate how far the fighter moves when being knocked back"

For elements:

"There are three featured elements, each with corresponding elemental moves. These moves have relative strengths and weaknesses depending on pairing"

However when a user mints an NFT through MergingPool::claimRewards, they can choose any arbitrary values for elements and weights in customAttributes:

    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]

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

This means that NFT fighters with undefined attributes can be created. As a fighter's weight and element type are very important in combat, allowing arbitrary values for these key parameters at best creates invalid fighters, at worst may significantly damage and exploit gameplay.

Proof of Concept

Add the following test to MergingPoolTest test suite and run:

forge test --match-test testSetIncorrectCustomAttributes -vvv
    function testSetIncorrectCustomAttributes() external {
        _mintFromMergingPool(_ownerAddress);
        _mintFromMergingPool(_DELEGATED_ADDRESS);
        assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress);
        assertEq(_fighterFarmContract.ownerOf(1), _DELEGATED_ADDRESS);
        uint256[] memory _winners = new uint256[](2);
        _winners[0] = 0;
        _winners[1] = 1;
        _mergingPoolContract.pickWinner(_winners);
        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] = type(uint256).max;        // Elements should be bounded between 0-2
        _customAttributes[0][1] = type(uint256).max;        // Weight should be bounded between 65-95 (3 weight classes)
        // other winner claims rewards for previous roundIds
        vm.prank(_DELEGATED_ADDRESS);
        _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);
        (, , uint256 weight, uint256 element, , ,) = _fighterFarmContract.getAllFighterInfo(2);
        assertEq(element, type(uint256).max);
        assertEq(weight, type(uint256).max);
    }

The test passes, confirming that type(uint256).max has been successfully set for both element type and weight, highlighting an extreme out-of-bounds scenario.

Tools Used

Manual inspection, Foundry

Bound the element type and weight by the modulo operator in the desired range, potentially just making use of the already existing FighterFarm::_createFighterBase logic.

Assessed type

Other

#0 - c4-pre-sort

2024-02-24T08:59:33Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-24T08:59:43Z

raymondfam marked the issue as duplicate of #226

#2 - c4-judge

2024-03-11T10:23:53Z

HickupHH3 changed the severity to 2 (Med Risk)

#3 - c4-judge

2024-03-11T10:25:15Z

HickupHH3 marked the issue as satisfactory

Lines of code

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

Vulnerability details

Impact

The DNA of a fighter is very important when determining rarity attributes. If one knows the DNA, one can deterministically calculate the rarity of a future NFT to mint. The protocol generally uses a pseudorandom method for calculating DNA with 2 degrees of freedom:

  1. The NFT mint recipient address
  2. The length of the fighters array

This method works sufficiently well to prevent users from playing timing games in order to maximize the mint NFT rarity, as each user has a different address, and hence the DNA would be different for different users (with the same fighters length). So there is basically no competition/MEV to mint a certain fighter at a certain fighters array length, as it would be different for each user anyway.

However this is not the case with the mintFromMergingPool user flow. We can see in FighterFarm::mintFromMergingPool below, the DNA is set as uint256(keccak256(abi.encode(msg.sender, fighters.length))). This is a mistake because in this particular function msg.sender will always be MergingPool, hence the pseudorandom 2 degrees of freedom for DNA has now collapsed to only 1: the length of the fighters array.

This means that, say, 20 unclaimed winners from MergingPool will all be competing for the exact same DNA (and hence fighter rarity) for a given fighter length, as there is only 1 degree of freedom. This introduces front-running from users to compete for deterministic rarity traits, and users will hold out from claiming their rewards until a sufficiently rare NFT is able to be claimed:

e.g. Fighter length from 200-350 may not produce any sufficiently rare fighter, but fighter length 351 gives a very rare NFT when claimed through MergingPool. Hence users will delay claiming their rewards until the next fighter length gets to 351, at which point all users will compete for the same, highly valuable NFT.

Overall this allows users to manipulate the rarity and hence value of their NFT (to a limited degree) by selectively choosing when to claim their reward (based on fighters length). This introduces undesirable timing/front-running games and rarity manipulation into the ecosystem. It also hurts fighter NFT liquidity, as sophisticated users will hold off minting fighters until they can maximise their rarity/value, with the most optimal fighters length.

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

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

Tools Used

Manual inspection

Change the DNA formula in FighterFarm::mintFromMergingPool from uint256(keccak256(abi.encode(msg.sender, fighters.length))) to uint256(keccak256(abi.encode(to, fighters.length))), i.e. use to instead of msg.sender. This maintains 2 degrees of freedom within the DNA pseudorandom formula (consistent with other minting functions in FighterFarm), and prevents the exploitative timing and rarity games:

diff --git a/src/FighterFarm.sol b/src/FighterFarm.sol index 06ee3e6..a90d03e 100644 --- a/src/FighterFarm.sol +++ b/src/FighterFarm.sol @@ -321,7 +321,7 @@ contract FighterFarm is ERC721, ERC721Enumerable { require(msg.sender == _mergingPoolAddress); _createNewFighter( to, - uint256(keccak256(abi.encode(msg.sender, fighters.length))), + uint256(keccak256(abi.encode(to, fighters.length))), modelHash, modelType, 0,

Assessed type

MEV

#0 - c4-pre-sort

2024-02-24T01:46:22Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-24T01:46:53Z

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:51:05Z

HickupHH3 marked the issue as satisfactory

#4 - c4-judge

2024-03-15T02:10:54Z

HickupHH3 changed the severity to 2 (Med Risk)

#5 - c4-judge

2024-03-22T04:21:16Z

HickupHH3 marked the issue as duplicate of #376

Awards

107.2533 USDC - $107.25

Labels

bug
downgraded by judge
grade-a
insufficient quality report
primary issue
QA (Quality Assurance)
satisfactory
edited-by-warden
:robot:_170_group
Q-45

External Links

Lines of code

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

Vulnerability details

Impact

MergingPool::claimRewards and FighterFarm::mintFromMergingPool function input argument tuples both contains a dynamic type, and the last element is a fixed size uint256 array with the calldata keyword (see below). This exactly fits the criteria for the Solidity compiler bug AbiReencodingHeadOverflowWithStaticArrayCleanup, which the Solidity team assigned as medium severity. This bug was resolved in solc v0.8.16, however this project currently uses solc 0.8.13:

    function claimRewards(
        string[] calldata modelURIs, 
        string[] calldata modelTypes,
        uint256[2][] calldata customAttributes
    ) 

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

    function mintFromMergingPool(
        address to, 
        string calldata modelHash, 
        string calldata modelType, 
        uint256[2] calldata customAttributes
    ) 

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

The bug effectively deletes the data in the first dynamic type, which would be modelHash/modelURIs in this case. Although this bug will only directly manifest in situations where abi-encoding reoccurs, i.e. external call, event, error etc. This happens with MergingPool::claimRewards (external call to FighterFarm.sol), meaning that 100% of the time a user calls MergingPool::claimRewards, all of their modelURIs values will be deleted to become empty strings.

Overall this bug causes unintended NFT data corruption in 100% of cases, when any user calls MergingPool::claimRewards. Future changes to code that include events or errors relating to this parameter would also trigger this bug, causing misleading logs/debugging.

Proof of Concept

Run testClaimRewardsForWinnersOfMultipleRoundIds from the MergingPoolTest test suite and add the following to MergingPoolTest::testClaimRewardsForWinnersOfMultipleRoundIds. Note we can demonstrate this bug on FighterFarm::mintFromMergingPool as well, although explicit ABI-reencoding would need to be added, as this function doesn't currently do this.

diff --git a/test/MergingPool.t.sol b/test/MergingPool.t.sol index 7ad2ba6..349ef3f 100644 --- a/test/MergingPool.t.sol +++ b/test/MergingPool.t.sol @@ -190,6 +190,23 @@ contract MergingPoolTest is Test { // other winner claims rewards for previous roundIds vm.prank(_DELEGATED_ADDRESS); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); + // Getting attributes for claimed fighters, demonstrating data corruption of '_modelURIs' + (, , , , string memory _modelHash, string memory _modelType, ) = _fighterFarmContract.getAllFighterInfo(2); + console.log("modelHash: ", _modelHash); + console.log("modelType: ", _modelType); + assertEq(_modelHash, ""); + (, , , , _modelHash, _modelType, ) = _fighterFarmContract.getAllFighterInfo(3); + console.log("modelHash: ", _modelHash); + console.log("modelType: ", _modelType); + assertEq(_modelHash, ""); + (, , , , _modelHash, _modelType, ) = _fighterFarmContract.getAllFighterInfo(4); + console.log("modelHash: ", _modelHash); + console.log("modelType: ", _modelType); + assertEq(_modelHash, ""); + (, , , , _modelHash, _modelType, ) = _fighterFarmContract.getAllFighterInfo(5); + console.log("modelHash: ", _modelHash); + console.log("modelType: ", _modelType); + assertEq(_modelHash, ""); uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(_DELEGATED_ADDRESS); emit log_uint(numRewards); assertEq(numRewards, 0);
forge test --match-test testClaimRewardsForWinnersOfMultipleRoundIds -vvv

Output:

Compiling 1 files with 0.8.13
Solc 0.8.13 finished in 7.24s
Compiler run successful!

Running 1 test for test/MergingPool.t.sol:MergingPoolTest
[PASS] testClaimRewardsForWinnersOfMultipleRoundIds() (gas: 2711770)
Logs:
  modelHash:  
  modelType:  original
  modelHash:  
  modelType:  original
  modelHash:  
  modelType:  original
  modelHash:  
  modelType:  original
  0

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.30ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

As we can observe from the output, modelHash/_modelURIs from all 4 new fighter NFTs have been entirely corrupted to become empty strings (they should be ipfs strings from test), as function is currently susceptible to this bug.

Tools Used

Manual inspection, Foundry

Compile code with >= solc 0.8.16 or otherwise rearrange FigherFarm::mintFromMergingPool and MergingPool::claimRewards input args so uint256[2] calldata customAttributes and uint256[2][] calldata customAttributes are not the last arguments respectively.

Assessed type

en/de-code

#0 - c4-pre-sort

2024-02-25T07:01:45Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-25T07:01:49Z

raymondfam marked the issue as primary issue

#2 - raymondfam

2024-02-25T07:02:03Z

Informational low QA.

#3 - HickupHH3

2024-03-16T03:09:13Z

verified. a bit confusing because the _modelURI[] fields passed in claimRewards() is actually _modelHashes[], but yes, it'll be zero due to the compiler bug

logging info for the first 4 fighterIDs (original)

[PASS] testClaimRewardsForWinnersOfMultipleRoundIds() (gas: 2713015) Logs: owner: 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0 modelHash: _neuralNetHash modelType: original owner: 0x90193C961A926261B756D1E5bb255e67ff9498A1 modelHash: modelType: original owner: 0x90193C961A926261B756D1E5bb255e67ff9498A1 modelHash: modelType: original owner: 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0 modelHash: modelType: original 0

logging info for the first 4 fighterIDs (compiled with 0.8.16)

[PASS] testClaimRewardsForWinnersOfMultipleRoundIds() (gas: 3062553) Logs: owner: 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0 modelHash: _neuralNetHash modelType: original owner: 0x90193C961A926261B756D1E5bb255e67ff9498A1 modelHash: ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m modelType: original owner: 0x90193C961A926261B756D1E5bb255e67ff9498A1 modelHash: ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m modelType: original owner: 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0 modelHash: ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m modelType: original 0

#4 - c4-judge

2024-03-16T03:09:48Z

HickupHH3 marked the issue as selected for report

#5 - c4-judge

2024-03-16T03:09:51Z

HickupHH3 marked the issue as satisfactory

#6 - mcgrathcoutinho

2024-03-19T19:14:17Z

Hi @HickupHH3, I think this issue should be of QA-severity since:

  • The availability of the protocol is not impacted i.e. the user can always set the modelHash due to updateModel() being a public function.
  • On the frontend, the user will always have the option to set the modelHash and modelType directly if it has not been set yet (see here).

As such there is no risk involved, I believe this deems QA-severity.

Thank you for your time.

#7 - HickupHH3

2024-03-20T08:22:11Z

am on the fence regarding severity. am aware that the model hash can always be updated subsequently, but it's an inconvenience to the user having to perform another tx.

i suppose it falls under state handling.

#8 - c4-judge

2024-03-20T08:22:41Z

HickupHH3 changed the severity to QA (Quality Assurance)

#9 - HickupHH3

2024-03-20T08:24:02Z

tagging @SonnyCastro @brandinho so this doesn't go unnoticed

#10 - PeterMcQuaid

2024-03-20T09:39:21Z

@HickupHH3 I maintain this is a medium severity. There is clear and consistent data corruption in the most important project token (fighter NFT). The probability that a user is going to be aware of the data corruption and how to fix it is low. Furthermore this requires the user to pay out of pocket for an extra transaction in 100% of cases to fix the issue, which adds additional economic and time costs for each user.

#11 - mcgrathcoutinho

2024-03-20T09:48:55Z

Hi @PeterMcQuaid, just to provide some context:

  1. The probability that a user is going to be aware of the data corruption and how to fix it is low. - The users will be having the option to set it from the frontend if not already set (see sponsors comment here and the one linked in my comment above).
  2. The user to pay out of pocket for an extra transaction - This sounds more of a gas optimization to me but even if we do not consider it that way, the project is going to be deployed on Arbitrum, where gas fees are extremely cheap, so there are almost no economic costs involved.

Due to this I believe the issue is of QA severity.

#12 - PeterMcQuaid

2024-03-20T10:06:28Z

Thanks for your feedback on this matter @mcgrathcoutinho. Please see below:

The users will be having the option to set it from the frontend if not already set - This is purely speculation on your part, based on tenuous inferences from the sponsor comments attached. There is no guarantee or even evidence that this feature will be in the frontend, and assuming most users could easily interact with the smart contracts themselves is incorrect.

This sounds more of a gas optimization to me - Requiring users to always submit an extra transaction due to data corruption of their NFT, caused by a bug in the protocol, is not a gas optimization

#13 - mcgrathcoutinho

2024-03-20T19:27:08Z

Hi @PeterMcQuaid, thank you for your comments. I'll leave my final comments on this and leave the judge to decide the final verdict.

  1. based on tenuous inferences from the sponsor comments attached - You can confirm this with the sponsor as well if you want.
  2. There is no guarantee or even evidence that this feature will be in the frontend - I do not agree with this statement. The updateModel() function is public and only restricted to the owner of the Fighter NFT, which means the frontend would require to provide users with the option to update their modelHash and modelType if they want (which again is clearly explained here and here by the sponsor in the public channel).

This is definitely a great find but as for the severity of the issue, I believe it is QA at best.

#14 - HickupHH3

2024-03-21T00:34:24Z

awaiting sponsor input before I comment further

#15 - vnavascues

2024-03-21T13:44:00Z

Hi all, I stand with @PeterMcQuaid and couldn't agree more with Peter's words.

According to Code Arena's Docs - Severity Categorization an issue qualifies as Med when:

2 β€” Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.

AI Arena was supposed to be deployed with Solidity 0.8.13 with a bug on its cornerstone token with the following consequences:

  • On-chain data corruption.
  • Impaired UX for the user, the protocol and any 3rd party integrating with it. For instance, querying the claimed fighter via the FighterCreated event will return corrupted data and the updateModel() function does not emit any event.
  • Extra economic cost for the user but also for the protocol. If we are going to hypothesize that the front-end can deal with it, then this front-end will require new requirements, User Guides and/or How-Tos explaining why the 2-step claiming process, etc. Also the unnoticed risks for the sponsor on further code changes (this could very well happen if other wardens report unrelated issues whose suggested changes get involved with the code and end up being affected too).

It impacts the functionality and users notice it.

I'm just gonna use a web2 analogy on the bug and the proposed front-end patch: on a user-centric app just letting corrupted data make it into the DB because of an untested endpoint and having to deal with it suggesting the user to always make a PUT request after the POST one to fix it seems to me greater than a low.

If you finally decide to downgrade it to QA, at least Peter and I should get an A-grade QA ( :D ). Thanks!

#16 - brandinho

2024-03-25T02:51:26Z

I can add some color here. I think we should address this, but our front-end does deal with this. Not because we knew this was an issue and have some special handling for it, but because we require users to update their model (after initial minting) before they can participate in ranked battle (which is where the hash gets used). They have to do this through our application (i.e. not interact with the blockchain directly) otherwise they cannot participate in ranked.

#17 - c4-judge

2024-03-25T09:39:41Z

HickupHH3 marked the issue as not selected for report

#18 - c4-judge

2024-03-25T09:39:54Z

HickupHH3 marked the issue as grade-a

#19 - HickupHH3

2024-03-25T09:40:50Z

grade-a for the very notable finding

Awards

13.6293 USDC - $13.63

Labels

bug
G (Gas Optimization)
grade-b
sufficient quality report
G-27

External Links

Winners of later rounds in MergingPool contract are charged more gas to claim their winnings

Explanation

The MergingPool::claimRewards function is not gas efficient as each loop through another round requires 2 extra SHA3 opcodes, 2 extra SLOADs and 1 extra SSTORE. This results in approximately 3000 extra gas per round (see PoC below) and is effectively an arbitrary penalty for winners of later rounds.

Since each round is intended to last 1 week, after 2 years there will have been +100 rounds. If a user wins round 99 instead of round 0, they must pay an additional arbitrary penalty of ~300,000 gas, which is inequitable.

This can be seen in the snippet and full function code below:

        for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) {
            numRoundsClaimed[msg.sender] += 1;
            winnersLength = winnerAddresses[currentRound].length;

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

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

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

PoC

Add the following test to the MergingPoolTest test suite:

    function testDemonstrateGasChange() external {
        _mintFromMergingPool(_ownerAddress);
        _mintFromMergingPool(_DELEGATED_ADDRESS);
        assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress);
        assertEq(_fighterFarmContract.ownerOf(1), _DELEGATED_ADDRESS);
        uint256[] memory _winners = new uint256[](2);
        _winners[0] = 0;
        _winners[1] = 1;

        // Moving to round 99. Comment out if doing round 0
        vm.store(address(_mergingPoolContract), bytes32(uint256(1)), bytes32(uint256(99)));

        // winners of roundId 99 are picked, or otherwise
        _mergingPoolContract.pickWinner(_winners);
        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);

        // other winner claims rewards for previous roundIds
        vm.prank(_DELEGATED_ADDRESS);
        _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);
        uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(_DELEGATED_ADDRESS);
        emit log_uint(numRewards);
        assertEq(numRewards, 0);
    }

Run:

forge test --match-test testDemonstrateGasChange -vvv

Output for round 99 and round 0 respectively:

No files changed, compilation skipped

Running 1 test for test/MergingPool.t.sol:MergingPoolTest
[PASS] testDemonstrateGasChange() (gas: 1640704)
Logs:
  0

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.30ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Compiling 1 files with 0.8.13
Solc 0.8.13 finished in 6.44s
Compiler run successful!

Running 1 test for test/MergingPool.t.sol:MergingPoolTest
[PASS] testDemonstrateGasChange() (gas: 1361907)
Logs:
  0

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.33ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

As we can see from the output, round 99 winner test cost 1640704 gas, whereas exact same setup except for round 0 cost only 1361907 gas, a 278,797 gas difference. Thus a winner at round 99 had to pay 278,797 more gas than winner 0, which should not occur.

Use a storage pointer for numRoundsClaimed[msg.sender] to avoid an extra SHA3 opcode each round, and otherwise refactor the code to avoid unnecessary extra storage reads and writes

#0 - raymondfam

2024-02-25T22:13:34Z

Possible upgrade to #1541

#1 - c4-pre-sort

2024-02-25T22:13:41Z

raymondfam marked the issue as sufficient quality report

#2 - HickupHH3

2024-03-12T02:46:45Z

not upgrading as the user classified as a gas opt, not a vuln

#3 - c4-judge

2024-03-12T02:47:07Z

HickupHH3 marked the issue as grade-b

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