在智能合约安全演进史上,重入漏洞(Reentrancy Vulnerability)从未真正退出舞台。从 2016 年让 The DAO 分叉的单合约同函数重入(Single-function Reentrancy),到跨函数重入(Cross-function Reentrancy),再到依靠外部协议报价的只读重入(Read-only Reentrancy),重入攻击的攻击向量正变得越来越隐蔽和复杂。
近年来在多个头部借贷与合成资产协议中爆发的安全事故揭示了一种更为隐蔽的终极形态:跨合约只写重入(Cross-contract Write Reentrancy)与多状态机污染。在这类攻击中,恶意合约并不直接读取受害者的中间状态,也不直接递归提款,而是在外部回调(如 ERC-777 的tokensReceived或 ERC-1155 的onERC1155Received)触发时,主动调用另一个强相关的兄弟合约执行写入操作。由于两个独立合约的状态更新时序被割裂,破坏了全局事务一致性,最终导致抵押品被掏空。
本文将通过全套可复现的 Solidity 源码,剖析这一终极变体的执行机理,并给出多状态机协同下的防御架构。
漏洞根源:独立合约间的隐式状态耦合
在微服务化或模块化智能合约架构中,协议往往将业务拆分为多个相互依赖的独立合约。例如:
- 资金池合约(Vault):负责资产的存取、托管与记账。
- 抵押借贷合约(LendingController):负责管理用户借贷限额、计算清算阈值并决定能否赎回抵押品。
通常情况下,开发者会为 Vault 合约加上标准的nonReentrant互斥锁。然而,当攻击发生时:
- 用户在 Vault 提款,Vault 触发外部代币转账;
- 代币转账激活恶意合约的接收钩子函数(Hook);
- 此时 Vault 的状态尚未完成结算,而 LendingController并未被同一个重入锁锁定;
- 恶意合约在钩子中调用 LendingController 的写入方法(例如:
transferCollateral转移质押凭证,或调用liquidateBorrow重构债务关系); - LendingController 以为 Vault 里的资产依然完好,允许了写入操作;
- 最终,Vault 完成提款结算,但 LendingController 的账本已经与 Vault 物理脱节。
这就是典型的“只写重入”:它利用跨合约状态写入破坏了分布式系统的原子一致性。
漏洞场景实战还原:脆弱的借贷凭证联动
以下是一组展示跨合约只写重入的典型脆弱合约模型:
// SPDX-License-Identifier: MIT pragma solidity 0.8.28; interface IERC777Recipient { function tokensReceived( address operator, address from, address to, uint256 amount, bytes calldata data, bytes calldata operatorData ) external; } /// @dev 模拟带有转账回调的代币 contract MockHookToken { mapping(address => uint256) public balanceOf; function mint(address to, uint256 amount) external { balanceOf[to] += amount; } function transfer(address to, uint256 amount) external returns (bool) { require(balanceOf[msg.sender] >= amount, "Balance low"); balanceOf[msg.sender] -= amount; balanceOf[to] += amount; // 如果接收方是合约,触发 ERC-777 风格的回调 if (to.code.length > 0) { try IERC777Recipient(to).tokensReceived(msg.sender, msg.sender, to, amount, "", "") {} catch {} } return true; } } /// @dev 资金质押金库(自带局部重入锁) contract VulnerableVault { MockHookToken public immutable token; LendingLedger public immutable ledger; mapping(address => uint256) public deposits; bool private _locked; modifier nonReentrant() { require(!_locked, "ReentrancyGuard: reentrant call"); _locked = true; _; _locked = false; } constructor(address _token, address _ledger) { token = MockHookToken(_token); ledger = LendingLedger(_ledger); } function deposit(uint256 amount) external { token.transfer(address(this), amount); deposits[msg.sender] += amount; ledger.onDeposit(msg.sender, amount); } /// @notice 脆弱的提款逻辑:先转账代币,后调用账本清退份额 function withdraw(uint256 amount) external nonReentrant { require(deposits[msg.sender] >= amount, "Insufficient deposit"); // 外部转账(触发接收方 Hook) token.transfer(msg.sender, amount); // 状态更新推迟在转账之后 deposits[msg.sender] -= amount; ledger.onWithdraw(msg.sender, amount); } } /// @dev 借贷账本合约(独立合约,用于记录借款额度与信用质押) contract LendingLedger { address public immutable vault; mapping(address => uint256) public creditScores; constructor(address _vault) { vault = _vault; } function onDeposit(address user, uint256 amount) external { require(msg.sender == vault, "Only vault"); creditScores[user] += amount; } function onWithdraw(address user, uint256 amount) external { require(msg.sender == vault, "Only vault"); creditScores[user] -= amount; } /// @notice 允许用户在信用未受损时向兄弟地址授权转让信用凭证 function transferCredit(address recipient, uint256 amount) external { require(creditScores[msg.sender] >= amount, "Not enough credit"); creditScores[msg.sender] -= amount; creditScores[recipient] += amount; } }攻击流程复盘:
- 攻击者调用
VulnerableVault.deposit(100),获得 100 单位存单,此时LendingLedger.creditScores为 100; - 攻击者发起提款
VulnerableVault.withdraw(100); - Vault 在执行到
token.transfer时,控制流被移交至攻击合约的tokensReceived; - 攻击合约直接调用
LendingLedger.transferCredit(sybilAddress, 100)将 100 信用额度转移给女巫钱包;由于当前LendingLedger.onWithdraw尚未执行,账本验证通过,信用转移成功; - 回调结束,Vault 继续执行
ledger.onWithdraw(attacker, 100),但此时攻击者账户余额已为 0,系统触发静默减法或者下溢报错;若无下溢报错,则凭空制造了 100 单位的双花信用。
终极防御:全局重入锁与严格 CEI 规范
防范跨合约只写重入与状态机污染,单纯依赖 OpenZeppelin 的单合约ReentrancyGuard是完全无效的。必须在系统架构层面采取全局同步策略:
1. 全局状态机注册表与跨合约共享重入锁
将多个业务合约挂载在同一个全局互斥锁下。可以通过一个轻量级的全局锁控制中心(Global Lock Coordinator)实现:
// SPDX-License-Identifier: MIT pragma solidity 0.8.28; abstract contract CrossContractReentrancyGuard { // 使用预定义的高安全存储槽,避免代理合约存储碰撞 bytes32 private constant GUARD_SLOT = keccak256("global.reentrancy.guard.slot"); modifier globalNonReentrant() { bytes32 slot = GUARD_SLOT; uint256 status; assembly { status := sload(slot) } require(status != 2, "GlobalReentrancy: Reentrant call detected"); assembly { sstore(slot, 2) } _; assembly { sstore(slot, 1) } } }通过将多合约的继承类指向同一个预先约定的存储槽(如果是通过 Delegatecall 架构)或者集中向全局控制器验证isProtocolLocked(),即可将所有兄弟合约置于统一防线内。
2. 跨合约级别的“检查-生效-交互”(Global Checks-Effects-Interactions)
任何涉及外部调用的操作,必须推迟至整个协议族所有关联状态全部更新完毕之后。
修改后的安全提款流程:
function safeWithdraw(uint256 amount) external nonReentrant { require(deposits[msg.sender] >= amount, "Insufficient deposit"); // 1. Effects:立即扣减本地金库账本 deposits[msg.sender] -= amount; // 2. Effects:立即同步通知兄弟关联账本扣减信用 ledger.onWithdraw(msg.sender, amount); // 3. Interactions:所有状态机收敛后,最后才发生外部代币转移 token.transfer(msg.sender, amount); }3. ERC-777 / ERC-1155 接收钩子的严格白名单隔离
在涉及 DeFi 核心借贷与清算的资产池中,严禁未受控的外部钩子随意执行状态写入。对于必须支持多资产类型的金库,必须在合约进入接收钩子时开启临时的“只读冻结标志”,直接拒绝下游合约响应任何状态变更请求。
多状态机协同下的安全核心,是把整个协议视作一个不可分割的单体状态机。唯有打破单合约边界的狭隘防御,才能将深藏在跨合约调用缝隙中的只写重入彻底歼灭。