区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,往往就意味着“代码即规则”。这句话听起来很酷,但也意味着另一件更现实的事:漏洞也会被永久写进链上。
我第一次参与合约审计时,最大的感受不是“漏洞多高级”,而是很多问题其实都不神秘——重入、权限控制缺失、整数边界、外部调用顺序错误,这些反复出现,只是换了个业务外衣。
这篇文章不讲太多空泛概念,而是带你从一个中级开发者最需要的角度出发:
- 先理解智能合约审计到底在查什么;
- 再看几类最常见、最容易漏掉的漏洞;
- 然后搭一个可落地的自动化检测流程;
- 最后给出排查清单和最佳实践,方便你自己接入项目。
背景与问题
传统 Web 服务出问题,通常还能热修复、回滚、封禁用户、恢复数据库。
但智能合约世界不同:
- 合约部署后修改成本高,甚至不可修改
- 资产直接由代码控制,漏洞会直接对应资金损失
- 外部调用复杂,依赖 token、预言机、代理合约等组合关系
- 审计不能只看“单个函数”,必须看状态变化、权限边界、调用链
审计里最常见的误区
很多团队在上线前会说:“我们跑过静态扫描了,应该没问题。”
这句话我建议保留一点警惕。因为:
- 静态扫描能发现很多模式化问题,但业务逻辑漏洞常常抓不住
- 单元测试覆盖高,不代表攻击路径覆盖高
- 一些漏洞只有在“跨合约交互”“异常 token 实现”“边界输入”下才出现
所以真正实战中的安全审计,通常是三层结合:
- 人工审计:看业务、看权限、看状态机
- 静态分析:快速发现高频漏洞模式
- 动态测试/Fuzzing:验证异常路径和组合路径
前置知识
如果你准备跟着一起做,建议你至少熟悉:
- Solidity 基础语法
- EVM 调用模型
- ERC-20 常见交互方式
msg.sender、msg.value、call、delegatecall- 单元测试工具,例如 Hardhat 或 Foundry
如果这些概念还不稳,也没关系,本文会尽量边讲边解释。
环境准备
下面给出一个轻量但实用的审计环境。为了便于复现,我选择 Hardhat + Slither 这条组合:
- Hardhat:编译、测试、部署本地合约
- Slither:静态分析
- Node.js:脚本支持
- Solidity:示例合约
安装基础环境
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
初始化一个 JavaScript 项目即可。
再安装 Slither。它依赖 Python 环境:
pip install slither-analyzer
如果你本机没有 solc 多版本管理,可以再装一个:
pip install solc-select
solc-select install 0.8.20
solc-select use 0.8.20
核心原理
智能合约审计,不是“看有没有敏感词”,而是围绕以下几个核心问题展开:
1. 资产能否被非预期转移
最先要问的永远是:
谁能转钱?在什么条件下转?转账前后状态是否一致?
比如:
- 用户提现时有没有先更新余额
- 管理员权限能否被篡改
- 外部合约回调时,能否反复进入关键逻辑
2. 状态机是否严格
很多协议类合约,其实本质是一个状态机:
- 创建
- 激活
- 结算
- 关闭
如果状态切换不严格,就会出现:
- 重复领取奖励
- 未开始阶段提前执行
- 已结束任务再次提交
- 多次初始化
3. 权限边界是否清晰
权限问题不只是 onlyOwner 有没有加。还包括:
- 初始化函数是否可被任意人调用
- 代理合约升级权限是否安全
- 白名单设置是否有绕过路径
- 管理员是否能误操作导致锁死
4. 外部交互是否可信
链上世界一个经典问题是:你调用的对象,未必按你想象工作。
例如:
- 非标准 ERC-20 不返回
bool - 恶意合约在回调中重新进入
delegatecall修改了当前存储- 预言机延迟或被操纵
常见漏洞识别
下面选几类最值得优先检查的问题。
漏洞一:重入攻击
这是经典中的经典。典型错误顺序是:
- 先向外部地址转账
- 再更新内部余额
如果接收方是恶意合约,就能在回调里再次调用提现函数。
sequenceDiagram
participant U as 用户/攻击合约
participant V as 漏洞合约
U->>V: withdraw()
V->>U: call{value: amount}
U->>V: fallback 中再次调用 withdraw()
V-->>U: 重复转账
漏洞二:权限控制缺失
比如管理员函数没加限制:
function setOwner(address newOwner) external {
owner = newOwner;
}
这类漏洞不复杂,但后果非常严重。
漏洞三:未检查外部调用返回值
即使 Solidity 0.8 以后很多边界更安全了,外部调用仍然要明确检查:
call- token
transfer - token
transferFrom
如果不检查结果,可能出现“逻辑上成功、实际上失败”。
漏洞四:业务逻辑漏洞
这是静态分析最难搞定的一类。比如:
- 奖励可重复领取
- 清算价格窗口不合理
- 抵押率判断顺序错误
- 初始化函数可重复执行
这类问题常常不体现在语法层,而体现在条件组合里。
实战代码(可运行)
下面我用一个小例子演示:先写一个存在重入漏洞的合约,再写攻击合约和修复版,最后用测试验证。
示例 1:存在漏洞的银行合约
新建 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(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;
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);
}
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
示例 3:修复版合约
新建 contracts/SafeBank.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SafeBank {
mapping(address => uint256) public balances;
bool private locked;
modifier nonReentrant() {
require(!locked, "reentrant call");
locked = true;
_;
locked = false;
}
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 getBalance() external view returns (uint256) {
return address(this).balance;
}
}
修复点有两个:
- 使用 Checks-Effects-Interactions 顺序
- 增加
nonReentrant锁
这两步最好一起做,不建议只靠其中一个。
测试验证
新建 test/reentrancy.js:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Reentrancy Audit Demo", function () {
it("should exploit 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"));
});
it("should block attack on SafeBank", async function () {
const [deployer, user, attackerEOA] = await ethers.getSigners();
const SafeBank = await ethers.getContractFactory("SafeBank", deployer);
const safeBank = await SafeBank.deploy();
await safeBank.waitForDeployment();
await safeBank.connect(user).deposit({ value: ethers.parseEther("5") });
const Attacker = await ethers.getContractFactory("Attacker", attackerEOA);
const attacker = await Attacker.deploy(await safeBank.getAddress());
await attacker.waitForDeployment();
await expect(
attacker.connect(attackerEOA).attack({ value: ethers.parseEther("1") })
).to.be.reverted;
});
});
运行测试:
npx hardhat test
如果环境正常,你会看到:
VulnerableBank被攻击成功SafeBank攻击失败
审计流程怎么搭:从人工检查到自动化检测
实际项目里,审计不该靠“某个专家看一遍”。更靠谱的是建立一个持续执行的检测链路。
flowchart TD
A[代码提交] --> B[格式化与编译]
B --> C[单元测试]
C --> D[静态分析 Slither]
D --> E[Fuzz/属性测试]
E --> F[人工审计清单复核]
F --> G[审计报告与修复验证]
第一步:编译和基础测试
先确保最基本的事情成立:
- 能编译
- 单元测试通过
- 核心流程有测试
- 失败路径有测试
第二步:静态分析
运行 Slither:
slither contracts/VulnerableBank.sol
或者对整个项目跑:
slither .
Slither 常能发现:
- 重入风险
- 未初始化存储指针
- 错误的可见性
- 危险的低级调用
- 常量可优化项
- 死代码
不过要注意,Slither 的结果不能“全信也不能无视”。
我的习惯是把结果分成三类:
- 必须修:高危、明确可利用
- 需人工确认:可能误报
- 工程优化项:不影响安全但值得整理
第三步:增加属性测试 / Fuzzing
单元测试是“我预设输入去测”;Fuzzing 是“工具帮我乱试边界”。
如果你使用 Foundry,这一步会更顺手;如果仍在 Hardhat,也可以结合其他工具。核心思路是给出不变量,例如:
- 合约总资产不应凭空减少
- 非 owner 不能执行管理员操作
- 用户提取金额不能超过自己的净存款
- 初始化函数只能成功一次
第四步:人工审计清单
这一层不能省。自动化工具抓不到所有业务漏洞。
推荐按下面几个维度检查:
- 权限
- 资金流
- 状态迁移
- 外部调用
- 数学边界
- 升级/初始化
- 紧急暂停与恢复策略
自动化检测脚本示例
如果你希望把流程接进 CI,可以先写一个简单脚本。
新建 scripts/audit.sh:
#!/usr/bin/env bash
set -e
echo "==> compile"
npx hardhat compile
echo "==> test"
npx hardhat test
echo "==> slither"
slither . || true
echo "==> audit pipeline done"
给执行权限:
chmod +x scripts/audit.sh
./scripts/audit.sh
这里我故意把 slither . || true 保留了。原因很实际:
- 在团队早期接入阶段,静态分析可能报很多历史问题
- 如果直接让 CI 因全部告警失败,团队容易立刻把工具关掉
- 更合理的做法是:先接入、再分级治理
等你们告警收敛后,再逐步改成“高危失败即阻断”。
审计视角下的代码检查框架
为了避免“看着看着就漏”,我建议固定用一套框架。
classDiagram
class AuditChecklist {
+权限控制
+资金流向
+状态机完整性
+外部调用安全
+数学与边界
+升级与初始化
+事件与可观测性
}
class Permission {
+onlyOwner
+role based access
+init guard
}
class FundFlow {
+deposit withdraw
+accounting consistency
+unexpected token behavior
}
class ExternalCall {
+call delegatecall
+reentrancy
+return value check
}
AuditChecklist --> Permission
AuditChecklist --> FundFlow
AuditChecklist --> ExternalCall
你可以把它理解成一个“不会漏大项”的脑内模板。
常见坑与排查
这部分非常实战,我尽量写得接地气一点。
坑 1:以为 Solidity 0.8+ 就不会有整数问题
确实,0.8+ 默认带溢出检查,很多老问题少了。
但这不等于数学安全就万事大吉。你仍要检查:
- 精度损失
- 除法截断
- 价格换算顺序
- 费率计算时的舍入偏差
尤其是 DeFi 合约,精度错误最后会变成套利入口。
排查建议:
- 用极小值、极大值测试
- 用不整除数值测试
- 检查先乘后除还是先除后乘
坑 2:只看 ETH 转账,不看 Token 行为差异
很多人把 ERC-20 当成“和 ETH 一样”。这是典型踩坑点。
现实里你会遇到:
transfer不返回bool- fee-on-transfer token
- rebasing token
- 黑名单 token
- 回调型 token
排查建议:
- 不要假设 token 行为绝对标准
- 资金核算以“实际到账”为准
- 使用成熟安全库做 token 交互
坑 3:升级合约把初始化暴露了
代理模式中最危险的问题之一是:
- 实现合约未禁用初始化
- 代理初始化函数能被重复调用
- 升级权限控制不严
这类问题往往一旦被利用,直接就是控制权丢失。
排查建议:
- 检查
initializer/reinitializer - 检查实现合约构造中是否做禁用初始化
- 升级函数必须加严格权限控制
坑 4:以为测试通过就代表安全
我自己就踩过这个坑。
单元测试通常覆盖的是“正常业务路径”,而攻击发生在“异常组合路径”。
排查建议:
- 给每个资金函数写失败路径测试
- 尝试跨函数组合调用
- 针对边界状态写 invariant
坑 5:把告警全当误报
静态工具确实会误报,但“全部忽略”通常比“多看几眼”危险得多。
更好的方式:
- 为每条告警打标签:高危 / 待确认 / 忽略
- 在 PR 中写清楚忽略依据
- 对重复误报建立团队规则,而不是口头跳过
安全/性能最佳实践
这一节我按“真正值得落地”的方式来总结。
1. 遵守 CEI 模式
即:
- Checks:先校验条件
- Effects:先修改内部状态
- Interactions:最后与外部交互
这不是万能公式,但对多数提现、领取、结算逻辑都非常重要。
2. 关键函数加重入保护
尤其是这些函数:
- 提现
- 领取奖励
- 清算
- 任意外部调用后再写状态的函数
如果合约复杂,建议优先使用成熟库里的重入保护实现。
3. 权限最小化
不要让 owner 拥有过多不可逆能力。
更稳妥的做法是:
- 分角色授权
- 敏感操作增加 timelock
- 升级与资金提取权限分离
- 对高危操作发事件并留审计痕迹
4. 对外部依赖保持“不信任”假设
包括:
- token
- 预言机
- 回调接收者
- 第三方协议合约
任何外部依赖都要假设它可能:
- 失败
- 延迟
- 回调攻击
- 返回异常值
5. 先做可观测性,再做排障
很多项目出问题后第一反应是:日志太少,根本不知道哪一步错了。
建议关键路径都发事件:
- 存款
- 提现
- 权限变更
- 升级
- 紧急暂停
- 参数修改
事件不是直接提升安全,但能大幅提升排查效率。
6. 自动化检测要“持续运行”,而不是上线前跑一次
真正有效的流程是:
- 每次 PR 自动编译与测试
- 每次合并自动静态分析
- 每个版本发布前跑完整审计清单
- 高危修改必须人工复核
安全不是某次活动,而是一条流水线。
逐步验证清单
如果你准备在自己的项目里落地,可以直接按这份清单走:
基础层
- 合约可稳定编译
- 核心业务路径有单元测试
- 失败路径有测试
- 关键状态变更有事件
安全层
- 提现/领取逻辑检查重入
- 管理员函数检查权限
- 初始化函数检查是否只能执行一次
- 外部调用结果有检查
- token 交互考虑非标准行为
工程层
- 接入静态分析工具
- CI 自动跑测试
- 告警有分级处理
- 修复后有回归测试
发布层
- 高危改动进行人工审计
- 升级流程有回滚或暂停策略
- 部署参数经过二次核对
- 审计结论有留档
一个更实用的落地建议
如果你是中型团队,不要一上来就追求“全自动、全覆盖、零误报”。这通常会把流程做得很重,最后没人用。
更现实的路线是:
- 先把测试与 Slither 接进 CI
- 把高频漏洞清单固化到 code review
- 对资金相关模块做重点人工审计
- 逐步补属性测试和更深的动态分析
也就是说,先追求“稳定执行”,再追求“极致完美”。
总结
智能合约安全审计的关键,不在于记住多少漏洞名词,而在于建立一套稳定的方法:
- 从资金流、权限、状态机、外部调用四个维度看代码
- 用静态分析快速抓高频问题
- 用测试和 Fuzzing 验证边界路径
- 用人工审计识别业务逻辑漏洞
- 把这些动作沉淀成自动化流程,而不是临上线前突击
如果你刚开始搭审计流程,我建议先完成三件事:
- 给资金相关函数补全失败路径测试
- 在 CI 中接入 Slither
- 建立一份团队统一的审计检查表
做到这三步,你们的合约安全基线通常就会比“只靠经验看代码”稳很多。
安全这件事没有银弹,但有方法。方法一旦固定下来,很多看似复杂的问题,其实都能被提前发现。