找回密码
立即注册
搜索
发回帖 发新帖

6532

积分

0

好友

820

主题
发表于 9 小时前 | 查看: 3| 回复: 0

ERC-20 支付即使在发送合约拥有足够 token 时也可能失败;也可能看起来成功,却没有真正移动 token;第三种变体则更隐蔽——token 成功移动了,但协议记录了错误的金额。

这三种情况并非同一个 bug 的三个名称,而是三项独立的证明义务:

  1. 权限:合约选择的转移操作是否与谁拥有 token、谁在花费 token 相匹配?
  2. 调用成功:token 调用是否按照 token 的实际返回行为完成?
  3. 记账:余额变化是否与协议记入、铸造、结算或释放的金额匹配?

区分这三层至关重要,因为针对一层的修复并不能证明另外两层。将 transferFrom 替换为 transfer 可以纠正权限不匹配,但未检查的返回值问题依然存在;添加 SafeERC20 能规范化返回处理,却保留了错误的 owner-spender 模型;即使两者都检查了,如果协议信任请求的金额而非实际收到的余额增量,fee-on-transfer 存款仍可能被超额记入。

本文围绕这三个边界构建一个范围较窄的比较模型。锚点是 Bastion 案例研究 中描述的 Bastion Wallet 订阅公开发现,随后将 ERC-20 规范、OpenZeppelin 实现指南以及 PoolTogether、ENS、Tigris 的公开审计记录纳入对比。[1][2][4][5][6]

方法与主张边界

如果某条公开记录描述了以下三个边界之一处的不匹配,该记录就会进入比较:

  • token 托管与所选转移原语之间的不匹配;
  • EVM 调用完成与 ERC-20 成功语义之间的不匹配;
  • 请求的转移金额与协议产生的记账之间的不匹配。

这是一份以机制为导向的目的性样本,并非流行率研究。这些记录使用不同的评审系统、token 假设和协议上下文,样本不能推导出频率、行业损失或相对严重性主张。

全文使用四个标签:

  • 来源事实:链接来源中陈述的行为、标签、状态或建议。
  • Zealynx 分类:我们根据哪项证明义务失败对记录进行分组。
  • 推论:跨记录推导出的可复用工程结论。
  • 局限:公开材料未确立的内容(生产环境暴露、修复部署、利用或资金影响等)。

模型:证明权限、成功和记账

只有当以下三个陈述全部成立时,协议级 token 移动才是正确的:

正确的转移
    = 已授权的移动
    AND 已确认的 token 调用成功
    AND 协议记账与实际移动相匹配

这些层是顺序的,但不可互换。

证明 1:权限与托管匹配

ERC-20 将 transfer(to, value) 定义为从消息调用者的余额移动 token;将 transferFrom(from, to, value) 定义为委托提款工作流,并明确要求 from 账户应已刻意授权调用者。[1]

由此得到一条简单的决策规则:

如果 address(this) 拥有这些 token:
    token.transfer(recipient, amount)

如果另一个地址拥有这些 token,且该合约是已批准的 spender:
    token.transferFrom(owner, recipient, amount)

OpenZeppelin 的实现明确区分了这一点:transfer 从调用者移动价值,transferFrom 在移动 token 之前检查并花费调用者对 from 的 allowance。[2]

常见的抽象错误是将这两个函数解读为“发送 ERC-20”的同义词。它们不是——函数选择的是授权模型。

证明 2:调用完成意味着 token 成功

ERC-20 规范明确警告调用者处理 false,并要求不要假设它永远不会被返回。[1] 也就是说,低级调用完全可以在没有 revert 的情况下完成,而实际 ERC-20 操作却报告了失败。

OpenZeppelin 的 SafeERC20 工具处理两种兼容性情形:返回 false 的操作,以及不返回值但不 revert 的 token。其包装器在操作返回除 true 以外的值时 revert,同时将没有返回数据的非 revert 调用视为成功。[2]

