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
Rank: 66/283
Findings: 2
Award: $111.72
π Selected for report: 0
π Solo Findings: 0
π Selected for report: abhishek_thaku_r
Also found by: 0xAlix2, 0xDetermination, 0xShitgem, Draiakoo, Fulum, Greed, MrPotatoMagic, PoeAudits, Tychai0s, ahmedaghadi, alexzoid, dimulski, fnanni, givn, iamandreiski, immeas, kartik_giri_47538, kiqo, klau5, korok, ktg, maxim371, offside0011, pontifex, sashik_eth, stakog, swizz, yotov721
111.676 USDC - $111.68
The maximum number which can be stored in a uint8 is 255 or uint8 is able to store whole numbers between 0 and 255.
Currently, AI Arena has minted 420 mint passes as it can be seen from their OpenSea collection which automatically means that tokenIds between 256 and 419 will exist, since the tokenId is assigned according to the length of the fighters array:
uint256 newId = fighters.length;
Users with ids above 255 will never be able to re-roll their fighters if they want their fighter to receive new traits as the tokenId function argument in reRoll()
is uint8.
function reRoll(uint8 tokenId, uint8 fighterType) public {
Starting from Solidity 0.8.0 and up, checks for overflow are made automatic, meaning that if a value which is above the maximum that a certain unsigned integer can hold is passed, the function will revert so that overflow can't happen.
In FighterFarm.sol we can see that the tokenIds assigned to each new fighter that is minted are assigned based on the length of the fighters array OR how many fighters are in the protocol, that many tokenIds will exist:
... uint256 newId = fighters.length; ... FighterOps.Fighter( weight, element, attrs, newId, modelHash, modelType, generation[fighterType], iconsType, dendroidBool
In the FighterOps.sol contract from the Fighter struct we can see that the id is a uint256:
struct Fighter { uint256 weight; uint256 element; FighterPhysicalAttributes physicalAttributes; uint256 id; string modelHash; string modelType; uint8 generation; uint8 iconsType; bool dendroidBool; }
And as explained in the above section, more than 255 tokenIds will exist for sure. When users who've minted their fighter through their mint pass or won one through the Merging Pool and who got tokenIds of 256+ will never be able to re-roll their fighter for it to receive new traits since the reRoll()
function will always revert.
The tokenId argument which is passed in the reRoll function, with which we choose which fighter we want to receive new traits on a pseudo-random basis, will always revert for fighter ids above 255.
function reRoll(uint8 tokenId, uint8 fighterType) public {
Manual Review
Change the uint8 tokenId argument in the reRoll
function to uint256.
Under/Overflow
#0 - c4-pre-sort
2024-02-22T01:26:30Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T01:26:37Z
raymondfam marked the issue as duplicate of #68
#2 - c4-judge
2024-03-05T01:57:30Z
HickupHH3 marked the issue as satisfactory
#3 - c4-judge
2024-03-05T02:04:51Z
HickupHH3 changed the severity to 3 (High Risk)
π Selected for report: klau5
Also found by: 0rpse, 0xBinChook, 0xDetermination, 0xGreyWolf, 0xLogos, 0xWallSecurity, 0xaghas, 0xgrbr, 0xkaju, 0xlyov, AlexCzm, BARW, Blank_Space, BoRonGod, Daniel526, DanielArmstrong, Draiakoo, FloatingPragma, Giorgio, Greed, Jorgect, Matue, McToady, MidgarAudits, Nyxaris, PUSH0, PedroZurdo, Pelz, PoeAudits, Silvermist, SpicyMeatball, Tekken, Tricko, Tumelo_Crypto, VAD37, WoolCentaur, Zac, alexzoid, andywer, aslanbek, bgsmallerbear, cats, d3e4, desaperh, dimulski, dutra, erosjohn, evmboi32, favelanky, fnanni, forkforkdog, gesha17, givn, grearlake, haxatron, honey-k12, iamandreiski, immeas, juancito, kaveyjoe, ke1caM, kiqo, klau5, korok, lil_eth, lsaudit, n0kto, ni8mare, niser93, pa6kuda, peanuts, peter, shaka, sl1, soliditywala, solmaxis69, t0x1c, tallo, thank_you, tpiliposian, visualbits, vnavascues, web3pwn, yotov721
0.0352 USDC - $0.04
https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/MergingPool.sol#L139-L167 https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/FighterFarm.sol#L233-L263 https://github.com/code-423n4/2024-02-ai-arena/blob/f2952187a8afc44ee6adc28769657717b498b7d4/src/FighterFarm.sol#L313-L331
The current system for creating fighters either through redeeming a mint pass or if you're chosen as a winner from the Merging Pool can be gamed to create a custom avatar with custom attributes, choose an element you'd like and avoid any kind of pseudo-random attribute assignment.
This is a different issue from the weak randomness (which I've raised as different) due to multitude of reasons:
There are multiple entry points through which the system can be gamed to create a custom fighter either by owning a mint pass or as a winner from the Merging Pool.
There are different type of attributes that fighters can have and based on those attributes a few key characteristics are dependent on:
From Arena's documentation we can see how each attribute can have a distinct impact on fighter classes (light, middle or heavyweight).
https://docs.aiarena.io/gaming-competition/nft-makeup
Since all attributes can be gamed and custom-picked users will be able customize their warriors to whichever weight, power, stamina, range, defense, etc. they want.
In addition to standard battle attributes, each NFT is endowed with special powers based on their element which can also be gamed and users can custom-choose whether fire, water or electricity.
The last and final vulnerability is that users can also game the system to pick a dendroid.
According to the documentation:
"There are currently two main types of NFTs.
π€ AR-X Bots - Most NFTs are AR-X Bots (short for ArenaX Bots, also known as Champions). They are the standard class of NFTs battling in the Arena.
πΊ Dendroids - Dendroids are a more exclusive class of NFTs. They have the ability to incorporate other metaverse assets (e.g. NFTs from other projects) into the Arena! These are very rare and more difficult to obtain."
This isn't an issue whose cause is pseudo randomness only, but also improperly validating fighter creation as well and allowing users to pass arbitrary arguments in the functions.
First scenario (Redeeming a fighter):
The only checks present in the redeemMintPass
function are whether the input data provided as the function arguments are the same size and whether the msg.sender is the owner of the ID which was provided, the other data can be passed as a function argument and can be arbitrary, meaning that for example an owner of whatever mint pass can mint a dendroid for example.
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(
To prove this you can already use the existing test suites and change the testRedeemMintPass()
_fighterTypes
to be 1, for example and a dendroid will be created.
Here is the output of the test from which we can see that we've created a dendroid and successfully gamed the system:
β [1043] AiArenaHelper::createPhysicalAttributes(1, 0, 1, true) [staticcall] β β ββ β FighterPhysicalAttributes({ head: 99, eyes: 99, mouth: 99, body: 99, hands: 99, feet: 99 })
This would allow literally anyone to buy a mint pass from OpenSea for example, and just pass the id as one of the function arguments and anything else can be custom which would allow any user to mint whatever fighter they want, including a dendroid (as shown above).
Besides this we can also game the DNA as well as the attributes by passing custom inputs.
Next example would be creating a fighter if we're chosen as a winner from the Merging pool.
In this case we can't create a dendroid since the "0" is hardcoded as an input in the fighterType
but we can affect the rarity, weight and basically every other attribute except for whether it's a champion or a dendroid.
When we claim our warrior, the only checks performed is whether or not we were picked as winners, we can still pass the customAtributes as whatever data we want.
string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes
Manual Review
Since the core of the issue is allowing users to pass arbitrary data, this should be limited if the and not allow users to pass arbitrary arguments that would influence the warrior's abilities, rarity, etc.
Invalid Validation
#0 - c4-pre-sort
2024-02-24T06:33:57Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T06:34:34Z
raymondfam marked the issue as duplicate of #53
#2 - c4-judge
2024-03-06T03:54:29Z
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-20T01:04:05Z
HickupHH3 marked the issue as duplicate of #376
π Selected for report: klau5
Also found by: 0rpse, 0xBinChook, 0xDetermination, 0xGreyWolf, 0xLogos, 0xWallSecurity, 0xaghas, 0xgrbr, 0xkaju, 0xlyov, AlexCzm, BARW, Blank_Space, BoRonGod, Daniel526, DanielArmstrong, Draiakoo, FloatingPragma, Giorgio, Greed, Jorgect, Matue, McToady, MidgarAudits, Nyxaris, PUSH0, PedroZurdo, Pelz, PoeAudits, Silvermist, SpicyMeatball, Tekken, Tricko, Tumelo_Crypto, VAD37, WoolCentaur, Zac, alexzoid, andywer, aslanbek, bgsmallerbear, cats, d3e4, desaperh, dimulski, dutra, erosjohn, evmboi32, favelanky, fnanni, forkforkdog, gesha17, givn, grearlake, haxatron, honey-k12, iamandreiski, immeas, juancito, kaveyjoe, ke1caM, kiqo, klau5, korok, lil_eth, lsaudit, n0kto, ni8mare, niser93, pa6kuda, peanuts, peter, shaka, sl1, soliditywala, solmaxis69, t0x1c, tallo, thank_you, tpiliposian, visualbits, vnavascues, web3pwn, yotov721
0.0352 USDC - $0.04
https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L16-L23 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/AiArenaHelper.sol#L83-L121 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L214 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L324
I've explained the consequences and ways to game the system through passing arbitrary arguments to most fighter creation functions in another issue, and how the arguments are never validated, here we will focus only on weak randomness and how this can be gamed in a different way.
Since this protocol uses "pseudo-randomness", a lot of the traits which are created through these kind of calculations can be pre-calculated and since blockchains are deterministic systems (produce the same output from a given starting condition or initial state), this can be exploited to receive a fighter with traits that are more desirable, rare, etc.
This is unfair gameplay and breaks core game mechanics.
Let's take the reRoll()
function first. The idea of this function is to re-roll a new fighter with random traits.
From this function we can see that the dna input is calculated as a hash from the msg.sender, tokenid, and the number of rolls:
uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
Once the dna
is calculated it is passed as an argument in this function:
function createPhysicalAttributes( uint256 dna, uint8 generation, uint8 iconsType, bool dendroidBool ) external view returns (FighterOps.FighterPhysicalAttributes memory) { if (dendroidBool) { return FighterOps.FighterPhysicalAttributes(99, 99, 99, 99, 99, 99); } else { uint256[] memory finalAttributeProbabilityIndexes = new uint[](attributes.length); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { if ( i == 0 && iconsType == 2 || // Custom icons head (beta helmet) i == 1 && iconsType > 0 || // Custom icons eyes (red diamond) i == 4 && iconsType == 3 // Custom icons hands (bowling ball) ) { finalAttributeProbabilityIndexes[i] = 50; } else { uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100; uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]); finalAttributeProbabilityIndexes[i] = attributeIndex; } } return FighterOps.FighterPhysicalAttributes( finalAttributeProbabilityIndexes[0], finalAttributeProbabilityIndexes[1], finalAttributeProbabilityIndexes[2], finalAttributeProbabilityIndexes[3], finalAttributeProbabilityIndexes[4], finalAttributeProbabilityIndexes[5] ); } }
This means that we would know exactly what the output of a re-roll would be. Since both the number of re-rolls, the msg.sender and the number of re-rolls can produce different numbers, we can buy a fighter id which we know if we re-roll that id would produce a desirable trait.
Same goes for msg.sender, we can transfer the NFT to a different address that we're owners of and then re-roll if we know that again that would yield a desirable result, same goes for number of re-rolls as well.
Second example, we will be looking at _createNewFighter()
, we can see here that dna
in this function is passed as a different argument from different functions:
claimFighters()
and mintFromMergingPool
produces it as a hash from the following inputs:
uint256(keccak256(abi.encode(msg.sender, fighters.length)))
As explained above, these can also be gamed in order to receive an NFT with more desirable traits, we can try out different addresses and transfer the NFT to that address to produce a different result with a different msg.sender
, same would go with the length of the fighters array, as it increases the output of the hash would change and we can predict when a desirable trait will pop up and use that to our advantage to get the dna we want.
Manual Review
Use Chainlink VRF to get a provably fair and verifiable random number, so that honest game mechanics are present and the system can't be gamed.
Other
#0 - c4-pre-sort
2024-02-24T01:26:06Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T01:26:49Z
raymondfam marked the issue as duplicate of #53
#2 - c4-judge
2024-03-06T03:47:31Z
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-20T01:04:06Z
HickupHH3 marked the issue as duplicate of #376