3
年
来到社区 3 年
LEVEL 100
XP
一、背景
智能合约一旦部署,代码通常难以直接修改,错误还可能影响链上资产。因此,合约安全审计不能只检查某个函数是否存在明显漏洞,而应围绕资产流、权限流、外部调用和状态变化建立完整的攻击面模型。
在实际审计中,重入漏洞经常与权限配置、回调机制、代币兼容性和业务状态机相互叠加。例如,提现函数即使使用了权限修饰器,如果在更新余额前调用外部地址,获授权的恶意合约仍可能通过回调重复进入;反过来,即使使用了重入锁,错误的管理员权限也可能允许攻击者绕过正常提现流程。
本文使用本地 Foundry 项目构建授权实验,重点说明如何发现和修复以下问题:
1. 外部调用发生在状态更新之前;
2. 用户余额、总负债和合约真实余额之间缺少一致性检查;
3. 管理员权限边界过大或可被错误转移;
4. 对 ERC-20 返回值和特殊代币行为处理不完整;
5. 修复后只验证正常流程,没有补充攻击回归测试。
所有示例仅用于本地环境、授权靶场和防御性审计,不应针对未授权的真实合约进行测试。
二、漏洞原理
重入的核心不是“使用了 call 就一定有漏洞”,而是合约在完成关键状态更新前,把执行权交给了不可信外部地址。
一个典型的脆弱提现逻辑如下:
function withdraw(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "insufficient");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balanceOf[msg.sender] -= amount;
}
执行 msg.sender.call 时,如果 msg.sender 是合约,其 receive 或 fallback 函数会被触发。此时 balanceOf[msg.sender] 尚未减少,接收方就可能再次调用 withdraw。只要余额判断仍然通过,提现流程便可能重复执行。
审计时需要特别注意:重入不只发生在 ETH 转账中。ERC-777 回调、ERC-721/ERC-1155 安全转账回调、自定义代币钩子、跨链消息接收器以及外部价格或路由合约调用,都可能产生类似的控制权转移。
三、漏洞成立的前置条件
一次可利用的重入通常需要同时满足若干条件:
1. 目标函数包含对外部合约或不可信地址的调用;
2. 外部调用发生在余额、份额、领取状态等关键数据更新之前;
3. 被调用方可以通过回调重新进入原函数或另一个共享状态的函数;
4. 合约在重入期间仍持有足够资产;
5. 没有有效的重入锁,或重入锁只覆盖单个入口,未覆盖跨函数重入;
6. 业务校验依赖尚未更新的旧状态。
如果提现需要白名单或角色授权,也不能直接判定安全。审计人员仍需检查获授权主体是否可能是合约、权限是否可以被转移,以及管理员能否错误地把高权限授予不可信地址。
四、本地实验环境
建议在独立目录使用 Foundry:
forge init reentrancy-audit-lab
cd reentrancy-audit-lab
forge install OpenZeppelin/openzeppelin-contracts --no-commit
可选地启动本地节点:
anvil
在另一个终端中运行测试:
forge test -vvv
如果需要查看完整调用轨迹:
forge test --match-test test_ReentrancyBlocked -vvvv
不要把真实私钥、生产 RPC 密钥或包含资产的助记词放入测试脚本。即使连接测试网,也应使用专门创建且无生产权限的测试账户。
五、分步骤审计实践
步骤一:建立资产流清单
先列出合约可以接收、记录和转出的全部资产:
1. 原生币通过 receive、fallback 或 payable 函数进入;
2. ERC-20 通过 transferFrom 进入;
3. ERC-721、ERC-1155 是否实现对应接收接口;
4. 用户余额是以 amount、share 还是内部记账单位保存;
5. 管理员是否存在紧急提款、手续费提取或资产迁移入口。
建议记录以下不变量:
address(this).balance >= totalUserLiability
如果是份额模型,还应检查总份额和总资产的换算关系。发现某个转账入口没有同步更新总负债,通常意味着会计逻辑存在缺口。
步骤二:定位所有外部调用
重点搜索:
call{value:
.delegatecall(
.staticcall(
.safeTransfer(
.safeTransferFrom(
.transfer(
.transferFrom(
外部调用不仅包括低级 call,也包括接口调用。例如 oracle.latestRoundData()、router.swap() 和 token.transfer() 都会进入其他合约代码。
对每个调用点依次确认:
1. 调用目标能否被用户控制;
2. 调用前是否已经完成关键状态更新;
3. 回调能否进入其他共享状态函数;
4. 调用失败时是否会完整回滚;
5. 返回值、返回数据长度和异常代币行为是否得到处理。
步骤三:检查 CEI 顺序
CEI 指 Checks、Effects、Interactions,即先检查条件,再更新内部状态,最后执行外部交互。
修复后的基本结构应为:
function withdraw(uint256 amount) external {
require(amount > 0, "zero amount");
require(balanceOf[msg.sender] >= amount, "insufficient");
balanceOf[msg.sender] -= amount;
totalLiability -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
如果外部调用失败,require 会使整笔交易回滚,前面的状态更新也会被撤销。因此,先更新状态并不会导致用户在失败时永久损失余额。
步骤四:增加重入保护
对于涉及资产转移的敏感入口,可以使用 OpenZeppelin 的 ReentrancyGuard:
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
mapping(address => uint256) public balanceOf;
uint256 public totalLiability;
function withdraw(uint256 amount) external nonReentrant {
require(amount > 0, "zero amount");
require(balanceOf[msg.sender] >= amount, "insufficient");
balanceOf[msg.sender] -= amount;
totalLiability -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}
需要注意,nonReentrant 不是错误业务逻辑的替代品。正确做法通常是同时应用 CEI、重入锁和会计不变量。
步骤五:检查跨函数重入
假设 withdraw 使用了重入锁,但 claimReward 没有使用,而两个函数都会读取或修改用户份额,那么攻击者可能在 withdraw 的外部调用期间进入 claimReward。
审计时不要只问“原函数能否再次进入”,还要问:
1. 回调期间有哪些 public 或 external 函数可调用;
2. 哪些函数共享 balance、share、rewardDebt、claimed 等状态;
3. 是否存在只读重入,即回调期间读取到临时不一致状态;
4. 是否有一个函数带锁、另一个函数未带锁的情况。
对于共享同一资产状态机的入口,应统一保护,或将内部逻辑重构为私有函数,由带锁的外部入口调用。
步骤六:审计权限边界
常见危险设计包括:
1. 使用 tx.origin 判断管理员;
2. initialize 可被重复调用;
3. owner 可以立即转移给任意地址,没有两步确认;
4. 紧急提款函数可以转走用户托管资产;
5. 代理合约实现地址可由单一热钱包直接修改;
6. 角色撤销后仍存在旧授权映射或签名权限。
应避免:
require(tx.origin == owner, "not owner");
因为 tx.origin 会穿透中间合约,用户可能在与恶意合约交互时,被诱导执行本不希望执行的管理操作。权限判断应基于 msg.sender,并结合 Ownable2Step、AccessControl、多签和时间锁等机制。
对于高风险升级操作,推荐流程是:提议新实现、等待时间锁、链下验证字节码、执行升级、验证存储布局和关键状态。
步骤七:处理 ERC-20 兼容性
不要假设所有 ERC-20 都严格返回 bool。一些历史代币可能不返回值,另一些代币可能收取转账手续费。
建议使用 SafeERC20:
using SafeERC20 for IERC20;
token.safeTransferFrom(msg.sender, address(this), amount);
token.safeTransfer(msg.sender, amount);
如果业务必须支持手续费代币,入账金额应使用余额差计算:
uint256 beforeBalance = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - beforeBalance;
balanceOf[msg.sender] += received;
否则,用户请求存入 100 个代币,而合约实际只收到 98 个,却按 100 个记账,长期会形成资不抵债。
六、本地攻击回归测试
安全测试的目标不是只证明旧代码有问题,更重要的是证明修复后攻击路径无法成功。
可以编写一个本地测试合约,在 receive 中尝试再次调用 withdraw:
contract ReentryProbe {
Vault public vault;
uint256 public attempts;
constructor(Vault _vault) {
vault = _vault;
}
receive() external payable {
attempts++;
if (attempts < 3) {
try vault.withdraw(1 ether) {} catch {}
}
}
function deposit() external payable {
vault.deposit{value: msg.value}();
}
function run() external {
vault.withdraw(1 ether);
}
}
测试断言应关注:
1. 攻击合约最多只能取回其合法余额;
2. totalLiability 与用户余额同步减少;
3. 其他用户的资产没有变化;
4. 重入尝试失败后,正常提现仍然可用;
5. 合约余额始终覆盖剩余负债。
示例测试思路:
function test_ReentrancyBlocked() public {
probe.deposit{value: 1 ether}();
probe.run();
assertEq(address(probe).balance, 1 ether);
assertEq(vault.balanceOf(address(probe)), 0);
assertEq(vault.totalLiability(), 0);
}
还应增加模糊测试,让 amount 在合法范围内变化:
function testFuzz_WithdrawAccounting(uint96 rawAmount) public {
uint256 amount = bound(uint256(rawAmount), 1, 100 ether);
// 存款、提现并检查余额与总负债不变量
}
运行:
forge test --fuzz-runs 5000
如果使用不变量测试,可以让处理器随机执行 deposit、withdraw 和 transfer,并持续断言:
assertGe(address(vault).balance, vault.totalLiability());
七、风险与注意事项
第一,不要为了“修复重入”简单改用 transfer。transfer 的固定 gas 假设可能影响某些合约钱包兼容性,而且不能替代正确的状态顺序和重入保护。
第二,delegatecall 的风险高于普通外部调用。被调用代码在当前合约的存储上下文中执行,错误实现可能修改管理员、实现地址或用户余额。插件式架构必须限制实现来源并验证存储布局。
第三,升级合约需要单独检查初始化。构造函数不会按普通方式初始化代理存储,应确保 initializer 只能执行一次,并禁用实现合约自身的初始化入口。
第四,不要只测试单用户场景。很多会计问题只有在多个用户依次存款、部分提现、手续费扣除或资产增值后才会出现。
第五,链上余额相等不代表记账正确。有人可能通过 selfdestruct 的历史行为或直接转账把原生币强制送入合约,因此不应把 address(this).balance 精确等于内部负债作为唯一安全判断。
第六,静态分析工具只能辅助定位。发现 nonReentrant 或 SafeERC20 并不代表合约整体安全,审计仍需结合业务状态机、权限配置和经济模型。
八、常见问题
问题一:使用 nonReentrant 后是否可以忽略 CEI?
不建议。重入锁可能遗漏其他入口,也可能在后续重构时被移除。CEI 能降低临时不一致状态,是更基础的设计原则。
问题二:private 函数是否不会发生重入?
private 函数不能被外部直接调用,但如果它由未加保护的 external 函数触发,整体流程仍可能被重入。审计单位应是完整调用路径,而不是单个函数的可见性。
问题三:只读函数也有重入风险吗?
有。某些协议会在外部调用期间出现短暂的不一致状态,另一个协议如果在此时读取价格、份额或储备量,可能得到错误结果,这通常被称为只读重入。
问题四:管理员紧急提款是否属于后门?
不一定。合理的紧急机制可以用于迁移误转资产或应对故障,但必须明确不能侵占用户负债,最好由多签和时间锁控制,并记录事件。
问题五:审计通过是否意味着永远安全?
不是。审计结论对应特定代码版本、编译配置和部署参数。升级实现、修改权限、接入新代币或改变预言机后,都应重新进行差异审计和回归测试。
九、总结
智能合约重入审计的关键,是从“控制权何时交给外部”出发,检查交互前后的状态是否一致,并把权限、代币行为和业务会计纳入同一威胁模型。
一个较稳妥的修复组合通常包括:遵循 CEI、为共享资产状态的入口增加统一重入保护、使用 SafeERC20、按照实际到账金额记账、限制管理员权限、采用两步权限转移,并通过单元测试、模糊测试和不变量测试验证修复。
真正有效的审计结果不只是列出漏洞名称,而是能够回答三个问题:攻击者需要什么前置条件、漏洞会破坏哪条资产或权限不变量、修复后如何用自动化测试证明同类路径已被阻断。只有完成这三步,安全修复才具备可验证性和持续维护价值。
智能合约一旦部署,代码通常难以直接修改,错误还可能影响链上资产。因此,合约安全审计不能只检查某个函数是否存在明显漏洞,而应围绕资产流、权限流、外部调用和状态变化建立完整的攻击面模型。
在实际审计中,重入漏洞经常与权限配置、回调机制、代币兼容性和业务状态机相互叠加。例如,提现函数即使使用了权限修饰器,如果在更新余额前调用外部地址,获授权的恶意合约仍可能通过回调重复进入;反过来,即使使用了重入锁,错误的管理员权限也可能允许攻击者绕过正常提现流程。
本文使用本地 Foundry 项目构建授权实验,重点说明如何发现和修复以下问题:
1. 外部调用发生在状态更新之前;
2. 用户余额、总负债和合约真实余额之间缺少一致性检查;
3. 管理员权限边界过大或可被错误转移;
4. 对 ERC-20 返回值和特殊代币行为处理不完整;
5. 修复后只验证正常流程,没有补充攻击回归测试。
所有示例仅用于本地环境、授权靶场和防御性审计,不应针对未授权的真实合约进行测试。
二、漏洞原理
重入的核心不是“使用了 call 就一定有漏洞”,而是合约在完成关键状态更新前,把执行权交给了不可信外部地址。
一个典型的脆弱提现逻辑如下:
function withdraw(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "insufficient");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balanceOf[msg.sender] -= amount;
}
执行 msg.sender.call 时,如果 msg.sender 是合约,其 receive 或 fallback 函数会被触发。此时 balanceOf[msg.sender] 尚未减少,接收方就可能再次调用 withdraw。只要余额判断仍然通过,提现流程便可能重复执行。
审计时需要特别注意:重入不只发生在 ETH 转账中。ERC-777 回调、ERC-721/ERC-1155 安全转账回调、自定义代币钩子、跨链消息接收器以及外部价格或路由合约调用,都可能产生类似的控制权转移。
三、漏洞成立的前置条件
一次可利用的重入通常需要同时满足若干条件:
1. 目标函数包含对外部合约或不可信地址的调用;
2. 外部调用发生在余额、份额、领取状态等关键数据更新之前;
3. 被调用方可以通过回调重新进入原函数或另一个共享状态的函数;
4. 合约在重入期间仍持有足够资产;
5. 没有有效的重入锁,或重入锁只覆盖单个入口,未覆盖跨函数重入;
6. 业务校验依赖尚未更新的旧状态。
如果提现需要白名单或角色授权,也不能直接判定安全。审计人员仍需检查获授权主体是否可能是合约、权限是否可以被转移,以及管理员能否错误地把高权限授予不可信地址。
四、本地实验环境
建议在独立目录使用 Foundry:
forge init reentrancy-audit-lab
cd reentrancy-audit-lab
forge install OpenZeppelin/openzeppelin-contracts --no-commit
可选地启动本地节点:
anvil
在另一个终端中运行测试:
forge test -vvv
如果需要查看完整调用轨迹:
forge test --match-test test_ReentrancyBlocked -vvvv
不要把真实私钥、生产 RPC 密钥或包含资产的助记词放入测试脚本。即使连接测试网,也应使用专门创建且无生产权限的测试账户。
五、分步骤审计实践
步骤一:建立资产流清单
先列出合约可以接收、记录和转出的全部资产:
1. 原生币通过 receive、fallback 或 payable 函数进入;
2. ERC-20 通过 transferFrom 进入;
3. ERC-721、ERC-1155 是否实现对应接收接口;
4. 用户余额是以 amount、share 还是内部记账单位保存;
5. 管理员是否存在紧急提款、手续费提取或资产迁移入口。
建议记录以下不变量:
address(this).balance >= totalUserLiability
如果是份额模型,还应检查总份额和总资产的换算关系。发现某个转账入口没有同步更新总负债,通常意味着会计逻辑存在缺口。
步骤二:定位所有外部调用
重点搜索:
call{value:
.delegatecall(
.staticcall(
.safeTransfer(
.safeTransferFrom(
.transfer(
.transferFrom(
外部调用不仅包括低级 call,也包括接口调用。例如 oracle.latestRoundData()、router.swap() 和 token.transfer() 都会进入其他合约代码。
对每个调用点依次确认:
1. 调用目标能否被用户控制;
2. 调用前是否已经完成关键状态更新;
3. 回调能否进入其他共享状态函数;
4. 调用失败时是否会完整回滚;
5. 返回值、返回数据长度和异常代币行为是否得到处理。
步骤三:检查 CEI 顺序
CEI 指 Checks、Effects、Interactions,即先检查条件,再更新内部状态,最后执行外部交互。
修复后的基本结构应为:
function withdraw(uint256 amount) external {
require(amount > 0, "zero amount");
require(balanceOf[msg.sender] >= amount, "insufficient");
balanceOf[msg.sender] -= amount;
totalLiability -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
如果外部调用失败,require 会使整笔交易回滚,前面的状态更新也会被撤销。因此,先更新状态并不会导致用户在失败时永久损失余额。
步骤四:增加重入保护
对于涉及资产转移的敏感入口,可以使用 OpenZeppelin 的 ReentrancyGuard:
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
mapping(address => uint256) public balanceOf;
uint256 public totalLiability;
function withdraw(uint256 amount) external nonReentrant {
require(amount > 0, "zero amount");
require(balanceOf[msg.sender] >= amount, "insufficient");
balanceOf[msg.sender] -= amount;
totalLiability -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}
需要注意,nonReentrant 不是错误业务逻辑的替代品。正确做法通常是同时应用 CEI、重入锁和会计不变量。
步骤五:检查跨函数重入
假设 withdraw 使用了重入锁,但 claimReward 没有使用,而两个函数都会读取或修改用户份额,那么攻击者可能在 withdraw 的外部调用期间进入 claimReward。
审计时不要只问“原函数能否再次进入”,还要问:
1. 回调期间有哪些 public 或 external 函数可调用;
2. 哪些函数共享 balance、share、rewardDebt、claimed 等状态;
3. 是否存在只读重入,即回调期间读取到临时不一致状态;
4. 是否有一个函数带锁、另一个函数未带锁的情况。
对于共享同一资产状态机的入口,应统一保护,或将内部逻辑重构为私有函数,由带锁的外部入口调用。
步骤六:审计权限边界
常见危险设计包括:
1. 使用 tx.origin 判断管理员;
2. initialize 可被重复调用;
3. owner 可以立即转移给任意地址,没有两步确认;
4. 紧急提款函数可以转走用户托管资产;
5. 代理合约实现地址可由单一热钱包直接修改;
6. 角色撤销后仍存在旧授权映射或签名权限。
应避免:
require(tx.origin == owner, "not owner");
因为 tx.origin 会穿透中间合约,用户可能在与恶意合约交互时,被诱导执行本不希望执行的管理操作。权限判断应基于 msg.sender,并结合 Ownable2Step、AccessControl、多签和时间锁等机制。
对于高风险升级操作,推荐流程是:提议新实现、等待时间锁、链下验证字节码、执行升级、验证存储布局和关键状态。
步骤七:处理 ERC-20 兼容性
不要假设所有 ERC-20 都严格返回 bool。一些历史代币可能不返回值,另一些代币可能收取转账手续费。
建议使用 SafeERC20:
using SafeERC20 for IERC20;
token.safeTransferFrom(msg.sender, address(this), amount);
token.safeTransfer(msg.sender, amount);
如果业务必须支持手续费代币,入账金额应使用余额差计算:
uint256 beforeBalance = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - beforeBalance;
balanceOf[msg.sender] += received;
否则,用户请求存入 100 个代币,而合约实际只收到 98 个,却按 100 个记账,长期会形成资不抵债。
六、本地攻击回归测试
安全测试的目标不是只证明旧代码有问题,更重要的是证明修复后攻击路径无法成功。
可以编写一个本地测试合约,在 receive 中尝试再次调用 withdraw:
contract ReentryProbe {
Vault public vault;
uint256 public attempts;
constructor(Vault _vault) {
vault = _vault;
}
receive() external payable {
attempts++;
if (attempts < 3) {
try vault.withdraw(1 ether) {} catch {}
}
}
function deposit() external payable {
vault.deposit{value: msg.value}();
}
function run() external {
vault.withdraw(1 ether);
}
}
测试断言应关注:
1. 攻击合约最多只能取回其合法余额;
2. totalLiability 与用户余额同步减少;
3. 其他用户的资产没有变化;
4. 重入尝试失败后,正常提现仍然可用;
5. 合约余额始终覆盖剩余负债。
示例测试思路:
function test_ReentrancyBlocked() public {
probe.deposit{value: 1 ether}();
probe.run();
assertEq(address(probe).balance, 1 ether);
assertEq(vault.balanceOf(address(probe)), 0);
assertEq(vault.totalLiability(), 0);
}
还应增加模糊测试,让 amount 在合法范围内变化:
function testFuzz_WithdrawAccounting(uint96 rawAmount) public {
uint256 amount = bound(uint256(rawAmount), 1, 100 ether);
// 存款、提现并检查余额与总负债不变量
}
运行:
forge test --fuzz-runs 5000
如果使用不变量测试,可以让处理器随机执行 deposit、withdraw 和 transfer,并持续断言:
assertGe(address(vault).balance, vault.totalLiability());
七、风险与注意事项
第一,不要为了“修复重入”简单改用 transfer。transfer 的固定 gas 假设可能影响某些合约钱包兼容性,而且不能替代正确的状态顺序和重入保护。
第二,delegatecall 的风险高于普通外部调用。被调用代码在当前合约的存储上下文中执行,错误实现可能修改管理员、实现地址或用户余额。插件式架构必须限制实现来源并验证存储布局。
第三,升级合约需要单独检查初始化。构造函数不会按普通方式初始化代理存储,应确保 initializer 只能执行一次,并禁用实现合约自身的初始化入口。
第四,不要只测试单用户场景。很多会计问题只有在多个用户依次存款、部分提现、手续费扣除或资产增值后才会出现。
第五,链上余额相等不代表记账正确。有人可能通过 selfdestruct 的历史行为或直接转账把原生币强制送入合约,因此不应把 address(this).balance 精确等于内部负债作为唯一安全判断。
第六,静态分析工具只能辅助定位。发现 nonReentrant 或 SafeERC20 并不代表合约整体安全,审计仍需结合业务状态机、权限配置和经济模型。
八、常见问题
问题一:使用 nonReentrant 后是否可以忽略 CEI?
不建议。重入锁可能遗漏其他入口,也可能在后续重构时被移除。CEI 能降低临时不一致状态,是更基础的设计原则。
问题二:private 函数是否不会发生重入?
private 函数不能被外部直接调用,但如果它由未加保护的 external 函数触发,整体流程仍可能被重入。审计单位应是完整调用路径,而不是单个函数的可见性。
问题三:只读函数也有重入风险吗?
有。某些协议会在外部调用期间出现短暂的不一致状态,另一个协议如果在此时读取价格、份额或储备量,可能得到错误结果,这通常被称为只读重入。
问题四:管理员紧急提款是否属于后门?
不一定。合理的紧急机制可以用于迁移误转资产或应对故障,但必须明确不能侵占用户负债,最好由多签和时间锁控制,并记录事件。
问题五:审计通过是否意味着永远安全?
不是。审计结论对应特定代码版本、编译配置和部署参数。升级实现、修改权限、接入新代币或改变预言机后,都应重新进行差异审计和回归测试。
九、总结
智能合约重入审计的关键,是从“控制权何时交给外部”出发,检查交互前后的状态是否一致,并把权限、代币行为和业务会计纳入同一威胁模型。
一个较稳妥的修复组合通常包括:遵循 CEI、为共享资产状态的入口增加统一重入保护、使用 SafeERC20、按照实际到账金额记账、限制管理员权限、采用两步权限转移,并通过单元测试、模糊测试和不变量测试验证修复。
真正有效的审计结果不只是列出漏洞名称,而是能够回答三个问题:攻击者需要什么前置条件、漏洞会破坏哪条资产或权限不变量、修复后如何用自动化测试证明同类路径已被阻断。只有完成这三步,安全修复才具备可验证性和持续维护价值。