这是调用语义证明。它并不决定 transfer 还是 transferFrom 是正确的权限模型。

证明 3:记账跟随观察到的移动

即使一次调用已正确授权并报告成功,也仍然不能证明精确的余额增量。Token 实现可能施加转移费用或其他余额行为。如果协议根据输入参数发行份额、信用、债务偿还或收据,那么它隐含了一个明确假设——收到的金额等于该参数。

更强的不变量是:

记入金额 <= 接收者之后余额 - 接收者之前余额

是否需要严格相等取决于产品设计。一个 vault 可能拒绝具有非精确转移语义的 token,另一个协议则可能接受它们并只记入实际到达的金额。危险状态是意外支持——接口看起来通用,记账却默默假设精确移动。

Bastion:余额充足,权限模型错误

Bastion Wallet 审查覆盖了一个面向 ERC-4337 账户抽象系统的订阅合约。公开报告记录,SubExecutor 持有订阅者的 ERC-20 资金,并在结算前检查自己的余额。[3]

被审查的路径随后调用:

token.transferFrom(address(this), sub.initiator, sub.amount);

源地址和消息调用者都是 SubExecutor。然而 transferFrom 却在这两个角色之间引入了 allowance 证明。报告的 fuzz 测试触发了 ERC20: insufficient allowance,尽管合约本身持有足够的 token。[3]

Zealynx 将此分类为权限-托管不匹配:

实际所有者:      SubExecutor
实际调用者:      SubExecutor
所选模型:       委托 spender
缺失证明:       从 SubExecutor 到 SubExecutor 的 allowance

报告建议改用 transfer(sub.initiator, sub.amount),并记录了团队回应为已确认并修复。[3] 案例研究 追踪了完整的托管和执行路径。

来源并未确立受影响代码已部署、用户实际遇到过该失败,或修复已进入生产环境。当前可见的公开仓库也没有暴露被审查的订阅代码以供独立验证。[3]

PoolTogether:调用返回了,转移没有成功

一个针对 PoolTogether 的公开 Code4rena 发现项描述了在两个 yield-source 合约中未检查 transfer 和 transferFrom 的返回值。该发现以 BADGER 为例,说明 token 在转移失败时可能返回 false 而非 revert。[4]

附加在该 issue 上的讨论对影响和可能性提出了争议,一位 judge 在考虑这些限制后将其标记为 Medium Risk。[4] 这种评审上下文不应被简化为普遍严重性主张。

Zealynx 将该机制分类为调用成功不匹配。所选的转移原语本身可能是合适的,但调用合约将"非 revert"误当作了转移证明:

// 不安全的结论:外部调用没有 revert,因此 token 移动了。
token.transfer(recipient, amount);
_accountAsPaid(amount);

ERC-20 并不支持这个结论。标准明确要求处理 false。[1]

这一记录与 Bastion 有一个重要区别:Bastion 选择了错误的授权原语并在测试 token 下 revert;PoolTogether 的问题则涉及一次可能以失败值正常返回的 token 调用。前者在结算前因不必要的许可要求而失败,后者则可能让本地执行在结算失败后继续推进。

ENS:返回 false 的转移与合成信用发生偏离

一个公开的 Code4rena ENS 发现项报告,未检查的 transferFrom 结果可能让 ERC-1155 表示更新,即使底层 ERC-20 转移返回了 false。概念验证使用了一个 mock token,其第一次 transferFrom 返回 false,随后展示了合成余额在没有相应 ERC-20 移动的情况下更新。[5]

该 issue 在公开记录中带有 Medium Risk 标签、judge 降级标签以及与重复项相关的元数据。[5] 这些标签是来源事实,并非 Zealynx 的重新评估。

Zealynx 将此分类为调用成功不匹配并演变为记账偏离:

底层移动报告失败
+ 调用者忽略了布尔结果
+ 表示记账继续
= 债权可能与支撑资产发生偏离

