区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,改起来比传统后端麻烦得多。很多团队在功能联调阶段看起来一切正常,真到了主网、遇到恶意调用、闪电贷、复杂授权链路时,问题才开始暴露。
我自己做过几次合约审计后,一个很深的感受是:安全审计不是“最后扫一扫”,而是把漏洞识别方法、测试习惯、自动化工具和上线流程串起来。
这篇文章不只讲“有哪些漏洞”,更会带你搭一条能实际跑起来的智能合约安全检测流程。目标读者默认已经会写一些 Solidity,知道 Hardhat 或 Foundry 的基本用法。
背景与问题
智能合约的安全问题,有几个和传统 Web 系统非常不一样的地方:
- 不可逆
- 链上交易一旦确认,资金转移通常无法撤回。
- 公开透明
- 源码、ABI、交易行为都可能被逆向分析。
- 对抗性环境
- 不是“用户误操作”,而是有人专门盯着你设计里的边界条件。
- 组合性强
- 合约之间会互调,DeFi 场景里还会叠加预言机、借贷、兑换、治理。
因此,审计不能只盯着某一行代码,而要同时看:
- 单函数逻辑是否安全
- 状态变更顺序是否合理
- 权限模型是否可绕过
- 外部调用是否可重入
- 数值计算是否会造成经济损失
- 自动化工具能否持续兜底
很多团队的问题不是“完全没做安全”,而是:
- 只跑了静态扫描,没做动态验证
- 只看单个漏洞,没有形成流程
- 本地能过,CI/CD 里没集成
- 上线前一次性人工审计,之后代码迭代没人看
这就是本文要解决的问题。
前置知识
建议你至少具备以下基础:
- Solidity 0.8+ 语法
- 了解
msg.sender、msg.value、call - 会使用 npm / node
- 能执行基本测试命令
如果你熟悉 Hardhat,会更容易跟着跑起来。本文示例也会尽量控制复杂度,方便你本地复现。
环境准备
本文用一套偏实用的组合:
- Node.js 18+
- Hardhat
- OpenZeppelin Contracts
- Slither:静态分析
- Mythril:符号执行/漏洞检测补充
1)初始化项目
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
npx hardhat
选择一个基础 JavaScript 项目即可。
2)安装 Slither
Slither 依赖 Python 环境,推荐用 pipx 或虚拟环境安装。
pip install slither-analyzer
3)安装 Mythril
pip install mythril
如果你的环境比较“挑剔”,建议把 Python 工具放进虚拟环境,不然版本冲突很常见。我第一次装 Mythril 时就被依赖卡了半天。
核心原理
智能合约审计,实践中通常会组合三层手段:
- 人工代码审阅
- 理解业务逻辑、权限边界、资金流向
- 静态分析
- 不运行代码,通过 AST / IR / CFG 识别危险模式
- 动态测试与对抗验证
- 单元测试、模糊测试、攻击合约复现
你可以把它理解为:
flowchart TD
A[需求与业务理解] --> B[人工审阅关键逻辑]
B --> C[静态分析 Slither]
C --> D[单元测试/攻击复现]
D --> E[CI 自动化拦截]
E --> F[上线前人工复核]
常见漏洞关注面
中级开发者最容易遇到的,通常是这几类:
- 重入攻击
- 权限控制缺失
- 不安全的外部调用
- 整数边界与精度问题
- 拒绝服务(DoS)
- 时间戳/区块变量误用
- 签名校验缺陷
- 升级代理存储冲突
这篇教程重点挑最常见、最有代表性的几种来讲,并把它们串进自动化流程里。
漏洞识别思路:先看“钱怎么流”,再看“谁能调”
我一般审计一个合约,第一轮会先不急着读细节,而是问三个问题:
- 这个合约里,资产从哪里进、到哪里出?
- 哪些函数能改关键状态?谁有权限调用?
- 外部调用发生在什么位置?状态更新是在前还是在后?
这个顺序非常重要。因为大量严重漏洞,本质上就两类:
- 调用顺序有问题
- 权限边界没封住
下面用一个最经典的例子来说明。
实战代码(可运行)
我们先故意写一个有漏洞的银行合约,再写攻击合约复现重入。
1)有漏洞的目标合约
创建 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 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)攻击合约
创建 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;
address public owner;
uint256 public attackAmount;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
owner = msg.sender;
}
function attack() external payable {
require(msg.sender == owner, "not owner");
require(msg.value >= 1 ether, "need at least 1 ether");
attackAmount = 1 ether;
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/reentrancy.js:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Reentrancy attack demo", function () {
it("Attacker should drain the 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 user.sendTransaction({
to: await bank.getAddress(),
value: 0
}).catch(() => {});
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.equal(ethers.parseEther("6"));
});
});
4)运行测试
npx hardhat test
如果环境正常,你会看到攻击成功:攻击者用 1 ETH 递归提走了合约里的更多资金。
重入攻击到底是怎么发生的
很多人知道“重入”这个词,但一到真实代码就看不出来。核心点其实很朴素:
- 合约调用
msg.sender.call(...) - 对方是个合约,不是普通地址
- 对方在
receive()或fallback()中再次回调你的withdraw - 而你自己的余额状态还没扣减
过程可以画成这样:
sequenceDiagram
participant U as Attacker
participant A as AttackerContract
participant B as VulnerableBank
U->>A: attack() with 1 ETH
A->>B: deposit(1 ETH)
A->>B: withdraw(1 ETH)
B->>A: call{value:1 ETH}
A->>B: re-enter withdraw(1 ETH)
B->>A: call{value:1 ETH}
A->>B: repeat until drained
B-->>A: balances updated too late
这就是为什么安全里一直强调 Checks-Effects-Interactions:
- 先检查条件
- 再更新内部状态
- 最后做外部交互
修复版本:先改顺序,再加防护
创建 contracts/SafeBank.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
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 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;
}
}
这里做了两件事:
- 先扣余额,再转账
- 增加
nonReentrant作为二次保险
我的经验是:不要把 ReentrancyGuard 当作唯一修复手段。
更底层的修复,永远是状态更新顺序正确。
再看一个高频问题:权限控制不严
很多漏洞不靠复杂攻击,而是“某个不该公开的函数居然 anyone can call”。
不安全示例
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BadVault {
address public owner;
uint256 public totalFunds;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
totalFunds += msg.value;
}
// 漏洞:任何人都能调用
function emergencyWithdraw() external {
payable(msg.sender).transfer(address(this).balance);
}
}
这类问题在审计里其实很常见,尤其是:
- 忘记加
onlyOwner - 升级函数没做管理员控制
- 初始化函数可重复调用
- “内部运维函数”误设为
external
安全版本
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract GoodVault {
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor() {
owner = msg.sender;
}
function deposit() external payable {}
function emergencyWithdraw(address payable to) external onlyOwner {
to.transfer(address(this).balance);
}
}
自动化检测流程搭建
到这里,我们已经有了“怎么看漏洞”的基础。下面开始把它变成团队能复用的流程。
目标是形成这样一条流水线:
flowchart LR
A[编写/修改合约] --> B[Solidity 编译]
B --> C[单元测试]
C --> D[静态分析 Slither]
D --> E[补充符号执行 Mythril]
E --> F[人工审阅高风险结果]
F --> G[合并代码/发布]
第一步:保证基本测试可跑
先在 package.json 里加脚本:
{
"scripts": {
"test": "hardhat test",
"compile": "hardhat compile"
}
}
执行:
npm run compile
npm run test
第二步:接入 Slither
在项目根目录执行:
slither .
常见输出会包含:
- reentrancy 风险
- low-level call 使用
- 未检查返回值
- 未初始化状态变量
- 权限问题提示
如果你只想看高价值结果,可以:
slither . --exclude-informational --exclude-low
第三步:对关键合约跑 Mythril
myth analyze contracts/VulnerableBank.sol --solv 0.8.20
Mythril 更偏符号执行,对复杂路径有帮助,但速度通常比静态扫描慢,所以我建议:
- PR 阶段:优先跑 Slither
- 发布前:对核心资金合约补跑 Mythril
第四步:接入 GitHub Actions
创建 .github/workflows/contract-security.yml:
name: Contract Security Checks
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-and-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 18
- name: Install Node dependencies
run: npm ci
- name: Compile
run: npx hardhat compile
- name: Test
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 . --exclude-informational --exclude-low
这份配置先做到最关键的事情:
- 编译不过,不让合并
- 测试不过,不让合并
- Slither 扫出高等级问题,人工必须处理
如果你们团队已经有更成熟的流程,可以再加:
- 覆盖率阈值
- PR 评论机器人
- SARIF 结果上报
- 主网部署前的强制审批
如何理解自动化工具的边界
这一点非常重要。很多团队第一次接安全工具时,容易有两个极端:
极端一:迷信工具
认为只要 Slither 没报错,就安全了。
其实不是。工具对这几类问题往往不够好:
- 复杂经济攻击
- 业务层权限绕过
- 多合约组合风险
- 预言机操纵与价格依赖
- 闪电贷驱动的状态异常
极端二:完全不用工具
这也不现实。人工审计很贵,而且重复劳动非常多。
更合理的方法是:
- 工具负责高频、标准化、可重复的问题
- 人工负责理解业务与判断攻击面
- 测试负责把真实攻击路径跑出来
常见坑与排查
下面是我在实际项目里经常见到的坑,很多不是“理论漏洞”,而是“工具接入后跑不起来”。
1)Slither 扫描失败,提示找不到编译信息
现象:
slither .
报错类似无法解析编译输出。
原因:
- Hardhat 没编译
- 项目依赖未安装完整
- 编译器版本与合约声明不一致
排查方式:
npx hardhat compile
先确认编译通过,再执行 Slither。
如果有多个 Solidity 版本,检查 hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: {
compilers: [
{ version: "0.8.20" },
{ version: "0.8.19" }
]
}
};
2)测试能过,但主网环境仍然危险
常见原因:
- 测试里只覆盖正常路径
- 没有模拟恶意合约调用
- 没有测试边界金额和异常回滚场景
建议:
至少补三类测试:
- 正常用户路径
- 恶意调用路径
- 权限绕过与极端输入
比如针对提款函数,不只要测“能成功提”,还要测:
- 重复提取
- 提 0
- 提超过余额
- 合约地址作为调用方
- 回调失败时状态是否一致
3)使用 transfer/send 误以为天然安全
以前很多教程会说 transfer 因为 gas 限制更安全,但这套经验现在不能机械套用了。
现代 Solidity 实践里,很多项目会使用 call,因为兼容性更强。
关键不在于你用 transfer 还是 call,而在于:
- 是否先更新状态
- 是否做了重入防护
- 是否检查返回值
- 是否预期对方是任意合约
4)代理合约升级后存储错位
如果你们用 UUPS 或 Transparent Proxy,审计一定要关注:
- 新老版本状态变量顺序
- 是否保留 storage gap
- 初始化函数是否只能执行一次
这个问题很隐蔽,因为单元测试可能没暴露,但一升级主网就会把状态读乱。
5)把“onlyOwner”当成万能权限模型
真实项目里,光有 owner 远远不够。常见问题:
- owner 私钥泄露就是全盘失守
- 权限过于集中
- 没有 timelock
- 没有多签保护
如果合约管理的是大额资金,建议最少做到:
- 关键函数交给多签
- 变更操作设置延迟
- 高风险动作输出事件日志
安全/性能最佳实践
这一节给出能直接落地的建议,不讲空话。
安全最佳实践
1)遵循 CEI 模式
即:
- Checks
- Effects
- Interactions
尤其是有资金转移的函数,先改状态,再外部调用。
2)默认把外部调用视为不可信
包括:
calldelegatecall- 第三方协议接口调用
- ERC777 / ERC721 / ERC1155 回调
只要发生外部交互,就要问自己一句:
如果对方恶意回调,会发生什么?
3)权限最小化
不要让一个管理员承担所有危险操作。可拆分为:
- 参数配置角色
- 暂停角色
- 升级角色
- 资金提取角色
4)关键操作必须发事件
比如:
- 管理员变更
- 资金提取
- 黑名单变更
- 参数更新
- 合约升级
这不仅是审计需要,也方便链上监控。
5)对核心逻辑写“攻击型测试”
不是只测 happy path,而是主动写:
- 重入攻击合约
- 伪造调用者
- 异常 token 合约
- 返回值不规范的 ERC20
很多漏洞在正常测试里永远不会出现。
性能与工程实践
安全不是越多修饰器越好,也要考虑 gas 和可维护性。
1)不要为“省一点 gas”牺牲安全边界
比如:
- 去掉权限判断
- 合并导致逻辑难审计
- 手写复杂汇编但没人能 review
2)把高频检查放自动化里
建议至少形成这张清单:
- 编译通过
- 单元测试通过
- 静态扫描通过
- 关键合约攻击测试通过
- 部署前人工 review
3)对告警做分级
不是所有工具提示都要一刀切拦截。建议分级:
- High:阻塞合并
- Medium:要求解释或修复
- Low:记录并评估
- Info:供人工参考
4)保留审计基线
每次发版前,记录:
- 合约版本
- 编译器版本
- 依赖版本
- 审计结果摘要
- 已知风险与接受理由
这样后续追溯问题会轻松很多。
逐步验证清单
如果你想把本文真正落地,可以按这个顺序做:
stateDiagram-v2
[*] --> 编译通过
编译通过 --> 单元测试通过
单元测试通过 --> 攻击测试通过
攻击测试通过 --> Slither扫描完成
Slither扫描完成 --> 高风险问题清零
高风险问题清零 --> 发布前人工复核
发布前人工复核 --> [*]
对应操作清单如下:
- 本地
npx hardhat compile成功 - 本地
npx hardhat test成功 - 已复现至少一个真实漏洞案例
- 已编写修复版并验证
-
slither .已运行并处理高风险告警 - 核心资金合约已做人工检查
- GitHub Actions 已接入
- 发布流程包含安全复核点
这份清单看起来朴素,但真能把很多低级事故挡在上线前。
一个更实用的审计视角:从函数清单到资产路径
如果你已经不满足于“看到漏洞例子”,我建议实际审计时用这个顺序:
第一步:列出高风险函数
通常包括:
- 提款
- 授权
- 升级
- 清算
- 管理员设置
- 外部协议调用
- 预言机价格读取
第二步:画资金流
比如:
- 用户资产进入哪里
- 合约资产如何出账
- 是否依赖外部价格
- 是否能被第三方合约打断或回调
第三步:标记信任边界
问清楚:
- 谁是可信管理员
- 哪些合约地址可变
- 哪些 token 是不可信输入
- 是否允许任意合约调用
第四步:把这些点转成自动化测试
这是很多团队最容易忽略的一步。
审计结论如果没有沉淀成测试,下次改代码还会再犯。
总结
智能合约安全审计,真正有用的不是记住几十个漏洞名词,而是建立一个稳定的方法:
- 先看资产流与权限边界
- 用人工审阅识别核心攻击面
- 用测试复现真实攻击路径
- 用 Slither / Mythril 做自动化兜底
- 把安全检查接进 CI,而不是发布前临时抱佛脚
如果你刚开始落地,我建议先别追求一步到位。最小可行方案就是:
- 先写出一个可复现漏洞的 demo
- 再写修复版
- 跑单元测试
- 接入 Slither
- 放进 GitHub Actions
只要这五步跑通,你们团队就已经从“凭感觉写安全代码”,进入“有审计闭环”的阶段了。
最后给一个边界条件提醒:
自动化流程能大幅降低常见错误,但它替代不了对业务逻辑和经济模型的理解。
尤其在 DeFi、治理、跨链桥这类高复杂场景里,人工审计依然是最后一道关键防线。