区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约最大的特点,是“上线即公开、部署后难改、资金直接受影响”。这也意味着,传统 Web 开发里一些还能靠热修复补救的问题,到了链上很可能就变成真金白银的事故。
这篇文章我会按“实战审计”的思路来讲,不只列漏洞名词,而是带你从漏洞识别一路走到自动化检测流程搭建。读完后,你至少能完成下面几件事:
- 看懂几类高频智能合约漏洞的成因
- 用可运行示例复现典型问题
- 用工具把手工审计经验固化成自动化流程
- 建立一个适合中小团队的审计基线
背景与问题
很多团队做合约安全时,最容易陷入两个误区:
- 只靠人工看代码
- 只跑工具,不理解漏洞原理
前者效率低、容易漏;后者报告很多,但无法判断真实风险。真正可落地的做法,应该是:
- 先理解漏洞模式
- 再结合代码结构做人工确认
- 最后把高频检查固化进 CI/CD
智能合约审计到底在审什么?
从实务上看,主要审下面几类问题:
- 资产安全:会不会被盗、被锁死、被重复提取
- 权限安全:管理员、升级权限、铸币权限是否失控
- 业务逻辑安全:价格计算、奖励发放、状态流转是否可被绕过
- 可用性与 DoS 风险:是否会因某个地址、某笔交易导致核心功能卡死
- 代码实现安全:重入、整数问题、低级调用返回值忽略等
如果把审计流程画成一张图,大致是这样:
flowchart TD
A[需求与业务梳理] --> B[威胁建模]
B --> C[代码静态检查]
C --> D[人工审计高风险模块]
D --> E[测试与攻击路径验证]
E --> F[自动化规则固化]
F --> G[CI/CD 持续扫描]
G --> H[修复回归与复审]
前置知识与环境准备
本文默认你已经了解:
- Solidity 基础语法
- 合约部署与调用流程
- Remix / Hardhat 的基本使用
推荐环境
为了让示例更贴近真实项目,我建议用 Hardhat:
- Node.js 16+
- npm 或 pnpm
- Hardhat
- Solidity 0.8.x
- Slither
- Mythril(可选)
- solhint(可选)
初始化项目
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
选择一个 JavaScript 项目模板即可。
再安装审计相关工具:
npm install --save-dev solhint
pip3 install slither-analyzer mythril
核心原理
先说一句我自己的经验:不要背漏洞定义,要背“攻击者能改什么状态、抢在什么时候调用、让谁失去控制”。这样看代码会快很多。
下面挑几类最常见、也最值得自动化覆盖的漏洞来讲。
1. 重入攻击
当合约在更新自身状态前,先调用外部地址,攻击者就可能在回调中再次进入原函数,重复执行敏感逻辑。
典型风险点:
callsendtransfer的误用- ERC777、带回调的 token
- 跨合约调用后再更新余额
重入攻击过程
sequenceDiagram
participant U as 用户/攻击合约
participant V as 脆弱合约
participant A as 攻击者回调
U->>V: withdraw()
V->>A: call.value(amount)
A->>V: 再次调用 withdraw()
V->>A: 再次转账
V-->>U: 状态最终才更新
2. 权限控制缺失或设计不当
常见问题包括:
- 敏感函数没加
onlyOwner - 使用
tx.origin做鉴权 - 多角色权限边界混乱
- 初始化函数可重复调用
- 升级合约实现地址可被随意修改
这类问题往往比重入更“业务化”,但危害一点不小。尤其在代币合约、治理合约、桥接合约中,权限失控经常直接导致全量资产风险。
3. 低级调用返回值未检查
Solidity 低级调用如 call 返回 (bool success, bytes memory data)。如果忽略 success,逻辑上可能“看起来执行成功”,实际却没有完成预期操作。
风险包括:
- 账务状态已更新,但资金未转出
- 批量分发中部分失败未感知
- 外部系统状态不一致
4. 拒绝服务(DoS)
常见模式:
- 在循环中给大量地址转账
- 单个恶意地址回退导致整批流程失败
- 动态数组过长导致 gas 超限
- 必须依赖某个外部合约成功响应
5. 价格与时间依赖
比如:
- 直接使用区块时间做关键随机数
- 直接依赖可操纵价格源
- 没有对预言机价格做新鲜度和边界检查
这一类在 DeFi 中尤其高发。审计时不能只盯 Solidity 语法,还要看协议假设是否成立。
实战代码(可运行)
下面我们从一个故意写得不安全的合约开始,边看边改。
示例 1:存在重入漏洞的 Ether Bank
在 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() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// 漏洞点:先转账,后更新状态
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
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() external;
}
contract Attacker {
IVulnerableBank public bank;
address public owner;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
owner = msg.sender;
}
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether, "Need at least 1 ether");
bank.deposit{value: 1 ether}();
bank.withdraw();
}
function collect() external {
require(msg.sender == owner, "Not owner");
payable(owner).transfer(address(this).balance);
}
}
Hardhat 测试复现漏洞
在 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", 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());
expect(bankBalance).to.equal(0n);
});
});
执行:
npx hardhat test
如果一切正常,你会看到测试通过,说明攻击成功把合约里的 Ether 抽干了。
修复版本:Checks-Effects-Interactions
最经典的修复方案是 CEI 模式:先检查、再更新状态、最后与外部交互。
在 contracts/SafeBank.sol 中写入:
// 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() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// 先更新状态
balances[msg.sender] = 0;
// 再外部调用
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
不过仅靠 CEI 还不够保险。更稳妥的方式是叠加 ReentrancyGuard。
使用 OpenZeppelin 的重入锁
安装依赖:
npm install @openzeppelin/contracts
合约代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SaferBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
实战代码(第二部分):权限控制与 tx.origin 问题
很多初学者会觉得 tx.origin 能识别“真实发起人”,所以更安全。实际上它经常被钓鱼合约利用。
错误示例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BadAuth {
address public owner;
constructor() {
owner = msg.sender;
}
function withdrawAll(address payable to) external {
require(tx.origin == owner, "Not owner");
to.transfer(address(this).balance);
}
receive() external payable {}
}
如果 owner 被诱导去调用攻击合约,而攻击合约再转调 withdrawAll,tx.origin 仍然是 owner,校验就会通过。
正确示例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract GoodAuth {
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function withdrawAll(address payable to) external onlyOwner {
to.transfer(address(this).balance);
}
receive() external payable {}
}
自动化检测流程搭建
讲完漏洞原理,重点来了:如何把这些经验变成可重复执行的流程。
我的建议是分三层:
- 语法/规范层:solhint
- 静态分析层:Slither
- 测试验证层:Hardhat + 单元测试/攻击测试
一套实用的审计流水线
flowchart LR
A[开发提交代码] --> B[solhint 规范检查]
B --> C[Slither 静态分析]
C --> D[Hardhat 单元测试]
D --> E[攻击场景测试]
E --> F[人工复核高危告警]
F --> G[合并或阻断发布]
1. 配置 solhint
创建 .solhint.json:
{
"extends": "solhint:recommended",
"rules": {
"compiler-version": ["error", "^0.8.20"],
"func-visibility": ["error", { "ignoreConstructors": true }],
"avoid-tx-origin": "error",
"not-rely-on-time": "warn"
}
}
运行:
npx solhint "contracts/**/*.sol"
它更像“代码规范守门员”,对低级错误和团队统一风格很有帮助。
2. 使用 Slither 做静态分析
直接运行:
slither .
你通常会看到类似输出:
- reentrancy vulnerabilities
- low-level calls
- uninitialized state variables
- arbitrary from in transferFrom
- missing zero-address validation
Slither 的价值在于:快、规则多、适合集成 CI。缺点是可能有误报,所以一定要人工确认。
3. 用测试把漏洞“打出来”
真正有说服力的审计,不是截图一份报告,而是能写出攻击路径测试。
建议至少覆盖:
- 正常业务流程
- 边界输入
- 权限绕过尝试
- 恶意合约交互
- 批量/极端 gas 场景
4. 在 GitHub Actions 中自动执行
新建 .github/workflows/audit.yml:
name: Contract Audit Pipeline
on:
push:
branches: [ main ]
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: Install Node dependencies
run: npm install
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install Slither
run: pip install slither-analyzer
- name: Run solhint
run: npx solhint "contracts/**/*.sol"
- name: Run tests
run: npx hardhat test
- name: Run Slither
run: slither . || true
这里我故意把 slither . || true 保留了。为什么?因为团队初期接入时,告警可能很多,如果第一次就强制阻断,开发体验会很差。更实际的做法是:
- 第 1 阶段:先收集报告
- 第 2 阶段:只阻断高危规则
- 第 3 阶段:再逐步提高门槛
逐步验证清单
如果你准备把这套流程真正用起来,可以按这个顺序做:
第一步:确认高风险函数
优先检查以下类型函数:
- 提现
- 铸币/销毁
- 权限变更
- 升级入口
- 外部合约调用
- 批量转账/分发
- 清算、质押、赎回等资金路径
第二步:做状态机梳理
问自己几个问题:
- 哪些状态只能从 A 到 B,不能反向?
- 有没有函数能跳过中间状态?
- 多个函数之间是否共享同一份关键余额或额度?
- 外部调用失败时,状态是否仍然一致?
对于复杂协议,用状态图很有帮助:
stateDiagram-v2
[*] --> Created
Created --> Funded: deposit
Funded --> Withdrawn: withdraw
Funded --> Frozen: emergencyPause
Frozen --> Funded: unpause
Withdrawn --> [*]
第三步:补攻击测试
我一般会要求至少有这些测试:
- 重入攻击测试
- 非 owner 调用敏感函数测试
- 零地址输入测试
- 极小值/极大值测试
- 回调失败测试
- 批量处理 gas 边界测试
第四步:工具结果人工分级
不要看到工具报错就一律当高危。建议分级:
- Critical:直接导致资产损失、权限接管
- High:影响核心业务安全,利用门槛不高
- Medium:特定条件下影响可用性或一致性
- Low:规范、可维护性、潜在误用风险
- Info:建议项
常见坑与排查
这一节我尽量讲“真会踩”的坑,而不是只列概念。
1. 以为 Solidity 0.8+ 就没有整数问题了
0.8 之后默认有溢出检查,确实比早期安全很多,但这不代表数值逻辑没问题。
仍然要注意:
- 精度丢失
- 除法向下取整
- 不同 token 小数位不一致
- 费率计算导致套利窗口
排查方法:
- 给所有金额计算补单元测试
- 特别测试 1、最小值、最大值、临界值
- 检查是否依赖
decimals()的假设
2. 把 transfer 当万能安全方案
以前很多文章会说 transfer 限制 2300 gas,更安全。但随着 EVM gas 成本变化,这个假设早就不稳了。现在更常见做法是:
- 优先使用
call - 检查返回值
- 配合 CEI 和重入锁
3. 忽略代理合约和初始化问题
升级合约体系中,最常见的问题之一不是函数本身,而是:
- 初始化函数未调用
- 初始化函数可重复调用
- 实现合约暴露危险入口
- 存储布局冲突
排查思路:
- 检查是否使用
initializer - 检查升级权限归属
- 检查 storage gap
- 检查实现合约是否被错误初始化
4. 误把“工具没报错”当“代码安全”
这是我见过最多的误区。工具对语法层、模式层问题很有效,但对下面这些事常常无能为力:
- 业务逻辑绕过
- 经济模型攻击
- 权限设计不合理
- 多合约组合风险
所以一定要回到问题本身:这个协议的钱,最终沿着哪些路径流动?谁有权改变这些路径?
5. 测试只测 happy path
很多团队测试覆盖率挺高,但只测“正常使用”。这对安全价值有限。
更有效的方式是每个关键函数都问一句:
- 如果我是恶意合约,我怎么调它?
- 如果我连续调两次会怎样?
- 如果外部调用失败呢?
- 如果参数看起来合法但语义异常呢?
安全/性能最佳实践
智能合约里,安全和性能有时会互相拉扯。下面给一些比较务实的建议。
安全最佳实践
1. 资金逻辑优先采用拉取模式
与其主动给用户批量打钱,不如记录可领取余额,让用户自己提取。
优点:
- 降低批量转账 DoS 风险
- 失败隔离更清晰
- 更容易做审计
2. 遵循最小权限原则
- owner 只做治理操作
- 日常操作使用独立角色
- 升级权限放多签
- 紧急暂停能力与资金转移能力分离
3. 所有外部交互都当成不可信
包括:
- 用户地址
- ERC20 合约
- 预言机
- 回调合约
- 跨链消息入口
4. 关键操作必须发事件
这不仅方便链上追踪,也方便审计、风控和事故复盘。
例如:
event Withdraw(address indexed user, uint256 amount);
event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);
event Paused(address indexed operator);
5. 给紧急场景留止血手段
例如:
pause/unpause- 提现限速
- 白名单模式
- 升级冻结窗口
但要注意,止血能力本身也是高权限入口,必须严格控制。
性能最佳实践
1. 避免无界循环
特别是在:
- 大数组遍历
- 批量分发
- 清算列表
- 全量用户结算
无界循环不仅贵,还容易形成 DoS。
2. 减少不必要的存储写入
SSTORE 很贵。能缓存到内存的,尽量不要反复读写存储。
3. 合理拆分函数职责
复杂函数既难审计,也难测。把“校验、记账、转账、事件”拆清楚,安全性往往会更高。
4. 对高频路径优先做 gas 分析
例如:
- 存款/提款
- 交易撮合
- 奖励结算
- 清算路径
优化时不要只盯 gas 数字,更要看会不会引入新的攻击面。
一套适合中小团队的审计落地方案
如果你所在团队资源有限,不可能每次都做顶级全面审计,我建议先把下面这套“基础盘”搭起来:
开发阶段
- 使用 OpenZeppelin 标准库
- 统一 Solidity 版本
- 启用 solhint
- 关键函数强制双人 review
提测阶段
- 跑 Slither
- 补攻击测试
- 对权限图和资金流做一次人工梳理
上线前
- 核查部署参数
- 核查 owner/多签地址
- 核查初始化状态
- 做一次测试网演练
上线后
- 监控关键事件
- 对异常大额提取预警
- 对升级、暂停、角色变更做告警
- 保留应急响应流程
这套方案不花哨,但非常实用。很多事故并不是因为没有“高深审计”,而是因为最基本的上线检查都没做完。
总结
智能合约安全审计,真正难的不是记住十几种漏洞名,而是建立一套**“原理理解 + 人工验证 + 自动化落地”**的闭环。
这篇文章我们做了几件事:
- 梳理了智能合约审计关注的核心问题
- 重点分析了重入、权限控制、低级调用等高频漏洞
- 用 Hardhat + Solidity 复现并修复了典型漏洞
- 搭建了基于 solhint、Slither、测试与 CI 的自动化检测流程
- 总结了实战中最常踩的坑和排查方法
如果你准备马上动手,我建议按这个最小路径开始:
- 先给现有项目跑一遍
solhint和slither - 把提现、权限、升级相关函数逐个补攻击测试
- 把高风险检查接入 CI
- 对主资金路径做一次人工状态流审计
最后强调一个边界条件:自动化工具能显著提高下限,但替代不了人工审计,尤其替代不了对业务逻辑和经济模型的理解。
真正靠谱的安全工作,永远不是“跑过了工具”,而是“知道系统为什么在攻击下仍然成立”。
如果你能把这套思路坚持两三个迭代,你会明显感觉到:团队讨论安全问题时,不再只是“这个函数有没有漏洞”,而是开始谈“这个协议的信任边界在哪里”。这时候,审计才算真正进入实战阶段。