这一点很有价值,因为它连接了第二和第三个证明。直接缺陷是未检查的成功语义,但协议后果之所以产生,只是因为后续记账效果假定了转移已经发生。

公开发现项描述了概念验证和潜在路径,本文不主张存在已实现的利用、生产损失或未解决的部署。

Tigris:一个接口,两种兼容性失败

一个公开的 Tigris 竞赛提交描述了稳定币 vault 中裸用的 IERC20.transfer 和 transferFrom 调用。提交者识别了两种行为:token 可能返回 false 而调用者忽略它,或者省略预期的返回值,导致 ABI 解码的接口调用 revert。[6]

该 issue 的历史同样重要——judge 最终将该提交标记为超出范围。[6] 因此,它不是已接受的范围内漏洞的证据,但仍然是 SafeERC20 旨在规范化的两种兼容性假设的公开技术示例。

Zealynx 将其分类为支持策略歧义,而不是协议损失的证据。如果协议声称支持任意 ERC-20 资产,裸接口调用可能让这种支持依赖于返回数据约定;如果协议仅支持严格控制的 token 列表,那么相关任务就是记录并测试这些特定于 token 的假设。

可复用的教训不是“每个 token 都必须被支持”,而是:

通用接口主张需要通用行为测试。受限支持需要明确的 allowlist 和明确的假设。

比较矩阵

记录 来源报告的行为 Zealynx 分类 该记录不能证明什么
Bastion H-02 持有合约对自己的余额使用 transferFrom;报告测试因 allowance 不足而 revert;记录为已修复 [3] 权限-托管不匹配 部署、用户影响或经独立验证的生产修复
PoolTogether #112 transfer 和 transferFrom 的返回值未检查;评审讨论涉及严重性 [4] 调用成功不匹配 对每个受支持 token 的普遍结果
ENS #313 返回 false 的 transferFrom 之后可能跟随 ERC-1155 记账更新 [5] 成功不匹配导致记账偏离 已实现的利用或生产损失
Tigris #6 提交描述了返回 false 和无返回 token 的兼容性;被判定为超出范围 [6] 支持策略歧义 被接受的竞赛发现项或已确认的协议影响
ERC-20 规范 调用者必须处理 false;transferFrom 是委托提款工作流 [1] 规范性基线 每个已部署 token 的精确行为
OpenZeppelin ERC20 和 SafeERC20 transferFrom 花费 allowance;SafeERC20 规范化可选返回 [2] 参考实现模式 调用后的正确协议记账

这些记录不具有相同的严重性或证据权重。该表比较的是机制,而不是结果。

为什么 SafeERC20 是必要的但不充分

使用 safeTransfer 和 safeTransferFrom 是一个强默认选择,因为它消除了一类返回值错误,但并不能解决完整的转移问题。

它不能选择 owner-spender 模型

如果合约自己拥有这些 token,以下调用依然是错误的:

asset.safeTransferFrom(address(this), recipient, amount);

包装器可以忠实执行错误的原语。如果 token 强制要求持有合约对自身的 allowance,调用就会像 Bastion 被审查的路径一样 revert。

它不能定义记入金额

对于接收者收到的金额少于 amount 的 token,以下代码依然可能是错误的:

asset.safeTransferFrom(msg.sender, address(this), amount);
shares[msg.sender] += amount;

SafeERC20 证明 token 调用符合其接受的返回约定,但并不能证明 address(this) 收到了恰好 amount。

它不能定义 token 支持

协议仍然需要针对 rebasing token、fee-on-transfer token、带 hook 的 token、暂停的 token、blocklist、可升级 token 逻辑以及其他行为制定策略。没有任何包装器能够推断出周围系统的经济假设。

用于设计评审的转移证明表

在审查代码之前,先将预期移动写成一张表:

问题 存款或拉取 提款或支付
调用前谁拥有 token? 通常是用户 通常是协议合约
谁调用 token? 协议合约 协议合约
哪个原语匹配? safeTransferFrom(user, protocol, amount) safeTransfer(recipient, amount)
什么证明调用成功? SafeERC20 或显式检查的返回值 SafeERC20 或显式检查的返回值
记入什么金额? 观察到的收款或明确支持的精确金额 在需要时观察到的发送者和接收者增量
允许什么 token 行为? 记录的 allowlist 和假设 相同策略,包括 hook 和费用

“通常”是刻意保留的——Router、智能账户、vault、回调和 permit 系统都可能改变调用者和所有者。这张表的意义在于强制命名这些角色,而不是从名为 deposit 或 pay 的函数名中盲目推断。

独立测试每个证明

使用传统 mock token 的单个 happy-path 测试是弱证据。更好的做法是构建隔离每个假设的对抗性 token 或 mock。

权限测试

  1. 直接为协议合约提供资金。
  2. 将 self-allowance 保持为零。
  3. 执行支付。
  4. 断言协议余额下降且接收者余额上升。

这能捕获 Bastion 被审查的模式,因为直接所有者支付不应需要自我批准。

返回 false 测试

使用一个返回 false 但不 revert 的 token,断言完整协议操作 revert,且没有份额、债权、收据或支付标记发生变化:

function transferFrom(address, address, uint256)
    external
    returns (bool)
{
    return false;
}

无返回测试

使用一个 transfer 函数不返回数据的 token。先决定它是否受支持:如果支持,验证包装器接受非 revert 调用且发生预期的余额移动;如果不支持,验证准入控制在记账开始前将其拒绝。

余额增量测试

使用一个从发送者扣除 amount 但向接收者记入更少的 token,断言以下任一情况:

  • 协议拒绝该 token 或转移;或
  • 协议信用等于观察到的收款。

在这里,balance delta 成为安全断言,而不仅仅是记账便利。

原子性测试

对于每种失败模式,验证所有相邻状态都回滚。失败的 token 移动不得留下支付时间戳、已铸造份额、委托余额、债务减少或已完成订单标志。

属性测试 可以表达核心规则:

如果协议信用增加了 C,
那么收到的已认证 token 资产必须至少增加 C,
除非有记录的转换规则解释了差异。

fuzzing 术语表条目 提供了测试上下文。该属性应变化调用者、余额、allowance、返回行为、转移费用和顺序。

审查清单

  1. 命名所有者、调用者、spender 和接收者——在确切的 token 调用边界处执行此操作。
  2. 根据这些角色选择原语:对调用者的 token 使用直接转移,对另一个所有者的 token 使用委托转移。
  3. 使用返回安全的包装器。不要仅将非 revert 视为 ERC-20 成功。
  4. 定义受支持的 token 行为。通用 ERC-20 支持是一个测试矩阵,而不是导入一个接口。
  5. 当无法保证精确转移时,根据观察到的移动进行记账。测量之前和之后的余额。
  6. 保持 token 移动和协议记账原子化。不要在未验证的调用之后更新债权。
  7. 使用零 self-allowance 测试自托管支付。这隔离了权限-托管混淆。
  8. 测试 false、无返回数据和 revert——它们是单独的调用结果。
  9. 测试带费用的移动。确认协议是拒绝它还是正确记账。
  10. 保留修复测试。一行原语变更值得一个永久回归测试。

局限

该比较有意保持狭窄。它不深入覆盖 allowance 抢跑、permit 签名验证、ERC-777 hook、回调重入、token blocklist、rebase 供应或升级风险。

公开审计记录在权威性上也各不相同。Bastion 的规范报告记录了被审查的发现项和团队回应。[3] Code4rena issue 保留了提交和评审元数据,包括争议、降级、重复项和超出范围决定。[4][5][6] 将它们纳入此处的目的在于确立一个有记录的机制,而非同等的验证级别或严重性。

