AI Arena - gesha17's results

In AI Arena you train an AI character to battle in a platform fighting game. Imagine a cross between Pokémon and Super Smash Bros, but the characters are AIs, and you can train them to learn almost any skill in preparation for battle.

General Information

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

AI Arena

Findings Distribution

Researcher Performance

Rank: 277/283

Findings: 1

Award: $0.04

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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.

Tools Used

Manual Review

Use a real random number like one from a Chainlink VRF instead of uint256(keccak256(abi.encode(msg.sender, fighters.length)))

Assessed type

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter