AI Arena - niser93'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: 3/283

Findings: 4

Award: $5,015.67

๐ŸŒŸ Selected for report: 0

๐Ÿš€ Solo Findings: 0

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#L224-L262 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L476-L531 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L78-L121

Vulnerability details

Impact

This issues is inside FighterFarm.sol#L224-L262, in redeemMintPass() function:

/// @notice Burns multiple mint passes in exchange for fighter NFTs. /// @dev This function requires the length of all input arrays to be equal. /// @dev Each input array must correspond to the same index, i.e., the first element in each /// array belongs to the same mint pass, and so on. /// @param mintpassIdsToBurn Array of mint pass IDs to be burned for each fighter to be minted. /// @param mintPassDnas Array of DNA strings of the mint passes to be minted as fighters. /// @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. 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)] ); } }

According to contest's Discord channel, this function is the entry point for a player. This function burns multiple mint passes and creates a new NFT fighter for each of them. To do so, it calls the FighterFarm._createNewFighter() function and passes parameters from user's input. This means that a user could choose this parameter maliciously to obtain arbitrarily characteristic for the new fighter. This characteristics include the PhysicalAttributes, that represent just the visual appearance, and the weight and element variables, that can influence the battle mechanisms. Due to the fact the fighter is represented using an NFT, this means that a player can create an NFT with arbitrarily characteristics, and the pseudo-random mechanism is broken. This game is based on fighter characteristics, and this finding strongly impacts the game mechanics.

Proof of Concept

The FighterFarm.redeemMintPass() described above calls FighterFarm._createNewFighter(). This function should create the fighter characteristics in a pseudo-random way, according to input parameters. In particular it calls FighterFarm._createFighterBase() passing dna and fighterType parameters:

FighterFarm.sol 500 (element, weight, newDna) = _createFighterBase(dna, fighterType);

and then uses newDna to create PhysicalAttributes:

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

So, the mintPassDnas passed by player using redeemMintPass() influences directly the fighter attributes. A player could brute-force mintPassDnas to obtain every wanted characteristics of the fighter. Even if mintPassDnas is hashed using keccak256(), and this makes the whole operation not revertible (or, better, not computationaly feasible), it is easy to find a particular string to obtain wanted fighter attributes, thanks an extraordinary number of collisions of hash function and the birthday paradox. In fact, we have 2^255 - 1 dnas in input. However, the possible fighter attributes are bounded at very low values:

_createFighterBase attributes

FighterFarm.sol#L470-L47

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

So element is bounded by numElements[generation[fighterType]] and weight is bounded by 31.

PhysicalAttributes

Physical attributed are computed according to rarityRank:

AiArenaHelper.sol#L107 107 uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;

rarityRank and AttributeProbabilities are used to compute PhysicalAttributes:

AiArenaHelper.sol#L169-L186 169 function dnaToIndex(uint256 generation, uint256 rarityRank, string memory attribute) 170 public 171 view 172 returns (uint256 attributeProbabilityIndex) 173 { 174 uint8[] memory attrProbabilities = getAttributeProbabilities(generation, attribute); 175 176 uint256 cumProb = 0; 177 uint256 attrProbabilitiesLength = attrProbabilities.length; 178 for (uint8 i = 0; i < attrProbabilitiesLength; i++) { 179 cumProb += attrProbabilities[i]; 180 if (cumProb >= rarityRank) { 181 attributeProbabilityIndex = i + 1; 182 break; 183 } 184 } 185 return attributeProbabilityIndex; 186 }

Combination probabilities

In tests, the maximum value for a single physical attribute is 6, because the lenght of AttributeProbabilities arrays is 6. On the other hand, in the deployment in test network, the maximum value for a single physical attribute is 10. Anyway, whatever AttributeProbabilities arrays are chosen, the maximum value that can be reached is 100, thanks to line AiArenaHelper.sol#L107: so each PhysicalAttributes is bounded by 100.

This means that, in the worst case, we have

P = Combination(element) * Combination(weight) * Combination(physicalAttributes)

P = numElements[generation[fighterType]] * 31 * 100^6

P = numElements[generation[fighterType]] * 31^12

where P is the number of possible combination of fighter characteristics. For example, when the contract is created, numElements[0] = 3, and we can assume numElements[0] < 31, in general.

So, P = 31^13

This means that every combination have about 2.37 * 10^57 dna strings to obtain it. Even if some combination is more probable than other, this remains a very big number. As a result, find a string to obtain wanted fighter attributes become not so hard.

To understand how probabilities work, let's see the probabilities written in deployer script:

25 function getProb() public { 26 probabilities.push([13, 7, 7, 13, 10, 13, 13, 10, 10, 4]); 27 probabilities.push([15, 15, 10, 7, 7, 15, 7, 10, 10, 4]); 28 probabilities.push([15, 15, 7, 4, 10, 10, 15, 10, 7, 7]); 29 probabilities.push([15, 7, 7, 10, 15, 4, 10, 10, 7, 15]); 30 probabilities.push([15, 10, 10, 7, 15, 10, 7, 15, 7, 4]); 31 probabilities.push([15, 15, 4, 10, 7, 7, 15, 10, 7, 10]); 32 }

This means, for example, that if rarityRank<= 13, then head = 1; if 13 < rarityRank<= 20, then head = 2, and so on. Furthermore, this means that the rare physical attributes belong to the smaller interval. According to our example:

Head ValueHead Probabilities
114%
27%
37%
413%
510%
613%
713%
810%
910%
103%

If the probabilities array don't cover 100% of probabilities (the sum is less than 100), we could obtain head=0. We describe this issue in another finding. However, 0 is not a valid value, and this was confirmed by sponsor. In the next table we report the rarest value for each physical attribute.

Physical attributeRarest valueInterval
head10 (3%)[97:99]
eyes10 (3%)[97:99]
mouth4 (4%)[38:41]
body6 (4%)[55:58]
hands10 (3%)[97:99]
feet3 (4%)[31:34]

In another report, we describe that some values of attributes are incompatible with each other.

We wrote a python script and a foundry test function to show how a player can arbitrarily choose fighter attributes. We tried to find some improbable combination of physical attributes. This is the python script:

from subprocess import check_output import re import time import csv start_time = time.time() # The rarity probabilities, according to the deployer script rarity = [ [ None, 14, 7, 7, 13, 10, 13, 13, 10, 10, 3], [ None, 16, 15, 10, 7, 7, 15, 7, 10, 10, 3], [ None, 16, 15, 7, 4, 10, 10, 15, 10, 7, 6], [ None, 16, 7, 7, 10, 15, 4, 10, 10, 7, 14], [ None, 16, 10, 10, 7, 15, 10, 7, 15, 7, 3], [ None, 16, 15, 4, 10, 7, 7, 15, 10, 7, 9] ] def compute_rarity(attrs): total_rarity = 0 for i, attr in enumerate(attrs.split("-")): int_attr = int(attr) if(int_attr == 50): continue total_rarity += rarity[i][int_attr] return total_rarity def replace_in_file(filename, pre_string, post_string): # Read in the file with open(filename, 'r') as file: filedata = file.read() # Replace the target string filedata = filedata.replace(pre_string, post_string) # Write the file out again with open(filename, 'w') as file: file.write(filedata) wanted_rarity = 18 start = 0 end = start + 200 replace_in_file('test/AiArenaHelper.t.sol', f'uint256 base = 0;', f'uint256 base = {start};') for i in range(start, end): print(i) # Run Foundry test out = check_output(["forge", "test", "--match-test", "testCreatePhysicalAttributesNonDendroid2", "-vv"]) # Get Foundry test output log = bytes.decode(out).split("Logs:")[1] # Split Foundry test output to retrieve computes dnas and physical attributes sublogs = log.split("----") for sublog in sublogs[:-1]: lines = sublog.splitlines() dna_input = lines[-4].strip() attrs = lines[-2].strip() # Check if we have obtained the wanted rarity of attributes if compute_rarity(attrs) <= wanted_rarity: with open('rarestDNA.csv', 'a', newline='') as csvfile: spamwriter = csv.writer(csvfile, delimiter=',', quotechar='|', quoting=csv.QUOTE_MINIMAL) spamwriter.writerow([dna_input, attrs, compute_rarity(attrs)]) # Update the file for next iteration of the loop replace_in_file('test/AiArenaHelper.t.sol', f'uint256 base = {i};', f'uint256 base = {i+1};')

This script runs testCreatePhysicalAttributesNonDendroid2 test multiple times (in order to avoid the OutOfGas exception). Below, the testCreatePhysicalAttributesNonDendroid2 test we wrote:

