区块链智能合约安全审计实战:从常见漏洞识别到自动化测试流程搭建
智能合约一旦部署,往往就不是“改个 bug 再发版”那么简单。代码即资产、代码即规则,这句话在链上不是口号,而是现实。很多团队在业务逻辑设计上投入很大,却把审计理解成“上线前跑一遍扫描器”,结果真正出问题时,损失并不是一个小补丁能挽回的。
这篇文章我想换一个更实战的角度来讲:不是只列漏洞清单,而是带你从“识别风险”走到“搭建自动化测试与审计流程”。如果你已经写过 Solidity,或者至少看得懂基本合约结构,这篇内容会比较适合你。
背景与问题
智能合约安全和传统 Web 安全有一个很大的不同:攻击面不只来自输入参数,还来自链上状态、调用顺序、外部合约行为、Gas 限制、权限配置以及经济模型设计。
实际审计中,常见问题通常集中在这几类:
- 重入攻击(Reentrancy)
- 权限控制缺失
tx.origin误用- 整数边界问题(旧版本更多,0.8+ 已内建检查)
- 价格预言机依赖不安全
- 随机数伪随机
- DoS 与 Gas 放大
- 升级合约存储冲突
- ERC20/721 非标准实现兼容性问题
很多团队的问题不是“不知道漏洞”,而是:
- 知道概念,但不会落到代码审计点
- 测试只测正常路径,不测攻击路径
- 没有把静态分析、单测、模糊测试、CI 串起来
- 上线前没有形成“可重复执行”的安全流程
我们这篇就围绕这几点展开。
前置知识
建议你至少具备以下基础:
- 会读 Solidity 合约
- 知道
msg.sender、msg.value、事件、修饰器 - 用过 Hardhat 或 Foundry 之一
- 知道 ERC20 的基本调用流程
如果你是第一次系统做审计,没关系,下面我会尽量把“为什么这样测”讲清楚。
环境准备
本文选用 Foundry 做演示,原因很简单:写测试快、跑得快、模糊测试和主网分叉测试很顺手。静态分析部分再配合 Slither。
安装 Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
初始化项目
forge init smart-audit-demo
cd smart-audit-demo
安装 OpenZeppelin
forge install OpenZeppelin/openzeppelin-contracts
安装 Slither
需要 Python 环境:
pip install slither-analyzer
核心原理
做智能合约审计时,我一般把分析思路拆成四层:
- 权限层:谁能调用?谁不该调用?
- 状态层:状态变化顺序是否安全?
- 外部交互层:调用外部合约前后有没有保护?
- 经济层:价格、奖励、惩罚、精度和套利空间是否合理?
你可以把它理解成一个从“代码语法”到“协议行为”的逐层检查过程。
审计流程总览
flowchart TD
A[阅读需求与资产边界] --> B[梳理权限模型]
B --> C[识别关键状态变量]
C --> D[检查外部调用与资金流]
D --> E[手工审计常见漏洞]
E --> F[静态分析 Slither]
F --> G[单元测试]
G --> H[模糊测试 Fuzz]
H --> I[不变量测试 Invariant]
I --> J[主网分叉验证]
J --> K[修复与回归测试]
这张图里最容易被忽略的是:自动化测试不是手工审计的替代,而是手工审计结论的放大器。
从典型漏洞入手:重入攻击为什么总是高频
重入攻击几乎是入门必学,因为它同时暴露了一个核心原则:状态更新与外部调用顺序不能乱。
一个典型危险模式是:
- 用户发起提现
- 合约先给用户转账
- 之后才更新用户余额
如果用户是恶意合约,就可以在收到 ETH 时再次回调提现逻辑,于是旧余额还没清零,钱就被重复提走了。
漏洞调用过程
sequenceDiagram
participant U as 攻击合约
participant V as 漏洞合约 Vault
U->>V: deposit()
U->>V: withdraw()
V-->>U: call.value(amount)
U->>V: fallback/receive 中再次调用 withdraw()
V-->>U: 再次转账
V->>V: 最后才更新余额
这也是为什么你经常会看到一个老原则:Checks-Effects-Interactions。
- Checks:先检查条件
- Effects:先更新状态
- Interactions:最后再和外部交互
实战代码(可运行)
下面我们直接做一个完整的小实验:
- 一个带重入漏洞的金库合约
- 一个攻击合约
- 一个修复后的版本
- Foundry 测试验证漏洞存在与修复有效
目录结构
smart-audit-demo/
├─ src/
│ ├─ VulnerableVault.sol
│ ├─ SecureVault.sol
│ └─ Attacker.sol
├─ test/
│ └─ Vault.t.sol
└─ foundry.toml
编写漏洞合约
src/VulnerableVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0, "zero amount");
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
// 漏洞点:先转账,再更新余额
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
编写攻击合约
src/Attacker.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableVault {
function deposit() external payable;
function withdraw() external;
}
contract Attacker {
IVulnerableVault public vault;
uint256 public attackCount;
uint256 public maxAttackCount = 3;
constructor(address _vault) {
vault = IVulnerableVault(_vault);
}
function attack() external payable {
require(msg.value >= 1 ether, "need >= 1 ether");
vault.deposit{value: 1 ether}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= 1 ether && attackCount < maxAttackCount) {
attackCount++;
vault.withdraw();
}
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
编写修复版本
src/SecureVault.sol
这里我演示两个修复点:
- 使用 Checks-Effects-Interactions
- 增加一个简单的重入锁
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SecureVault {
mapping(address => uint256) public balances;
bool private locked;
modifier nonReentrant() {
require(!locked, "reentrant call");
locked = true;
_;
locked = false;
}
function deposit() external payable {
require(msg.value > 0, "zero amount");
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
// 先更新状态
balances[msg.sender] = 0;
// 再做外部调用
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
生产环境里,我更建议直接用 OpenZeppelin 的
ReentrancyGuard,不要自己重复造轮子,除非你非常清楚实现边界。
编写测试
test/Vault.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/VulnerableVault.sol";
import "../src/SecureVault.sol";
import "../src/Attacker.sol";
contract SecureAttacker {
SecureVault public vault;
uint256 public count;
constructor(address _vault) {
vault = SecureVault(_vault);
}
function attack() external payable {
vault.deposit{value: 1 ether}();
vault.withdraw();
}
receive() external payable {
if (count < 1) {
count++;
try vault.withdraw() {} catch {}
}
}
}
contract VaultTest is Test {
VulnerableVault vulnerableVault;
SecureVault secureVault;
Attacker attacker;
address user = address(0x123);
function setUp() public {
vulnerableVault = new VulnerableVault();
secureVault = new SecureVault();
attacker = new Attacker(address(vulnerableVault));
vm.deal(user, 10 ether);
vm.deal(address(attacker), 1 ether);
}
function testDepositAndWithdrawNormal() public {
vm.startPrank(user);
vulnerableVault.deposit{value: 2 ether}();
assertEq(vulnerableVault.balances(user), 2 ether);
vulnerableVault.withdraw();
assertEq(vulnerableVault.balances(user), 0);
vm.stopPrank();
}
function testReentrancyAttack() public {
vm.prank(user);
vulnerableVault.deposit{value: 5 ether}();
vm.deal(address(attacker), 2 ether);
attacker.attack{value: 1 ether}();
assertGt(address(attacker).balance, 1 ether);
assertLt(address(vulnerableVault).balance, 5 ether);
}
function testSecureVaultBlocksAttack() public {
SecureAttacker secureAttacker = new SecureAttacker(address(secureVault));
vm.prank(user);
secureVault.deposit{value: 5 ether}();
vm.deal(address(secureAttacker), 2 ether);
secureAttacker.attack{value: 1 ether}();
// 攻击者只能拿回自己的 1 ether,不应额外盗取池子资金
assertEq(address(secureVault).balance, 5 ether);
}
}
运行测试
forge test -vv
如果你想看更详细日志:
forge test -vvvv
如果需要 Gas 报告:
forge test --gas-report
用 Slither 做静态分析
在项目目录执行:
slither src/VulnerableVault.sol
很多时候 Slither 会提示:
- reentrancy 风险
- low-level call 风险
- 未检查返回值
- 命名、可见性、优化建议
它不是万能的,但很适合做第一轮快速筛查。
自动化测试流程怎么搭
这里是重点:不要把安全测试理解成“多写几个单元测试”。真正可落地的流程,至少要包括这几层。
一、静态分析
适合发现明显模式问题:
- 重入模式
- 危险低级调用
- 未初始化变量
- 权限/可见性异常
- 死代码
二、单元测试
验证明确业务规则:
- 正常充值/提现
- 非法权限访问
- 极限输入
- 边界金额
- 事件是否正确发出
三、模糊测试
Foundry 很适合做 fuzz。比如你可以让输入金额随机,检查“余额永远不会凭空增发”。
四、不变量测试
不变量比普通单测更像审计思维。
例如:
- 合约总资产 >= 所有可提余额之和
- 非 owner 永远不能调用管理函数
- 提现后个人余额必须归零
- 清算后仓位状态不能回到活跃状态
五、主网分叉测试
如果你的协议依赖真实 ERC20、DEX、预言机、借贷协议,一定要做 fork test。很多问题在本地 mock 环境根本看不出来。
自动化流程示意图
flowchart LR
A[提交代码 PR] --> B[格式化与编译检查]
B --> C[单元测试]
C --> D[Fuzz 测试]
D --> E[Invariant 测试]
E --> F[Slither 静态分析]
F --> G[主网分叉测试]
G --> H[生成审计报告与风险清单]
这个顺序不是绝对固定,但在 CI 里一般会把“快反馈”的步骤放前面,把耗时长的 fork test 放后面。
用 Foundry 做一个简单 Fuzz 测试
test/FuzzVault.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/SecureVault.sol";
contract FuzzVaultTest is Test {
SecureVault vault;
address user = address(0xBEEF);
function setUp() public {
vault = new SecureVault();
vm.deal(user, 100 ether);
}
function testFuzzDepositWithdraw(uint96 amount) public {
vm.assume(amount > 0);
vm.assume(amount <= 10 ether);
vm.startPrank(user);
vault.deposit{value: amount}();
assertEq(vault.balances(user), amount);
vault.withdraw();
assertEq(vault.balances(user), 0);
vm.stopPrank();
}
}
运行:
forge test --match-test testFuzzDepositWithdraw -vv
这类测试的意义不是替代逻辑推理,而是帮你快速覆盖“大量你没手动想到的输入组合”。
用 Invariant 测试守住核心资金属性
test/InvariantVault.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "forge-std/StdInvariant.sol";
import "../src/SecureVault.sol";
contract Handler is Test {
SecureVault public vault;
address[] public users;
constructor(SecureVault _vault) {
vault = _vault;
users.push(address(0x1));
users.push(address(0x2));
users.push(address(0x3));
for (uint256 i = 0; i < users.length; i++) {
vm.deal(users[i], 100 ether);
}
}
function deposit(uint256 userIndex, uint96 amount) public {
address user = users[userIndex % users.length];
amount = uint96(bound(amount, 1, 10 ether));
vm.prank(user);
vault.deposit{value: amount}();
}
function withdraw(uint256 userIndex) public {
address user = users[userIndex % users.length];
if (vault.balances(user) > 0) {
vm.prank(user);
vault.withdraw();
}
}
function totalTrackedBalances() public view returns (uint256 total) {
for (uint256 i = 0; i < users.length; i++) {
total += vault.balances(users[i]);
}
}
}
contract InvariantVaultTest is StdInvariant, Test {
SecureVault vault;
Handler handler;
function setUp() public {
vault = new SecureVault();
handler = new Handler(vault);
targetContract(address(handler));
}
function invariant_contractBalanceGteTrackedBalances() public view {
assertGe(address(vault).balance, handler.totalTrackedBalances());
}
}
运行:
forge test --match-path test/InvariantVault.t.sol -vv
在真实项目里,不变量测试往往比单测更能暴露深层问题,因为它会随机组合一系列状态迁移。
逐步验证清单
如果你准备把这套流程真正接到项目里,可以按下面这个清单执行:
第一步:确认资产与权限边界
- 哪些函数会转移资产?
- 哪些角色能升级、暂停、配置参数?
- 是否存在多签/Timelock?
第二步:列出关键风险点
- 外部调用在哪些地方发生?
- 价格来源是否可信?
- 是否依赖回调机制?
- 是否存在循环遍历用户列表?
第三步:把风险点变成测试项
比如:
- “提现不可重入”
- “非 owner 不能改费率”
- “奖励发放总额不能超上限”
- “极端输入下不会 revert 或资金锁死”
第四步:接入自动化
至少包含:
forge testforge fmt --checkslither- fuzz / invariant
- 关键模块 fork test
常见坑与排查
这部分我尽量讲得接地气一点,因为很多问题真不是“看不懂漏洞原理”,而是实际跑起来各种卡。
1. 以为 transfer 比 call 安全
早些年很多文章会说 transfer 自带 2300 gas 限制,能防重入。但现在这种说法已经不够稳妥了。EVM Gas 成本变化后,很多场景下 transfer 反而会导致兼容性问题。
建议:
- 优先用
call - 配合 CEI 原则
- 配合
ReentrancyGuard
2. 单测全绿,但真实协议一跑就出问题
这是 mock 环境过度理想化的典型表现。比如:
- 真实 ERC20 不一定返回
bool - 某些代币有手续费
- 预言机更新频率不稳定
- DEX 滑点与池子深度影响执行结果
排查方法:
- 做主网分叉测试
- 用真实协议地址交互
- 引入极端行情和低流动性场景
3. 权限只测“有权限的人能调”,没测“没权限的人不能调”
这类遗漏很常见。很多团队习惯写 happy path,不习惯写 negative test。
建议:
- 每个管理函数都补一条非授权调用用例
- 检查初始化函数是否可重复调用
- 升级入口必须有权限与初始化保护
4. 忽略升级合约的存储布局
如果你使用代理模式,审计范围不能只看逻辑合约。存储槽冲突、初始化顺序错误、实现合约被直接初始化,都是高风险问题。
排查重点:
- 是否使用标准代理模式
- 是否禁用实现合约初始化
- 新版本变量追加是否遵守布局规则
5. 模糊测试跑不出有效结果
很多人第一次用 fuzz,会发现测试“跑了很多轮,但没发现问题”。原因往往是:
- 输入约束太松,绝大多数 case 没意义
- 没定义好不变量
- 没构造出关键状态组合
建议:
- 用
vm.assume和bound收紧输入空间 - 先从资金守恒、权限守恒开始定义 invariant
- 为关键状态构造 handler
安全/性能最佳实践
安全和性能在链上经常是绑在一起讨论的,因为 Gas 设计不好,有时也会变成安全问题,比如 DoS。
1. 使用成熟库,不轻易手写底层安全逻辑
推荐优先考虑:
- OpenZeppelin 的
Ownable AccessControlReentrancyGuardPausableSafeERC20
特别是 ERC20 交互,请尽量用 SafeERC20,不要假设所有 token 都标准实现。
2. 资金操作遵循 CEI 原则
这是最基本也最值钱的一条:
- 先检查
- 再更新状态
- 最后外部交互
3. 避免无界循环
比如给所有用户一次性发奖励、批量清算大数组,这些都可能在用户规模大后无法执行,最终演变成可用性问题。
更好的做法:
- 分批处理
- 拉模式领取(pull over push)
- 用索引/快照替代全量遍历
4. 显式定义失败策略
一个函数失败时,你要明确:
- 回滚全部?
- 允许部分成功?
- 是否记录失败事件?
- 是否提供重试机制?
这点在批处理、跨合约调用、桥接协议中特别重要。
5. 把“业务规则”写成测试,而不是写在文档里
很多安全事故不是程序员没写代码,而是规则只存在于 PR 评论、飞书消息、口头同步里。
比较可靠的方式是:
- 上限/下限写成常量与校验
- 核心约束写成 invariant
- 权限矩阵写成单测
6. 加入暂停与应急机制,但别滥用
pause 是救命开关,但不是万能药。它适合:
- 漏洞止血
- 异常市场波动时限制高风险操作
- 上线初期观察期防御
但边界也要清楚:
- 不能依赖 pause 掩盖架构缺陷
- pause 权限最好交给多签
- 恢复流程要清晰可审计
一个可落地的 CI 示例
如果你准备接 GitHub Actions,可以用下面这类思路:
.github/workflows/ci.yml
name: Smart Contract CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Show forge version
run: forge --version
- name: Format check
run: forge fmt --check
- name: Build
run: forge build
- name: Run tests
run: forge test -vv
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Slither
run: pip install slither-analyzer
- name: Run Slither
run: slither src/VulnerableVault.sol || true
这里我特意把 slither 后面加了 || true,是因为很多团队在接 CI 初期,不希望因为静态分析告警太多导致所有 PR 都直接失败。更成熟的做法是:
- 先以告警模式运行
- 梳理误报
- 再逐步把高危规则升级为阻断条件
这个节奏比“一上来全拦截”更容易落地。
审计时我常用的一套检查视角
如果你在手工审计时不知道从哪下手,可以按下面顺序过一遍:
资产流
- 钱从哪来?
- 钱往哪去?
- 谁能触发流动?
权限流
- admin 能做什么?
- operator 能做什么?
- 普通用户能不能越权?
状态流
- 状态是否会卡死?
- 是否存在先后顺序依赖?
- 是否可能重复执行?
外部依赖
- Oracle 是否可操纵?
- Token 是否标准?
- 回调是否可重入?
- 第三方协议失败如何处理?
经济攻击面
- 是否能闪电贷操纵价格?
- 是否能通过精度损耗套利?
- 是否能通过先存后提/先借后清算套取奖励?
这套方法不花哨,但很实用,尤其适合中级开发者把“漏洞知识点”组织成真正能用的审计思路。
总结
智能合约安全审计,真正难的不是记住十几种漏洞名字,而是把下面这件事做好:
从“知道风险”走到“能稳定复现、验证、修复,并通过自动化流程持续防回归”。
如果你只带走三条建议,我建议是这三条:
- 所有资金相关函数,先按 CEI 原则过一遍
- 把关键安全规则写成单测、Fuzz 和 Invariant
- 用静态分析 + 主网分叉测试补齐人工审计盲区
最后也提醒一个边界条件:
自动化流程能显著提高安全基线,但它不能替代人工审计对业务逻辑、经济模型和协议组合风险的判断。尤其是 DeFi 协议,很多真正昂贵的漏洞,往往不是一句 reentrancy 能概括的,而是“机制本身允许被套利”。
所以更稳妥的做法是:
- 开发阶段建立自动化安全测试
- 上线前做人工专项审计
- 上线后持续监控异常行为与资产变动
如果你现在就要开始,我建议你先从一件小事做起:把项目里最关键的提现、授权、参数配置函数,各补一条失败路径测试。这是最小成本、但收益很高的安全改进。