1、NFT合约基础回顾:ERC-721与ERC-1155标准核心差异、元数据存储方案(链上/链下)、合约所有权与权限模型
好,咱们直接进入正题。做NFT交易合约审计,第一步得把地基打牢。说白了,你得先搞清楚你审计的对象到底是个什么玩意儿。ERC-721和ERC-1155,这两个标准是绕不开的。我见过太多审计报告,漏洞根源就是对这两个标准理解不透彻。
1.1 ERC-721与ERC-1155:一对“亲兄弟”
先聊聊ERC-721。这是最经典的NFT标准,一个Token ID对应一个唯一的资产。你想想看,CryptoPunks、BAYC,都是这个路子。每个NFT都是独一无二的,不能拆分。
ERC-1155就不一样了。它是个多代币标准。一个合约里可以同时管理同质化代币(比如游戏金币)和非同质化代币(比如武器皮肤)。我去年审计过一个GameFi项目,他们用ERC-1155管理所有游戏资产,一个合约搞定,省了不少Gas费。
核心差异在哪?我列个表,你一看就明白:
| 特性 | ERC-721 | ERC-1155 |
|---|---|---|
| 代币唯一性 | 每个Token ID唯一 | Token ID可对应多个副本 |
| 批量操作 | 不支持原生批量 | 原生支持批量铸造/转账 |
| Gas效率 | 单次操作Gas较高 | 批量操作Gas大幅降低 |
| 安全钩子 | _beforeTokenTransfer | _beforeTokenTransfer + onERC1155Received |
| 典型场景 | 数字艺术品、收藏品 | 游戏道具、混合资产 |
审计要点: 检查合约是否混用了两个标准。比如,一个ERC-721合约里出现了ERC-1155的批量转账逻辑,这通常是个危险信号。我见过一个项目,在ERC-721的safeTransferFrom里偷偷调用了ERC-1155的批量接口,结果导致资产被重复铸造。
1.2 元数据存储方案:链上还是链下?
元数据,就是描述NFT的那堆信息——图片URL、名称、属性。存储方案直接决定了NFT的“生死”。
链下存储是最常见的。把元数据JSON文件放在IPFS或中心化服务器上,合约里只存一个URI。优点是便宜,缺点是——嗯,你懂的,服务器一挂,NFT就变成了一张“白纸”。
我建议你审计时重点关注tokenURI()函数的实现。很多项目直接返回一个固定的Base URI,然后拼接Token ID。这有个坑:如果项目方修改了Base URI,所有NFT的元数据都会变。说白了,项目方可以“换皮”。
// 不安全的实现
function tokenURI(uint256 tokenId) public view override returns (string memory) {
return string(abi.encodePacked(baseURI, tokenId.toString()));
}
// 更安全的做法:使用内容寻址(如IPFS哈希)
function tokenURI(uint256 tokenId) public view override returns (string memory) {
require(_exists(tokenId), "URI query for nonexistent token");
return string(abi.encodePacked("ipfs://", _tokenURIs[tokenId]));
}
链上存储就硬核多了。元数据直接写在合约里,或者用SVG/Base64编码。优点是去中心化,谁也改不了。缺点嘛,部署成本高,而且元数据一旦上链就改不了了——这对某些需要动态更新的项目来说是个麻烦。
我的经验: 审计时一定要检查元数据是否可被篡改。我曾经审计过一个项目,他们的setBaseURI()函数没有加权限控制,结果被攻击者改成了钓鱼网站的链接。嗯,这种低级错误其实挺常见的。
1.3 合约所有权与权限模型
所有权模型,说白了就是“谁说了算”。OpenZeppelin的Ownable是最基础的方案——一个owner地址拥有所有管理权限。但说实话,这在NFT项目里风险很大。单点故障,你懂的。
我更喜欢AccessControl这种基于角色的方案。你可以定义MINTER_ROLE、PAUSER_ROLE、BURNER_ROLE等角色,把权限分散给不同地址。这样即使某个角色私钥泄露,损失也是可控的。
避坑指南: 我曾经审计过一个项目,他们的onlyOwner修饰符用在了mint()函数上。结果项目方为了省Gas,把owner设成了一个多签钱包。但多签钱包的调用需要时间,导致用户铸造体验极差。更糟的是,owner权限可以随时调用withdraw()把合约里的ETH转走——这其实是个中心化风险。
权限模型还有一个容易被忽视的点:代理合约的权限。如果你用的是UUPS或透明代理,逻辑合约的initialize()函数必须加initializer修饰符。否则,攻击者可以直接调用initialize()把自己设为owner。
// 正确的初始化方式
function initialize(address _owner) external initializer {
__Ownable_init();
transferOwnership(_owner);
}
// 错误的做法——没有initializer保护
function initialize(address _owner) external {
owner = _owner; // 任何人都可以调用!
}
嗯,这里要注意:__Ownable_init()和transferOwnership()的顺序不能乱。先初始化Ownable,再转移所有权。我见过有人写反了,结果owner变成了零地址,整个合约直接废了。
最后提一嘴权限升级。很多项目用Ownable的transferOwnership()来更换owner。但如果你用的是多签钱包,建议加上时间锁。这样即使owner私钥被盗,你还有时间反应。我有个朋友的项目就是吃了这个亏——owner被钓鱼,攻击者直接调用了withdraw(),几百万美元瞬间没了。
总结一下: 审计NFT合约,先看标准选对了没,再看元数据能不能被篡改,最后检查权限模型有没有单点风险。这三个点踩住了,至少能防住80%的常见漏洞。