深入剖析 ERC721

  • BY_DLIFE
  • 更新于 2024-04-25 10:13
  • 阅读 133

ERC721和ERC20一样,都是一个代币标准,ERC721代币是不可细分的,每一个代币都是唯一的。每一个ERC721代币都有自己的标识符,通常用于表示独立的资产,例如数字艺术品,游戏中的虚拟角色或房地产。

1. ERC721简介

​ 该协议允许在智能合约中实施NFT标准API。该标准提供了跟踪和传输NFT的基本功能。

​ 首先了解什么是非同质化代币,NTF 的全称是 Non-Fungible Token,即非同质化代币。非同质化的意思是某物不可与另一物互换,它是独一无二的。例如,我家的钥匙和你家的钥匙,看起来都是钥匙,但是不能交换的,因为我家的钥匙打不开你家的门,反之你的钥匙也是。

​ ERC721和ERC20一样,都是一个代币标准,ERC721代币是不可细分的,每一个代币都是唯一的。每一个ERC721代币都有自己的标识符,通常用于表示独立的资产,例如数字艺术品,游戏中的虚拟角色或房地产。

2. 解析代码

源码来自:链接

2.1 Core部分

2.1.1 IERC721.sol

接口内容:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC721 is IERC165 {
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
    function balanceOf(address owner) external view returns (uint256 balance);
    function ownerOf(uint256 tokenId) external view returns (address owner);
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
    function safeTransferFrom(address from, address to, uint256 tokenId) external;
    function transferFrom(address from, address to, uint256 tokenId) external;
    function approve(address to, uint256 tokenId) external;
    function setApprovalForAll(address operator, bool approved) external;
    function getApproved(uint256 tokenId) external view returns (address operator);
    function isApprovedForAll(address owner, address operator) external view returns (bool);
}

分析各接口的功能:

  • balanceOf(): 返回由_owner 持有的NFTs的数量。
  • ownerOf(): 返回tokenId代币持有者的地址。
  • approve(): 授予地址_to具有_tokenId的控制权,方法成功后需触发Approval 事件。
  • setApprovalForAll(): 授予地址_operator具有所有NFTs的控制权,成功后需触发ApprovalForAll事件。
  • getApproved()、isApprovedForAll(): 用来查询授权。
  • transferFrom():用来转移NFT,接收者不能为零地址,且msg.sender须要被ownerOf(tokenId)授权,执行完transferFrom之后,msg.sender的权限便会被移除。
  • safeTransferFrom():用来转移NFT,功能和 transferFrom一样,但是一些要求,如果receiver是一个合约,那么该合约需要实现ERC721TokenReceiver::onERC721Received(address _from, uint256 _tokenId, bytes data) external returns(bytes4) 函数,同时返回值为:bytes4(keccak256("onERC721Received(address,uint256,bytes)")),因为在调用safeTransferFrom函数时,会对 receiver 进行检测,如果recever是EOA也是可以的。

transferFrom和safeTransferFrom的区别,后面再详细说明。

2.1.2 IERC721Metadata

该接口和 IERC20Metadata 类似,都是可选择的。该接口是用来存储额外数据的,比如代币的name,symbol以及URI。 这个URI可以是图片链接等。

2.1.3 IERC721Enumerable
interface ERC721Enumerable  {
    function totalSupply() external view returns (uint256);
    function tokenByIndex(uint256 _index) external view returns (uint256);
    function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256);
}

该接口提供了三个函数,旨在提高NTF的可读性和可访问性,同时也进一步完善了NTF交易市场的功能,这些功能可用于查询NTF市场发行了多少代币,某人的第index号代币的tokenId是多少,还可以根据发行的index找到对应的tokenId。

  • totalSupply() :返回发行Token的总量。
  • tokenByIndex():根据索引返回对应的tokenId。
  • tokenOfOwnerByIndex():根据索引查询所有者Token列表中对应索引的tokenId。
2.1.4 ERC721

​ 这个合约是ERC-721协议中最重要也是最核心的部分,需要重点解读。

_update()函数是贯穿了,铸币,销币,以及转币,所以首先要重点看懂这个函数,代码如下:

    function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) {
        address from = _ownerOf(tokenId);

        // Perform (optional) operator check
        if (auth != address(0)) {
            _checkAuthorized(from, auth, tokenId);
        }

        // Execute the update
        if (from != address(0)) {
            // Clear approval. No need to re-authorize or emit the Approval event
            _approve(address(0), tokenId, address(0), false);

            unchecked {
                _balances[from] -= 1;
            }
        }

        if (to != address(0)) {
            unchecked {
                _balances[to] += 1;
            }
        }

        _owners[tokenId] = to;

        emit Transfer(from, to, tokenId);

        return from;
    }

其中参数是需要明确的,to是待转移的地址,可以理解为 接收者receiver;tokenId是待操作的token;auth是代币的所有者或是被授权者。