function testCreatePhysicalAttributesNonDendroid2() public { uint8 generation = 0; uint8 iconsType = 3; bool dendroidBool = false; FighterOps.FighterPhysicalAttributes memory physAttrs; //2043 uint256 base = 0; uint256 step = 13000; uint256 fine = step*(base+1); for (uint256 dna_i=step*base; dna_i<fine; dna_i++){ uint256 dna = uint256(keccak256(abi.encode(Strings.toString(dna_i)))); physAttrs = _helperContract.createPhysicalAttributes(dna, generation, iconsType, dendroidBool); console.log(Strings.toString(dna_i)); console.log(Strings.toString(dna)); console.log( string.concat( vm.toString(physAttrs.head) ,"-", vm.toString(physAttrs.eyes), "-", vm.toString(physAttrs.mouth), "-", vm.toString(physAttrs.body), "-", vm.toString(physAttrs.hands), "-", vm.toString(physAttrs.feet)) ); console.log("----"); } }

We have executed the python script and in about 1 hour we have obtained 500 valid and rare DNAs to obtain the following physical attributes:

head=10 (3%) eyes=50 (we are using iconTypes=3) mouth=4 (4%) body=6 (4%) hands=50 (we are using iconTypes=3) feet=[5, 6, 9] (7%)

These combinations are the most rare we obtained (and we show in other finding, the most rare that can be obtained with this probabilities). They should have 0.00001008% probability to happen. We found 500 valid DNAs over about 2.600.000 analyzed DNAs (0.0192%).

For example, "2604797" is a valid DNAs to obtain this combination (with feet=5):

/// @notice Test redeeming a mintpass for a fighter function testRedeemMintPass2() public returns(string memory, uint256){ 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; _fighterTypes[0] = 0; _neuralNetHashes[0] = "neuralnethash"; _modelTypes[0] = "original"; _iconsTypes[0] = 3; _mintPassDNAs[0] = Strings.toString(2604797); // approve the fighterfarm contract to burn the mintpass _mintPassContract.approve(address(_fighterFarmContract), 1); _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 balance to see if the mintpass was burned assertEq(_mintPassContract.balanceOf(_ownerAddress), 0); assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress); (uint256 fig,,FighterOps.FighterPhysicalAttributes memory physAttrs,,,,,,) = _fighterFarmContract.fighters(0); assertEq(physAttrs.head, 10); assertEq(physAttrs.eyes, 50); assertEq(physAttrs.mouth, 4); assertEq(physAttrs.body, 6); assertEq(physAttrs.hands, 50); assertEq(physAttrs.feet, 5); }
Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testRedeemMintPass2():(string,uint256) (gas: 658656) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.93ms

Tools Used

Visual ispection, Foundry, Python

In this report we show that input parameters of FighterFarm.redeemMintPass() need some validation. Developers wrote a validation in other parts of the code, like for the FighterFarm.claimFighters(), but we report in another finding that it could be not enough. We suggest to avoid to pass DNAs through FighterFarm.redeemMintPass(), and instead compute DNA using msg.sender, fighters.length and block.timestamp

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-02-22T07:40:47Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-22T07:40:53Z

raymondfam marked the issue as duplicate of #33

#2 - c4-pre-sort

2024-02-26T00:53:14Z

raymondfam marked the issue as duplicate of #1626

#3 - c4-judge

2024-03-06T03:09:08Z

HickupHH3 marked the issue as satisfactory

Findings Information

๐ŸŒŸ Selected for report: d3e4

Also found by: fnanni, niser93

Labels

bug
2 (Med Risk)
downgraded by judge
insufficient quality report
satisfactory
duplicate-1979

Awards

5012.4807 USDC - $5,012.48

External Links

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L476-L531 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L78-L121 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L169-L186

Vulnerability details

Impact

We found an issue with the computation of fighter physical attributes while creating a new fighter. In particular, some combinations of physical attributes cannot be never reached. The physical attributes are computed pseudo-randomly with an algorithm that takes a value in input, the DNA, and generates 6 physical attributes: head, eyes, mouth, body, hands, and feet. DNA can be every uint256 value. Physical attributes also are uint256, but can, at most, rely on the [0:99] interval. The probability of having a specific value for a certain attribute should be computed according to a matrix of probability, and the maximum value that can be obtained for an attribute is equal to the length of lines of this matrix. Despite what developers wanted, we want to show that a combination of probability cannot be obtained. For example, according to the probability matrix described in Deployer.s.sol#L25-L32, a player can not ever create a fighter with head = 10 and feet=3.

Proof of Concept

The FighterFarm aims to create new fighters using claimFighters(), redeemMintPass(), and mintFromMergingPool(): These three functions call FighterFarm._createNewFighter():

476 /// @notice Creates a new fighter and mints an NFT to the specified address. 477 /// @param to The address to mint the new NFT to. 478 /// @param dna The DNA of the new fighter. 479 /// @param modelHash The hash of the ML model. 480 /// @param modelType The type of the ML model. 481 /// @param fighterType The type of fighter to create. 482 /// @param iconsType Type of icons fighter (0 means it's not an icon). 483 /// @param customAttributes Array with [element, weight] of the newly created fighter. 484 function _createNewFighter( 485 address to, 486 uint256 dna, 487 string memory modelHash, 488 string memory modelType, 489 uint8 fighterType, 490 uint8 iconsType, 491 uint256[2] memory customAttributes 492 ) 493 private 494 { 495 require(balanceOf(to) < MAX_FIGHTERS_ALLOWED); 496 uint256 element; 497 uint256 weight; 498 uint256 newDna; 499 if (customAttributes[0] == 100) { 500 (element, weight, newDna) = _createFighterBase(dna, fighterType); 501 } 502 else { 503 element = customAttributes[0]; 504 weight = customAttributes[1]; 505 newDna = dna; 506 } 507 uint256 newId = fighters.length; 508 509 bool dendroidBool = fighterType == 1; 510 FighterOps.FighterPhysicalAttributes memory attrs = _aiArenaHelperInstance.createPhysicalAttributes( 511 newDna, 512 generation[fighterType], 513 iconsType, 514 dendroidBool 515 ); 516 fighters.push( 517 FighterOps.Fighter( 518 weight, 519 element, 520 attrs, 521 newId, 522 modelHash, 523 modelType, 524 generation[fighterType], 525 iconsType, 526 dendroidBool 527 ) 528 ); 529 _safeMint(to, newId); 530 FighterOps.fighterCreatedEmitter(newId, weight, element, generation[fighterType]); 531 }

This function computes the physical attributes of the fighter calling AiArenaHelper.createPhysicalAttributes():

78 /// @notice Create physical attributes for a fighter based on DNA. 79 /// @param dna The DNA of the fighter. 80 /// @param iconsType Type of icons fighter (0 means it's not an icon). 81 /// @param dendroidBool Whether the fighter is a dendroid or not 82 /// @return Fighter physical attributes. 83 function createPhysicalAttributes( 84 uint256 dna, 85 uint8 generation, 86 uint8 iconsType, 87 bool dendroidBool 88 ) 89 external 90 view 91 returns (FighterOps.FighterPhysicalAttributes memory) 92 { 93 if (dendroidBool) { 94 return FighterOps.FighterPhysicalAttributes(99, 99, 99, 99, 99, 99); 95 } else { 96 uint256[] memory finalAttributeProbabilityIndexes = new uint[](attributes.length); 97 98 uint256 attributesLength = attributes.length; 99 for (uint8 i = 0; i < attributesLength; i++) { 100 if ( 101 i == 0 && iconsType == 2 || // Custom icons head (beta helmet) 102 i == 1 && iconsType > 0 || // Custom icons eyes (red diamond) 103 i == 4 && iconsType == 3 // Custom icons hands (bowling ball) 104 ) { 105 finalAttributeProbabilityIndexes[i] = 50; 106 } else { 107 uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100; 108 uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]); 109 finalAttributeProbabilityIndexes[i] = attributeIndex; 110 } 111 } 112 return FighterOps.FighterPhysicalAttributes( 113 finalAttributeProbabilityIndexes[0], 114 finalAttributeProbabilityIndexes[1], 115 finalAttributeProbabilityIndexes[2], 116 finalAttributeProbabilityIndexes[3], 117 finalAttributeProbabilityIndexes[4], 118 finalAttributeProbabilityIndexes[5] 119 ); 120 } 121 }

Let's describe how the physical attributes are computed.

In AiArenaHelper.sol#L20, is initialized the defaultAttributeDivisor array:

20 uint8[] public defaultAttributeDivisor = [2, 3, 5, 7, 11, 13];

These divisors are prime numbers (and so, are coprime with each other) and are used inside the AiArenaHelper.createPhysicalAttributes() to divide the dna value:

107 uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100; 108 uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]); 109 finalAttributeProbabilityIndexes[i] = attributeIndex;

The obtained rarityRank, which is a uint256 and belongs to [0:99] interval, is passed to AiArenaHelper.dnaToIndex() to compute the attributeIndex:

169 function dnaToIndex(uint256 generation, uint256 rarityRank, string memory attribute) 170 public 171 view 172 returns (uint256 attributeProbabilityIndex) 173 { 174 uint8[] memory attrProbabilities = getAttributeProbabilities(generation, attribute); 175 176 uint256 cumProb = 0; 177 uint256 attrProbabilitiesLength = attrProbabilities.length; 178 for (uint8 i = 0; i < attrProbabilitiesLength; i++) { 179 cumProb += attrProbabilities[i]; 180 if (cumProb >= rarityRank) { 181 attributeProbabilityIndex = i + 1; 182 break; 183 } 184 } 185 return attributeProbabilityIndex; 186 }

As we can see, the computation works in this way: once we have chosen a generation (for example, 0), for each attribute the AiArenaHelper.dnaToIndex() function keeps to sum 1 until the value of cumProb reaches or exceeds the value of rarityRank.

In Deployer.s.sol#L25-L32 there is the AttributeProbabilities array used in the test environment:

25 function getProb() public { 26 probabilities.push([13, 7, 7, 13, 10, 13, 13, 10, 10, 4]); 27 probabilities.push([15, 15, 10, 7, 7, 15, 7, 10, 10, 4]); 28 probabilities.push([15, 15, 7, 4, 10, 10, 15, 10, 7, 7]); 29 probabilities.push([15, 7, 7, 10, 15, 4, 10, 10, 7, 15]); 30 probabilities.push([15, 10, 10, 7, 15, 10, 7, 15, 7, 4]); 31 probabilities.push([15, 15, 4, 10, 7, 7, 15, 10, 7, 10]); 32 }

The first array is used to compute the head attribute, the second one to compute the eyes attribute, and so on. For example, if rarityRank is in [0:13], head=1, if rarityRank is in [14:27], head=2, and so on.

However, the rarityRank is computed by dividing the same initial value, the DNA, with defaultAttributeDivisor:

attributedivisorrarityRank
head2(dna/2) % 100
eyes3(dna/3) % 100
mouth5(dna/5) % 100
body7(dna/7) % 100
hands11(dna/11) % 100
feet13(dna/13) % 100

We want to show that some combinations of attributes are not compatible with each other. For example, if we want to obtain head=10 and feet=3, we should obtain:

head=10 rarityRank = (dna/2) % 100 should be in [97:99] feet=3 rarityRank = (dna/13) % 100 should be in [31:34]

These two conditions are not compatible (i.e. no dna could satisfy them). This means that some sequences of physical attributes cannot be reached. So, the math behind the computation of these attributes works wrong, and the probability developers think to set inside the getProb() function could be not true. According to the sponsor on Discord, this is not the intended behavior, and every combination of physical attributes should be available, even with a very small probability. We want to add that in other reports we describe attacks that can be exploited by a player to obtain the wanted characteristics of the new fighter he/she is creating. The issue reported here improves those problems because the set of reachable characteristics is smaller than developers would have.

