标准是链上世界的接口宪法。同一个 transfer 签名背后可能藏着返回值缺失、转账扣税、余额弹性变化三种截然不同的语义,而 ERC-4626 的舍入方向、ERC-1155 的接收钩子、ERC-4337 的验证阶段存储限制,又分别在组合层引入了新的攻击面。本文从接口语义出发,逐条拆解主流代币与金库标准在真实代码里的实现陷阱。
多数漏洞不是「写错了函数」,而是「误判了标准承诺的边界」:集成方默认 transfer 返回 bool,默认 balanceOf 单调不减,默认 approve 不会被抢跑,默认回调是安全的——每一次默认,都是一次被利用的机会。
目录
- 1. ERC-20 的实现陷阱
- 2. ERC-721 与元数据安全
- 3. ERC-1155 多代币与批量操作
- 4. ERC-4626:代币化金库与舍入攻击
- 5. ERC-4337 与账户抽象接口
- 6. 周边关键标准:165、1271、2981、2771、2612
- 7. 组合风险:再入、授权与钩子
- 8. 标准实现的 gas 对比
- 9. 审计清单与测试要点
- 10. 选型与扩展模式
- 延伸阅读
1. ERC-20 的实现陷阱
1.1 返回值与 approve 竞态
EIP-20 明确规定 transfer / transferFrom / approve 必须返回 bool,但 USDT 等早期合约并未返回值。若集成方直接写 require(token.transfer(to, amt)),在无返回值的合约上会因解码空数据而回滚。安全做法是用低层调用并兼容空返回:
function safeTransfer(address token, address to, uint256 amount) internal {
(bool ok, bytes memory data) = token.call(
abi.encodeWithSelector(0xa9059cbb, to, amount)
);
require(ok && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED");
}
approve 的经典问题是「先改额度再消费」的竞态:用户把额度从 100 改为 50,攻击者在两笔交易之间用掉 100。EIP-20 建议客户端先 approve(spender, 0) 再设新值,OpenZeppelin 则提供 increaseAllowance / decreaseAllowance 规避。另一条路线是 EIP-2612 的 permit,用链下签名一次性授权,省掉一次 approve 交易。
1.2 fee-on-transfer 与 rebasing 陷阱
「余额可变化」的代币是集成方最容易踩的坑。fee-on-transfer 代币(如部分反射型代币)实际到账少于传入金额,若合约记账用 amount 而非前后余额差,就会产生坏账:
uint256 before = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - before;
// 必须用 received 记账,而不是 amount
rebasing 代币(如 stETH 早期形态)则让 balanceOf 随质押收益自动变动,任何把「余额 == 记账」当作不变量的合约都会在 rebase 后失衡。集成前必须确认代币是否属于 fee-on-transfer、rebasing、或可暂停/可黑名单的「非标准三件套」。
2. ERC-721 与元数据安全
2.1 safeTransferFrom 的接收回调
safeTransferFrom 与 transferFrom 的唯一区别,是前者会对合约接收方调用 onERC721Received,并校验返回值等于 0x150b7a02:
function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory data) private {
if (to.code.length > 0) {
try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data)
returns (bytes4 retval) {
require(retval == IERC721Receiver.onERC721Received.selector, "UNSAFE_RECIPIENT");
} catch {
revert("UNSAFE_RECIPIENT");
}
}
}
这个回调是外部调用,意味着状态更新必须在回调之前完成(checks-effects-interactions)。若在回调之后才写 ownerOf[tokenId] = to,接收方就能在回调里再次调用 safeTransferFrom 实现重入,把同一个 NFT 转移两次。OpenZeppelin 的 _safeTransfer 正是先 _transfer 更新所有权、再执行回调。
2.2 tokenURI 与元数据可变性
元数据的三种存储路线各有权衡:ipfs:// 内容寻址但依赖网关、ar:// 永久存储但写入昂贵、data:application/json;base64,... 全链上但 gas 极高。ERC-721 允许 tokenURI 在 baseURI 未设置时返回空字符串,但市场方通常要求可解析。
可变元数据(reveal 机制)会带来稀有度泄露与 front-running:若 tokenURI 在揭示前就暴露特征,用户可在快照前挑选稀有 token。稳妥做法是揭示前统一返回占位 URI,并冻结 baseURI 后放弃修改权限。
3. ERC-1155 多代币与批量操作
3.1 批量转账与 gas 节省
ERC-1155 用 id 区分同质与非同质资产,safeBatchTransferFrom 一次调用可转移多个 id:
function safeBatchTransferFrom(
address from, address to,
uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data
) public {
require(from == msg.sender || isApprovedForAll(from, msg.sender), "NOT_AUTHORIZED");
require(ids.length == amounts.length, "LENGTH_MISMATCH");
_beforeTokenTransfer(msg.sender, from, to, ids, amounts, data);
for (uint256 i = 0; i < ids.length; ++i) {
_balances[ids[i]][from] -= amounts[i];
_balances[ids[i]][to] += amounts[i];
}
emit TransferBatch(msg.sender, from, to, ids, amounts);
_doSafeBatchTransferAcceptanceCheck(msg.sender, from, to, ids, amounts, data);
}
批量省 gas 的来源是「共享交易基础成本」:21000 的交易基础费、21k 左右的外部调用开销、以及事件与循环的固定部分被多个 id 摊薄。转移 10 个不同 id 时,逐个 safeTransferFrom 约需 10 次外部调用与 10 次事件,批量版本可省下约 30%~50% 的 gas;id 越多、节省比例越接近摊销上限。
3.2 接收钩子与重入防护
ERC-1155 的接收方若为合约,必须实现 onERC1155Received / onERC1155BatchReceived 并返回 0xf23a6e61 / 0xbc197c81。与 ERC-721 同理,钩子在状态更新之后调用;但 1155 的批量循环里如果中途做外部调用,就会出现「部分状态已更新、部分未更新」的中间态,被重入者利用。OpenZeppelin 的 ERC1155Supply 与 _update 钩子配合 nonReentrant 修饰器是最小防护。
另一个易忽略点是数组长度校验:ids.length != amounts.length 若不检查,会因越界访问 amounts[i] 直接 revert(新版本 Solidity 有边界检查),但旧编译器或 unchecked 块里则可能读到脏数据。
4. ERC-4626:代币化金库与舍入攻击
4.1 convertToShares 与舍入方向
ERC-4626 把收益金库标准化为「用资产换份额」:deposit 存入资产换份额,redeem 烧份额取资产。核心是 convertToShares / convertToAssets 两个换算函数,以及它们的预览版本 previewDeposit / previewRedeem。
舍入方向必须始终对金库有利:用户存入时份额向下取整(mulDiv(assets, totalSupply, totalAssets)),用户赎回时资产向下取整。方向写反,攻击者就能反复存取把金库的零头「磨」出来。
function convertToShares(uint256 assets) public view returns (uint256) {
uint256 supply = totalSupply();
// 向下取整:金库不亏
return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), Math.Rounding.Down);
}
function convertToAssets(uint256 shares) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? shares : shares.mulDiv(totalAssets(), supply, Math.Rounding.Down);
}
4.2 通胀攻击与虚拟份额
ERC-4626 最著名的攻击是「首存通胀攻击」(inflation / donation attack)。攻击者先存入 1 wei 得到 1 份额,然后直接向金库转账(捐赠)大量资产抬高 totalAssets。此时后进用户存入 X 资产,因 shares = X * 1 / (X + donation) 向下取整为 0,攻击者独吞全部资产。
三种缓解手段:一是 OpenZeppelin 的 decimals offset(虚拟份额),在换算中人为放大 totalSupply 与 totalAssets 的基准;二是初始化时铸造最小流动性份额并永久锁仓;三是要求首次存款金额下限。虚拟份额方案最通用:
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view returns (uint256) {
return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
}
+1 让 totalAssets 永不为零,10 ** offset 让攻击者需要付出指数级成本才能把后来者的份额压到 0。
5. ERC-4337 与账户抽象接口
5.1 UserOperation 与 EntryPoint 流程
EIP-4337 不修改共识层,而是引入 UserOperation 结构体与单例 EntryPoint 合约。用户把操作签名后交给 Bundler,Bundler 打包成交易调用 EntryPoint.handleOps:
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
bytes32 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
流程分四步:validateUserOp 验证签名与预付款、可选的 Paymaster 验证、执行 callData、最后结算 gas。Paymaster 可代付 gas 实现「无感 Web3」,但必须防止其被滥用——validatePaymasterUserOp 中要校验用户身份与额度。
5.2 validateUserOp 与签名验证
账户需实现 IAccount.validateUserOp,校验失败时返回 SIG_VALIDATION_FAILED(1)而非 revert,避免 Bundler 被恶意操作 DoS:
function validateUserOp(
PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds
) external returns (uint256 validationData) {
require(msg.sender == entryPoint, "only entryPoint");
if (missingAccountFunds > 0) {
(bool ok, ) = msg.sender.call{value: missingAccountFunds}("");
ok; // 允许失败,EntryPoint 会处理
}
return _validateSignature(userOp, userOpHash);
}
验证阶段有一条硬性规则:不得访问与操作自身相关的存储槽之外的状态(EIP-7562 的 storage access rules),否则同一账户的多笔操作无法在 bundler 模拟中并行验证。签名验证兼容 EOA 的 ecrecover、ERC-1271 的 isValidSignature(智能合约钱包),以及聚合签名方案。
6. 周边关键标准:165、1271、2981、2771、2612
6.1 ERC-165 与 ERC-1271
ERC-165 是接口探测标准:合约实现 supportsInterface(bytes4) 返回是否支持某接口,0x01ffc9a7 是 ERC-165 自身的标识。ERC-721 与 ERC-1155 都要求实现 165,市场方据此判断能否调用 safeTransferFrom。
ERC-1271 让合约也能「签名」:isValidSignature(bytes32 hash, bytes signature) 校验通过时返回魔数 0x1626ba7e。这是智能合约钱包(含 ERC-4337 账户)被 dApp 识别的关键——dApp 不能只靠 ecrecover,必须回退到 1271:
bytes4 constant MAGICVALUE = 0x1626ba7e;
function isValidSignature(bytes32 hash, bytes memory sig) external view returns (bytes4) {
require(_verify(hash, sig), "invalid signature");
return MAGICVALUE;
}
6.2 ERC-2981、2771 与 2612
ERC-2981 定义版税查询:royaltyInfo(uint256 tokenId, uint256 salePrice) 返回收款地址与版税金额。注意它只提供「查询」,并不强制市场执行,因此版税可被市场方绕过,实践中常配合链下白名单或 ERC-721 的 transfer 钩子。
ERC-2771 解决「元交易」的 msg.sender 伪造问题:受信任的 forwarder 在 calldata 尾部追加真实发送者地址,合约通过 _msgSender() 读取。风险在于若合约接受任意 forwarder 追加的数据,攻击者可自行拼接地址冒充任何人,因此必须硬编码可信 forwarder。
EIP-2612 的 permit 用 EIP-712 结构化签名做授权,签名字段含 owner、spender、value、nonce、deadline 与 domainSeparator。domainSeparator 绑定 chainId 与合约地址,防止签名跨链或跨合约重放;nonce 防重放,deadline 限制时效。
7. 组合风险:再入、授权与钩子
7.1 再入与只读重入
外部回调是重入的温床。2020 年 imBTC 事件中,Uniswap V1 的 ETH-imBTC 池被 ERC-777 的 tokensToSend 钩子重入,攻击者在 balanceOf 更新前反复触发转账,套走约 30 万美元等值资产。教训是:不要假设被集成的代币没有钩子,ERC-777、ERC-1155、ERC-721、ERC-4626 的回调都可能成为入口。
只读重入(read-only reentrancy)更隐蔽:攻击者在状态不一致的中间态调用只读函数(如 getVirtualPrice、convertToAssets),把错误价格喂给依赖它的其他协议。防护手段是加 nonReentrant 到只读函数,或使用重入锁状态变量让外部协议可查询。
7.2 授权、钩子与闪电贷回调
无限授权(approve(type(uint256).max))让被盗私钥的损失从「当前余额」放大到「全部余额」。集成方应最小化授权额度并定期 revoke。permit 的钓鱼则利用用户对签名的低警惕:一个看似无害的签名请求可能包含 permit 或 setApprovalForAll,签完即被清空资产。
ERC-3156 标准化了闪电贷回调 onFlashLoan,攻击者可在回调中利用协议尚未更新的状态。ERC-6093 则统一了代币自定义错误(如 ERC20InsufficientAllowance、ERC721IncorrectOwner),降低集成方的错误解码成本,但尚未被所有旧合约采纳。
8. 标准实现的 gas 对比
8.1 存储与转账成本
EVM 的 gas 结构决定了优化方向:首次写入一个非零存储槽(SSTORE,0→非 0)花费 20000 gas,改写已有非零值(非 0→非 0)为 5000 gas,清零(非 0→0)退还部分 gas。ERC-20 的 transfer 在接收方余额槽首次写入时约 51k gas,热路径(槽已存在)约 29k gas;approve 因只改一个槽,约 46k / 24k。
ERC-1155 批量转移的边际成本远低于逐个 ERC-721 转移:每个新增 id 只需一次 SSTORE 加循环开销,没有额外的交易基础费与外部调用开销。存储打包(把多个 uint128 塞进一个 256 位槽)能把多次 SSTORE 合并为一次。
8.2 成本对照
| 操作 | 大致 gas | 主要成本来源 |
|---|---|---|
| ERC-20 transfer 首次 | 51000 | 接收方余额槽 SSTORE |
| ERC-20 transfer 热路径 | 29000 | 两个余额槽改写 |
| ERC-20 approve | 46000 | 授权槽 SSTORE |
| ERC-721 transferFrom | 85000 | 所有权与余额槽 |
| ERC-1155 单次转移 | 52000 | 一个余额槽对 |
| ERC-1155 批量 10 个 id | 约 150000 | 摊薄后的循环 |
| ERC-4626 deposit | 90000 | 份额与资产记账 |
减少 calldata、使用自定义错误替代字符串 revert(省下 require 的字符串存储与拷贝)、以及把不变量校验放进 unchecked 块,都是常见优化。
9. 审计清单与测试要点
9.1 常见漏洞清单
审计时按标准逐条过:ERC-20 检查返回值兼容、approve 竞态、fee-on-transfer 记账;ERC-721 检查回调重入、tokenURI 权限、ownerOf 与 balanceOf 一致性;ERC-1155 检查数组长度、批量钩子、uri 替换;ERC-4626 检查舍入方向、首存通胀、totalAssets 是否可被捐赠操纵;ERC-4337 检查验证阶段存储访问、missingAccountFunds 处理、Paymaster 额度。
跨标准的不变量同样重要:totalSupply 与所有余额之和是否始终相等、授权额度是否单调、convertToShares(convertToAssets(x)) <= x 是否成立。
9.2 Foundry 测试要点
单元测试覆盖正常路径,模糊测试(fuzz)覆盖边界值,不变量测试(invariant)覆盖跨函数组合:
function testFuzz_DepositRedeemNeverProfits(uint96 assets) public {
assets = uint96(bound(assets, 1, type(uint96).max));
uint256 shares = vault.deposit(assets, address(this));
uint256 back = vault.redeem(shares, address(this), address(this));
assertLe(back, assets, "round-trip must not profit");
}
function invariant_TotalSupplyMatchesBalances() public {
assertEq(vault.totalSupply(), sumOfBalances());
}
分叉测试(fork test)在真实主网状态下验证与 Uniswap、Aave 等既有协议的组合行为;配合 Echidna 或 Medusa 做基于属性的状态探索,能覆盖手工用例想不到的调用序列。
10. 选型与扩展模式
10.1 标准选型决策
同质化代币选 ERC-20,需要元交易加 ERC-2771、需要签名授权加 EIP-2612;NFT 若每件独立且需独立元数据选 ERC-721,若存在同类多份(游戏道具、票券)选 ERC-1155;收益金库选 ERC-4626,但务必处理首存通胀与舍入;智能钱包选 ERC-4337,配合 ERC-1271 让 dApp 识别签名。
选型时还要看生态兼容性:市场方是否支持 2981 版税、钱包是否支持 4337、桥是否识别 2612 的 permit。标准之外的自定义扩展(如 ERC-7579 模块化账户、ERC-6900 模块化智能账户)仍在演进,采用前需评估审计成熟度。
10.2 扩展与组合模式
主流扩展模式有三类。其一是钩子(hooks):在 _beforeTokenTransfer / _update 中插入费用、白名单、暂停逻辑,但要警惕钩子本身引入重入。其二是代理升级:用 ERC-1967 存储槽配合 UUPS 或 Transparent 代理,升级时注意存储布局兼容与初始化函数只调用一次。其三是组合封装:把 ERC-4626 金库代币再包装成 ERC-20 抵押品接入借贷协议,此时必须复核两层换算的舍入方向与清算阈值。
组合越深,不变量越脆弱。每引入一层封装,都应重新验证「份额—资产—债务」三者的换算单调性与极端行情下的清算安全性。
10.3 速查表与一句话记忆
| 标准 | 核心接口 | 最易踩的坑 |
|---|---|---|
| ERC-20 | transfer / approve / allowance | 无返回值、approve 竞态、fee-on-transfer |
| ERC-721 | safeTransferFrom / tokenURI | 接收回调重入、元数据可变 |
| ERC-1155 | safeBatchTransferFrom / uri | 数组长度、批量钩子重入 |
| ERC-4626 | deposit / redeem / convertToShares | 舍入方向、首存通胀攻击 |
| ERC-4337 | validateUserOp / handleOps | 验证阶段存储限制、Paymaster 滥用 |
| ERC-165 | supportsInterface | 接口 id 计算错误 |
| ERC-1271 | isValidSignature | 魔数 0x1626ba7e 校验 |
| ERC-2981 | royaltyInfo | 仅查询、市场可不执行 |
| ERC-2771 | _msgSender | 可信 forwarder 必须硬编码 |
| EIP-2612 | permit | domainSeparator 与 nonce 重放 |
一句话记忆:读标准先读「隐含假设」,写实现先写「舍入方向」,做集成先问「余额会不会变、回调会不会重入」。
延伸阅读
- ERC-721 与 ERC-1155 的元数据存储与市场实践
- 代币经济模型与供应机制的完整设计方法
- ERC-4337 账户抽象与 Paymaster 的工程落地
- 主流 DeFi 协议如何组合 ERC-20 与 ERC-4626
- 智能合约常见攻击面与防御总览
- Web3 区块链专题 — 区块链 Web3 专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。