跳到正文

重入攻击

重入攻击利用外部调用或回调,在合约状态不一致时再次进入合约逻辑。本文说明攻击路径、防护方法与审查边界。

更新于

仅供教育用途,不构成投资建议。重入漏洞可能迅速造成不可逆的合约资产损失,任何单一防护措施或审计都不能证明合约绝对安全。

直接回答

当合约逻辑发起外部调用,而被调用方在原始执行尚未结束、合约不变量尚未恢复之前回调该合约时,就可能发生重入攻击。如果过时的余额、份额、权限或价格仍然可见,回调就可能利用不一致的状态重复某项操作或影响另一项操作。

回调不必进入同一个函数,也不一定涉及原生货币转账。代币钩子、NFT 接收方回调、金库或策略调用以及闪电贷回调都可能把控制权交给不受信任的代码。重入可以跨函数、跨合约和跨模块发生;只读重入也能让另一个协议使用暂时值。

工作原理

典型利用过程如下:

  1. 攻击者进入一个会改变状态的函数并通过初始检查。
  2. 易受攻击的合约在完成记账前调用某个地址、代币或协议。
  3. 攻击者控制的代码在旧状态仍可见时进入原合约或相连合约。
  4. 嵌套调用重复某项效果或改变共享状态,随后执行逐层返回,但不变量已经被破坏。

外部控制权转移可能很明显,例如底层调用;也可能隐藏在代币转账、接收方钩子、安全铸造、策略适配器或任意调用接口之后。回滚通常会撤销发生回滚的调用树,但攻击者可以安排一组成功的嵌套调用,在提取价值或破坏记账后正常返回。

防护应当分层:

  • 遵循检查-生效-交互模式:先验证,再提交所有相关内部状态变化,最后才进行外部交互。
  • 对共享受保护不变量的所有入口使用重入锁,而不只保护含有明显调用的函数。
  • 在合适场景采用主动领取模式,并尽量减少对不受信任的代币、接收方、钩子、代理和集成的调用。
  • 定义跨合约不变量,并测试恶意回调、跨函数路径、嵌套多调用和只读使用方。
  • 在设计允许时,将实现审查与监控、暂停和事件响应控制结合起来。

示例

假设某金库发送价值,并且仅在调用之后才清空用户余额:

function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "empty balance");

    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "transfer failed");

    balances[msg.sender] = 0;
}

balances[msg.sender] 仍保留旧数额时,收款方已经取得控制权。其接收函数可以再次调用 withdraw(),通过同一项检查并请求再次转账。把余额更新移到调用之前可消除这一特定窗口;重入锁可以拒绝嵌套进入。但两者都不能证明整个系统安全,因为其他入口或相连合约仍可能暴露同一项未完成的不变量。

风险

  • 重入锁只保护一个函数,而另一个函数暴露了相同状态。
  • 局部调用顺序正确,但跨合约不变量在回调期间仍不一致。
  • 代币、接收方、闪电贷回调或策略适配器意外执行外部代码。
  • 视图函数发布暂时价格或兑换率,另一个协议在同一笔交易中使用了它。
  • 升级、模块或存储布局变更绕过或破坏原有锁。
  • 测试覆盖递归提款,却遗漏跨函数、跨合约和只读路径。

对用户而言,审计标识或重入锁只是存在某项控制措施的证据,不是安全保证。升级、集成和特权紧急操作都可能改变攻击面。应限制授权与风险敞口,核实已部署的实现,并且不要假定损失能够撤销。

常见误解

  • 重入只是重复调用同一个提款函数。 它也可能进入另一个函数或合约,或者向读取方暴露不一致的数据。
  • 只有原生货币转账才会触发回调。 代币标准、接收方钩子和协议集成同样可以执行外部代码。
  • 重入锁或检查-生效-交互能保证合约安全。 其范围、每个共享入口和跨合约不变量仍需审查。
  • 审计通过就排除了重入风险。 审计只在有限范围内进行;后续变更和未经审查的集成可能引入新路径。

相关主题

来源

导航

搜索知识库...