Let's continue with the example above.

head=10 rarityRank = (dna/2) % 100 should be in [97:99] feet=3 rarityRank = (dna/13) % 100 should be in [31:34]

We want to show that these two conditions are not compatible.

Let's describe the set V1, composed of the values of DNA for which we have head=10. It should happen for the initial interval, and the same interval plus (2*100)N=200N, where N is a natural number. The initial interval I1 is computed in this way:

$I1 = [97:99]*2 = [194:198]$

Thanks to the integer round, also 199 belongs to the valid interval:

$I1 = [194:198] \cup [199] = [194:199]$

$V1 = [194:199] \cup [394:399] \cup [594:599] \cup [594:599] \cup [594:599] \cup ...$

Let's describe the set V2, composed of the values of DNA for which we have feet=13. It should happen for the initial interval, and the same interval plus (13*100)N=1300N, where N is a natural number. The initial interval is computed in this way:

$I2 = [31:34]*13 = [403:442]$

Thanks to the integer round, also [443:454] belongs to the valid interval (because 35*13=455):

$I2 = [403:442] \cup [443:454] = [403:454]$

$V2 = [403:454] \cup [1703:1754] \cup [3003:3054] \cup [4303:4354] \cup [5603:5654] \cup ...$

Thanks to the fact that we are using mod 100, we can say that these two sets are disjointed, because all the elements in the head set are in the form [*94,*95, ...,*98], and all elements in the feet set are in the form [*03, *04, ..., *42]. So, no dna can be taken to have rarityRank which belongs to both sets.

The proof above is the mathematical one. We have also created a python script to confirm this. Of course, it cannot prove anything, but can give a statistical idea about our result.

def check_interval(divisor, min_rank, max_rank, last_digit_set): for i in range(0, 10000000000000000000): dna = i rarityRank = int((dna/float(divisor))) % 100 if max_rank >= rarityRank >= min_rank: last_digit = str(rarityRank)[-2:] if last_digit not in last_digit_set: print(dna/divisor) print(dna) break last_digit_set = ["94", "95", "96", "97", "98", "99"] check_interval(2, 97, 99, last_digit_set) last_digit_set = [str(i) for i in range(3,55)] check_interval(13, 31, 34, last_digit_set)

The execution of this code didn't print anything. This means it didn't find any valid dna with a prefix out from the sets above. Due to the lack of time, we don't show precisely how many combinations are infeasible. Anyway, we wrote a script that computes many combinations:

import random probs = [ [13, 7, 7, 13, 10, 13, 13, 10, 10, 4], [15, 15, 10, 7, 7, 15, 7, 10, 10, 4], [15, 15, 7, 4, 10, 10, 15, 10, 7, 7], [15, 7, 7, 10, 15, 4, 10, 10, 7, 15], [15, 10, 10, 7, 15, 10, 7, 15, 7, 4], [15, 15, 4, 10, 7, 7, 15, 10, 7, 10], ] def generate_all_possibilities(): possibilities = [] for a in range(1, 11): for c in range(1, 11): for d in range(1, 11): for f in range(1, 11): combination = f"{a}-50-{c}-{d}-50-{f}" possibilities.append(combination) return possibilities def compute_dna_index(rarityRank, probs_array): cumProb = 0 probs_index = 0 for i in range(0, len(probs_array)): cumProb += probs_array[i] if cumProb >= rarityRank: probs_index = i + 1 break return probs_index all_possibilities = generate_all_possibilities() for i in range(0, 10000000000000000000): dna = random.randint(0, 1000000) rarityRank_head = int((dna / float(2))) % 100 rarityRank_eyes = int((dna / float(3))) % 100 rarityRank_mouth = int((dna / float(5))) % 100 rarityRank_body = int((dna / float(7))) % 100 rarityRank_hands = int((dna / float(11))) % 100 rarityRank_feet = int((dna / float(13))) % 100 head = compute_dna_index(rarityRank_head, probs[0]) eyes = compute_dna_index(rarityRank_eyes, probs[1]) mouth = compute_dna_index(rarityRank_mouth, probs[2]) body = compute_dna_index(rarityRank_body, probs[3]) hands = compute_dna_index(rarityRank_hands, probs[4]) feet = compute_dna_index(rarityRank_feet, probs[5]) eyes = 50 hands = 50 combination = f"{head}-{eyes}-{mouth}-{body}-{hands}-{feet}" if combination in all_possibilities: all_possibilities.remove(combination) print(len(all_possibilities))

With this script, we create an array with all combinations in the form {head}-50-{mouth}-{body}-50-{feet} (i.e., when iconType=3). Then, we emulate using Python the AiArenaHelper.dnaToIndex() function on several DNAs. When we run this script, all_possibilities initial length is 10^4=10000. Removing all valid combinations, it sticks to 4988. This means that about half of the combinations aren't reachable. This makes sense because we think it should have a Gaussian behavior: the probs chosen by developers in Deployer.s.sol#L25-L32 aren't the better ones neither the worst ones. However, we want to underline that there will be unreachable combinations whatever probs are chosen initially.

Tools Used

Visual inspection, Python, Foundry

We suggest to change the way to compute the physical attributes. Instead to compute them using mod 100, it could work to use:

attributeformula
head((dna/2) % 101)*(100/101)
eyes((dna/3) % 103)*(100/103)
mouth((dna/5) % 107)*(100/107)
body((dna/7) % 109)*(100/109)
hands((dna/11) % 113)*(100/113)
feet((dna/13) % 117)*(100/117)

In this way, the sets of solutions change when dna changes.

Assessed type

Error

#0 - c4-pre-sort

2024-02-25T17:28:54Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-25T17:29:36Z

raymondfam marked the issue as duplicate of #15

#2 - c4-judge

2024-03-05T03:21:04Z

HickupHH3 changed the severity to 2 (Med Risk)

#3 - c4-judge

2024-03-05T03:52:22Z

HickupHH3 marked the issue as duplicate of #1191

#4 - c4-judge

2024-03-05T04:06:13Z

HickupHH3 marked the issue as not a duplicate

#5 - c4-judge

2024-03-18T02:32:23Z

HickupHH3 marked the issue as duplicate of #1979

#6 - c4-judge

2024-03-18T02:33:07Z

HickupHH3 marked the issue as satisfactory

#7 - niser93

2024-03-19T21:26:48Z

Hi @HickupHH3. First of all, thank you for your judgment. I would like to point your attention to a few considerations on this finding, and why I consider it a high-severity issue:

  • This issue is strongly related to #366 reported by Abdessamed and others (and also by me, #736 ). It's also related to #1017 reported by Aslanbek (and also by me, #519). The former is a high-severity issue, the latter a medium one. The point is that these two issues can be exploited very easily thanks to the wrong math behind the computation of physical attributes. The brute force exploit can be done with less effort by the attacker. But this issue doesn't just represent something complementary to those two issues. We should consider that even if developers increase the space of physical attributes thinking to make those attacks infeasible, they wouldn't become so hard, thanks to the wrong math that I showed here.
  • According to the high severity definition, assets are compromised. In this DApp, the assets are the NFT of fighters. They would have a value based on their attributes. Without this finding, developers, players, and the market, in general, would give a very wrong value to fighters based on math that doesn't work correctly. The percentages proposed by developers and hard-written on the contract wouldn't represent anything but will be a reference on which the market will compute NFT values. A player with a fighter that should be found with the probability of 1/10000 and has a huge amount of value will see the value decrease suddenly without any reason if somebody makes public the wrong math when this DApp is in the wild.
  • This issue is a Math issue, like I said before. So, I think it needs a POC that explains the math behind the problem and gives the possibility to developers to improve their code. In my POC, I deeply explore the implications of the pseudo-random technique and modulus operation. For this reason, my problem should be selected for the report. In my mitigation proposal, I don't just suggest a random algorithm using an oracle. I like the will of developers to avoid a fully random approach and I think some methods can work. For this reason, and thanks to my math analysis, I proposed an alternative to developers. A math finding like this should have an extensive POC to explain mathematical steps.
  • In other related findings, #1979 and #440, wardens correctly report the lack of combinations, but they are just talking about rarityRank that doesn't represent the physical attribute. For this reason, I think it is a bit different finding from mine, and I would like to show why. They explain that 2,427,000 combinations are possible, far from 100<sup>6</sup>. Those combinations are used by the dnaToIndex function to compute the attribute value. Let's see dnaToIndex() like a black box, and let's consider that we have 6 physical attributes from 1 to 10, i.e., 10<sup>6</sup> possibilities (they are less than 2,427,000). This means that every combination of physical attributes has two rarityRank combinations to obtain it. From this point of view, the math behind physical attributes seems correct and #1979 and #440 are invalid. The problem isn't inside the lack of combinations of rarityRank, but the usage by the dnaToIndex function of rarityRank and the distributions of rarityRank combinations over the space of possibilities. For this reason, #1979 and #440 report a very partial view of this finding, and it needs more sophisticated math to understand it.

In conclusion, I hope I was able to explain my point of view. I think that my finding will have a strong impact on developers and their reasoning about the math behind physical attributes. Furthermore, I think that my finding was the only one that reported this kind of issue that isn't just related to the space of probabilities, but to the usage of modular arithmetic. For these reasons, I'm asking you to reconsider the severity of this issue and the correlation of it with #1979 and #440.

Thank you!

#8 - c4-judge

2024-03-20T03:25:55Z

HickupHH3 marked the issue as selected for report

#9 - c4-judge

2024-03-20T03:26:03Z

HickupHH3 changed the severity to 3 (High Risk)

#10 - d3e4

2024-03-21T01:17:28Z

I agree with the above comment in that the impact is High since it is on the valuation of fighters which are - or are directly related to - the assets of the protocol. But since the comment then also contests some aspects of the duplicate issues, I feel I need to respond to those points.

Lest there be any misunderstanding, I am not trying to invalidate anything, and I believe #1456, #1979 and #440 are all valid and duplicate of each other.

