区块链(23):外部调用前后的资产账本怎样保持一致
托管合约为卖家支付 70 时,如果先把资产发给收款合约,再把 claimable 置零,收款方可以在回调里再次进入 withdraw。攻击不需要修改托管源码,只要收款方是有逻辑的外部地址。区块链(22):签名域正确仍会发生业务重放中的签名 nonce 不会自动防住这个跨调用时刻的可重入债权。
sequenceDiagram
participant E as 托管合约
participant A as 恶意收款合约
E->>A: 先转出70(错误顺序)
A->>E: 回调 withdraw,仍看到 claimable=70
E-->>A: 第二次转出或破坏守恒
Note over E,A: 修复:先校验,再置零债权,再外调
检查—状态更新—交互(CEI)要求在控制权交给外部调用者之前消耗可领取债权;也可考虑可重入锁,但锁不能替代对每条业务状态路径的审计。采用 pull-payment 时,由收款方主动领取可降低订单流转阶段的外部调用数量。若外部调用失败,不能先永久清零余额又默默返回成功;要按策略 revert 使本次状态效果一起回滚,或将待领取状态维持到后续可重试。失败传播与 gas 限制、接收方行为均需在冻结 EVM 版本下测试。
两次领取必须说明钱从哪里来
假设买方 A 锁 70、买方 B 也锁 70,托管合约余额 140,而卖家 A 的 claimable=70。错误实现先把 70 发送给恶意收款合约,再清空 A 债权;收款合约的回调趁 A 债权还可见,再调一次相同领取。第二次转账有 B 的资金可用,账面却仍只打算清零 A 的 70,可能导致 liabilities > balance。若合约余额只有 70,第二次付款也许失败,不能据此判定实现安全;测试必须给攻击合约足够的其他订单资金、记录两层回调与最终资产变化。
修复路径先验证角色和金额,再原子地把 A 债权减至 0,最后做外部调用。回调即便再次进来,也因债权为 0 被拒;若第一次外调失败,选择整体 revert 时债权扣减也要回滚,选择保留待领时不能无声永久标记“已付”。重入锁帮助阻止某些跨调用路径,却不能代替检查不同订单或代理升级后的余额。真实验证需一份漏洞合约和攻击合约、触发前后收款人余额、失败调用回执以及修复版回归;本环境均未运行。
练习一:把债权置零再 call,调用失败后合约只发事件“稍后再试”却不恢复债权,会怎样?答案:若没有 revert 或显式恢复,账本与资产责任不一致;测试要覆盖失败分支。练习二:加了重入锁便允许任意管理员修改领取映射吗?答案:不能;权限错误与重入错误是不同攻击面。此处没有攻击合约部署证据、回调轨迹或修复回归,全部 NOT_RUN。
可迁移原则:外部调用是不可信控制权转移,状态责任要在交互前明确并可回滚。参考:Solidity Security Considerations、OpenZeppelin ReentrancyGuard(版本/原文待核);导航:区块链(22):签名域正确仍会发生业务重放 · 23 · 区块链(24):fuzz 和 invariant 要围住哪笔资产。