_update()是如何实现mint、burn和transferFrom的,函数的执行逻辑:

首先第一步都是获取待操作的tokenId的所有者。

  1. mint的实现,调用方式为:_update(to, tokenId, address(0))

    函数执行,from的值为零地址,程序直接执行到第三个if语句,to的_balance加一,该tokenId的所有者为to

  2. transferFrom的实现,safeTransferFrom同样是调用transferFrom,调用的方式为:address previousOwner = _update(to, tokenId, _msgSender()),首先可以知道to不为零地址,_msgSender()也不为零,至少目前零地址的私钥还没人知道。函数执行,from的值为代币所有者,进入第一个if语句,如果auth是代币所有者或是被授权者则通过检测,进入第二个if语句,移除原tokenId所有者对其他人的授权,代币所有者的token数减一,最后进入第三个if语句,接收者的token数加一,更新mapping。

  3. burn的实现,调用方法:_update(address(0), tokenId, address(0)),进入第二个if语句,移除原tokenId所有者对其他人的授权,代币所有者的token数减一,将代币所有者设置为零地址。

  4. transfer的实现,调用方式为:_update(to, tokenId, address(0)),首先to不为零,进入第二个if语句,移除原tokenId所有者对其他人的授权,代币所有者的token数减一,最后进入第三个if语句,接收者的token数加一,更新mapping。

合约中还有几个映射:

  • _owners:保存某个tokenId的所有者。
  • _balances:保存某个用户拥有的Token数量。
  • _tokenApprovals:保存某个token的授权账户。
  • _operatorApprovals:保存某账户是否将所有的token全部授权给某账户。

_checkAuthorized(address owner, address spender, uint256 tokenId)函数负责检测对于指定tokenId,owner和spender的关系,只有当spender不为零地址,且(owner==spender【代币所有者亲自操作】或owner将所有代币授权给了spender,或owner将tokenId这枚token授权给了spender)才可以通过。

2.1.5 ERC721Enumerable

该合同继承了ERC721,但是重写了 ERC721中的 _update()函数,这一操作使得ERC721中的 铸币、销币、转账都繁发生了变化。

题外话

这是solidity继承的基本知识,验证如下:

contract Person {

    function eat() public virtual {
        console.log("Person");
    }

    function call() public {
        eat();
    }
}

contract Man is Person {

    function eat() public override {
        console.log("Man");
    }
}

复现逻辑:部署Man合约,并调用call函数看看输出结果是什么。

image.png

就是Man,虽然call函数声明在父类中,但是父类会调用子类重写的eat函数,这可以理解为【就近原则】。

看到该合约_update的实现逻辑:

    function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) {
        address previousOwner = super._update(to, tokenId, auth);

        if (previousOwner == address(0)) {
            _addTokenToAllTokensEnumeration(tokenId);
        } else if (previousOwner != to) {
            _removeTokenFromOwnerEnumeration(previousOwner, tokenId);
        }
        if (to == address(0)) {
            _removeTokenFromAllTokensEnumeration(tokenId);
        } else if (previousOwner != to) {
            _addTokenToOwnerEnumeration(to, tokenId);
        }

        return previousOwner;
    }

ta调用了父类的_update函数,说明代币的转移逻辑还是还ERC721的一样,只是根据 tokenId的所有者以及接收者进行了一些对数组的添加和删除操作。利用数组来记录代币的发行量。

2.1.6 IERC721Receiver

​ 该合约只有一个函数,则是 onERC721Received(),这是当接收者 to 为合约地址时,在调用ERC721中的三个safe函数(_safeMint, _safeTransfer, safeTransferFrom)时的回调函数。实现这个接口的作用是什么呢?为什么当to为EOA时则可以不需要实现这个方法呢?我的理解是,如果有人向我tx.origin转入 ERC721 token的时候,我可以去调用transferFrom或者safeTransferFrom函数将手中的代币转出去,但是如果转入的是合约地址,那么如果你实现没有提供调用 ransferFrom或者safeTransferFrom函数 的功能,那么这个token将永远的留在了这个合约中,无法转移出去。而明确要求合约接收者实现这个函数,这是为了让接收者有意识地去处理接收到的token。当然,要是实现了接口而函数体只是按照要求返回固定值,那么还是会被锁死这个token,因为人家提醒你了,做不做就是自己的事了。

2.2 Extensions部分

2.2.1 ERC721Pausable

​ 该合约提供了一个紧急暂停功能,比如在合约被黑客攻击时,合约通过可以修饰器锁住所有的转账功能,这是一种防护措施。

2.2.2 ERC721Burnable

​ 该合约对外提供了一个销币的功能。

2.2.3 ERC721URIStorage

​ 该合约提供了一个设置 tokenURI的函数:_setTokenURI(uint256 tokenId, string memory _tokenURI),使其tokenId在mint的时候,就可以与某个URI绑定起来。

2.2.4 ERC721Wrapper

