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: 277/283
Findings: 1
Award: $0.04
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L191 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L462 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L484
A Fighter's weight is a measure of how powerful a fighter is, meaning users are interested in having this stat being as high as possible. Fighter weights are calculated using a pseudo-random formula. This formula can be manipulated by monitoring the mempool and submitting transactions at the right time. This makes it possible for a malicios user to claim only high-weight Fighter NFTs by backrunning/sandwiching transactions or submitting multiple transactions from different addresses, and potentially lower the weight of other users Fighter NFTs by frontrunning them.
Lets take a look at how the weight is calculated. As we can se below in the _createFighterBase() function, it is a simple formula that takes the dna
modulo 31
and adds 65.
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L462
function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { uint256 element = dna % numElements[generation[fighterType]]; // weight is deterministic. uint256 weight = dna % 31 + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
If the dna
input field is random, then there is no problem, but it is not. Lets take a look at how it is calculated. So let's trace the calls. The _createFightersBase() function is called by the _createNewFighter() function
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L484
function _createNewFighter( address to, uint256 dna, ... ) 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; } ... }
Note: Part of method omitted for readibility
This function only calculated a newDna
if the customAttributes[0]=100
. The
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L191
function claimFighters( ... ) external { ... 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)] ); } }
Note: Part of method omitted for readibility
As we can see the dna is calculated as:
uint256(keccak256(abi.encode(msg.sender, fighters.length))),
So, this is deterministic. It is simply a hash dependent on the msg.sender
and the length of the fighters array(which is essentially the tokenId). The msg.sender
cannot be manipulated, but it is known for every transaction, and the fighers length increases by a certain amount on every call to claim fighers. This means that a user can precalculate the weights they will receive on their claimed fighers based on which transaction they submit them in. If the malicios user has those precalculated weights, he can essentially backrun another users transaction to get fighter NFT's with optimal weights.
There are actually many ways to game this. A malicios user can also frontrun other users transactions, deploying an NFT if it would decrease the victim's fighters weights.
Another option would be to use transactions from multiple multiple addresses. The malicios user would get the calculation of weights for all of his new addresses and then order the calls to claim Fighter NFTs in an certain way, so his accounts get multiple fighters with optimal weights only.
Manual Review
Use a real random number like one from a Chainlink VRF instead of uint256(keccak256(abi.encode(msg.sender, fighters.length)))
Math
#0 - c4-pre-sort
2024-02-24T01:53:25Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T01:53:32Z
raymondfam marked the issue as duplicate of #53
#2 - c4-judge
2024-03-06T03:52:19Z
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:45Z
HickupHH3 marked the issue as duplicate of #376