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: 27/283
Findings: 4
Award: $239.22
π Selected for report: 0
π 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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L346 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L363
FigtherFarm
contract, an ERC721
token, implements custom check if the token can be transferred. This check is executed in the overridden ERC721::transferFrom()
and ERC721::safeTransferFrom()
methods. However, the contract does not override the 4-parameter version of ERC721::safeTransferFrom()
method, which can effectively be used to bypass the check.
The FighterFarm::_ableToTransfer()
method determines if the token transfer can be executed. The method is implemented as such:
function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && balanceOf(to) < MAX_FIGHTERS_ALLOWED && !fighterStaked[tokenId] ); }
As mentioned before, ERC721::safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data)
is not overridden in FighterFarm
contract, which means this method can be used to bypass the _isAbleToTransfer()
check.
The first check of _isAbleToTransfer()
method - whether or not the caller is approved or owner of the token - is implemented in the ERC721::safeTransferFrom()
method already, so it will make no difference. However, the remaining 2 checks are not implemented there. This means that the staked Fighter can still be transferred, and the MAX_FIGHTERS_ALLOWED
invariant can be breached.
Effectively, this bug allows the users to perform two prohibited actions:
MAX_FIGHTERS_ALLOWED
fighters (uncapped number of fighters), which violates game design choiceIn order to demonstrate the vulnerability, please make the following change to FighterFarm.t.sol::testTransferringFighterWhileStakedFails()
test:
function testTransferringFighterWhileStakedFails() public { _mintFromMergingPool(_ownerAddress); _fighterFarmContract.addStaker(_ownerAddress); _fighterFarmContract.updateFighterStaking(0, true); assertEq(_fighterFarmContract.fighterStaked(0), true); // check that i'm unable to transfer since i staked vm.expectRevert(); - _fighterFarmContract.transferFrom(_ownerAddress, _DELEGATED_ADDRESS, 0); + _fighterFarmContract.safeTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, 0, ""); assertEq(_fighterFarmContract.ownerOf(0) != _DELEGATED_ADDRESS, true); assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress); }
The test will fail since the expected revert won't happen - the transfer will be successful.
Manual Review
Override the ERC721:ERC721::safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data)
method in the FighterFarm
contract:
function safeTransferFrom( address from, address to, uint256 tokenId, bytes memory data ) public override(ERC721, IERC721) { require(_ableToTransfer(tokenId, to)); _safeTransfer(from, to, tokenId, ""); }
ERC721
#0 - c4-pre-sort
2024-02-23T04:17:18Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-23T04:17:26Z
raymondfam marked the issue as duplicate of #54
#2 - c4-pre-sort
2024-02-23T04:47:04Z
raymondfam marked the issue as duplicate of #739
#3 - c4-pre-sort
2024-02-23T04:49:45Z
raymondfam marked the issue as sufficient quality report
#4 - c4-judge
2024-03-11T02:09:26Z
HickupHH3 changed the severity to 3 (High Risk)
#5 - c4-judge
2024-03-11T02:33:57Z
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
The GameItems.sol
contract is an ERC1155
token representing the in-game items. The individual item has the following attributes:
struct GameItemAttributes { string name; bool finiteSupply; bool transferable; uint256 itemsRemaining; uint256 itemPrice; uint256 dailyAllowance; }
One of the attributes is the transferable
boolean flag, indicating whether or not this item can be transferred between users. This flag is checked inside the GameItems::safeTransferFrom()
method to ensure that trying to transfer non-transferable items reverts.
function safeTransferFrom( address from, address to, uint256 tokenId, uint256 amount, bytes memory data ) public override(ERC1155) { require(allGameItemAttributes[tokenId].transferable); super.safeTransferFrom(from, to, tokenId, amount, data); }
However, even though the contract correctly overrides the ERC1155::safeTransferFrom()
method to honor the flag, it does not override the ERC1155::safeBatchTransferFrom()
method. It means that the non-transferable items can still be transferred with safeBatchTransferFrom()
method.
The concept of non-transferable items is critical to the AI Arena economy. The transferable
flag was introduced to prevent users from transferring some of the items between each other, thus enforcing them to buy/mint them directly from the GameItems
contract. The possibility of bypassing the restriction allows the users to create secondary markets and trading the items. As the users may prefer to buy the items cheaper from each other, instead of minting them from the contract, it can seriously affect the economy of the Protocol and directly impact the profit it will be making.
The following test demonstrates that the transferable
flag can be bypassed. Please paste it in the GameItems.t.sol
test suite and run it with the following command: forge test --match-test testTransferabilityCanBeBypassed
function testTransferabilityCanBeBypassed() public { /* 1. Mint 1 battery to the _ownerAddress */ _fundUserWith4kNeuronByTreasury(_ownerAddress); _gameItemsContract.mint(0, 1); /* 2. Set the battery as non-transferable */ _gameItemsContract.adjustTransferability(0, false); /* 3. Test that safeTransferFrom reverts */ vm.expectRevert(); _gameItemsContract.safeTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, 0, 1, ""); /* 4. Test that safeBatchTransferFrom still works */ uint256[] memory ids = new uint256[](1); uint256[] memory amounts = new uint256[](1); ids[0] = 0; amounts[0] = 1; _gameItemsContract.safeBatchTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, ids, amounts, ""); assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 1); assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 0); }
Manual Review
Inside the GameItems.sol
contract override the ERC1155::safeBatchTransferFrom()
method to honor the transferable
flag:
+ function safeBatchTransferFrom( + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + public override { + for(uint256 i = 0; i < ids.length; ++i){ + require(allGameItemAttributes[i].transferable); + } + super.safeBatchTransferFrom(from, to, ids, amounts, data); + }
Invalid Validation
#0 - c4-pre-sort
2024-02-22T03:27:52Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T03:27:58Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:27:13Z
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:50:28Z
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/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/MergingPool.sol#L149-L163 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L294-L311
Winners may not be able to claim their NFTs from MergingPool
contract due to the out of gas error, since the method iterates through the two-dimensional array of all the winners from every round. The same issue may occur when user will try to claim the NRN
rewards from RankedBattle
contract.
To claim the NFT from MergingPool
, the winner has to call the MergingPool::claimRewards()
method. The method is implemented as such:
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
The for loops iterate through the rounds (outer loop) and winners (inner loop). The outer loop's lower bound is numRoundsClaimed[msg.sender]
- the number of last round for which the user has claimed the reward.
Let's consider a scenario where the new user, who has never claimed any rewards from MintingPool
, wins a round. The loop will iterate from round 0 to the current round, for each round checking all of the winners via costly operation of reading the state variable winnerAddresses
. If the current round number is high enough, the method is very likely to revert with out of gas error. Therefore the winner will not be able to claim the reward.
When the round number will be high enough, the same scenario may also occur in RankedBattle::claimNRN()
method here, locking the users from their NRN
prizes.
Manual Review
Instead of iterating through the winners from every round, add roundId
as a parameter to MergingPool::claimRewards()
and RankedBattle::claimNRN()
methods, so that they will perform the computations only for specific round.
DoS
#0 - c4-pre-sort
2024-02-23T23:44:06Z
raymondfam marked the issue as duplicate of #1541
#1 - c4-pre-sort
2024-02-23T23:44:10Z
raymondfam marked the issue as sufficient quality report
#2 - c4-judge
2024-03-11T13:00:26Z
HickupHH3 marked the issue as duplicate of #216
#3 - c4-judge
2024-03-11T13:08:20Z
HickupHH3 marked the issue as partial-50
#4 - c4-judge
2024-03-21T02:08:21Z
HickupHH3 marked the issue as satisfactory
π Selected for report: Timenov
Also found by: 0x11singh99, 0xblackskull, CodeWasp, MidgarAudits, MrPotatoMagic, Rolezn, Sabit, SovaSlava, andywer, btk, josephdara, lil_eth, merlinboii, sobieski, vnavascues
238.8948 USDC - $238.89
There is no option to revoke previously granted burning privilege in the GameItems.sol
contract. As the address with this privilege can burn any amount of any in-game items from an arbitrary address, not having an option to revoke the rights from a malicious/compromised address can lead to severe consequences.
In-game items, handled in the GameItems.sol
contract, are essential to the Protocol's economy as the source of income. The privileged roles in the Protocol are generally handled with caution, and for every privileged role there is a mechanism to revoke it. The burner role in GameItems
contract is an exception, as there is no way to revoke the previously granted privilege.
In a scenario where the address with burning privileges was compromised, a malicious actor can burn an arbitrary amount of any token from an address of choice. Effectively he will have a total control over the GameItems
economy and will be able to keep burning all the tokens and DOS the contract's functionality.
Admins of the protocol can grant the burning privilege by calling the GameItems::setAllowedBurningAddress()
method:
function setAllowedBurningAddresses(address newBurningAddress) public { require(isAdmin[msg.sender]); allowedBurningAddresses[newBurningAddress] = true; }
This role is checked inside the GameItems::burn()
method:
function burn(address account, uint256 tokenId, uint256 amount) public { require(allowedBurningAddresses[msg.sender]); _burn(account, tokenId, amount); }
There are no other restrictions to burn GameItems
tokens - the address allowed inside the allowedBurningAddresses
array can burn any amount of any token from anyone.
There is currently no way to revoke this privilege.
Manual Review
Change the GameItems::setAllowedBurningAddress()
method to accept a second parameter bool allowed
, that can be used to revoke the access.
- function setAllowedBurningAddresses(address newBurningAddress) public { + function setAllowedBurningAddresses(address newBurningAddress, bool allowed) public { require(isAdmin[msg.sender]); - allowedBurningAddresses[newBurningAddress] = true; + allowedBurningAddresses[newBurningAddress] = allowed; }
Access Control
#0 - c4-pre-sort
2024-02-22T19:25:00Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T19:25:12Z
raymondfam marked the issue as duplicate of #47
#2 - c4-judge
2024-03-08T03:27:41Z
HickupHH3 marked the issue as satisfactory