The comment claims that focussing on the rarityRank is insufficient since the problem rather lies in some combinations of attributes being impossible (rather than simply non-uniformly distributed). However, this entirely depends on the probabilities, which are unknown (the test is not a source of truth). And this is just a trivial special case of the more general lack of uniformity. This report only discusses at length an example of this, but does not arrive at a general quantification of it, and is thus ultimately of little added value.

The suggested mitigation in this report is incorrect. I'm assuming what was meant is rather (((dna/2) % 101) * 100) / 101, to avoid the obvious rounding issue. But even then we have the problem of duplicate values. This does obviously not return values uniformly in the range [0,99] since e.g. 101 values are being squeezed into 100. E.g. ((0 * 100) / 101 = ((1 * 100) / 101 = 0, which is thus the twice as likely value. In fact this changes very little from the original distribution issue, and pairs of DNAs (0, 1), (16, 17), and (22, 23) still give the same result (cf. the examples given in #1979). Only the period of 3,003,000 has increased to 1.6041e12 (the non-uniformity remains essentially the same). The warden has misunderstood the mathematical reason for this issue, thinking the problem lies in the usage of mod 100 whereas it really is in the use of divisors to extract small random numbers from a big random number.

#1979 and #440, on the other hand, both propose the same correct mitigation. This alone I think should be sufficient to disqualify #1456 from being selected as the report.

Both #1979 and #440 show that the number of possible combinations is far too small. But #1979 is the only report that provides a quantification of the lack of uniformity, namely that there are exactly 2,427,000 possible combinations of which 576,000 (24%) are twice as likely. I think this merits selecting #1979 for the report.

#11 - Haxatron

2024-03-21T12:18:38Z

This report and its duplicates provide a very clever proof that the rarityRank generated are not uniformly random. Mainly that there exists two rarity ranks $r_1, r_2 \in R$, where $R$ is the set of all possible rarity ranks such that probability of obtaining rarity rank $r_1$ is not equal to the probability of obtaining the rarity rank $r_2$. To that I say kudos!

However, I am not sure whether this would qualify as High severity.

Firstly, the attacker does not have control over the random generation process, which is one of the reasons cited for upgrading this to a high severity.

This issue is strongly related to https://github.com/code-423n4/2024-02-ai-arena-findings/issues/366 reported by Abdessamed and others (and also by me, https://github.com/code-423n4/2024-02-ai-arena-findings/issues/736 )

Secondly, the reports consider the non-uniformness of the rarityRank but do not consider how this affects the attribute indices. So let me explain it simply,

  • Let us consider two parts of the fighter, the eyes and the head.
  • Suppose that probability of generating a Diamond Eyes is $\frac{1}{100}$
  • And the probability of generating a Diamond Head is $\frac{1}{100}$
  • Intuitively you might think that the probability of generating a fighter with Diamond Head and Diamond Eyes is $\frac{1}{100}*\frac{1}{100}=\frac{1}{10000}$
  • But these reports and its duplicates show that that may not be true, that the probability of generating a fighter with Diamond Head and Diamond Eyes may not be in fact $\frac{1}{10000}$ and in some cases, it may be impossible.

However, this does not consider the probabilities of generating these items individually are still correct. More specifically, the probability of generating a fighter with a Diamond Head is still $\frac{1}{100}$ and the probability of generating a fighter with a Diamond Eyes is still $\frac{1}{100}$.

Whether or not this warrants a High is up to the judge @HickupHH3 , but I would just like to state the facts here.

And if this is a high, I would also like you to consider upgrading https://github.com/code-423n4/2024-02-ai-arena-findings/issues/112 to a high considering that it affects the probabilities (of the first and last indices) of each individual item by skewing them by $\frac{1}{100}$.

#12 - niser93

2024-03-21T13:32:48Z

@d3e4 Thank you for your comment. What you wrote is the reason why I proposed to judge to divide my finding from #1979 and #440 : we reported two different issues. While my finding is about the impossibility having all combinations of physical attributes, #1979 and #440 report the lack of uniform distribution of rarityRank array. Like I said above, they aren't the same thing: even if we had a uniform distribution of rarityRank, we can have the impossibility having all combinations of physical attributes.

I would to go deeper into your mitigation proposal to understand together if there could be a better approach:

#1979

- uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;
+ uint256 rarityRank = dna % 100;
+ dna /= 100;

In this proposal, the rarityRank value isn't based on i anymore. This means you are computing the rarityRank vector in this form: [n,n,n,n,n,n], where n is the last two digits of the dna value. I want to show why this kind of solution doesn't solve the issue I reported, and why for this reason, #1979 is a different finding from mine. Let's think you are using the AttributeProbabilities proposed in Deployer.s.sol#L25-L32: I want to show that is not possible to obtain head=1 and eyes=2 (i.e. there exists a pair that is infeasible). To obtain head=1, you should have a dna that has the last two digits in the range [0:13]. To obtain eyes=2, you should have a dna that has the last two digits in the range [16:31]. Of course, these two ranges are not compatible. In this case, I want to underline that the issue isn't related to the AttributeProbabilities vector. Whatever AttributeProbabilities vector you choose, there will be a counterexample with a pair of physical attributes that is infeasible.

#440

- uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;
+ uint256 rarityRank = (dna / 100 ** i) % 100;

This solution is quite similar to #1979:

i = 0; rarityRank = dna_digits[-2:] i = 1; rarityRank = dna_digits[-3:-1] i = 2; rarityRank = dna_digits[-4:-2] and so on

In this case, this approach could work with many AttributeProbabilities vectors, but there are AttributeProbabilities vectors where this approach can fail. Let's do an example:

25 function getProb() public { 26 probabilities.push([79, 10, 11]); //head 27 probabilities.push([7, 2, 91]); //eyes 32 }

I want to show that head=1 and eyes=2 is infeasible. Let's consider the last 3 digits of dna: ABC

for i=1 (head) we have rarityRank = BC for i=2 (eyes) we have rarityRank = AB

To have head = 1, BC must be less than 80, i.e., B must be in the range [0:7] To have eyes = 2, AB must be 8 or 9, i.e. B must be in the range [8:9]

These two ranges are clearly not compatible.

The mitigation proposals of #1979 and #440 don't solve my finding because they aren't developed for it. They try to solve a different problem: the uniform distribution of rarityRank over the space of probabilities. And considering this, they work and are good. Because dna can be every number from 0 to 2<sup>255</sup> - 1, the computation of rarityRank just based on the dna value must work. However, the computation of rarityRank must be not reversible. In the solutions proposed above, it is easy to find a dna to obtain some combination of physical attributes (if that combination is feasible).

My finding is a bit different and reports a different issue: even if the element of rarityRank for each attribute is well distributed, if its computation isn't done using coprime numbers and avoiding mod 100, the set of combinations for each physical attribute will not overlap, leading to the incompatibility of some values of physical attributes with each other. My mitigation proposal tries to solve it (it doesn't try to make every combination of rarityRank reachable, but it tries to make every combination of physical attributes reachable). Furthermore, it uses an unreversible function. This means that an attacker must iterate, in the worst case, over 1.6041e12 combinations to obtain the wanted combination of physical attributes.

I also want to point your attention to the @Haxatron point of view. The attacker does not have the control over random generation process. However the attacker could potentially modify the value of assets when he/she publicly discloses these two problems: if he/she discloses the problem of uniform distribution, the market will change accordingly with a new different probabilities vector. If he/she discloses the fact that some combinations of physical attributes are infeasible, the market will change removing the infeasible combinations from the supply, modifying the value of feasible combinations consequentially. In both cases, the attacker is able to directly impact the value of assets.

#13 - fnanni-0

2024-03-21T13:50:10Z

While my finding is about the impossibility having all combinations of physical attributes, #1979 and #440 report the lack of uniform distribution of rarityRank array.

I think you should take another look at #1979 and #440. I don't understand your point here. The impossibility of having all combinations of physical attributes is a special case of the impossibility of having all combinations of rarity rank.

First Impact section sentence of #1979: "Only a tiny fraction, $0.0002427%$, of all rarity rank combinations are possible". This is very clearly "about the impossibility having all combinations of physical attributes" as well.

From #440 Impact's section: "making certain combinations of attributes unattainable", which explicitly mentions attributes, not only rarity rank.

On the other hand, you wrote...

Let's consider the last 3 digits of dna: ABC

for i=1 (head) we have rarityRank = BC for i=2 (eyes) we have rarityRank = AB

Given that the recommended mitigation was to first divide by 100, not 10, shouldn't this be:

Let's consider the last 4 digits of dna: ABCD

for i=1 (head) we have rarityRank = CD for i=2 (eyes) we have rarityRank = AB

#14 - niser93

2024-03-21T14:05:50Z

I think this isn't correct. I think that "the impossibility of having all combinations of rarity rank is a special case of the impossibility of having all combinations of physical attributes": we could have all combinations of physical attributes without having all combinations of rarity rank. Even if we have a tiny fraction of all rarity rank combinations that are possible, we could still have all combinations of physical attributes. Having tiny fraction of all rarity rank combinations don't prove that there are combination of physical attributes that are infeasible.

About #440 i'm sorry: my mistake. It cover all physical attributes with the mitigation proposal. It works correctly, in this way. This because the dna becomes just a sequence of pairs and of course this solution is able to cover all combinations of rarity rank (and so, all combinations of physical attributes). But in this way, it is very easy to build a dna to obtain the wanted physical attributes, i.e., it doens't need to bruteforce dna in order to obtain the wanted physical attributes

#15 - fnanni-0

2024-03-21T14:20:29Z

I think this isn't correct.

See https://en.wikipedia.org/wiki/Special_case. The root problem is missing rarity rank combinations. Depending on the probabilities of attributes, there are subsets of scenarios in which an entire bunch of rarity rank combinations mapping 100% to attribute combinations is missing. This is a special case (i.e. a subset of all possibilities) of missing rarity rank combinations.

#16 - HickupHH3

2024-03-21T15:17:25Z

i'm going to need some time to digest the arguments made here, but do continue the conversation, any final notes & comments (closing arguments) you'd like to make, kindly do so~

#17 - niser93

2024-03-21T16:20:34Z

The value of a fighter NFT relies on its physical attributes. This means that the root problem is missing physical attributes combination. Rarity rank is just used to compute physical attributes.

