1
月
在社区成长 1 月
LEVEL 1
1 XP
一、背景:为什么需要把审计拆成可验证的安全属性
智能合约一旦部署,代码和状态通常会长期暴露在公开环境中,攻击者可以反复调用入口、组合不同交易顺序,并利用区块时间、价格流动性和外部调用等因素寻找边界条件。与传统 Web 应用不同,合约漏洞往往不仅是某一行代码写错,还可能来自权限模型、资产记账、升级代理、跨合约交互和经济假设之间的不一致。
本文以本地实验和授权靶场为范围,介绍一套适合 Solidity 项目的审计流程。重点覆盖重入、访问控制、整数与业务逻辑、预言机操纵、代理升级,以及如何使用静态分析、单元测试、模糊测试和不变量测试进行验证。所有示例都应在本地 Anvil、Foundry 测试环境或明确授权的审计靶场中运行,不要对陌生合约、公共 RPC 或真实资金进行测试。
二、前置条件与实验环境
建议准备以下工具:
1. Foundry:包含 forge、cast、anvil,用于编译、测试、部署本地合约。
2. Solidity 0.8.x:该版本默认包含算术溢出检查,但不能替代业务逻辑审计。
3. Slither:用于快速发现重入、危险调用、权限和代码结构问题。
4. Echidna 或 Foundry invariant:用于持续生成输入,验证资产守恒、权限不可绕过等性质。
创建一个本地项目并运行测试:
forge init secure-audit-lab
cd secure-audit-lab
forge build
forge test -vvv
anvil
如果使用 Slither,可在项目根目录执行 slither .。在审计前应固定编译器版本、依赖版本和优化器配置,避免本地分析结果与实际部署字节码不一致。对于代理合约,还要确认实现合约地址、管理员地址、初始化状态和存储布局。
三、第一步:建立资产流和权限模型
不要一开始只盯着单个函数。先列出以下信息:
1. 资产从哪里进入:存款、铸币、质押、跨链消息还是管理员转账。
2. 资产在哪里记账:用户余额、份额、债务、抵押率和奖励指数分别由哪些变量表示。
3. 资产从哪里流出:提款、赎回、清算、转账、退款和紧急提取。
4. 谁可以改变关键状态:owner、role、治理合约、预言机、升级管理员和回调合约。
5. 哪些函数会进行外部调用:transfer、call、safeTransfer、token hook、闪电贷回调等。
建议把每个入口函数写成前置条件、状态变化、外部交互和后置条件四部分。例如提款函数的基本后置条件应是:用户成功收到的资产数量不能超过其可提款额度;内部余额扣减必须与实际转账一致;在外部调用失败时,整个交易应回滚或进入明确的失败处理路径。
四、典型漏洞一:重入的原理、错误示例与修复
重入并不只发生在 ETH 转账。任何向不可信地址发起外部调用的函数,都可能被对方合约在回调中再次进入。典型错误结构是先调用外部地址,之后才更新余额:
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, insufficient);
(bool ok, ) = msg.sender.call{value: amount}();
require(ok, transfer failed);
balances[msg.sender] -= amount;
}
如果 msg.sender 是恶意测试合约,它可以在 receive 或 fallback 中再次调用 withdraw。第一次调用尚未扣减余额,第二次调用仍然通过检查,最终造成重复提款。
推荐采用检查—生效—交互模式,并根据需要增加重入锁:
uint256 public constant NOT_ENTERED = 1;
uint256 public constant ENTERED = 2;
uint256 private status = NOT_ENTERED;
modifier nonReentrant() {
require(status != ENTERED, reentrant);
status = ENTERED;
_;
status = NOT_ENTERED;
}
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, insufficient);
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}();
require(ok, transfer failed);
}
本地验证时,编写一个攻击模拟合约即可,不需要连接真实网络。测试重点不是证明某种攻击收益,而是证明修复后的不变量:无论回调如何重入,用户余额和合约总资产都不会出现不一致。还要检查 ERC777、ERC721、ERC1155 等带有接收回调的代币交互,因为它们的回调路径与普通 ERC20 不同。
五、典型漏洞二:访问控制和初始化问题
很多严重事故不是算法错误,而是敏感函数缺少权限检查。例如 mint、setOracle、upgradeTo、setFee、pause 和 sweep 等函数,如果任何地址都能调用,攻击者不需要复杂技巧即可改变系统状态。
审计时逐一检查:
1. 权限修饰器是否真的应用在敏感函数上。
2. owner 或角色是否可以被无条件覆盖。
3. 部署后是否执行了初始化,初始化函数是否可重复调用。
4. 代理模式中,初始化是否在代理上下文执行,而不是只初始化实现合约。
5. 管理员是否使用多签、时间锁和事件记录,紧急权限是否有清晰边界。
对于可升级合约,应使用 OpenZeppelin 的初始化模式和升级插件进行存储布局检查。初始化函数通常需要 initializer 保护,升级实现需要限制为明确的升级管理员。不要把构造函数中的关键状态初始化误认为代理已经完成初始化,因为代理调用的是实现合约代码,但使用的是代理自己的存储。
六、典型漏洞三:预言机和价格操纵
合约不能直接知道链下价格,通常依赖预言机、交易对储备或多个来源。如果协议直接读取低流动性池的 spot price,攻击者可能在同一交易中先用大额临时资金改变储备,再触发借贷、清算或铸币逻辑,最后恢复价格。
防御时可采用以下方法:
1. 使用经过验证的时间加权平均价格,而不是单个区块的瞬时价格。
2. 设置价格偏离阈值、最大陈旧时间和异常值拒绝逻辑。
3. 使用多个独立来源,并明确缺失、停更和价格冲突时的处理方式。
4. 借贷协议将抵押率、债务上限和单区块可借额度纳入风险控制。
5. 测试闪电贷、低流动性、预言机暂停和极端价格变化场景。
本地测试可以部署一个可控的 MockOracle,分别返回正常、过期、零值、极大值和价格跳变数据,验证协议是否暂停敏感操作或拒绝异常报价。不要把单元测试中固定的理想价格当成真实安全证明。
七、第二步:用测试和模糊测试验证安全属性
建议至少编写以下测试:
1. 权限测试:普通用户调用管理函数必须失败;角色撤销后不能继续执行。
2. 重入测试:恶意接收合约回调时,余额不会被重复消费。
3. 资产守恒测试:所有用户余额、协议费用和实际代币余额满足预期关系。
4. 边界测试:零值、最大 uint、空地址、重复存款、重复提款和极端手续费。
5. 状态机测试:暂停后不能存取款;清算后不能再次清算同一仓位;初始化只能成功一次。
Foundry 的模糊测试会自动生成不同输入。例如可以把存款、提款、转账和暂停操作封装到 Handler 中,再通过 invariant 函数检查:
function invariant_totalAccounting() public view {
assertLe(totalUserBalance + totalFee, token.balanceOf(address(vault)));
}
实际项目中需要根据手续费、损失准备金和未结算奖励调整公式,不能机械复制。测试失败后,要记录最小复现序列、初始状态、调用者、参数和失败断言,避免只修复某个随机输入而忽略根本原因。
八、审计交付与风险分级
发现问题后应给出可复现证据,而不是只写结论。每个问题至少包含:漏洞位置、前置条件、影响范围、复现步骤、根因、修复建议和修复后的回归测试。风险可按是否能直接盗取资产、是否需要特殊权限、是否影响可用性、是否依赖外部市场条件进行分级。
高风险问题通常包括任意铸币、绕过提款权限、代理被未授权升级、可重复初始化、关键价格可被单一低流动性来源操纵。中风险问题可能包括异常价格未暂停、管理员权限过大、手续费计算边界错误和事件缺失。低风险问题包括注释与实现不一致、错误信息不清晰、无关键操作事件等,但低风险问题也可能在组合场景中放大,应记录其上下文。
九、风险与注意事项
1. 不要把 Slither 无告警当成没有漏洞,静态工具无法理解全部经济模型。
2. 不要只测试成功路径,失败回滚、代币返回值异常和外部调用失败同样重要。
3. 不要在主网或他人部署环境进行未授权探测、资金转移或压力测试。
4. 记录编译器、依赖、链 ID、区块高度和测试命令,保证结果可复现。
5. 代理升级后必须重新检查存储布局、权限、初始化状态和关键不变量。
6. 任何修复都应配套回归测试,避免通过更改顺序掩盖会计错误。
十、常见问题
问题一:使用 Solidity 0.8.x 后,是否不再需要检查整数安全?
答:算术溢出通常会自动回滚,但除法截断、单位换算、份额精度损失、错误的顺序和 unchecked 区块仍然可能造成严重业务漏洞。
问题二:加入 nonReentrant 后是否彻底解决重入?
答:不能。它主要限制同一合约的特定重入路径,无法修复跨合约状态不一致、错误的权限、跨函数回调或预言机操纵。仍需遵循先更新状态、再外部交互的原则。
问题三:为什么有单元测试仍会出现漏洞?
答:单元测试通常覆盖开发者预设的路径,而攻击者会组合调用顺序、身份、代币行为和市场状态。应增加模糊测试、不变量测试、差分测试以及人工审阅资产流。
问题四:多签是否等于安全?
答:多签降低单个密钥泄露风险,但不能修复危险的升级逻辑、错误的初始化和无时间锁的紧急权限。多签成员管理、签名阈值和操作审计同样重要。
十一、总结
智能合约安全审计的核心不是寻找某个固定关键词,而是把权限、资产、状态转换和外部依赖建立成可验证模型。实践上可以遵循资产流梳理、敏感入口枚举、外部调用检查、权限与初始化审计、预言机假设验证、单元测试、模糊测试和不变量测试这一流程。重入、访问控制、代理升级和价格操纵需要分别验证,也要关注它们的组合风险。只有在本地或授权靶场中复现问题,并用回归测试证明修复有效,审计结论才具备可操作性和持续价值。
智能合约一旦部署,代码和状态通常会长期暴露在公开环境中,攻击者可以反复调用入口、组合不同交易顺序,并利用区块时间、价格流动性和外部调用等因素寻找边界条件。与传统 Web 应用不同,合约漏洞往往不仅是某一行代码写错,还可能来自权限模型、资产记账、升级代理、跨合约交互和经济假设之间的不一致。
本文以本地实验和授权靶场为范围,介绍一套适合 Solidity 项目的审计流程。重点覆盖重入、访问控制、整数与业务逻辑、预言机操纵、代理升级,以及如何使用静态分析、单元测试、模糊测试和不变量测试进行验证。所有示例都应在本地 Anvil、Foundry 测试环境或明确授权的审计靶场中运行,不要对陌生合约、公共 RPC 或真实资金进行测试。
二、前置条件与实验环境
建议准备以下工具:
1. Foundry:包含 forge、cast、anvil,用于编译、测试、部署本地合约。
2. Solidity 0.8.x:该版本默认包含算术溢出检查,但不能替代业务逻辑审计。
3. Slither:用于快速发现重入、危险调用、权限和代码结构问题。
4. Echidna 或 Foundry invariant:用于持续生成输入,验证资产守恒、权限不可绕过等性质。
创建一个本地项目并运行测试:
forge init secure-audit-lab
cd secure-audit-lab
forge build
forge test -vvv
anvil
如果使用 Slither,可在项目根目录执行 slither .。在审计前应固定编译器版本、依赖版本和优化器配置,避免本地分析结果与实际部署字节码不一致。对于代理合约,还要确认实现合约地址、管理员地址、初始化状态和存储布局。
三、第一步:建立资产流和权限模型
不要一开始只盯着单个函数。先列出以下信息:
1. 资产从哪里进入:存款、铸币、质押、跨链消息还是管理员转账。
2. 资产在哪里记账:用户余额、份额、债务、抵押率和奖励指数分别由哪些变量表示。
3. 资产从哪里流出:提款、赎回、清算、转账、退款和紧急提取。
4. 谁可以改变关键状态:owner、role、治理合约、预言机、升级管理员和回调合约。
5. 哪些函数会进行外部调用:transfer、call、safeTransfer、token hook、闪电贷回调等。
建议把每个入口函数写成前置条件、状态变化、外部交互和后置条件四部分。例如提款函数的基本后置条件应是:用户成功收到的资产数量不能超过其可提款额度;内部余额扣减必须与实际转账一致;在外部调用失败时,整个交易应回滚或进入明确的失败处理路径。
四、典型漏洞一:重入的原理、错误示例与修复
重入并不只发生在 ETH 转账。任何向不可信地址发起外部调用的函数,都可能被对方合约在回调中再次进入。典型错误结构是先调用外部地址,之后才更新余额:
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, insufficient);
(bool ok, ) = msg.sender.call{value: amount}();
require(ok, transfer failed);
balances[msg.sender] -= amount;
}
如果 msg.sender 是恶意测试合约,它可以在 receive 或 fallback 中再次调用 withdraw。第一次调用尚未扣减余额,第二次调用仍然通过检查,最终造成重复提款。
推荐采用检查—生效—交互模式,并根据需要增加重入锁:
uint256 public constant NOT_ENTERED = 1;
uint256 public constant ENTERED = 2;
uint256 private status = NOT_ENTERED;
modifier nonReentrant() {
require(status != ENTERED, reentrant);
status = ENTERED;
_;
status = NOT_ENTERED;
}
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, insufficient);
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}();
require(ok, transfer failed);
}
本地验证时,编写一个攻击模拟合约即可,不需要连接真实网络。测试重点不是证明某种攻击收益,而是证明修复后的不变量:无论回调如何重入,用户余额和合约总资产都不会出现不一致。还要检查 ERC777、ERC721、ERC1155 等带有接收回调的代币交互,因为它们的回调路径与普通 ERC20 不同。
五、典型漏洞二:访问控制和初始化问题
很多严重事故不是算法错误,而是敏感函数缺少权限检查。例如 mint、setOracle、upgradeTo、setFee、pause 和 sweep 等函数,如果任何地址都能调用,攻击者不需要复杂技巧即可改变系统状态。
审计时逐一检查:
1. 权限修饰器是否真的应用在敏感函数上。
2. owner 或角色是否可以被无条件覆盖。
3. 部署后是否执行了初始化,初始化函数是否可重复调用。
4. 代理模式中,初始化是否在代理上下文执行,而不是只初始化实现合约。
5. 管理员是否使用多签、时间锁和事件记录,紧急权限是否有清晰边界。
对于可升级合约,应使用 OpenZeppelin 的初始化模式和升级插件进行存储布局检查。初始化函数通常需要 initializer 保护,升级实现需要限制为明确的升级管理员。不要把构造函数中的关键状态初始化误认为代理已经完成初始化,因为代理调用的是实现合约代码,但使用的是代理自己的存储。
六、典型漏洞三:预言机和价格操纵
合约不能直接知道链下价格,通常依赖预言机、交易对储备或多个来源。如果协议直接读取低流动性池的 spot price,攻击者可能在同一交易中先用大额临时资金改变储备,再触发借贷、清算或铸币逻辑,最后恢复价格。
防御时可采用以下方法:
1. 使用经过验证的时间加权平均价格,而不是单个区块的瞬时价格。
2. 设置价格偏离阈值、最大陈旧时间和异常值拒绝逻辑。
3. 使用多个独立来源,并明确缺失、停更和价格冲突时的处理方式。
4. 借贷协议将抵押率、债务上限和单区块可借额度纳入风险控制。
5. 测试闪电贷、低流动性、预言机暂停和极端价格变化场景。
本地测试可以部署一个可控的 MockOracle,分别返回正常、过期、零值、极大值和价格跳变数据,验证协议是否暂停敏感操作或拒绝异常报价。不要把单元测试中固定的理想价格当成真实安全证明。
七、第二步:用测试和模糊测试验证安全属性
建议至少编写以下测试:
1. 权限测试:普通用户调用管理函数必须失败;角色撤销后不能继续执行。
2. 重入测试:恶意接收合约回调时,余额不会被重复消费。
3. 资产守恒测试:所有用户余额、协议费用和实际代币余额满足预期关系。
4. 边界测试:零值、最大 uint、空地址、重复存款、重复提款和极端手续费。
5. 状态机测试:暂停后不能存取款;清算后不能再次清算同一仓位;初始化只能成功一次。
Foundry 的模糊测试会自动生成不同输入。例如可以把存款、提款、转账和暂停操作封装到 Handler 中,再通过 invariant 函数检查:
function invariant_totalAccounting() public view {
assertLe(totalUserBalance + totalFee, token.balanceOf(address(vault)));
}
实际项目中需要根据手续费、损失准备金和未结算奖励调整公式,不能机械复制。测试失败后,要记录最小复现序列、初始状态、调用者、参数和失败断言,避免只修复某个随机输入而忽略根本原因。
八、审计交付与风险分级
发现问题后应给出可复现证据,而不是只写结论。每个问题至少包含:漏洞位置、前置条件、影响范围、复现步骤、根因、修复建议和修复后的回归测试。风险可按是否能直接盗取资产、是否需要特殊权限、是否影响可用性、是否依赖外部市场条件进行分级。
高风险问题通常包括任意铸币、绕过提款权限、代理被未授权升级、可重复初始化、关键价格可被单一低流动性来源操纵。中风险问题可能包括异常价格未暂停、管理员权限过大、手续费计算边界错误和事件缺失。低风险问题包括注释与实现不一致、错误信息不清晰、无关键操作事件等,但低风险问题也可能在组合场景中放大,应记录其上下文。
九、风险与注意事项
1. 不要把 Slither 无告警当成没有漏洞,静态工具无法理解全部经济模型。
2. 不要只测试成功路径,失败回滚、代币返回值异常和外部调用失败同样重要。
3. 不要在主网或他人部署环境进行未授权探测、资金转移或压力测试。
4. 记录编译器、依赖、链 ID、区块高度和测试命令,保证结果可复现。
5. 代理升级后必须重新检查存储布局、权限、初始化状态和关键不变量。
6. 任何修复都应配套回归测试,避免通过更改顺序掩盖会计错误。
十、常见问题
问题一:使用 Solidity 0.8.x 后,是否不再需要检查整数安全?
答:算术溢出通常会自动回滚,但除法截断、单位换算、份额精度损失、错误的顺序和 unchecked 区块仍然可能造成严重业务漏洞。
问题二:加入 nonReentrant 后是否彻底解决重入?
答:不能。它主要限制同一合约的特定重入路径,无法修复跨合约状态不一致、错误的权限、跨函数回调或预言机操纵。仍需遵循先更新状态、再外部交互的原则。
问题三:为什么有单元测试仍会出现漏洞?
答:单元测试通常覆盖开发者预设的路径,而攻击者会组合调用顺序、身份、代币行为和市场状态。应增加模糊测试、不变量测试、差分测试以及人工审阅资产流。
问题四:多签是否等于安全?
答:多签降低单个密钥泄露风险,但不能修复危险的升级逻辑、错误的初始化和无时间锁的紧急权限。多签成员管理、签名阈值和操作审计同样重要。
十一、总结
智能合约安全审计的核心不是寻找某个固定关键词,而是把权限、资产、状态转换和外部依赖建立成可验证模型。实践上可以遵循资产流梳理、敏感入口枚举、外部调用检查、权限与初始化审计、预言机假设验证、单元测试、模糊测试和不变量测试这一流程。重入、访问控制、代理升级和价格操纵需要分别验证,也要关注它们的组合风险。只有在本地或授权靶场中复现问题,并用回归测试证明修复有效,审计结论才具备可操作性和持续价值。