区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,往往就很难“热修复”。这和普通后端服务很不一样:Web 服务挂了还能回滚、补丁、限流,但合约里的 bug,可能直接变成链上永久资产风险。
所以,安全审计不是上线前的形式化动作,而是开发流程的一部分。
这篇文章我会按“能上手”的思路来讲,不只列漏洞名词,而是带你从:
- 识别典型漏洞;
- 编写最小可复现合约;
- 用自动化工具做静态分析;
- 用测试验证风险;
- 搭建一条可复用的审计流水线。
文章默认读者对 Solidity、EVM 和常见开发工具有基础了解。
背景与问题
智能合约安全的难点,不在于“漏洞列表很多”,而在于它同时具备以下特点:
- 代码公开:攻击者可以反复阅读你的逻辑;
- 资金直接绑定:漏洞不是报错,而是资产被转走;
- 执行环境受限:Gas、调用栈、回退逻辑都会影响安全;
- 权限和状态强耦合:一个小的状态变量更新顺序问题,就可能造成严重后果;
- 第三方依赖复杂:代理合约、预言机、ERC20 实现差异,都会带来额外风险。
很多团队刚开始做审计时,容易陷入两个误区:
误区一:只靠人工读代码
人工审计很重要,但纯人工会遇到几个问题:
- 容易遗漏边界分支;
- 重复性检查效率低;
- 不同审计人员标准不一致;
- 对历史回归问题缺乏机制保障。
误区二:只跑工具,不理解结果
像 Slither、Mythril、Echidna 这类工具很强,但它们输出的是线索,不是最终结论。
我自己第一次跑 Slither 的时候,输出了一屏 warning,看着很吓人,后来才发现不少是误报,真正危险的是其中一个低调的重入路径。
所以更现实的方式是:
人工建模 + 工具筛查 + 测试验证 + 持续集成
前置知识与环境准备
为了让代码能直接跑起来,这里采用 Hardhat + Solidity + Slither 的组合。
环境
- Node.js 16+
- npm 或 pnpm
- Python 3.8+
- Solidity 0.8.x
- Hardhat
- Slither
安装步骤
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
选择创建一个 JavaScript 项目后,再安装 Slither:
pip install slither-analyzer
项目结构大致如下:
contract-audit-demo/
├── contracts/
├── test/
├── scripts/
├── hardhat.config.js
└── package.json
核心原理
在实战前,先统一一下审计视角。一个比较好用的切入方式是把审计拆成四层:
- 资产流:钱从哪里来,到哪里去;
- 权限流:谁可以调用什么函数;
- 状态流:状态在调用前、中、后如何变化;
- 外部交互:是否依赖外部合约、预言机、代币返回值。
审计流程总览
flowchart TD
A[明确业务与资产模型] --> B[识别关键函数与权限边界]
B --> C[人工代码走查]
C --> D[静态分析 Slither]
D --> E[编写单元测试与攻击测试]
E --> F[回归修复验证]
F --> G[接入 CI 自动化]
常见漏洞的底层原因
智能合约里很多漏洞,本质上不是“语法问题”,而是状态机设计错误。
比如:
- 重入:状态更新发生在外部调用之后;
- 权限绕过:关键函数缺少访问控制;
- 整数处理失误:虽然 0.8+ 默认检查溢出,但业务逻辑仍可能算错;
- DoS:依赖循环、依赖外部返回、依赖某个参与者配合;
- 不安全随机数:把
block.timestamp、blockhash当作强随机源。
一个简单的安全分析模型
classDiagram
class ContractAudit {
+资产入口
+资产出口
+权限角色
+关键状态变量
+外部调用点
}
class RiskPoint {
+重入
+权限缺失
+价格操纵
+拒绝服务
+升级风险
}
ContractAudit --> RiskPoint : 映射分析
这个模型的好处是:你在拿到一个陌生合约时,不会直接陷进实现细节,而是先问:
- 谁能存钱?
- 谁能提钱?
- 提钱前后余额如何变化?
- 调用了谁?
- 失败会不会卡死全局流程?
实战代码(可运行)
这一部分我们故意写一个存在重入漏洞的合约,然后用攻击合约和工具把问题跑出来。
1. 漏洞合约
新建 contracts/VulnerableBank.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0, "zero amount");
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
// 漏洞点:先转账,后更新余额
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
balances[msg.sender] -= amount;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
这里的核心问题非常经典:
call会把控制权交给对方合约;- 如果对方在 fallback/receive 里再次调用
withdraw; - 而当前余额还没扣减,就会重复提款。
2. 攻击合约
新建 contracts/Attacker.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
function getBalance() external view returns (uint256);
}
contract Attacker {
IVulnerableBank public bank;
address public owner;
uint256 public attackAmount = 1 ether;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
owner = msg.sender;
}
function attack() external payable {
require(msg.sender == owner, "not owner");
require(msg.value >= attackAmount, "need more ether");
bank.deposit{value: attackAmount}();
bank.withdraw(attackAmount);
}
receive() external payable {
if (address(bank).balance >= attackAmount) {
bank.withdraw(attackAmount);
}
}
function collect() external {
require(msg.sender == owner, "not owner");
payable(owner).transfer(address(this).balance);
}
}
3. 修复后的安全版本
新建 contracts/SafeBank.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SafeBank {
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(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "insufficient balance");
// 修复点 1:先更新状态
balances[msg.sender] -= amount;
// 修复点 2:再外部调用
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
这里用了两层修复思路:
- Checks-Effects-Interactions
- 重入锁
实际生产环境中,我更建议优先考虑 OpenZeppelin 的 ReentrancyGuard,避免自己手写锁逻辑时出细节问题。
编写测试并验证漏洞
新建 test/reentrancy.js:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Reentrancy Audit Demo", function () {
it("should exploit VulnerableBank", async function () {
const [deployer, user, attackerEOA] = await ethers.getSigners();
const Bank = await ethers.getContractFactory("VulnerableBank", deployer);
const bank = await Bank.deploy();
await bank.waitForDeployment();
// 普通用户先存入 5 ETH,制造池子
await bank.connect(user).deposit({
value: ethers.parseEther("5")
});
const Attacker = await ethers.getContractFactory("Attacker", attackerEOA);
const attacker = await Attacker.deploy(await bank.getAddress());
await attacker.waitForDeployment();
// 发起攻击,先存 1 ETH 再反复提取
await attacker.connect(attackerEOA).attack({
value: ethers.parseEther("1")
});
const bankBalance = await ethers.provider.getBalance(await bank.getAddress());
const attackerBalance = await ethers.provider.getBalance(await attacker.getAddress());
expect(bankBalance).to.equal(0n);
expect(attackerBalance).to.equal(ethers.parseEther("6"));
});
it("should block exploit on SafeBank", async function () {
const [deployer, user, attackerEOA] = await ethers.getSigners();
const SafeBank = await ethers.getContractFactory("SafeBank", deployer);
const safeBank = await SafeBank.deploy();
await safeBank.waitForDeployment();
await safeBank.connect(user).deposit({
value: ethers.parseEther("5")
});
const Attacker = await ethers.getContractFactory("Attacker", attackerEOA);
const attacker = await Attacker.deploy(await safeBank.getAddress());
await attacker.waitForDeployment();
await expect(
attacker.connect(attackerEOA).attack({
value: ethers.parseEther("1")
})
).to.be.reverted;
const bankBalance = await ethers.provider.getBalance(await safeBank.getAddress());
expect(bankBalance).to.equal(ethers.parseEther("5"));
});
});
运行测试:
npx hardhat test
如果一切正常,你会看到:
VulnerableBank被成功打穿;SafeBank中攻击回滚。
攻击过程时序图
sequenceDiagram
participant A as Attacker
participant B as VulnerableBank
A->>B: deposit(1 ETH)
A->>B: withdraw(1 ETH)
B-->>A: call{value: 1 ETH}
A->>B: receive() 中再次 withdraw(1 ETH)
B-->>A: 再次转账
Note over B: 余额尚未更新,重复提款成立
使用 Slither 做自动化静态检测
1. 基础运行
在项目根目录执行:
slither .
如果环境正常,Slither 会尝试解析 Hardhat 工程,并给出检测结果。
你通常会看到类似输出:
- reentrancy vulnerabilities
- low level calls
- missing zero address validation
- naming / visibility / optimization 建议
2. 关注重点而不是“全量告警”
中级开发者最容易踩的坑是:把工具输出当成漏洞结论。
更合理的做法是按优先级筛选:
高优先级
- Reentrancy
- Arbitrary external call
- Access control
- Delegatecall misuse
- Unchecked return values(尤其是老旧 ERC20)
中优先级
- DoS with failed call
- Unbounded loop
- Timestamp dependence
- Weak randomness
低优先级
- 命名风格
- 可见性优化
- Gas 微优化
3. 输出审计报告线索
我建议把工具结果整理成统一表格,而不是直接贴原始日志。
| 检测项 | 风险级别 | 是否确认 | 说明 |
|---|---|---|---|
| Reentrancy in withdraw | 高 | 是 | 先外部调用后更新余额 |
| Low-level call usage | 中 | 是 | 需结合重入上下文确认 |
| Missing events | 低 | 否 | 可观测性问题,不直接构成漏洞 |
搭建自动化检测流程
真正好用的审计流程,不是“某天跑一次工具”,而是每次提交都能检查。
推荐流水线结构
flowchart LR
A[git commit] --> B[Solidity 编译]
B --> C[单元测试]
C --> D[覆盖率检查]
D --> E[Slither 静态分析]
E --> F[审计结果归档]
F --> G[人工复核高危项]
一个简化版 package.json 脚本
{
"scripts": {
"compile": "hardhat compile",
"test": "hardhat test",
"audit:slither": "slither .",
"check": "npm run compile && npm run test && npm run audit:slither"
}
}
执行:
npm run check
GitHub Actions 示例
新建 .github/workflows/audit.yml:
name: Contract Audit Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
security-check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Node dependencies
run: npm install
- name: Install Slither
run: pip install slither-analyzer
- name: Compile
run: npx hardhat compile
- name: Test
run: npx hardhat test
- name: Slither
run: slither .
这条流水线虽然不复杂,但已经能覆盖三类问题:
- 编译错误;
- 回归逻辑问题;
- 一部分静态安全风险。
逐步验证清单
如果你打算把这套流程迁到自己的项目,可以按下面的顺序执行:
第一步:梳理业务面
- 资产流是否闭环?
- 权限角色是否明确?
- 升级入口是否受控?
- 是否依赖外部价格源或第三方 token?
第二步:手工检查关键函数
重点扫这些函数:
depositwithdrawclaimmintburnupgradeTosetAdminemergencyWithdraw
第三步:运行自动化检测
- Hardhat compile
- Hardhat test
- Slither static analysis
第四步:为每个高危点补测试
比如发现重入风险,不是只修代码,而是要补:
- 正常提款测试
- 攻击路径测试
- 修复回归测试
第五步:纳入 CI
确保后续每次 PR 都会自动触发。
常见坑与排查
这一节我想讲一些“工具会跑,但结果不一定好懂”的坑。
1. 误把 transfer 当成绝对安全方案
过去很多文章会建议用 transfer 防重入,因为它限制 2300 gas。
但现在这已经不是推荐做法,原因包括:
- Gas 成本变化可能导致兼容性问题;
- 无法覆盖更广泛的安全语义;
- 更好的方案是状态先更新 + 重入保护。
建议:优先使用 call,但必须配合安全模式。
2. 只看单个函数,不看跨函数状态
有些重入不是发生在同一个函数里,而是:
- 在 A 函数回调中调用 B 函数;
- B 函数也依赖相同状态;
- 最终形成“跨函数重入”。
排查方法:
- 列出所有外部调用点;
- 标记这些调用点之前哪些状态尚未落账;
- 查看回调后能否进入其他敏感函数。
3. ERC20 返回值处理不一致
不是所有 ERC20 都严格按标准实现。有些:
- 返回
bool - 有些不返回值
- 有些直接 revert
如果直接调用 token.transfer(...),可能出现兼容性问题。
建议:使用 OpenZeppelin SafeERC20。
4. 升级代理的存储布局问题
这类问题在业务测试中不一定能第一时间暴露,但上线后非常危险。
常见表现:
- 新版本变量顺序改了;
- 覆盖旧存储槽;
- 导致管理员地址、余额映射异常。
排查方法:
- 升级合约前做 storage layout diff;
- 禁止随意调整已存在变量顺序;
- 预留 storage gap。
5. 工具报了“重入”,但其实是误报
静态分析对外部调用非常敏感,只要看到:
- 状态变量读写;
- 外部 call;
- 资金转移;
就可能提示风险。
但误报常见于:
- 只读回调;
- 已做锁保护;
- 外部调用无法控制回调路径。
我的建议是:
别急着忽略,先回答三个问题:
- 回调是否可控?
- 状态是否已更新?
- 是否存在跨函数二次进入?
只要这三个问题里有一个答不上来,就别把它当误报。
安全/性能最佳实践
安全和性能在智能合约里不是完全对立,但优先级一定是安全先于 gas 优化。
1. 先建立最小安全基线
我认为每个资金类合约至少要做到:
- 权限控制清晰;
- 外部调用最小化;
- 关键状态先更新;
- 核心流程可暂停;
- 关键操作有事件日志;
- 高危路径有回归测试。
2. 优先采用成熟组件
不要为了“更轻量”自己重写一套:
OwnableAccessControlReentrancyGuardSafeERC20Pausable
成熟库不是绝对无风险,但比手搓稳定得多。
3. 测试要覆盖“对抗性场景”
很多项目测试只验证“正常用户会成功”,但攻击者关心的是“边界条件会怎样”。
建议至少补这些测试:
- 重复调用
- 极值输入
- 回调攻击
- 权限绕过
- 暂停后行为
- 升级后兼容性
4. 把审计前移到开发期
最有效的方式不是上线前集中修 bug,而是:
- 开发时就写安全注释;
- 提交前自动跑基础检查;
- PR 中明确“新增外部调用点”和“新增权限点”。
5. 不要过度依赖单一工具
一个实用的组合是:
- Slither:静态分析,快;
- Hardhat/Foundry 测试:逻辑验证;
- Echidna 或 Fuzzing:发现边界异常;
- 人工审计:业务理解和误报确认。
边界条件也要说清楚:
如果你的合约涉及复杂 DeFi 机制,比如闪电贷、价格预言机、多合约协同、代理升级,那么仅靠本文这套基础流水线是不够的,还需要更深入的形式化验证、经济攻击建模和主网分叉测试。
总结
智能合约审计真正有价值的地方,不是“记住多少漏洞名词”,而是形成一套稳定的方法:
- 先建模:看资产、权限、状态、外部交互;
- 再识别:优先关注重入、权限、外部调用、DoS;
- 再验证:用测试把漏洞和修复都跑出来;
- 最后固化:接入 CI,让检查持续发生。
如果你现在就要落地,我建议从下面三件事开始:
- 给资金类函数补一遍攻击测试;
- 在 CI 里加入
hardhat test + slither .; - 对所有外部调用点做一次“状态是否先更新”的人工复核。
这三步不花哨,但非常有效。
而且我自己的经验是,很多严重问题并不是藏得多深,往往只是因为团队一直没有把“审计流程”真正放进开发流程里。只要这件事做起来,安全水平会比单次突击审计提升得更明显。