Let's think you have 6 physical attributes from 1 to 10: 10<sup>6</sup> combinations. If you have at least 10<sup>6</sup> rarity rank combination, you can build a mapping between rarity rank and physical attributes and you can obtain every combination of physical attributes (maybe with wrong probabilities, as you reported, but you can write a mapping to have at least a link between each pair of rarity rank vector and physical attributes vector)

About special case, from Wikipedia: A is a special case or specialization of concept B [means] "If B is true, one can immediately deduce that A is true as well, and if B is false, A can also be immediately deduced to be false." This is wrong. The correct statement is "If A is true, one can immediately deduce that B is true as well, and if B is false, A can also be immediately deduced to be false." (the example of square and rectangle could help to understand). This means that: (A implies B) and (NOT(B) implies NOT(A))

You wrote "The impossibility of having all combinations of physical attributes is a special case of the impossibility of having all combinations of rarity rank".

Let's define B = having all combinations of physical attributes A = having all combinations of rarity rank

You wrote: NOT(B) is a special case of NOT(A), so

(NOT(A) implies NOT(B)) and (B implies A)

that is equivalent to

The impossibility of having all combinations of rarity rank implies the impossibility of having all combinations of physical attributes and having all combinations of physical attributes implies having all combinations of rarity rank

I think they are both false.

On the other hand

I wrote "the impossibility of having all combinations of rarity rank is a special case of the impossibility of having all combinations of physical attributes".

I wrote: NOT(A) is a special case of NOT(B), so

(NOT(B) implies NOT(A)) and (A implies B)

that is equivalent to

the impossibility of having all combinations of physical attributes implies the impossibility of having all combinations of rarity rank and having all combinations of rarity rank implies having all combinations of physical attributes

That I think are both correct.

Anyway, I don't want to formalize on this aspect. The meaning of "special case" and the logic behind can help to understand, but can also bring off the road. The main point is this: we can have a subset of rarity rank and still build a mapping to the whole space of physical attributes. The "tiny fraction of all rarity rank combinations" doesn't implies the "tiny fraction of all rarity rank physical attributes", that is the thing we care about.

I hope i was able to explain my point of view. I would like to thank @d3e4, @fnanni-0 and @Haxatron for their observation that for sure will help to improve Ai-Arena security. @HickupHH3 thank you for your work. We talked about many things in this comments. The fact that these arguments are so complex is the proof that this issue was hidden very well and we should be proud of our work.

#18 - d3e4

2024-03-21T16:21:07Z

@niser93 I understand your point, but it is still just a special case. Maybe you misunderstood that this non-uniformity extends to the entire range of $100^6$ rarity rank combinations. These can have any of three different probabilities: $\frac{1}{2427000}$, $\frac{2}{2427000}$ and $0$. $0$ of course means they are impossible, which is what leads to your special case. But we cannot say anything specific about this without knowing the attribute probabilities. It could be that all attribute combinations still are possible, but with different probabilities. You delve into an example of how it could happen that some are impossible, but don't even discuss the difference in probabilities among those that are possible. Thus your report actually misses the root cause, but since your example (which is good) is indeed merely a trivial consequence of the root cause, and so one can easily reason backwards to find it, I think that's ok.

