区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建
智能合约一旦部署,往往很难“热修复”。这也是为什么 Web3 世界里,安全审计不是上线前的加分项,而是生死线。
很多团队一开始做安全,只停留在“跑一下扫描器”。但真实项目里,审计远不只是找几个告警:你需要理解漏洞形成的根因,知道哪些问题能被自动化发现,哪些必须人工推理,还要把这些能力沉淀成可重复执行的流程。本文我会从一个更偏实战的角度,带你把这件事串起来:先识别常见漏洞,再搭一条自动化检测流水线,最后结合人工复核完成闭环。
背景与问题
智能合约安全和传统后端安全有一个很大的不同:攻击面小,但代价极高。
常见痛点通常集中在下面几类:
- 合约逻辑可见,攻击者可以反复研究
- 一旦部署到主网,升级能力有限
- 资产直接由代码控制,漏洞能直接变现
- 开发节奏快,很多团队测试覆盖不足
- 单纯依赖人工审计,效率低且容易遗漏边界路径
从审计角度看,问题不只是“有没有漏洞”,而是:
- 哪些漏洞是高频且高危的
- 如何快速筛出明显问题
- 如何把检测过程标准化、自动化
- 如何在自动化无法覆盖的地方补上人工分析
换句话说,真正有价值的审计体系,应该像这样分层:
flowchart TD
A[需求与威胁建模] --> B[静态分析]
B --> C[单元测试与属性测试]
C --> D[模糊测试]
D --> E[人工审计]
E --> F[修复与回归验证]
F --> G[上线前复审]
如果你所在团队还停留在“代码写完后临时找人看一遍”,那这篇文章会很适合你。
前置知识
建议你至少具备以下基础:
- 会读 Solidity 合约
- 知道 EVM、
msg.sender、msg.value、delegatecall - 用过 Hardhat 或 Foundry 其中之一
- 能理解基本的测试与 CI 流程
如果暂时没全掌握,也没关系。本文会尽量把重点放在“如何动手做”。
环境准备
本文示例采用 Foundry + Slither,原因很简单:
- Foundry:测试快,适合写攻击复现和属性测试
- Slither:静态分析成熟,适合做自动化基线扫描
1)安装 Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
2)安装 Python 与 Slither
python3 -m pip install --upgrade pip
pip install slither-analyzer
3)初始化项目
forge init audit-demo
cd audit-demo
项目结构大致如下:
audit-demo/
├── src/
├── test/
├── script/
└── foundry.toml
核心原理
在进入代码前,我们先统一一个思路:智能合约审计,本质是“找出状态变化与权限边界中的错误假设”。
最常见的风险,大多落在以下几个维度:
1. 权限控制错误
比如:
- 本该只有 owner 才能调用的函数,没有加限制
- 升级入口、资金提取入口暴露给任意地址
- 使用
tx.origin做鉴权
2. 外部调用导致的重入
经典问题。合约在更新内部状态之前,先把钱发出去了,攻击者就可以在回调中再次进入敏感函数。
3. 整数与精度问题
Solidity 0.8 以后默认检查溢出,但这不代表数值安全问题消失了。常见问题变成:
- 精度截断
- 份额计算偏差
- 利率、手续费舍入方向错误
4. 业务状态机不完整
例如:
- 认购阶段结束后仍可重复领取
- 销毁后还能转账
- 清算流程顺序错误
这类问题工具不一定能直接报出来,但损失往往很大。
5. 不安全的低级调用
如:
call返回值未检查delegatecall指向不可信地址- 任意外部地址可注入逻辑合约
审计流程怎么拆
我通常会把合约审计拆成三层:
- 基线自动化扫描:快速找显性漏洞
- 攻击路径验证:用测试或 PoC 证明问题真实可利用
- 人工业务审计:核对状态机、资产流、权限图
可以把它理解为下面这个闭环:
sequenceDiagram
participant Dev as 开发
participant Tool as 自动化工具
participant Auditor as 审计者
participant CI as CI流水线
Dev->>Tool: 提交合约代码
Tool->>CI: 静态分析/测试/模糊测试结果
CI->>Auditor: 输出高风险告警
Auditor->>Dev: 复现漏洞与修复建议
Dev->>CI: 提交修复
CI->>Auditor: 回归验证
Auditor->>Dev: 通过上线前复审
实战代码(可运行)
下面我们用一个故意写得不安全的合约做演示,重点展示:
- 如何识别重入漏洞
- 如何编写攻击合约复现问题
- 如何修复
- 如何纳入自动化检测
示例 1:存在重入漏洞的 EtherBank
在 src/EtherBank.sol 中写入:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract EtherBank {
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 getBankBalance() external view returns (uint256) {
return address(this).balance;
}
}
这个问题很典型:外部调用发生在状态更新之前。
编写攻击合约
在 src/Attacker.sol 中写入:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IEtherBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
contract Attacker {
IEtherBank public bank;
uint256 public attackAmount;
address public owner;
constructor(address _bank) {
bank = IEtherBank(_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: 1 ether}();
bank.withdraw(1 ether);
}
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);
}
}
编写 Foundry 测试复现漏洞
在 test/EtherBank.t.sol 中写入:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/EtherBank.sol";
import "../src/Attacker.sol";
contract EtherBankTest is Test {
EtherBank bank;
Attacker attacker;
address user = address(0x1);
address attackerOwner = address(0x2);
function setUp() public {
bank = new EtherBank();
vm.deal(user, 10 ether);
vm.deal(attackerOwner, 10 ether);
vm.prank(user);
bank.deposit{value: 5 ether}();
vm.prank(attackerOwner);
attacker = new Attacker(address(bank));
}
function testNormalWithdraw() public {
vm.prank(user);
bank.withdraw(1 ether);
assertEq(address(bank).balance, 4 ether);
assertEq(bank.balances(user), 4 ether);
}
function testReentrancyAttack() public {
vm.prank(attackerOwner);
attacker.attack{value: 1 ether}();
assertEq(address(bank).balance, 0 ether);
assertGt(address(attacker).balance, 1 ether);
}
}
运行测试:
forge test -vv
如果一切正常,你会看到攻击测试通过,这说明漏洞是真实可利用的,不只是理论风险。
修复漏洞:Checks-Effects-Interactions
最基础也最有效的修复方式,是遵循 CEI 模式:
- 先检查条件
- 再更新状态
- 最后进行外部调用
将 EtherBank.sol 修改为:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract EtherBank {
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");
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
function getBankBalance() external view returns (uint256) {
return address(this).balance;
}
}
更进一步,你还可以加重入锁,例如 OpenZeppelin 的 ReentrancyGuard。
示例 2:权限控制错误
很多时候,资金损失不是因为“高级攻击”,而是因为一个函数忘了加权限。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
address public owner;
constructor() {
owner = msg.sender;
}
// 漏洞:任何人都能提走全部余额
function sweep(address payable to) external {
to.transfer(address(this).balance);
}
receive() external payable {}
}
正确写法至少应加上 onlyOwner:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor() {
owner = msg.sender;
}
function sweep(address payable to) external onlyOwner {
to.transfer(address(this).balance);
}
receive() external payable {}
}
这类问题看起来“低级”,但在真实审计里非常常见,尤其是:
- 管理员函数多次迭代后漏掉限制
- 代理升级初始化流程不完整
- 测试时默认部署者就是管理员,导致误以为逻辑没问题
自动化检测流程搭建
下面开始搭建一条最小可用的自动化审计流程。目标不是替代人工,而是把“每次都要手动做的事”固化下来。
步骤 1:本地静态分析
在项目根目录执行:
slither . --print human-summary
如果 Slither 成功识别 EtherBank 的重入模式,通常会提示重入相关风险。
也可以输出更详细结果:
slither . --detect reentrancy-eth,unchecked-lowlevel,tx-origin
这里我建议先聚焦几类高价值检测器:
reentrancy-*unchecked-lowleveltx-originuninitialized-*shadowing-*arbitrary-send-*
步骤 2:测试与攻击复现纳入 CI
创建 GitHub Actions 文件 .github/workflows/security.yml:
name: security-check
on:
push:
branches: [ main ]
pull_request:
jobs:
smart-contract-security:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
- name: Run Forge Tests
run: forge test -vvv
- 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 . --fail-high --print human-summary
这个流程做了三件事:
- 每次提交自动跑测试
- 自动运行静态分析
- 高危问题直接让 CI 失败
这是非常关键的一步。因为安全能力一旦不进 CI,就会迅速退化成“上线前临时想起来”。
步骤 3:加入模糊测试思路
静态分析很适合发现模式型漏洞,但不擅长复杂业务约束。这个时候模糊测试很有价值。
例如,我们给 EtherBank 写一个简单的不变量思路:用户余额映射之和不应该超过合约总余额。
虽然这个例子比较简单,但能帮你建立“从功能测试走向安全属性测试”的意识。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/EtherBank.sol";
contract EtherBankInvariantTest is Test {
EtherBank bank;
address user1 = address(0x11);
address user2 = address(0x12);
function setUp() public {
bank = new EtherBank();
vm.deal(user1, 10 ether);
vm.deal(user2, 10 ether);
}
function testFuzz_Deposit(uint96 amount1, uint96 amount2) public {
vm.assume(amount1 > 0 && amount1 <= 5 ether);
vm.assume(amount2 > 0 && amount2 <= 5 ether);
vm.prank(user1);
bank.deposit{value: amount1}();
vm.prank(user2);
bank.deposit{value: amount2}();
uint256 totalTracked = bank.balances(user1) + bank.balances(user2);
assertEq(totalTracked, address(bank).balance);
}
}
运行:
forge test --match-test testFuzz -vv
用“资产流 + 权限图”做人工复核
自动化工具能帮你发现一批问题,但真正决定审计深度的,仍然是业务理解。
我自己做人工审计时,通常会先画两张图:
- 资产流图:钱从哪里来,到哪里去,中间谁能改变路径
- 权限图:谁能调用什么函数,谁能升级、暂停、提币、改参数
比如一个典型资金合约可以抽象为:
classDiagram
class User {
+deposit()
+withdraw()
}
class Vault {
+balances
+deposit()
+withdraw()
+pause()
+sweep()
}
class Owner {
+pause()
+sweep()
+setConfig()
}
User --> Vault : 调用
Owner --> Vault : 管理权限
只要把这张图画出来,很多问题会立刻变清楚:
- 是否存在“普通用户可触发管理员行为”
- 是否存在“管理员权限过大且无延迟”
- 是否存在“升级后能直接替换资产逻辑”
- 是否存在“暂停后仍可提币/铸币/清算”
常见坑与排查
这部分我尽量说一些工程里真会踩到的坑。
1. 扫描器没报,不代表安全
这是最常见误区。
像下面这些问题,扫描器经常帮不上太多:
- 经济模型设计缺陷
- 清算阈值设置不合理
- 手续费可被绕过
- 状态机顺序错误
- 多合约协同时序漏洞
排查建议:
- 对核心流程画状态转换图
- 手动列出“谁能改变哪些关键变量”
- 针对资产相关函数做逐条推演
2. 只测 happy path,不测攻击路径
很多测试写得很多,但其实都在验证“正常用户怎么成功操作”,没有验证“恶意用户怎么破坏系统”。
排查建议:
- 每个资金函数至少问自己三个问题:
- 能否重入?
- 能否重复领取?
- 能否绕过权限或前置条件?
3. 升级合约初始化遗漏
代理模式里非常容易出事故:
- 初始化函数可被重复调用
- 实现合约未禁用初始化
- 存储布局升级冲突
排查建议:
- 使用 OpenZeppelin Upgradeable 规范
- 检查
initializer/reinitializer - 升级前后对关键存储槽做回归测试
4. call 成功不等于业务成功
低级调用只代表“没 revert”,不代表被调方真的完成了你的业务意图。
排查建议:
- 明确区分“调用成功”和“状态符合预期”
- 对跨合约交互增加结果校验
- 尽量使用清晰接口而非裸
call
5. 误用 block.timestamp 和链上随机性
有人会用时间戳做奖池开奖、稀有属性生成,这在安全上通常不可靠。
排查建议:
- 不把矿工可影响的变量作为随机源
- 涉及随机性时采用 VRF 等可验证方案
- 对“时间窗口”逻辑加入边界测试
安全/性能最佳实践
安全和性能不是完全对立的,但顺序一定要对:先保证正确性,再谈 gas 优化。
安全最佳实践
1. 先写权限模型,再写代码
在编码前先定义:
- 谁是 owner
- 哪些角色可以暂停、升级、提币、改参数
- 是否需要多签和时间锁
如果权限边界一开始没想清楚,后面靠补丁很难补完整。
2. 资金相关逻辑优先采用 CEI
对于任何涉及资产转移的函数,优先检查:
- 状态是否先更新
- 外部调用是否必要
- 是否需要重入锁
3. 关键逻辑写成“不变量”
比如:
- 总资产 >= 总负债
- 用户可提金额 <= 用户份额对应资产
- 一个订单只能成交或取消一次
一旦你能把业务规则写成不变量,就能明显提升自动化检测效果。
4. 对管理员能力做约束
管理员不是“无限信任”的代名词。建议:
- 高危管理操作接入多签
- 关键变更增加 timelock
- 参数修改设置上下限
- 升级前后做差异审计
性能最佳实践
1. 不要为了省 gas 牺牲可读性与安全性
比如用复杂内联汇编、手写位运算,确实可能省一点 gas,但也会显著提高审计成本。
中级团队尤其要克制这一点。
2. 把高频读路径和高频写路径分开考虑
- 高频读:关注 view 函数结构和索引设计
- 高频写:关注存储写次数、循环长度、批量操作上限
3. 避免无上界循环处理用户资金
这不仅是性能问题,也是 DoS 风险。比如批量分红、批量清算、批量退款,都需要分页或用户自助领取机制。
逐步验证清单
如果你想把本文内容落地到自己的项目,我建议按这个顺序执行:
第一步:做最小基线扫描
- 跑
forge test - 跑
slither . - 修掉高危和中危显性问题
第二步:补攻击复现测试
针对以下函数至少写一类攻击测试:
- 提现
- 铸造/销毁
- 升级
- 奖励领取
- 清算
第三步:列出系统不变量
至少写出 3 个以上,例如:
- 总供应量与余额映射一致
- 提现后用户余额不会为负
- 重复领取不会增加收益
第四步:人工走查权限与状态机
检查:
- 是否有未授权入口
- 是否有状态切换遗漏
- 是否存在暂停绕过
- 是否存在升级后初始化风险
第五步:接入 CI
确保每次 PR 都会自动执行:
- 编译
- 单元测试
- 模糊测试
- 静态分析
一个更实用的审计判断标准
中级开发者经常会问:“工具都绿了,是不是就能上线了?”
我的建议是,至少同时满足下面三个条件:
stateDiagram-v2
[*] --> 静态分析通过
静态分析通过 --> 攻击测试覆盖核心路径
攻击测试覆盖核心路径 --> 人工审计完成
人工审计完成 --> 上线评审
上线评审 --> [*]
也就是:
- 工具层面:没有明显高危告警
- 测试层面:关键资产路径有攻击复现和回归测试
- 人工层面:权限、状态机、经济模型都被审过
少任何一层,都容易留下盲区。
总结
智能合约安全审计,真正难的不是“知道几个漏洞名词”,而是把它变成一套稳定的工程流程。
这篇文章我们做了几件关键的事:
- 识别了重入、权限控制等高频漏洞
- 用 Foundry 编写了可运行的漏洞复现代码
- 用 Slither 搭了基础自动化检测
- 把测试、扫描、回归纳入 CI
- 说明了自动化边界,以及人工审计该怎么补位
如果你现在就要落地,我给你的可执行建议很简单:
- 先把高风险函数列出来:提现、升级、管理员操作、奖励发放
- 给每个函数补一条攻击测试:不是只测成功路径
- 在 CI 里接入静态分析和测试:让安全变成默认动作
- 人工画出权限图和资产流图:很多业务漏洞会自己浮出来
- 主网部署前做一次完整回归:尤其是升级合约和参数修改
最后也要提醒边界条件:
自动化工具能显著提高下限,但很难保证上限。 对于 DeFi、跨链桥、清算系统、复杂治理模块这类高价值场景,专业人工审计仍然是必需项,必要时还应结合形式化验证与赏金计划。
如果你能把“漏洞识别 + PoC 复现 + 自动化流水线 + 人工复核”这四步走顺,团队的智能合约安全水平,通常会有一个非常明显的提升。