区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,代码通常就“焊死”在链上了。和传统 Web 服务不同,出了问题不能热修复、不能紧急回滚,尤其涉及资产时,一个小漏洞就可能直接变成真金白银的损失。
我自己第一次系统做合约审计时,最大的感受不是“漏洞多难找”,而是容易漏掉系统性问题:单看代码似乎没事,但一旦放到权限、调用链、代币交互、部署配置里一起看,风险就出来了。
这篇文章我会从一个中级开发者实际能落地的角度,带你做一遍:
- 怎么识别常见智能合约漏洞
- 怎么写一个可运行的漏洞示例和修复版
- 怎么把静态分析、单元测试、简单自动化脚本串成一条审计流水线
- 怎么排查误报、漏报和测试不稳定问题
前置知识
开始前,默认你已经了解这些概念:
- Solidity 基础语法
- EVM 调用模型
msg.sender、msg.value、call、delegatecall- Hardhat 或 Foundry 其中一种开发工具
- ERC20 的基本行为
如果你只写过简单合约,也没关系,本文的实战代码尽量控制在能跑、能验证、能看懂的范围内。
背景与问题
智能合约安全审计,不只是“找几个已知漏洞模式”。真正的难点在于:
-
攻击面是组合式的
- 权限控制 + 外部调用 + 状态更新顺序
- 价格预言机 + 闪电贷 + 精度问题
- 升级代理 + 存储布局冲突
-
漏洞不一定长得像漏洞
- 没有
reentrancy字样,不代表不会重入 - 没有
tx.origin,也可能有权限绕过 - 没有算术溢出,也可能有精度截断导致资产偏移
- 没有
-
只靠人工容易漏
- 人工审代码适合发现业务逻辑问题
- 工具擅长扫模式化问题
- 真正稳定的流程,一定是“人工 + 自动化 + 测试验证”结合
一个更现实的目标是:
把审计工作拆成可重复执行的步骤,而不是依赖“天才审计员灵感爆发”。
核心原理
1. 审计关注的不只是代码,还包括“状态变化路径”
智能合约的安全,本质上是在检查:
- 谁可以调用
- 调用后状态怎么变化
- 是否依赖外部系统
- 外部系统能否反向影响当前合约
- 关键不变量是否始终成立
比如一个提现函数,表面只是“给用户转账”,但真正要审的是:
- 提现前是否验证余额
- 状态是在转账前更新还是后更新
- 使用的是什么调用方式
- 是否可能被 fallback/receive 重新进入
- 是否有管理员能绕过正常流程
2. 常见漏洞类型的检查思路
下面是审计里最常见、也最值得优先建立自动化检测的几类问题。
重入攻击
特征:
- 向外部地址转账或调用
- 状态更新发生在外部调用之后
- 没有重入锁或检查-生效-交互模式
权限控制错误
特征:
- 敏感函数缺少
onlyOwner/AccessControl - 使用
tx.origin - 升级、铸币、提取资产等函数权限边界不清
整数与精度问题
特征:
- 价格、份额、利率计算中先除后乘
- 不同 token 精度混用
- 四舍五入方向错误,导致可套利
未检查的外部调用返回值
特征:
call返回值未处理- ERC20
transfer/transferFrom假设一定成功 - 非标准代币行为未兼容
DoS 与 gas 风险
特征:
- unbounded loop
- 遍历动态数组执行转账
- 强依赖某个外部合约执行成功
一张总览图:审计流程怎么走
flowchart TD
A[需求与资产梳理] --> B[威胁建模]
B --> C[人工代码审查]
C --> D[静态分析工具扫描]
D --> E[单元测试与攻击用例]
E --> F[修复与回归验证]
F --> G[CI自动化集成]
这张图的重点是:
不要上来就跑工具。
如果没有业务上下文,工具告诉你“这里可能有风险”,你也很难判断它是真问题还是误报。
环境准备
本文用 Hardhat 做演示,Node.js 版本建议 18+。
初始化项目
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
选择一个基础 JavaScript 项目。
安装静态分析工具
如果你本机有 Python 环境,推荐装 Slither:
pip install slither-analyzer
如果你习惯 Docker,也可以直接用镜像执行。
项目目录建议像这样:
contract-audit-demo/
├── contracts/
│ ├── VulnerableBank.sol
│ └── Attacker.sol
├── test/
│ └── bank.js
├── scripts/
│ └── audit.js
├── hardhat.config.js
└── package.json
实战代码(可运行)
我们从一个经典但非常有代表性的漏洞开始:重入攻击。
1. 漏洞合约:VulnerableBank.sol
这个合约允许用户存款和取款,但取款流程有问题:
它先给用户转账,再更新余额。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
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. 攻击合约:Attacker.sol
攻击逻辑是:
在接收到 ETH 的回调里再次调用 withdraw,趁目标合约还没更新余额时重复提取。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
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.value >= attackAmount, "need more ETH");
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. 测试代码:test/bank.js
这段测试会部署银行合约、注入资金、发起攻击,然后验证目标余额被抽走。
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("VulnerableBank Reentrancy Demo", function () {
it("should be drained by attacker", 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"));
});
});
4. 运行测试
npx hardhat test
如果一切正常,你会看到测试通过,说明这个漏洞是可利用的,而不是纸面风险。
漏洞修复:不是只加一个锁那么简单
很多人看到重入问题,第一反应是“加个 nonReentrant”。这当然常见,但我更建议先理解根因。
修复原则
-
检查-生效-交互
- 先检查条件
- 再更新状态
- 最后做外部调用
-
必要时使用重入锁
- 尤其是在复杂函数中,状态路径多时更稳
-
缩小外部调用面
- 能不把控制权交给外部,就尽量不交
修复版合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
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 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;
}
}
调用时序图:为什么会重入
sequenceDiagram
participant U as Attacker
participant A as AttackerContract
participant B as VulnerableBank
U->>A: attack()
A->>B: deposit(1 ETH)
A->>B: withdraw(1 ETH)
B-->>A: call{value:1 ETH}
A->>B: fallback/receive 再次 withdraw(1 ETH)
B-->>A: 再次转账
Note over B: 此时余额还没来得及减少
时序图一眼能看出来,问题不在“转账”本身,而在转账前余额未扣减。
自动化检测流程搭建
到这里,我们已经有了:
- 一个真实漏洞示例
- 一个攻击用例
- 一个修复思路
下面开始把它串成日常可执行的审计流程。
第一步:静态分析
使用 Slither 扫描
slither . --print human-summary
或者直接扫描某个文件:
slither contracts/VulnerableBank.sol
Slither 通常会报告类似:
- reentrancy vulnerabilities
- low-level calls
- missing zero-address checks
- naming/style 提示
注意:
静态分析工具不是法官,它只是线索提供者。
你需要把结果分成三类:
- 确认漏洞
- 可接受风险
- 误报
第二步:最小攻击用例验证
很多团队会停在“工具报了问题”,但真正有价值的是:
能不能写出一个最小可复现实验。
这一步的价值非常大:
- 能区分真实风险和误报
- 能帮助开发理解漏洞成因
- 能作为修复后的回归测试
对于重入、权限绕过、价格操纵,这一步尤其重要。
第三步:脚本化审计入口
我们可以写一个简单 Node.js 脚本,把编译、测试、静态分析串起来。
虽然不复杂,但已经足够成为 CI 的基础版本。
scripts/audit.js
const { execSync } = require("child_process");
function run(cmd, title) {
console.log(`\n=== ${title} ===`);
execSync(cmd, { stdio: "inherit" });
}
function main() {
run("npx hardhat compile", "Compile");
run("npx hardhat test", "Unit Tests");
try {
run("slither contracts/VulnerableBank.sol", "Slither Static Analysis");
} catch (e) {
console.error("\nSlither found issues or exited with non-zero status.");
process.exitCode = 1;
}
}
main();
运行:
node scripts/audit.js
package.json 增加命令
{
"scripts": {
"compile": "hardhat compile",
"test": "hardhat test",
"audit": "node scripts/audit.js"
}
}
之后你只需要:
npm run audit
自动化流程图:从本地到 CI
flowchart LR
A[开发提交代码] --> B[Hardhat编译]
B --> C[单元测试]
C --> D[攻击用例回归]
D --> E[Slither静态分析]
E --> F{是否通过}
F -- 是 --> G[允许合并]
F -- 否 --> H[修复并重新提交]
这就是一个非常实用的基础版安全门禁流程。
别嫌它简单,很多项目真正缺的就是这条“基础流水线”。
第四步:把审计结论结构化
工具扫描结果和测试结果最终要落到结论上。我建议至少维护下面这个格式:
| 编号 | 风险类型 | 严重级别 | 位置 | 影响 | 修复建议 | 状态 |
|---|---|---|---|---|---|---|
| 1 | 重入攻击 | 高 | withdraw() | 资金可被抽空 | 状态前置更新 + 重入锁 | 已修复 |
| 2 | 低级调用 | 中 | call | 外部调用风险扩大 | 封装统一转账逻辑 | 已确认 |
| 3 | 缺少事件 | 低 | deposit/withdraw | 链上追踪不便 | 增加事件日志 | 待处理 |
这样做有两个好处:
- 审计报告不是“散装评论”
- 后续回归验证有明确对象
常见坑与排查
下面这些问题,在实际做自动化审计时非常常见。我基本都踩过。
1. 工具报了重入,但实际上不可利用
可能原因:
- 外部调用目标受信任
- 调用后没有可重复获利路径
- 关键状态实际上已先更新
排查建议:
- 写 PoC 测试
- 检查状态变更顺序
- 看是否能真正打破不变量
经验建议:没有 PoC 的高危结论,要谨慎下。
2. ERC20 转账看起来没问题,实际兼容性出错
不少开发默认这样写:
token.transfer(to, amount);
问题在于并非所有 ERC20 都严格遵循统一行为。
有的返回 bool,有的直接 revert,还有的行为不标准。
排查建议:
- 使用成熟库,如 OpenZeppelin 的
SafeERC20 - 针对主流代币做集成测试
- 审查跨链桥、包装资产、老旧代币的兼容性
3. 测试通过,但主网环境仍然不安全
原因通常是测试过于理想化:
- 没模拟恶意合约
- 没模拟多角色权限冲突
- 没考虑预言机波动、区块时间、闪电贷
排查建议:
- 增加攻击者合约
- 引入 fork test
- 测试边界值与异常路径
4. 修复了一个漏洞,顺手引入新问题
比如:
- 为防重入加锁,结果导致某些可重入但安全的流程被错误阻断
- 把循环拆掉后,出现状态不一致
- 加权限后,管理员能力过大
排查建议:
- 每次修复都补一条回归测试
- 保留旧漏洞复现用例
- 做一次“修复后的二次审查”
5. 只扫 Solidity,不看部署和配置
这点经常被忽略。合约本身安全,不代表系统安全。
比如:
- owner 配成错误地址
- 代理合约 admin 暴露
- 初始化函数可重复调用
- 关键参数设置不合理
排查建议:
- 检查部署脚本
- 检查初始化流程
- 检查多签和权限移交步骤
安全/性能最佳实践
这里我把“能长期省事”的建议整理成一份清单。
1. 优先使用成熟安全模式
- 检查-生效-交互
- 最小权限原则
- pull over push 支付模式
- 显式事件记录关键操作
2. 不要迷信单一工具
推荐组合思路:
- 人工审查:发现业务逻辑缺陷
- Slither:扫常见静态模式
- 单元测试:验证功能
- 攻击测试:验证可利用性
- CI:保证每次提交都跑
3. 对关键不变量写测试
例如:
- 合约总资产 >= 所有用户余额之和
- 未授权用户无法执行敏感操作
- 提现后余额精确减少
- 任意攻击路径不会增发资产
把这些写成测试,长期收益非常高。
4. 避免在链上做大规模遍历
从性能和 DoS 风险上看,这类设计都要谨慎:
- 遍历所有用户结算奖励
- 遍历动态数组进行转账
- 在单笔交易里做复杂批量操作
更好的方式通常是:
- 分批处理
- 用户自行领取
- 使用快照或 Merkle 证明
5. 使用经过验证的库
不要自己手搓所有安全组件,尤其是:
- AccessControl
- ReentrancyGuard
- SafeERC20
- Upgradeable 合约基类
成熟库不能保证绝对安全,但能明显减少低级错误。
6. 将“高危功能”单独建模
以下功能建议单独审计:
- 升级
- 铸币/销毁
- 提现
- 清算
- 预言机读数
- 跨合约资产桥接
这些地方,不要只看函数本身,要看谁能调、何时调、调完后系统状态是否仍合理。
逐步验证清单
如果你要把这套流程带回团队,可以按这个顺序推进:
本地阶段
- 合约能正常编译
- 基础功能测试通过
- 至少 1 个攻击用例可复现
- 漏洞修复后回归测试通过
- Slither 扫描结果已分类处理
提交前阶段
- 敏感函数权限已核对
- 外部调用返回值已处理
- 关键状态更新顺序已检查
- 事件已覆盖重要操作
- 部署初始化参数已复核
发布前阶段
- 高危路径已人工复审
- 关键资产流转已做不变量测试
- 升级/多签/管理员权限已确认
- 审计问题单已关闭或记录风险接受理由
一个更完整的审计视角
到这里你会发现,真正可落地的智能合约安全审计,不是“工具一跑出报告”,而是形成下面这套闭环:
stateDiagram-v2
[*] --> ThreatModel
ThreatModel --> Review
Review --> StaticScan
StaticScan --> ExploitTest
ExploitTest --> Fix
Fix --> Regression
Regression --> CI
CI --> [*]
这个闭环里,每一环都不应该孤立存在。
比如:
- 没有威胁建模,人工审查容易失焦
- 没有攻击测试,静态扫描容易停留在“可能有问题”
- 没有回归测试,修复很容易反复打破
总结
智能合约审计的核心,不是死记漏洞清单,而是建立一套可复用、可验证、可持续执行的流程:
- 先理解业务和资产流
- 再定位高风险调用路径
- 用静态分析找线索
- 用 PoC 和测试验证漏洞是否真实
- 修复后做回归
- 最后接入 CI,变成团队默认动作
如果你现在就想开始,我建议你从最小版本落地:
- 选一个已有合约
- 用 Slither 扫一遍
- 为一个高风险函数补一条攻击测试
- 把
compile + test + scan串成npm run audit
这一步看起来不大,但它会把“偶尔做安全检查”变成“每次提交都做安全检查”。
对智能合约来说,这种习惯上的改变,往往比多记几个漏洞名字更有价值。