About my mitigation proposal, it was perhaps not clear that this was intended to be inside a for-loop, so it would depend on i, dividing by 100 for each i. Go rather by the description: "Extract multiple small random numbers by repeatedly taking the modulo and dividing.". Your example with AB and BC is incorrect. My mitigation (and #440) would return disjoint consecutive pairs of digits, i.e. if ABCD are the last four digits of the DNA, it would use CD and then AB. These numbers are all independent and uniformly distributed in the range [0, 99].

Since your mitigation extends the range much more than 3,003,000, it would indeed be more likely to cover all rarity rank combinations (but would probably still have some gaps since we only cover it 1.6041 times), but the root issue of non-uniformity remains. Also, brute-forcing a range of 1.6041e12 for a reverse image is not hard to do (except perhaps for Python...), but not even necessary since your mitigation is not a cryptographic hash function. And this argument is irrelevant anyway because we only need to brute-force a modest multiple (e.g. $-\ln{(1-0.99)} \approx 4.6$ for a 99% chance) of $100^6$ to expect to find a specific $1$ in $100^6$ attribute combination, if they are uniformly distributed.

#19 - d3e4

2024-03-21T17:09:41Z

@HickupHH3 Here is a summary:

Fundamental Issue: Not all rarity rank combinations are possible, and some are more likely than others.

Trivial Corollary: Impossible rarity rank combinations implies impossible attribute combinations.

It is trivial because the rarity rank sets the attribute combinations. There is nothing wrong with the function from rarity ranks to attributes itself; it is perfectly legitimate if an attribute combination could be set only by one or a few rarity rank combinations. If these specific rarity rank combinations cannot be obtained, then the attribute combination cannot be obtained. Therefore this corollary is not by itself an issue at all.

#1979 and #440 directly discuss the Fundamental Issue. #1456 only discusses the Trivial Corollary. But since it follows trivially, such that one should realise the Fundamental Issue as well when reading the corollary, I think #1456 should be considered sufficient as a duplicate.

Mitigation: The correct mitigation is the one offered by #1979 and #440. The one in #1456 is invalid.

More valuable information in #1979 #1979 and #440 are basically the same. But #1979 adds a precise quantification of how many rarity rank combinations are possible (2,427,000), and what the distribution is (24% twice as likely as the rest). #440 only gives an upper bound for the possible rarity rank combinations (3,003,000) and says that some appear more than once (without quantifying). I think this extra information provided by #1979 is valuable, because e.g. had only single combination been more likely the issue would not have been nearly as severe.

For these reasons I think #1979 should be selected for the report.

#20 - niser93

2024-03-21T17:29:37Z

Fundamental Issue: Not all rarity rank combinations are possible, and some are more likely than others. Trivial Corollary: Impossible rarity rank combinations implies impossible attribute combinations. It is trivial because the rarity rank sets the attribute combinations

I want to stress this: the goal isn't to reach all rarity rank combinations. The goal is to reach all physical attributes combination. A method to reach all physical attributes combinations is a solution of the problem and a valid mitigation. The fact that there are rarity rank combinations that are not reachable doesn't imply that there are physical attributes that are not reachable.

#21 - HickupHH3

2024-03-22T13:26:04Z

Severity

Decided on medium.

Firstly, the attacker does not have control over the random generation process, which is one of the reasons cited for upgrading this to a high severity.

Unlike #366 and #736, while the user can influence uint256 dna generation, they need to fight the right ingredients that hashes together to obtain the desired value instead of direct selection. This is just the first step: there's also figuring out what's the best value that can map to the rarest rank combination.

In other words, there's significant brute force effort needed.

Duplication

Since some fancy math broke out in the discussion, I gotta match it with my applied math major, but I'll keep it simple =)

image

Issues #1979 and #440 argue that the function mapping DNA to rarity rank combinations $f: X -> Y$ is non-injective: $\exists y \in Y s.t. \exists x_1 \neq x_2 \in X, f(x_1) = f(x_2) = y$.

this issue (#1456) is arguing that the function mapping rarity rank combinations to physical attributes combinations $g: Y -> Z$ is non-surjective: $\exists z \in Z s.t. \forall y \in Y, g(y) \neq z$.

From this, it's clear that #1456 and #1979 + #440 should be bucketed into 2 different groups.

#22 - c4-judge

2024-03-22T13:29:01Z

HickupHH3 changed the severity to 2 (Med Risk)

#23 - fnanni-0

2024-03-22T14:21:20Z

@HickupHH3 Thanks a lot for judging and taking the time to thoroughly review this discussion. I agree with medium severity. However, Iโ€™d like to bring Code4rena duplicate guidelines to the table, because I can't understand the argument for splitting this issue. The math discussion might have deviated the topic from what matters most in this decision:

Similar exploits under a single issue

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.

The argument you provided for de-duplicating these issues seems to be about impact, not root cause. The root cause is that not all rarity rank combinations are possible. This affects (1) the probability distribution of the possible combinations of rarity ranks, and (2) often, depending on the probability arrays, attribute combinations might not be possible.

In my opinion (2) is trivial at least for some cases and I even mentioned it in #440 although I didn't provide proof for it. Also note that #1456 proposes a change in the rarity rank calculation as mitigation and the mitigations proposed in #440 and #1979 solve both (1) and (2).

I think that what should be discussed is just the following, not de-duplication:

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.

I'd appreciate if you could further explain your reasoning and possibly review the de-duplication decision.

#24 - Haxatron

2024-03-22T14:23:26Z

With all due respect that is wrong, all 3 issues are arguing that the mapping from DNA to attribute index is non-surjective. That there exists a vector of attribute indices that are not possible from the DNA generation process. @HickupHH3

#25 - Haxatron

2024-03-22T14:45:27Z

Let me just add on the my previous point we have 2 functions

$f_1: DNA โ†’ rarityRank$ $f_2: rarityRank โ†’ attributes$

And we have a function $g$: $g = f_2(f_1(x))$

For context, here, we know that the function $f_2$ is always surjective and is therefore working fine, that for all attributes there exists a rarityRank that can be mapped to it. There is nothing wrong with $f_2$.

The issue is $f_1$ is non-surjective, that there exists a set of rarity ranks which are impossible, which all 3 reports have argued.

Here are where the reports differ:

Both https://github.com/code-423n4/2024-02-ai-arena-findings/issues/440 and https://github.com/code-423n4/2024-02-ai-arena-findings/issues/1979, prove that $f_1$ is non-surjective, and conclude that since $f_1$ is non-surjective then $g$ may also be non-surjective and as such there exists a vector of attribute indices cannot occur. (Note that may is used because it is still possible for $g$ to be surjective even if $f_1$ is non-surjective, however if $f_1$ and $f_2$ were both surjective then $g$ must be surjective - composition of two surjective functions must be surjective)

This report looks at the entire function $g$ itself and proves that the entire $g$ is non-surjective for certain set of values.

The end goal of all the reports is to show that $g$ might be non-surjective in certain cases which means some attribute indices cannot occur and therefore it should be grouped together.

#26 - d3e4

2024-03-22T14:47:32Z

Issues #1979 and #440 argue that the function mapping DNA to rarity rank f:Xโˆ’>Y is non-injective: โˆƒyโˆˆYs.t.โˆƒx1โ‰ x2โˆˆX,f(x1)=f(x2)=y.

this issue (#1456) is arguing that the function mapping rarity rank to physical attributes g:Yโˆ’>Z is non-surjective: โˆƒzโˆˆZs.t.โˆ€yโˆˆY,g(y)โ‰ z.

From this, it's clear that #1456 and #1979 + #440 should be bucketed into 2 different groups.

@HickupHH3 The DNA to rarity ranks mapping is by necessity non-injective. #1979 and #440 argue that the mapping is both non-surjective and non-uniform (not equidistributed), the latter of which is perhaps what you had in mind when thinking "non-injective". It is non-surjective because $3,003,000 < 100^6$, and non-uniform because $0$ and $1$, $16$ and $17$, etc., map to the same rarity rank combination. #1456 argues (implicitly) only that the mapping is non-surjective. It does so by considering the next mapping from rarity ranks to attributes, which may (or may not) impart this non-surjectivity of the DNA to rarity ranks mapping to the rarity ranks to attributes mapping.

Maths: We have two functions. A mapping from DNA to rarity rank combinations, $R: [0, 2^{256}-1] \to [0,99]^6$, and a mapping from rarity rank combinations to attribute combinations, $A: [0,99]^6 \to [0,9]^6$ (assuming 10 of each attribute).

$R$ clearly cannot be injective, but should be surjective and equidistributed, so that $R(X)$, where $X$ is a uniformly distributed random DNA, is uniformly distributed.

$A$ is also non-injective. It may be non-surjective if a probability is set to $0$, but is otherwise surjective. NOTE: $A$ is not the issue here, and it is correct (except for the issue reported by #112). If $R$ is surjective and equidistributed then, if $A$ is also surjective, $A \circ R$ is surjective. In order for the distribution of $(A \circ R)(X)$ to be as intended, $R$ must be surjective and equidistributed. This is what the issue is about.

#1979 and #440 argue that $R$ is non-surjective, the proof of which is that the range of $R$ is only $3,003,000$ which is less than the size of the domain, $100^6$. Furthermore, they argue that even within its range it is not equidistributed.

#1456 only argues that $A \circ R$ is non-surjective. From this it doesn't strictly follow that $R$ is non-surjective, but in the example it gave $A$ was surjective, in which case it does follow. #1456 fails to note that it is not equidistributed within its range.

Summary: The issue is that $R$ is non-surjective and non-uniform. #1979 and #440 argue for both of these, while #1456 implicitly argues only for non-surjectivity. #1456 is definitely not a separate issue. Rather it sets itself apart only by being a partial duplicate of #1979 and #440.

#27 - niser93

2024-03-22T14:52:09Z

Unlike https://github.com/code-423n4/2024-02-ai-arena-findings/issues/366 and https://github.com/code-423n4/2024-02-ai-arena-findings/issues/736, while the user can influence uint256 dna generation, they need to fight the right ingredients that hashes together to obtain the desired value instead of direct selection. This is just the first step: there's also figuring out what's the best value that can map to the rarest rank combination. In other words, there's significant brute force effort needed.

I would like to show you a vector attack that can be used in order to exploit my finding without brute force usage or external requirements.

We have two phases: before and after this issue is disclosed. Furthermore we define the following NFT values and prices:

A: this NFT is the rarest that can be obtained due to the issue i've reported. The market doesn't know that no rarer NFT can be obtained.

Before this issue is disclosed

A<sub>1</sub> is the price of A. Nobody knows that A is the rarest NFT that can be obtained

After this issue is disclosed

A<sub>2</sub> is the price of A. Now, everyone knows that A is the rarest NFT that can be obtained.

Once everyone knows that A is the rarest NFT in the world, its price will increase. This means that A<sub>2</sub> > A<sub>1</sub>

Now, let's think an attacker knows this issue. He/she can buy many NFT fighter of type A during the first phase. He/she doesn't need to mint them, i.e., he/she doesn't need to brute force and this vector attack doesn't exploit #366 or #736 vulnerabilities.

After the attacker bought n NFT fighter of type A with price A<sub>1</sub>, he/she disclose this issue. After that, he/she can sell his/her NFTs with price A<sub>2</sub>.

In this way, the attacker maliciously obtained a gain of n*(A<sub>2</sub>-A<sub>1</sub>) without any brute force effort.

According to the high severity official C4 definition,

3 โ€” High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals).

The attack vector above shows how an attacker can compromise the value of assets directly and obtain a profit without any assumption.

#28 - niser93

2024-03-22T15:02:32Z

https://github.com/code-423n4/2024-02-ai-arena-findings/issues/1979 and https://github.com/code-423n4/2024-02-ai-arena-findings/issues/440 argue that is non-surjective, the proof of which is that the range of is only which is less than the size of the domain, . Furthermore, they argue that even within its range it is not equidistributed.

The size of domain is 10<sup>6</sup>, not 100<sup>6</sup>. You are just show the algorithm doesn't cover all rarity rank combinations. You don't show anything about the surjectivity from dna to physical attributes. And that's the point! Your POC just describe there are duplicates among rarity rank. It is not enough to prove surjectivity.

#29 - d3e4

2024-03-22T15:49:04Z

The size of domain is 106, not 1006. You are just show the algorithm doesn't cover all rarity rank combinations. You don't show anything about the surjectivity from dna to physical attributes. And that's the point! Your POC just describe there are duplicates among rarity rank. It is not enough to prove surjectivity.

You speak of the domain of $A$. There is nothing wrong with $A$ (except #112) and the [relevant] surjectivity onto attributes depends on the surjectivity of $R$. The domain of $R$ is $100^6$. The reason you find a lack of surjectivity onto attributes in your example is only because of the issue with $R$, not with $A$. Your report considers the function $A \circ R$, which you can only do by using an example of $A$, since the probabilities are unspecified. But the issue is only in $R$ itself.

#30 - HickupHH3

2024-03-22T23:23:11Z

Thanks for the correction, forgot to account for domain + ranges, bleh. I've been schooled ><

The range of A is $10^6$ $R: [0, 2^{256} - 1] -> [0, 99]^6$ $A: [0, 99]^6 -> [1, 10]^6$

I agree that A isn't the issue; it's non-injective and surjective.

#1979 says that the range of $R$ is $3,003,000$ - $576,000$ = $2,427,000$, but this exceeds the range of $A$ = $10^6 = 1,000,000$. Mapping $2,427,000$ points to $1,000,000$ points doesn't guarantee non-surjectivity, although it's more probable. That is, after accounting for the non-uniformity and non-surjectivity of $A$, we still have a non-zero probability of feasibly generating all attribute combinations.

Hence, I think it is slightly more important to prove that the composite function is non-surjective, ie. that there exists some combinations which are unattainable. Showing that $R$ is non-uniform in its range is great though, and increases the probability of non-surjectivity of the composite function.


There are 2 separate issues highlighted:

1: Non-uniformity of $R$ This exacerbates the non-injectivity of $R$, causing the probabilities of attribute generation to be distorted, ie. even though the probability array is for instance [13, 7, 7, 13, 10, 13, 13, 10, 10, 4], the actual probabilities will be different.

  1. Non-surjectivity of composite function $R \circ A$. Certain attributes are not attainable, by definition.

By making $R$ uniform uniformly distributed, will it also solve the non-surjectivity of the composite function? Not necessarily. Yes. By making $R$ surjective, does it solve the skewing of the probabilities? No.


#1979 covers the first point, and a little on the second. This issue covers the 2nd the best. #440 covers the first, mentions the second. Not sure what the best way to de-duplicate this is. Will ponder on this, am open to suggestions.

#31 - niser93

2024-03-22T23:26:12Z

I want to try to show you a different proof. Until now, I used logic inferences. Now I'm using functions and domain cardinality.

Let's define two functions:

f: dna -> rarityrank In the code, it is implemented in the following line: uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;
g: rarityrank -> physical attributes In the code, it is implemented in the following line: uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]);

I hope you agree that in order to obtain the physical attributes, I have to perform the following operation:

g(f): dna -> physical attributes physical attributes = g(f(dna))

The cardinalities of domains of the three functions are:

|dna| = 2^255 -1 |rarityrank| = 100^6 |physical attributes| = 10^6

g(f) is the only function we should care about: it return a physical attributes vector from a dna.

Now, you proved that |rarityrank| is less than 100<sup>6</sup> and that there are many duplicate values. In particular, your POC showed that |rarityrank| is 2,427,000 ( only 2,427,000 different combinations are possible, from #1979 ). We can assume |rarityrank| < 2,427,000 (but it wasn't what you said). Anyway, from this statement we can just deduce that:

f is not monotonic: we can have f(dna_1) = f(dna_2). We already know that, because it is trivial when |dna| >> |rarityrank|

Based on your POC, we cannot say anything about the surjectiviy of f() or g(). You are just talking about duplicates and cardinalities. In order to show that a function is not surjective using just cardinalities, you have to show that the domain is smaller than codomain. Otherwise, you have to analyze the behavior of functions to understand if they are surjective or not: that is what I did in my POC.

In my POC, I proved that g(f) is not surjective. I showed that using those functions, you have elements inside the g's codomain that cannot be obtained starting from the f's domain. In order to do so, I didn't analyze the cardinalities of domains, because it is not enough: you have enough values in f's domain with respect to f's codomain and you could find a f() that is surjective (and you did that in #440 using a monotonic function). You have enough values in g's domain with respect to g's codomain and you could find a g() that is surjective. The tiny cardinalities isn't a proof: you have to analyze the developed f() and g() functions to prove that g(f) is not surjective.

I want to underline that #1979 never talk about physical attributes that cannot be: the title refers to rarityrank, and the POC is about rarityrank. It never shows or mentions that there are physical attributes that cannot be obtained, and the lack of surjectivity isn't a trivial consequence of his/her proof.

#32 - niser93

2024-03-22T23:49:58Z

#1979 covers the first point, and a little on the second. This issue covers the 2nd the best. #440 covers the first, mentions the second. Not sure what the best way to de-duplicate this is. Will ponder on this, am open to suggestions.

Non-uniformity of R is not important. The important fact is the Non-uniformity of Rโˆ˜A. And it is a trivial consequence of the non-surjectivity of composite function Rโˆ˜A. For this reason I didn't explicity talk about the erroneous probability computation: if there are values in codomain that can be obtained with the wrong probability 0%, others values can be obtained with an higher probability than the correct one.

1: Non-uniformity of R This exacerbates the non-injectivity of R, causing the probabilities of attribute generation to be distorted, ie. even though the probability array is for instance [13, 7, 7, 13, 10, 13, 13, 10, 10, 4], the actual probabilities will be different.

  1. Non-surjectivity of composite function Rโˆ˜A. Certain attributes are not attainable, by definition.

I add that 1. cannot be attacked. The probabilities are distorted, but the attacker still have to manipulate the dna input and work around the input validation to exploit it.

  1. can be attacked, and an attacker could exploit it to make an unfair profit and to broke the market, like i showed in the previous comment.

For this reason I think #440 and #1979 are quite similar to #112 and belong to Medium Severity, and my finding belongs to High severity

#33 - Haxatron

2024-03-23T01:40:44Z

By making $R$ uniform, will it also solve the non-surjectivity of the composite function? Not necessarily.

Not true. By definition, uniformity of $R$ means that given any rarity rank $r_1, r_2 \in R$ where $R$ is the set of all rarityRank we are concerned about, the probability of obtaining $r_1$ is equally likely to the probability of obtaining $r_2$ and that implies that every rarity rank in $R$ is possible, so uniformity implies surjectivity.

#34 - d3e4

2024-03-23T01:56:39Z

@niser93

g(f) is the only function we should care about: it return a physical attributes vector from a dna.

g is an inverse transform sampling of the attributeProbabilites. That means that g has to take a uniformly distributed random variable as input. dna is too large, so f is used to downscale, which must therefore be done uniformly. You (and we) have shown no issue with g itself.

f is not monotonic: we can have f(dna_1) = f(dna_2). We already know that, because it is trivial when |dna| >> |rarityrank|

When we said that f(0) = f(1) we had already shown that f(x) = f(x + 3003000*k), so we are in fact talking about the entire domain of f.

Based on your POC, we cannot say anything about the surjectiviy of f() or g(). You are just talking about duplicates and cardinalities. In order to show that a function is not surjective using just cardinalities, you have to show that the domain is smaller than codomain. Otherwise, you have to analyze the behavior of functions to understand if they are surjective or not: that is what I did in my POC.

We showed that f is non-surjective. g and g(f) can be either surjective or not, depending on the attributeProbabilites. However, if f and g are surjective then g(f) is surjective. Therefore, if g(f) is non-surjective, but g is surjective, then f is non-surjective. This is what you showed an example of.

In my POC, I proved that g(f) is not surjective.

You showed an example of this. It is not true in general.

I want to underline that #1979 never talk about physical attributes that cannot be

I did: The rarityRanks are input to dnaToIndex() which then will also be biased and restricted in output (depending on the attribute probabilities).

You seem to insist on the cardinality of the attribute combinations. This is not the set we care about. Rather, we need to consider the set of all potential attribute combinations, which is defined by the rarity ranks, i.e. the range of f. The probabilities may be such that g(r) = a_0 for only one r_0, which may be any rarity rank combination. If r_0 is not in the range of f we cannot obtain a_0. And there are many missing r in the range of f so for many different g there would be some a for which g(f(r)) = a has no solution r. This concerns only the non-existence of a dna such that g(f(dna)) = a. The other [half of this] issue is the non-uniformity.

#35 - d3e4

2024-03-23T01:56:53Z

@HickupHH3

#1979 says that the range of $R$ is $3,003,000 - 576,000 = 2,427,000$, but this exceeds the range of $A = 10^6 = 1,000,000$. Mapping $2,427,000$ points to $1,000,000$ points doesn't guarantee non-surjectivity, although it's more probable.

This reasoning is misleading. It is not the set of attributes that is relevant but the set of all possible attributes, which is defined by the rarity ranks. See also my comment above where I try to explain this in response to @niser93's comment.

Hence, I think it is slightly more important to prove that the composite function is non-surjective, ie. that there exists some combinations which are unattainable.

We cannot prove this because it is not true in general. But it follows trivially that it is true for some $A$ if $R$ is non-surjective. $R$ must be uniform over the entire domain of $A$. It is already always possible to select an $A$ such that $A \circ R$ is surjective. This is why I think the numbers $2,427,000$ and $24\%$ provided in #1979 in to quantify lack of uniformity is important; it shows that this is an issue not just for a tiny subset of all possible $A$. We only need to consider $R$; the potential non-surjectivity of $A \circ R$ is a trivial consequence of the non-surjectivity of $R$.

Showing that $R$ is non-uniform in its range is great though, and increases the probability of non-surjectivity of the composite function.

By making $R$ uniform, will it also solve the non-surjectivity of the composite function? Not necessarily. By making $R$ surjective, does it solve the skewing of the probabilities? No.

The issue can be stated as $R$ must be uniform over the domain of $A$. This solves both the non-surjectivity of $A \circ R$ and the skewing of the intended probabilities.

#1979 covers the first point, and a little on the second. This issue covers the 2nd the best. #440 covers the first, mentions the second. Not sure what the best way to de-duplicate this is. Will ponder on this, am open to suggestions.

#1456 gives a thorough example of the second point, which only proves that $R$ is not always surjective. It does not prove that $R$ is always non-surjective. And it proves nothing about the cases where the composition actually is surjective.

#1456 proves that $R$ may be non-surjective. #1979 and #440 prove that $R$ is always non-surjective, as well as non-uniform.

This means that #1456 has, by some measure, shown less than half of what #1979 and #440 have shown, but the thorough example in #1456 may be counted to round this up. I suggest a partial duplicate to #1456, a full duplicate to #440, and #1979 to be selected for report (for the extra quantification).

#36 - fnanni-0

2024-03-23T03:08:14Z

we still have a non-zero probability of feasibly generating all attribute combinations.

@HickupHH3 it's trivial that for plenty of possible setups the "probability of feasibly generating all attribute combinations" is close to zero. And not being zero shouldn't matter much in this discussion, since the probabilities arrays are likely to change as the game evolves in ways we cannot predict.

Take for example the potential setup in which each physical category has a rare 1% attribute. The chances of randomly getting all the rare attributes simultaneously is $\frac{1}{100^6}$, but we only have $2,427,000$ possible combinations in $100^6$. Correct me if my math is wrong, in this case the probability of the combination of rare attributes being absent is $100\times\frac{100^6-1}{100^6}^{2427000} = 99.999757$%.

Repeat the example with not so rare attributes of 5% probability and the result is 96.2788%.

My point however, these examples aside, is that it is very intuitive that by having only 0.0002427% of rarity rank combinations there will be a lot of potential setups in which physical attribute combinations will be missing. Mathematically proving this for a particular setup is nice but secondary.

I think it's a big overkill to discuss surjection and make a fancy math debate about it. It's staggering how few rarity rank combinations are possible, therefore it's highly likely that there's a problem with uniformity and missing attribute combinations. That's it. There's an obvious problem with the default divisors and how they are used, which has unintended consequences that impact the rarity model. Fix the root cause and both problems are gone.

Regarding the cardinality of physical attributes, it's not necessarily 10^6. As far as I know there are no requirements on the length of probability arrays. Although unlikely, the cardinality could be 2, in which case all attributes would be possible but there would still be a problem with uniformity. Again I insist, the root problem is how rarity rank combinations are missing and duplicated, which is best described in #1979 and #440. Partial credit for #1456 seems the most reasonable to me.

#37 - HickupHH3

2024-03-23T07:40:29Z

The issue can be stated as R must be uniform over the domain of A. This solves both the non-surjectivity of and the skewing of the intended probabilities.

Yeap, thought about it, agree with this as the primary root cause.

After considering all arguments, have come to the conclusion that #1979 will be selected as the best report, the other 2 will be satisfactory. The reason why #1456 isn't partially duplicated is because the impact of non-attainable rank combinations is the clearest, and the root cause is sufficiently identified (the other 2 are of course slightly better in this regard).

#38 - c4-judge

2024-03-23T07:40:49Z

HickupHH3 marked the issue as not selected for report

#39 - c4-judge

2024-03-23T07:40:59Z

HickupHH3 marked the issue as duplicate of #1979

#40 - niser93

2024-03-23T08:50:21Z

The issue can be stated as R must be uniform over the domain of A. This solves both the non-surjectivity of and the skewing of the intended probabilities.

Sorry @HickupHH3 but i'm not agree. I said from the beginning that the solution proposed by #440 solves also my issue. If A is surjective and R is surjective, Rโˆ˜A must be surjective. But I think a finding should be selected and awarded basing on the issue which is reported, not the solution.

The solution of #440 solves also my problem, this is true. But the issue proposed by #1979 and #440 doesn't imply my problem. It was the point from the beginning. Even if we have not-uniform R, Rโˆ˜A can be surjective. We don't care about the solution, but about the issue. And #440 and #1979 issue doens't imply this one.

we talked about many things, and I think we lose the focus on the initial disputed problem. @d3e4 and @fnanni-0 said that their findings imply mine. I show that is not true. Than they show their solutions solve mine. And it is true (for #440 , not for #1979).

#41 - d3e4

2024-03-23T12:05:37Z

Not true. By definition, uniformity of $R$ means that given any rarity rank $r_1, r_2 \in R$ where $R$ is the set of all rarityRank we are concerned about, the probability of obtaining $r_1$ is equally likely to the probability of obtaining $r_2$ and that implies that every rarity rank in $R$ is possible, so uniformity implies surjectivity.

Just to avert any potential confusion that this comment might easily cause. To be precise and more explicit it is the uniformity of $R$ over the domain of $A$ that implies surjectivity. $R$ could still be uniform over its own range, which may be a subset of the domain of $A$. But it is precisely the domain of $A$ ("the set of all rarityRank we are concerned about") we consider when we here speak of surjectivity (otherwise surjectivity is a moot point, since every function is by definition surjective over its image, and it makes no sense to talk here about some other intermediate codomain larger than the image of $R$, but smaller than the domain of $A$), so @Haxatron is correct, even though his statement may be misunderstood.

#42 - HickupHH3

2024-03-26T02:11:48Z

@niser93 acknowledged, won't be changing my decision.

Awards

1.876 USDC - $1.88

Labels

bug
2 (Med Risk)
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/FighterFarm.sol#L307-L331 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L476-L531

Vulnerability details

Impact

According to Discord channel, the fighter attributes should rely in a certain range. For example, the uint256 weight should be in [65:95], and uint256 element should be in [0:len[numElements]], where numElements is define inside the FighterFarm contract. However, inside the FighterFarm.mintFromMergingPool() there is no check to assert that these value will remain in those ranges. FighterFarm.mintFromMergingPool() can be called using the MergingPool.claimRewards() function. Anyone can call it and claim his/her rewards, if any. This function permit to create a new fighter with customAttributes, i.e., to arbitrarily set uint256 weight and uint256 element. This means that a fighter could have attributes that don't work correctly with respect the game logic. We report this issue with a medium severity, because even we think this could stronlgy impact the game, we can't establish precisely the consequences.

Proof of Concept

Let's analyze the FighterFarm.mintFromMergingPool() function:

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

This function can be just called by _mergingPoolAddress, and create a new fighter using the FighterFarm._createNewFighter() function.

Let's analyze where _mergingPoolAddress calls FighterFarm.mintFromMergingPool(). It does that in the MergingPool.claimRewards() function:

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

It takes every passed CustomAttributes and use them to call _fighterFarmInstance.mintFromMergingPool.

We modify the test function written by developers in to obtain a fighter with arbitrarily chosen attributes of weight and element:

import {FighterOps} from "../src/FighterOps.sol"; [...] /// @notice Test of winners claiming rewards for multiple rounds and checking if unclaimed rewards is updating correctly. function testClaimRewardsForWinnersOfMultipleRoundIds2() public { _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; // 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) == _DELEGATED_ADDRESS, true); 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(7); _customAttributes[1][1] = uint256(181); // winners of roundId 1 are picked _mergingPoolContract.pickWinner(_winners); // winner claims rewards for previous roundIds _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); // 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); assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress); assertEq(_fighterFarmContract.ownerOf(5), _DELEGATED_ADDRESS); (uint256 weight,uint256 element,FighterOps.FighterPhysicalAttributes memory physAttrs,,,,,,) = _fighterFarmContract.fighters(5); assertEq(weight, 181); assertEq(element, 7); }

The result of this execution is:

Running 1 test for test/MergingPool.t.sol:MergingPoolTest [PASS] testClaimRewardsForWinnersOfMultipleRoundIds2() (gas: 2661608) Logs: 0 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 20.58ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools used

Visual inspection, Foundry

The passed CustomAttributes should be checked inside the FighterFarm._createNewFighter:

476      /// @notice Creates a new fighter and mints an NFT to the specified address.
477      /// @param to The address to mint the new NFT to.
478      /// @param dna The DNA of the new fighter.
479      /// @param modelHash The hash of the ML model.
480      /// @param modelType The type of the ML model.
481      /// @param fighterType The type of fighter to create.
482      /// @param iconsType Type of icons fighter (0 means it's not an icon).
483      /// @param customAttributes Array with [element, weight] of the newly created fighter.
484      function _createNewFighter(
485          address to, 
486          uint256 dna, 
487          string memory modelHash,
488          string memory modelType, 
489          uint8 fighterType,
490          uint8 iconsType,
491          uint256[2] memory customAttributes
492      ) 
493          private 
494      {  
495          require(balanceOf(to) < MAX_FIGHTERS_ALLOWED);
496          uint256 element; 
497          uint256 weight;
498          uint256 newDna;
499          if (customAttributes[0] == 100) {
500              (element, weight, newDna) = _createFighterBase(dna, fighterType);
501          }
502          else {
+                require(customAttributes[0] <= numElements.length && customAttributes[1]>=65 && customAttributes[1]<=95>)
503              element = customAttributes[0];
504              weight = customAttributes[1];
505              newDna = dna;
506          }
507          uint256 newId = fighters.length;
508  
509          bool dendroidBool = fighterType == 1;
510          FighterOps.FighterPhysicalAttributes memory attrs = _aiArenaHelperInstance.createPhysicalAttributes(
511              newDna,
512              generation[fighterType],
513              iconsType,
514              dendroidBool
515          );
516          fighters.push(
517              FighterOps.Fighter(
518                  weight,
519                  element,
520                  attrs,
521                  newId,
522                  modelHash,
523                  modelType,
524                  generation[fighterType],
525                  iconsType,
526                  dendroidBool
527              )
528          );
529          _safeMint(to, newId);
530          FighterOps.fighterCreatedEmitter(newId, weight, element, generation[fighterType]);
531      }

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-02-24T09:11:17Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-24T09:11:27Z

raymondfam marked the issue as duplicate of #226

#2 - c4-judge

2024-03-11T10:30:48Z

HickupHH3 marked the issue as satisfactory

Lines of code

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

Vulnerability details

Impact

In another report, we describe the lack of validation inside FighterFarm.redeemMintPass(), which permits to create a new fighter with a dna that is arbitrarily passed by the player.

FighterFarm.claimFighter() has a validation logic and a different way to compute fighter from dna. However, we don't think this is enough. Let's see the FighterFarm.claimFighter() function.

185 /// @notice Enables users to claim a pre-determined number of fighters. 186 /// @dev The function verifies the message signature is from the delegated address. 187 /// @param numToMint Array specifying the number of fighters to be claimed for each fighter type. 188 /// @param signature Signature of the claim message. 189 /// @param modelHashes Array of hashes representing the machine learning models for each fighter. 190 /// @param modelTypes Array of machine learning model types for each fighter. 191 function claimFighters( 192 uint8[2] calldata numToMint, 193 bytes calldata signature, 194 string[] calldata modelHashes, 195 string[] calldata modelTypes 196 ) 197 external 198 { 199 bytes32 msgHash = bytes32(keccak256(abi.encode( 200 msg.sender, 201 numToMint[0], 202 numToMint[1], 203 nftsClaimed[msg.sender][0], 204 nftsClaimed[msg.sender][1] 205 ))); 206 require(Verification.verify(msgHash, signature, _delegatedAddress)); 207 uint16 totalToMint = uint16(numToMint[0] + numToMint[1]); 208 require(modelHashes.length == totalToMint && modelTypes.length == totalToMint); 209 nftsClaimed[msg.sender][0] += numToMint[0]; 210 nftsClaimed[msg.sender][1] += numToMint[1]; 211 for (uint16 i = 0; i < totalToMint; i++) { 212 _createNewFighter( 213 msg.sender, 214 uint256(keccak256(abi.encode(msg.sender, fighters.length))), 215 modelHashes[i], 216 modelTypes[i], 217 i < numToMint[0] ? 0 : 1, 218 0, 219 [uint256(100), uint256(100)] 220 ); 221 } 222 }

We can see that this function uses line 206 to verify the signature, and line 214 to compute dna for the FighterFarm._createNewFighter() function. Let's analyze the line 214:

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

It computes dna according to msg.sender and fighters.length. However, these two values can be manipulated to obtain wanted dna (or, at least, a subset of dnas). A malicious player can create a new contract with the wanted address. Furthermore, that player can choose the wanted fighters.length: even if Ai Arena works on Arbitrum, that player can exploit the centralized sequencer to send transaction in the right moment. We will explain more details in the PoC.

Proof of Concept

Let's think today the fighters.length is 100, and we can estimate that in two weeks fighters.length will be 1000. This is an example, of course. It is not important to know precisely when fighters.length will be 1000. The important thing is that we have built our attack before that moment.

We can generate a valid contract on a deterministic address using create2: Create2 contract factory

Let's think we have generated 100k different addresses changing the salt parameter of create2. Now, we can forecast the computed dna (and so, the fighter attributes), assuming fighters.length=1000.

/// @notice Test whether the correct sender of the signature can claim a fighter. function testClaimFighters2() public { uint8[2] memory numToMint = [1, 0]; bytes memory claimSignature = abi.encodePacked( hex"valid signature" ); string[] memory claimModelHashes = new string[](1); claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory claimModelTypes = new string[](1); claimModelTypes[0] = "original"; // We have generated 999 fighter before this test // Right sender of signature should be able to claim fighter vm.prank(fake_address); _fighterFarmContract.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes); assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1); assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress); assertEq(_fighterFarmContract.totalSupply(), 1); (uint256 fig,,FighterOps.FighterPhysicalAttributes memory physAttrs,,,,,,) = _fighterFarmContract.fighters(1000); assertEq(physAttrs.head, 3); assertEq(physAttrs.eyes, 50); assertEq(physAttrs.mouth, 2); assertEq(physAttrs.body, 3); assertEq(physAttrs.hands, 3); assertEq(physAttrs.feet, 2); }

We have created testClaimFighters2() to show this: a malicious player can generate a valid signature for every generated address and call testClaimFighters2() until he/she obtains the wanted physical attributes for the fighter.

The same can be done for weight and element attributes, that are used during the battle.

Now the attacker has obtained the wanted pair (msg.sender, fighter.length). When the block with fighter.length=1000 will be created, the attacker can be quite sure to put its transaction in the right place sending transaction in the right moment and exploiting the centralized sequencer of Arbitrum: in this way, he/she can generate the fighter with the wanted physical attributes.

Tools Used

Visual ispection and Foundry We used Python to run text, removing the signature from the validation mechanism, to show the feasibility of the attack.

We suggest to add block.timestamp or block.number:

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

In this way, it will be harder to forecast when some fighters.length will be reached

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-02-24T01:35:31Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-24T01:36:38Z

raymondfam marked the issue as duplicate of #53

#2 - c4-judge

2024-03-06T03:49:53Z

HickupHH3 marked the issue as satisfactory

#3 - c4-judge

2024-03-15T02:10:55Z

HickupHH3 changed the severity to 2 (Med Risk)

#4 - c4-judge

2024-03-22T04:20:59Z

HickupHH3 marked the issue as duplicate of #376

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