​ 该合约提供了一个包装功能,比如 用户将手中的 _underlying代币存入到该合约中,那么该合约会为用户铸造一个相同tokenId的代币,同理取出的话,会将用户从该合约获取的tokenId全部销毁,销毁的方式为:_update(address(0), tokenId, _msgSender())(突然感觉,这个update函数真的太牛了,这个兼容性真的,佩服的很。)合约还实现了onERC721Received函数,实现逻辑如下:

    function onERC721Received(address, address from, uint256 tokenId, bytes memory) public virtual returns (bytes4) {
        if (address(underlying()) != _msgSender()) {
            revert ERC721UnsupportedToken(_msgSender());
        }
        _safeMint(from, tokenId);
        return IERC721Receiver.onERC721Received.selector;
    }

这个函数是为了让其他用户通过 _underlying 的 safeTransferFrom 向这个合约转移代币的时候,同时为用户包装代币。正是因为接口如此实现,所以在 depositFor函数中使用的转账函数为:transferFrom()

不过在 depositFor() 和 withdrawTo()函数中都有重入的风险,depositFor::_safeMint(),withdrawTo::safeTransferFrom()。

2.3 Utilies部分

2.3.1 ERC721Utils.sol

​ 提供了一个校验功能,检验合约接收者是否实现了 IERC721Receiver接口,实现checkOnERC721Received函数的时候,是否按要求返回了 IERC721Receiver.onERC721Received.selector

3. 安全隐患

3.1 内部函数_transfer的安全隐患

在ERC721合约中,有一个内部函数 _transfer(),这函数有点奇怪,它可以实现任意转移某人的token,前提是只要知道tokenId以及ta的owner。可以看到他的实现代码:

    function _transfer(address from, address to, uint256 tokenId) internal {
        if (to == address(0)) {
            revert ERC721InvalidReceiver(address(0));
        }
        address previousOwner = _update(to, tokenId, address(0));
        if (previousOwner == address(0)) {
            revert ERC721NonexistentToken(tokenId);
        } else if (previousOwner != from) {
            revert ERC721IncorrectOwner(from, tokenId, previousOwner);
        }
    }

因为ta对_update的调用方式为:_update(to, tokenId, address(0)),即auth参数是零地址,则可以跳过第一个if语句的判断,换句话说就是不要验证身份。写了一个简单的示例代码进行复现:

pragma solidity ^0.8.20;

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol";

contract Test is ERC721("ERC721", "erc721") {

    uint256 public tokenId;

    function mint() public {
        _mint(msg.sender, tokenId);
        tokenId++;
    }

    function transfer(address from, address to, uint256 tokenId) public {
        _transfer(from, to, tokenId);
    }
}

攻击逻辑,假如A:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266账户拥有tokenId为0,1,2,3四个代币。并且我:0x70997970C51812dc3A010C7d01b50e0d17dc79C8知道A的地址以及tokenId,那么我可以调用 这样调用transfer(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266,0x70997970C51812dc3A010C7d01b50e0d17dc79C8,0),则可以完成攻击,此时查看 ownerOf(0)的所有者是不是我

image.png

结果很明显,我拥有了0号代币。同理,内部的_safeTransfer()也是同样的道理,只不过需要接收者实现指定的接口,但是这都无关紧要,钱还是可以照样拿走的。

如果你的合约继承了ERC721合约,并且调用了它的内部函数 _transfer(),则一定要给这个外部函数添加访问控制。加了访问控制之后,这个函数可以看作是管理员权限级别的,可以强制转移某人的代币。

3.2 transferFrom VS safeTransferFrom

​ 字面意思safeTransferFrom多了一个 safe前缀,看起来要比 transferFrom函数要更安全,在 ERC-721说明文档中也是这样说的,但是真的是这样吗?emmm,只能说各有各的安全吧。safeTransferFrom的安全之后在于,合约接收者必须要实现指定的接收函数,旨在让用户正确处理token(至少让token还能再次转移嘛),而transferFrom没有这个功能,要是直接转入给合约接收者,且合约接收者没有事先做好处理这笔token的操作,那么很遗憾,这笔token锁死了。。。这样想想safeTransferFrom确实要安全一点。但是执行safeTransferFrom的时候,会执行合约接收者的onERC721Received函数,及其类似 fallback,那么就会存在重入的风险,而transferFrom就没有。总而言之,各有各的好,各有各的坏。

3.3 其他的重入风险

在ERC721的内部函数中,_safeMint()_safeTransfer()都是具有重入的风险的。所以要是继承ERC721合约的项目,如果将这些内部函数暴露出来的话,这需要特别小心这几个点。

码字不易,点个赞再走呗~😜

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

0 条评论

请先 登录 后评论
BY_DLIFE
BY_DLIFE
0x994d...4240
立志成为一名智能合约安全审计师。文章都是我的个人理解,如果有不对的地方欢迎在评论区指出来。