先改账本再给钱:重入攻击之后,防御清单还剩什么 图 1
先改账本再给钱:重入攻击之后,防御清单还剩什么 · 图 1

重入攻击的教科书案例是二〇一六年的那起著名事件,此后「先记账、后转账」成了智能合约工程的第一课。很多人因此以为重入已经被修进了历史。链上事故记录不同意这个判断:攻击形式确实在变——从同一函数内的循环回调,走到只读调用、跨合约委托、跨协议组合的新路径上。这篇文章按防御层次拆解现状:哪些门已经焊死在语言和设计里,哪些门永远半开着,取决于你连的下一个合约是谁。

先复盘经典门是怎么焊死的。老式攻击的链条是:提款函数先把钱转给你,再更新你的余额——转账时你的恶意合约收到钱的同时立刻再调提款函数,此时余额还没扣,于是同一笔钱被提出多次。防御同样是教科书级的「检查—生效—交互」三段式:先验证条件,再改自己的账本,最后才对外转账。顺序一换,第二次进入时余额已经归零,循环就地失效。再配上可重入锁这类互斥旗标,同合约路径的重入在严肃项目里基本绝迹。这一层的防御是真正的结构性解决,代价是开发纪律——任何一次「为省 gas 把转账提前」的重构都可能把它拆掉,所以审计清单里它被写在最顶上。

但攻击者很快改了方向,于是有了第二层防御要对付的新路径。第一条新路径叫「只读重入」:攻击者不再调用改状态的函数,而是反复调用查询函数(余额、报价、储备),利用查询时机正好卡在你的状态更新与外部交互之间,从失真的读数里骗出有利价格。这类攻击焊不焊得死,取决于协议把「读外部状态」放在流程的哪个位置——防御规范由此多了一条:状态敏感期不要读外部可变的数。第二条新路径是「跨函数重入」:同一个合约里提款函数加了锁,但清算、还款、兑换这些相邻函数没有,恶意代币在回调里绕道调用兄弟函数。对应防御是把锁从「每个函数各自一把」升级为「全局或按地址分级的互斥」,再配合对新接入代币的入口审查。第三条新路径最工程化:通过委托调用、代理跳转或者闪电贷把「被攻击者」换成第三方协议,让受害者 A 的回调逻辑去重入协议 B——门都不在同一栋楼里了。

到这里可以给出读法:重入防御不是「有没有」的开关,而是一份分层的检查清单。语言和设计层看三段式是否贯彻;合约层看锁的粒度与全部入口(包括看似无害的查询函数和回调钩子);代币层看池子里的资产转账时会不会反手回调你(ERC-777 一类带收款回调的代币是天然重入源,主流协议普遍选择直接不支持);组合层看协议调用的外部地址是否可控、是否可能被替换。用户端能核查的信号依次是:审计报告里有没有单列「重入与回调」章节并写明审查过的全部入口;项目对异常代币的接入有没有准入流程;被攻击历史(如果有)里修复报告是否说明了补的是哪一层。

普通用户最容易误解的一点是把「协议没被重入攻击过」当成「不存在重入面」。更准确的说法是:经典重入面已经安全到不值得攻击者花力气,成本收益把攻击压向了更隐蔽的路径,而那些路径的暴露面恰恰藏在「协议与协议的接缝」里——你无法从单个协议的安全页面推断整个组合的风险。这也是为什么大协议的文档会专门警告「与第三方合约交互产生的组合风险超出审计范围」:那不是免责声明的客套,是真实的能力边界。

收尾给一条极简的用户侧动作:在使用任何带回调、带钩子或「先进后出」特性的新协议前,确认它是否支持带收款回调的代币、你存入的资产是否在它的异常代币黑名单机制内。这两个问题在多数协议的文档或审计报告里都有现成答案。智能合约实现持续演进,本清单反映的是写作时主流工程实践,请以各项目当前文档为准。 interacting with unaudited contracts 有资产损失风险——这句话翻译成人话就是:与未经审计的合约交互有资产损失风险。本文只做机制说明,不构成投资建议。

先改账本再给钱:重入攻击之后,防御清单还剩什么 图 2
先改账本再给钱:重入攻击之后,防御清单还剩什么 · 图 2