本语料库中没有任何来源确立流行率。本文不声称每个裸 ERC-20 调用都可被利用、每个非标准 token 都应受支持,或每个记账不匹配都造成了资金损失。

结论:token 调用不是结算证明

Bastion 的持久教训不仅仅是“使用 transfer 而不是 transferFrom”,而是 token 结算存在多个独立边界。

Bastion 展示了托管-权限不匹配:合约拥有 token,却选择了委托转移。[3] PoolTogether 的公开发现项说明当 token 可以返回 false 时,非 revert 远远不够。[4] ENS 将忽略的失败结果与可能偏离底层移动的合成记账连接起来。[5] Tigris 则说明为什么声称的 token 兼容性需要刻意的返回数据策略,即使该提交被判定为超出范围。[6]

简洁规则是:

证明谁可以移动 token,证明 token 报告了成功,然后证明协议记账与实际移动的内容匹配。
任何少于这些的,都只是一次 token 调用——它还不是结算的证据。

来源

[1] https://learnblockchain.cn/docs/eips/EIPS/eip-20 - ERC-20 Token 标准

[2] https://docs.openzeppelin.com/contracts/5.x/api/token/ERC20 - OpenZeppelin Contracts 5.x ERC20 和 SafeERC20 文档

[3] https://raw.githubusercontent.com/ZealynxSecurity/audits/main/web3/2024-03-zealynx-shieldify-bastion-wallet.pdf - Bastion Wallet 安全审查

[4] https://github.com/code-423n4/2021-06-pooltogether-findings/issues/112 - PoolTogether 发现项 #112

[5] https://github.com/code-423n4/2023-10-ens-findings/issues/313 - ENS 发现项 #313

[6] https://github.com/code-423n4/2022-12-tigris-findings/issues/6 - Tigris 提交 #6

常见问题

合约何时应使用 transfer 而不是 transferFrom?

当调用合约拥有被发送的 token 时,使用 transfer;当调用合约正在花费另一个地址拥有的 token、且基于 allowance 时,使用 transferFrom。在执行时确认角色,而不是根据操作的业务标签来选择。

SafeERC20 能修复每个 ERC-20 转移问题吗?

不能。SafeERC20 规范化返回值行为,包括不返回值的 token。它不选择正确的 owner-spender 模型,不定义受支持的 token 经济,也不证明收到的金额等于请求的金额。

为什么在成功转移后还要检查余额增量?

成功调用证明的是可接受的执行语义,不一定是精确移动。余额增量可以显示接收者实际收到了什么,当协议根据该收款创建份额或信用时,这一点至关重要。

合约转移自己的 token 前需要自我批准吗?

对于标准直接 transfer,不需要。只有当合约不必要地为自己已经拥有的 token 选择委托 transferFrom 路径时,才会出现自我批准。

最小有用的 ERC-20 集成测试矩阵是什么?

测试一个标准成功 token、一个返回 false 的 token、一个无返回 token(如果受支持)、一个 revert 的 token,以及一个带费用的 token。对于每种情况,断言 token balance deltas 和所有协议侧记账。

术语表

术语 定义
Allowance ERC-20 授权,允许 spender 通过 transferFrom 移动所有者的 token。
Balance Delta 账户在操作之前和之后余额之间的测量差异。
Fuzzing 跨生成的输入和状态组合进行自动化测试,以发现被违反的属性。
Pull Payment 一种支付设计,其中被授权方主动领取资金,而不是接收未经请求的推送。
Trust Boundary 数据或控制在不同假设的组件之间跨越的边界。

原文链接:zealynx.io/blogs/erc20-t...


本文由云栈社区编译整理,仅供技术学习与交流。




上一篇:AI审稿人评分总漂移?RobustReview与SciCore让审稿更稳定
下一篇:CTF密码学实战:Polybius方阵坐标密文识别与频率分析解密
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-14 19:05 , Processed in 0.079013 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表