区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,代码通常就很难修改;而资金、权限、治理逻辑又往往直接绑定在合约里。所以它和普通后端服务最大的不同,不是“写法”,而是出错成本极高。很多团队在业务开发阶段把重点放在功能上线,真正出事时才发现:审计不是“最后补一下”,而应该从设计、编码、测试到发布全程嵌入。
这篇文章我会从一个更偏“实战落地”的角度来讲:不是只列漏洞清单,而是带你搭一个从漏洞识别到自动化检测的基本流程。适合已经写过 Solidity、会用 Hardhat 或 Foundry 的中级读者。
背景与问题
智能合约安全审计常见的难点,不是“没工具”,而是:
-
漏洞类型多且表现隐蔽
- 重入
- 权限控制缺失
- 整数边界问题
- 外部调用返回值未检查
- 价格预言机依赖不安全
- 签名验证缺陷
- 升级代理存储冲突
-
工具扫描结果噪声高
- 误报不少
- 有些逻辑型漏洞,纯静态分析抓不出来
- 有些“看起来危险”的模式,在业务上下文里反而是合理的
-
团队流程不闭环
- 只跑一次 Slither
- 没有单元测试覆盖关键资金路径
- 没有 CI 阻断机制
- 部署前后缺少检查项
一句话概括:真正有效的合约审计,不是找工具,而是搭流程。
前置知识与环境准备
本文示例基于以下环境:
- Node.js 16+
- Hardhat
- Solidity 0.8.x
- Slither
- Mythril(可选)
- OpenZeppelin Contracts
安装步骤
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
npx hardhat
选择创建一个 JavaScript 项目即可。
如果要安装 Slither:
python3 -m pip install slither-analyzer
如果要安装 Mythril:
python3 -m pip install mythril
核心原理
智能合约安全审计,建议按下面这条链路来理解:
-
先看资产流
- 钱从哪里来
- 钱往哪里走
- 谁能触发转账
- 是否有重复领取、绕过校验、异常回滚等问题
-
再看权限流
- owner、admin、operator 权限边界是否清晰
- 是否存在“任意地址可调用敏感函数”
- 升级、暂停、铸币、提币等高危操作是否受控
-
最后看状态流
- 状态更新顺序是否安全
- 外部调用前后是否可被重入
- 是否存在依赖旧状态的竞态问题
审计流程总览图
flowchart TD
A[需求与架构梳理] --> B[识别资产与权限边界]
B --> C[手工代码审查]
C --> D[静态分析 Slither]
D --> E[单元测试与攻击用例]
E --> F[模糊测试/属性测试]
F --> G[修复与回归验证]
G --> H[CI 自动化门禁]
一个常见漏洞的本质:重入
重入不是“调用外部合约”本身有问题,而是:
- 合约在执行过程中调用了外部地址;
- 对方在回调中再次进入当前合约;
- 当前合约的关键状态尚未安全更新;
- 导致重复提现吗、重复记账或状态破坏。
重入调用时序图
sequenceDiagram
participant U as 用户/攻击者
participant V as 脆弱合约
participant A as 攻击合约
U->>A: 发起攻击
A->>V: withdraw()
V->>A: 转账 ETH
A->>V: fallback 重入 withdraw()
V->>A: 再次转账 ETH
V-->>A: 状态最终异常
实战代码(可运行)
下面我们从一个有漏洞的取款合约开始,再逐步修复,并补上测试与自动化检测。
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 value");
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. 攻击合约:利用 fallback 重入
创建 contracts/Attacker.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
contract Attacker {
IVulnerableBank public bank;
uint256 public attackAmount;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
}
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);
}
receive() external payable {
if (address(bank).balance >= attackAmount) {
bank.withdraw(attackAmount);
}
}
}
3. 编写测试复现漏洞
创建 test/reentrancy.js:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Reentrancy Attack Demo", function () {
it("should drain vulnerable bank", async function () {
const [deployer, user, attackerEOA] = await ethers.getSigners();
const Bank = await ethers.getContractFactory("VulnerableBank");
const bank = await Bank.deploy();
await bank.waitForDeployment();
await bank.connect(user).deposit({ value: ethers.parseEther("5") });
const Attacker = await ethers.getContractFactory("Attacker");
const attacker = await Attacker.connect(attackerEOA).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
如果测试通过,说明漏洞已被成功利用。
修复方案:Checks-Effects-Interactions + ReentrancyGuard
1. 修复后的安全合约
创建 contracts/SafeBank.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0, "zero value");
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
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;
}
}
2. 修复测试
创建 test/safeBank.js:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("SafeBank", function () {
it("should prevent reentrancy", async function () {
const [deployer, user, attackerEOA] = await ethers.getSigners();
const Bank = await ethers.getContractFactory("SafeBank");
const bank = await Bank.deploy();
await bank.waitForDeployment();
await bank.connect(user).deposit({ value: ethers.parseEther("5") });
const Attacker = await ethers.getContractFactory("Attacker");
const attacker = await Attacker.connect(attackerEOA).deploy(await bank.getAddress());
await attacker.waitForDeployment();
await expect(
attacker.connect(attackerEOA).attack({ value: ethers.parseEther("1") })
).to.be.reverted;
const bankBalance = await ethers.provider.getBalance(await bank.getAddress());
expect(bankBalance).to.equal(ethers.parseEther("5"));
});
});
从“单点修复”到“审计清单”
真实项目里,重入只是一个切口。下面是我更推荐的人工审计检查顺序。
1. 权限控制
重点检查:
- 是否所有管理函数都加了
onlyOwner/AccessControl - owner 是否可以任意提走用户资产
- 是否存在初始化函数被重复调用
- 是否存在代理升级权限泄露
有问题的示例:
function setTreasury(address newTreasury) external {
treasury = newTreasury;
}
任何人都能改资金归集地址,这是高危问题。
安全写法:
function setTreasury(address newTreasury) external onlyOwner {
require(newTreasury != address(0), "zero address");
treasury = newTreasury;
}
2. 外部调用与返回值检查
ERC20 并不总是行为一致。有些 Token 不返回 bool,有些失败会 revert,有些静默失败。建议统一使用 OpenZeppelin 的 SafeERC20。
不推荐:
token.transfer(to, amount);
推荐:
// 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);
}
}
3. 价格与时间依赖
很多 DeFi 合约喜欢直接信链上池子价格,但瞬时价格很容易被闪电贷操纵。如果你的逻辑依赖价格,至少要检查:
- 是否使用 TWAP
- 是否有价格上下界保护
- 是否有预言机停摆兜底
- 是否允许管理员手工暂停
4. 签名校验与重放攻击
如果合约通过签名授权执行操作,务必检查:
- 是否包含
chainId - 是否包含
nonce - 是否限定签名用途
- 是否使用 EIP-712
一个常见坑是:签名消息没带 nonce,导致同一签名可重复使用。
自动化检测流程搭建
手工审计很重要,但不能只靠人眼。下面我们把自动化流程搭起来。
自动化检测分层图
flowchart LR
A[源码提交] --> B[格式与编译检查]
B --> C[单元测试]
C --> D[静态分析 Slither]
D --> E[安全规则扫描]
E --> F[攻击回归测试]
F --> G[允许合并]
1. 基础目录结构建议
contract-audit-demo/
├── contracts/
├── test/
├── scripts/
├── slither.config.json
├── hardhat.config.js
├── package.json
└── .github/workflows/security.yml
2. 运行 Slither
先编译:
npx hardhat compile
然后执行:
slither . --filter-paths "node_modules|test"
你可能会看到类似输出:
- reentrancy vulnerability
- low level calls
- missing zero-address validation
- functions that send ether to arbitrary destinations
注意:不要把扫描结果当最终结论。工具告诉你“哪里值得看”,不是直接给你审计报告。
3. 配置 GitHub Actions
创建 .github/workflows/security.yml:
name: Smart Contract Security Check
on:
push:
branches: [main]
pull_request:
jobs:
test-and-scan:
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: Install Node dependencies
run: npm install
- name: Run tests
run: npx hardhat test
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install Slither
run: pip install slither-analyzer
- name: Compile contracts
run: npx hardhat compile
- name: Run Slither
run: slither . --filter-paths "node_modules|test"
这样每次提交代码时,测试和扫描都会自动执行。最直接的收益是:把低级错误挡在合并前。
4. 增加攻击回归测试
这是很多团队最容易忽略的一步。修完漏洞后,如果没有把 PoC 测试保留下来,下次重构时它很可能又回来。
建议把测试分三类:
- 正常业务路径
- 异常输入路径
- 攻击路径
比如:
- 重入攻击
- 重复初始化
- 非 owner 调敏感函数
- 零地址配置
- 超额提款
- 价格被操纵时的边界行为
逐步验证清单
当你要审一个中小型合约时,可以按这个清单走:
第一步:先画边界
- 哪些函数会动钱?
- 哪些函数会改权限?
- 哪些函数会调外部合约?
- 哪些状态变量是核心账本?
第二步:快速手审
- 敏感函数有没有权限限制?
- 更新状态和外部调用顺序是否合理?
- 参数有没有 zero address / 边界检查?
- 事件是否完整,便于审计追踪?
第三步:工具扫描
- Slither 发现了哪些高危点?
- 这些高危点是误报还是实锤?
- 哪些 warning 值得补测试?
第四步:攻击测试
- 能不能构造恶意合约重入?
- 能不能绕过权限?
- 能不能重复执行签名操作?
- 能不能利用极端状态让合约锁死?
第五步:修复后回归
- 原漏洞 PoC 是否已失败?
- 正常业务流程是否未被破坏?
- gas 是否显著变差?
- 是否引入新的可用性问题?
常见坑与排查
下面这些坑,我在合约审查里见得非常多。
坑 1:以为 Solidity 0.8 解决了所有整数问题
0.8 之后默认检查溢出,但这不代表“数值安全”就结束了。你仍然要关注:
- 精度截断
- 除零
- 价格换算顺序错误
- 单位混淆(wei / ether / token decimals)
排查方法:
uint256 value = amount * price / 1e18;
看起来没问题,但如果 amount 和 price 精度不统一,结果会偏得离谱。
坑 2:tx.origin 用错
不安全示例:
require(tx.origin == owner, "not owner");
攻击者可以诱导 owner 通过恶意合约发起交易,从而绕过预期逻辑。权限判断应使用 msg.sender。
坑 3:升级代理的存储布局冲突
如果你用了 UUPS 或 Transparent Proxy,一定要注意:
- 新版本不能随意调整状态变量顺序
- 继承层次变化可能影响 storage layout
- initializer 只能执行一次
这个坑不像重入那么“炸得快”,但一旦出问题,数据会直接坏掉,非常难救。
坑 4:把 call、delegatecall 当普通函数调用
尤其是 delegatecall,它在当前合约上下文执行目标代码。只要调用目标不受控,就可能直接导致权限接管或存储污染。
排查重点:
- 调用目标地址是否可被任意设置
- 是否白名单校验
- 是否真的需要
delegatecall
坑 5:误信静态分析“没报错就安全”
我自己踩过一个坑:某个合约静态分析结果很干净,但后面通过测试发现签名消息缺 nonce,导致可重复领取奖励。
这类问题属于业务逻辑漏洞,工具很难完全识别,所以必须补:
- 属性测试
- 对抗性测试
- 场景化审查
安全/性能最佳实践
安全和性能在智能合约里常常需要平衡。下面给一组实用建议。
安全最佳实践
-
采用成熟库
- OpenZeppelin 的权限、Token、ReentrancyGuard 不要自己重复造轮子
-
遵循 CEI 模式
- Checks
- Effects
- Interactions
-
最小权限原则
- 管理员权限尽量拆分
- 高危操作加多签控制
- 升级权限与资金权限分离
-
关键路径必须有测试
- 存款
- 提款
- 清算
- 升级
- 暂停/恢复
-
记录完整事件
- 安全事故追踪时,事件日志非常重要
性能最佳实践
-
避免不必要的存储写入
- SSTORE 最贵,能缓存到内存就缓存
-
减少循环中的链上操作
- 大数组遍历容易超 gas
- 可考虑分批处理或 Pull 模式
-
错误信息适度精简
- 自定义错误比长字符串更省 gas
示例:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
error NotOwner();
error ZeroAddress();
contract Config {
address public owner;
address public treasury;
constructor() {
owner = msg.sender;
}
function setTreasury(address _treasury) external {
if (msg.sender != owner) revert NotOwner();
if (_treasury == address(0)) revert ZeroAddress();
treasury = _treasury;
}
}
- 安全优先于省 gas
- 不要为了几百 gas 去删除关键校验
- 特别是权限、签名、金额边界检查
一个可落地的审计流程模板
如果你在团队里要推动流程,我建议至少做到下面这个版本:
开发阶段
- 使用 OpenZeppelin 基础组件
- 编写单元测试和异常路径测试
- PR 模板要求说明权限变更与资金流变更
提测阶段
- 人工审查核心逻辑
- Slither 静态分析
- 编写攻击 PoC
发布前
- 核验部署参数
- 核验初始化状态
- 核验 owner / multisig / treasury 地址
- 核验预言机和外部依赖地址
发布后
- 保留监控脚本
- 跟踪异常事件
- 准备暂停/熔断预案
- 修复后进行回归审计
总结
智能合约安全审计最怕两种极端:
- 一种是只靠经验手看,流程很散;
- 另一种是完全迷信工具,觉得“扫描通过就上线”。
更稳妥的做法是把它拆成三层:
- 人工理解业务与资产边界
- 工具做静态检测和持续集成
- 测试覆盖攻击路径并长期回归
如果你现在就想开始落地,我建议先做三件事:
- 给资金相关函数补一轮攻击测试
- 在 CI 里接入 Hardhat test + Slither
- 统一引入 OpenZeppelin 的权限与安全组件
最后给一个边界条件:自动化流程能显著提升下限,但它替代不了高质量的人工审计。尤其是 DeFi、治理、签名授权、升级代理这类强业务耦合场景,逻辑漏洞往往比语法漏洞更危险。工具负责“扫”,工程流程负责“挡”,而真正的安全能力,还是来自你对业务状态流、权限流、资产流的持续拆解。