跳转到内容
123xiao | 无名键客

《区块链智能合约安全审计实战:从常见漏洞识别到自动化测试流程搭建》

字数: 0 阅读时长: 1 分钟

区块链智能合约安全审计实战:从常见漏洞识别到自动化测试流程搭建

智能合约一旦部署,往往就不是“改个 bug 再发版”那么简单。代码即资产、代码即规则,这句话在链上不是口号,而是现实。很多团队在业务逻辑设计上投入很大,却把审计理解成“上线前跑一遍扫描器”,结果真正出问题时,损失并不是一个小补丁能挽回的。

这篇文章我想换一个更实战的角度来讲:不是只列漏洞清单,而是带你从“识别风险”走到“搭建自动化测试与审计流程”。如果你已经写过 Solidity,或者至少看得懂基本合约结构,这篇内容会比较适合你。


背景与问题

智能合约安全和传统 Web 安全有一个很大的不同:攻击面不只来自输入参数,还来自链上状态、调用顺序、外部合约行为、Gas 限制、权限配置以及经济模型设计

实际审计中,常见问题通常集中在这几类:

  • 重入攻击(Reentrancy)
  • 权限控制缺失
  • tx.origin 误用
  • 整数边界问题(旧版本更多,0.8+ 已内建检查)
  • 价格预言机依赖不安全
  • 随机数伪随机
  • DoS 与 Gas 放大
  • 升级合约存储冲突
  • ERC20/721 非标准实现兼容性问题

很多团队的问题不是“不知道漏洞”,而是:

  1. 知道概念,但不会落到代码审计点
  2. 测试只测正常路径,不测攻击路径
  3. 没有把静态分析、单测、模糊测试、CI 串起来
  4. 上线前没有形成“可重复执行”的安全流程

我们这篇就围绕这几点展开。


前置知识

建议你至少具备以下基础:

  • 会读 Solidity 合约
  • 知道 msg.sendermsg.value、事件、修饰器
  • 用过 Hardhat 或 Foundry 之一
  • 知道 ERC20 的基本调用流程

如果你是第一次系统做审计,没关系,下面我会尽量把“为什么这样测”讲清楚。


环境准备

本文选用 Foundry 做演示,原因很简单:写测试快、跑得快、模糊测试和主网分叉测试很顺手。静态分析部分再配合 Slither。

安装 Foundry

curl -L https://foundry.paradigm.xyz | bash
foundryup

初始化项目

forge init smart-audit-demo
cd smart-audit-demo

安装 OpenZeppelin

forge install OpenZeppelin/openzeppelin-contracts

安装 Slither

需要 Python 环境:

pip install slither-analyzer

核心原理

做智能合约审计时,我一般把分析思路拆成四层:

  1. 权限层:谁能调用?谁不该调用?
  2. 状态层:状态变化顺序是否安全?
  3. 外部交互层:调用外部合约前后有没有保护?
  4. 经济层:价格、奖励、惩罚、精度和套利空间是否合理?

你可以把它理解成一个从“代码语法”到“协议行为”的逐层检查过程。

审计流程总览

flowchart TD
    A[阅读需求与资产边界] --> B[梳理权限模型]
    B --> C[识别关键状态变量]
    C --> D[检查外部调用与资金流]
    D --> E[手工审计常见漏洞]
    E --> F[静态分析 Slither]
    F --> G[单元测试]
    G --> H[模糊测试 Fuzz]
    H --> I[不变量测试 Invariant]
    I --> J[主网分叉验证]
    J --> K[修复与回归测试]

这张图里最容易被忽略的是:自动化测试不是手工审计的替代,而是手工审计结论的放大器


从典型漏洞入手:重入攻击为什么总是高频

重入攻击几乎是入门必学,因为它同时暴露了一个核心原则:状态更新与外部调用顺序不能乱

一个典型危险模式是:

  1. 用户发起提现
  2. 合约先给用户转账
  3. 之后才更新用户余额

如果用户是恶意合约,就可以在收到 ETH 时再次回调提现逻辑,于是旧余额还没清零,钱就被重复提走了。

漏洞调用过程

sequenceDiagram
    participant U as 攻击合约
    participant V as 漏洞合约 Vault

    U->>V: deposit()
    U->>V: withdraw()
    V-->>U: call.value(amount)
    U->>V: fallback/receive 中再次调用 withdraw()
    V-->>U: 再次转账
    V->>V: 最后才更新余额

这也是为什么你经常会看到一个老原则:Checks-Effects-Interactions

  • Checks:先检查条件
  • Effects:先更新状态
  • Interactions:最后再和外部交互

实战代码(可运行)

下面我们直接做一个完整的小实验:

  • 一个带重入漏洞的金库合约
  • 一个攻击合约
  • 一个修复后的版本
  • Foundry 测试验证漏洞存在与修复有效

目录结构

smart-audit-demo/
├─ src/
│  ├─ VulnerableVault.sol
│  ├─ SecureVault.sol
│  └─ Attacker.sol
├─ test/
│  └─ Vault.t.sol
└─ foundry.toml

编写漏洞合约

src/VulnerableVault.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        require(msg.value > 0, "zero amount");
        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 getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

编写攻击合约

src/Attacker.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IVulnerableVault {
    function deposit() external payable;
    function withdraw() external;
}

contract Attacker {
    IVulnerableVault public vault;
    uint256 public attackCount;
    uint256 public maxAttackCount = 3;

    constructor(address _vault) {
        vault = IVulnerableVault(_vault);
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "need >= 1 ether");
        vault.deposit{value: 1 ether}();
        vault.withdraw();
    }

    receive() external payable {
        if (address(vault).balance >= 1 ether && attackCount < maxAttackCount) {
            attackCount++;
            vault.withdraw();
        }
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

编写修复版本

src/SecureVault.sol

这里我演示两个修复点:

  1. 使用 Checks-Effects-Interactions
  2. 增加一个简单的重入锁
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SecureVault {
    mapping(address => uint256) public balances;
    bool private locked;

    modifier nonReentrant() {
        require(!locked, "reentrant call");
        locked = true;
        _;
        locked = false;
    }

    function deposit() external payable {
        require(msg.value > 0, "zero amount");
        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 getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

生产环境里,我更建议直接用 OpenZeppelin 的 ReentrancyGuard,不要自己重复造轮子,除非你非常清楚实现边界。


编写测试

test/Vault.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/VulnerableVault.sol";
import "../src/SecureVault.sol";
import "../src/Attacker.sol";

contract SecureAttacker {
    SecureVault public vault;
    uint256 public count;

    constructor(address _vault) {
        vault = SecureVault(_vault);
    }

    function attack() external payable {
        vault.deposit{value: 1 ether}();
        vault.withdraw();
    }

    receive() external payable {
        if (count < 1) {
            count++;
            try vault.withdraw() {} catch {}
        }
    }
}

contract VaultTest is Test {
    VulnerableVault vulnerableVault;
    SecureVault secureVault;
    Attacker attacker;

    address user = address(0x123);

    function setUp() public {
        vulnerableVault = new VulnerableVault();
        secureVault = new SecureVault();
        attacker = new Attacker(address(vulnerableVault));

        vm.deal(user, 10 ether);
        vm.deal(address(attacker), 1 ether);
    }

    function testDepositAndWithdrawNormal() public {
        vm.startPrank(user);
        vulnerableVault.deposit{value: 2 ether}();
        assertEq(vulnerableVault.balances(user), 2 ether);

        vulnerableVault.withdraw();
        assertEq(vulnerableVault.balances(user), 0);
        vm.stopPrank();
    }

    function testReentrancyAttack() public {
        vm.prank(user);
        vulnerableVault.deposit{value: 5 ether}();

        vm.deal(address(attacker), 2 ether);
        attacker.attack{value: 1 ether}();

        assertGt(address(attacker).balance, 1 ether);
        assertLt(address(vulnerableVault).balance, 5 ether);
    }

    function testSecureVaultBlocksAttack() public {
        SecureAttacker secureAttacker = new SecureAttacker(address(secureVault));

        vm.prank(user);
        secureVault.deposit{value: 5 ether}();

        vm.deal(address(secureAttacker), 2 ether);
        secureAttacker.attack{value: 1 ether}();

        // 攻击者只能拿回自己的 1 ether,不应额外盗取池子资金
        assertEq(address(secureVault).balance, 5 ether);
    }
}

运行测试

forge test -vv

如果你想看更详细日志:

forge test -vvvv

如果需要 Gas 报告:

forge test --gas-report

用 Slither 做静态分析

在项目目录执行:

slither src/VulnerableVault.sol

很多时候 Slither 会提示:

  • reentrancy 风险
  • low-level call 风险
  • 未检查返回值
  • 命名、可见性、优化建议

它不是万能的,但很适合做第一轮快速筛查。


自动化测试流程怎么搭

这里是重点:不要把安全测试理解成“多写几个单元测试”。真正可落地的流程,至少要包括这几层。

一、静态分析

适合发现明显模式问题:

  • 重入模式
  • 危险低级调用
  • 未初始化变量
  • 权限/可见性异常
  • 死代码

二、单元测试

验证明确业务规则:

  • 正常充值/提现
  • 非法权限访问
  • 极限输入
  • 边界金额
  • 事件是否正确发出

三、模糊测试

Foundry 很适合做 fuzz。比如你可以让输入金额随机,检查“余额永远不会凭空增发”。

四、不变量测试

不变量比普通单测更像审计思维。

例如:

  • 合约总资产 >= 所有可提余额之和
  • 非 owner 永远不能调用管理函数
  • 提现后个人余额必须归零
  • 清算后仓位状态不能回到活跃状态

五、主网分叉测试

如果你的协议依赖真实 ERC20、DEX、预言机、借贷协议,一定要做 fork test。很多问题在本地 mock 环境根本看不出来。


自动化流程示意图

flowchart LR
    A[提交代码 PR] --> B[格式化与编译检查]
    B --> C[单元测试]
    C --> D[Fuzz 测试]
    D --> E[Invariant 测试]
    E --> F[Slither 静态分析]
    F --> G[主网分叉测试]
    G --> H[生成审计报告与风险清单]

这个顺序不是绝对固定,但在 CI 里一般会把“快反馈”的步骤放前面,把耗时长的 fork test 放后面。


用 Foundry 做一个简单 Fuzz 测试

test/FuzzVault.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/SecureVault.sol";

contract FuzzVaultTest is Test {
    SecureVault vault;
    address user = address(0xBEEF);

    function setUp() public {
        vault = new SecureVault();
        vm.deal(user, 100 ether);
    }

    function testFuzzDepositWithdraw(uint96 amount) public {
        vm.assume(amount > 0);
        vm.assume(amount <= 10 ether);

        vm.startPrank(user);
        vault.deposit{value: amount}();
        assertEq(vault.balances(user), amount);

        vault.withdraw();
        assertEq(vault.balances(user), 0);
        vm.stopPrank();
    }
}

运行:

forge test --match-test testFuzzDepositWithdraw -vv

这类测试的意义不是替代逻辑推理,而是帮你快速覆盖“大量你没手动想到的输入组合”。


用 Invariant 测试守住核心资金属性

test/InvariantVault.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "forge-std/StdInvariant.sol";
import "../src/SecureVault.sol";

contract Handler is Test {
    SecureVault public vault;
    address[] public users;

    constructor(SecureVault _vault) {
        vault = _vault;
        users.push(address(0x1));
        users.push(address(0x2));
        users.push(address(0x3));

        for (uint256 i = 0; i < users.length; i++) {
            vm.deal(users[i], 100 ether);
        }
    }

    function deposit(uint256 userIndex, uint96 amount) public {
        address user = users[userIndex % users.length];
        amount = uint96(bound(amount, 1, 10 ether));

        vm.prank(user);
        vault.deposit{value: amount}();
    }

    function withdraw(uint256 userIndex) public {
        address user = users[userIndex % users.length];
        if (vault.balances(user) > 0) {
            vm.prank(user);
            vault.withdraw();
        }
    }

    function totalTrackedBalances() public view returns (uint256 total) {
        for (uint256 i = 0; i < users.length; i++) {
            total += vault.balances(users[i]);
        }
    }
}

contract InvariantVaultTest is StdInvariant, Test {
    SecureVault vault;
    Handler handler;

    function setUp() public {
        vault = new SecureVault();
        handler = new Handler(vault);

        targetContract(address(handler));
    }

    function invariant_contractBalanceGteTrackedBalances() public view {
        assertGe(address(vault).balance, handler.totalTrackedBalances());
    }
}

运行:

forge test --match-path test/InvariantVault.t.sol -vv

在真实项目里,不变量测试往往比单测更能暴露深层问题,因为它会随机组合一系列状态迁移。


逐步验证清单

如果你准备把这套流程真正接到项目里,可以按下面这个清单执行:

第一步:确认资产与权限边界

  • 哪些函数会转移资产?
  • 哪些角色能升级、暂停、配置参数?
  • 是否存在多签/Timelock?

第二步:列出关键风险点

  • 外部调用在哪些地方发生?
  • 价格来源是否可信?
  • 是否依赖回调机制?
  • 是否存在循环遍历用户列表?

第三步:把风险点变成测试项

比如:

  • “提现不可重入”
  • “非 owner 不能改费率”
  • “奖励发放总额不能超上限”
  • “极端输入下不会 revert 或资金锁死”

第四步:接入自动化

至少包含:

  • forge test
  • forge fmt --check
  • slither
  • fuzz / invariant
  • 关键模块 fork test

常见坑与排查

这部分我尽量讲得接地气一点,因为很多问题真不是“看不懂漏洞原理”,而是实际跑起来各种卡。

1. 以为 transfercall 安全

早些年很多文章会说 transfer 自带 2300 gas 限制,能防重入。但现在这种说法已经不够稳妥了。EVM Gas 成本变化后,很多场景下 transfer 反而会导致兼容性问题。

建议

  • 优先用 call
  • 配合 CEI 原则
  • 配合 ReentrancyGuard

2. 单测全绿,但真实协议一跑就出问题

这是 mock 环境过度理想化的典型表现。比如:

  • 真实 ERC20 不一定返回 bool
  • 某些代币有手续费
  • 预言机更新频率不稳定
  • DEX 滑点与池子深度影响执行结果

排查方法

  • 做主网分叉测试
  • 用真实协议地址交互
  • 引入极端行情和低流动性场景

3. 权限只测“有权限的人能调”,没测“没权限的人不能调”

这类遗漏很常见。很多团队习惯写 happy path,不习惯写 negative test。

建议

  • 每个管理函数都补一条非授权调用用例
  • 检查初始化函数是否可重复调用
  • 升级入口必须有权限与初始化保护

4. 忽略升级合约的存储布局

如果你使用代理模式,审计范围不能只看逻辑合约。存储槽冲突、初始化顺序错误、实现合约被直接初始化,都是高风险问题。

排查重点

  • 是否使用标准代理模式
  • 是否禁用实现合约初始化
  • 新版本变量追加是否遵守布局规则

5. 模糊测试跑不出有效结果

很多人第一次用 fuzz,会发现测试“跑了很多轮,但没发现问题”。原因往往是:

  • 输入约束太松,绝大多数 case 没意义
  • 没定义好不变量
  • 没构造出关键状态组合

建议

  • vm.assumebound 收紧输入空间
  • 先从资金守恒、权限守恒开始定义 invariant
  • 为关键状态构造 handler

安全/性能最佳实践

安全和性能在链上经常是绑在一起讨论的,因为 Gas 设计不好,有时也会变成安全问题,比如 DoS。

1. 使用成熟库,不轻易手写底层安全逻辑

推荐优先考虑:

  • OpenZeppelin 的 Ownable
  • AccessControl
  • ReentrancyGuard
  • Pausable
  • SafeERC20

特别是 ERC20 交互,请尽量用 SafeERC20,不要假设所有 token 都标准实现。

2. 资金操作遵循 CEI 原则

这是最基本也最值钱的一条:

  • 先检查
  • 再更新状态
  • 最后外部交互

3. 避免无界循环

比如给所有用户一次性发奖励、批量清算大数组,这些都可能在用户规模大后无法执行,最终演变成可用性问题。

更好的做法:

  • 分批处理
  • 拉模式领取(pull over push)
  • 用索引/快照替代全量遍历

4. 显式定义失败策略

一个函数失败时,你要明确:

  • 回滚全部?
  • 允许部分成功?
  • 是否记录失败事件?
  • 是否提供重试机制?

这点在批处理、跨合约调用、桥接协议中特别重要。

5. 把“业务规则”写成测试,而不是写在文档里

很多安全事故不是程序员没写代码,而是规则只存在于 PR 评论、飞书消息、口头同步里。

比较可靠的方式是:

  • 上限/下限写成常量与校验
  • 核心约束写成 invariant
  • 权限矩阵写成单测

6. 加入暂停与应急机制,但别滥用

pause 是救命开关,但不是万能药。它适合:

  • 漏洞止血
  • 异常市场波动时限制高风险操作
  • 上线初期观察期防御

但边界也要清楚:

  • 不能依赖 pause 掩盖架构缺陷
  • pause 权限最好交给多签
  • 恢复流程要清晰可审计

一个可落地的 CI 示例

如果你准备接 GitHub Actions,可以用下面这类思路:

.github/workflows/ci.yml

name: Smart Contract CI

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1

      - name: Show forge version
        run: forge --version

      - name: Format check
        run: forge fmt --check

      - name: Build
        run: forge build

      - name: Run tests
        run: forge test -vv

      - name: Install Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install Slither
        run: pip install slither-analyzer

      - name: Run Slither
        run: slither src/VulnerableVault.sol || true

这里我特意把 slither 后面加了 || true,是因为很多团队在接 CI 初期,不希望因为静态分析告警太多导致所有 PR 都直接失败。更成熟的做法是:

  • 先以告警模式运行
  • 梳理误报
  • 再逐步把高危规则升级为阻断条件

这个节奏比“一上来全拦截”更容易落地。


审计时我常用的一套检查视角

如果你在手工审计时不知道从哪下手,可以按下面顺序过一遍:

资产流

  • 钱从哪来?
  • 钱往哪去?
  • 谁能触发流动?

权限流

  • admin 能做什么?
  • operator 能做什么?
  • 普通用户能不能越权?

状态流

  • 状态是否会卡死?
  • 是否存在先后顺序依赖?
  • 是否可能重复执行?

外部依赖

  • Oracle 是否可操纵?
  • Token 是否标准?
  • 回调是否可重入?
  • 第三方协议失败如何处理?

经济攻击面

  • 是否能闪电贷操纵价格?
  • 是否能通过精度损耗套利?
  • 是否能通过先存后提/先借后清算套取奖励?

这套方法不花哨,但很实用,尤其适合中级开发者把“漏洞知识点”组织成真正能用的审计思路。


总结

智能合约安全审计,真正难的不是记住十几种漏洞名字,而是把下面这件事做好:

从“知道风险”走到“能稳定复现、验证、修复,并通过自动化流程持续防回归”。

如果你只带走三条建议,我建议是这三条:

  1. 所有资金相关函数,先按 CEI 原则过一遍
  2. 把关键安全规则写成单测、Fuzz 和 Invariant
  3. 用静态分析 + 主网分叉测试补齐人工审计盲区

最后也提醒一个边界条件:
自动化流程能显著提高安全基线,但它不能替代人工审计对业务逻辑、经济模型和协议组合风险的判断。尤其是 DeFi 协议,很多真正昂贵的漏洞,往往不是一句 reentrancy 能概括的,而是“机制本身允许被套利”。

所以更稳妥的做法是:

  • 开发阶段建立自动化安全测试
  • 上线前做人工专项审计
  • 上线后持续监控异常行为与资产变动

如果你现在就要开始,我建议你先从一件小事做起:把项目里最关键的提现、授权、参数配置函数,各补一条失败路径测试。这是最小成本、但收益很高的安全改进。


分享到:

上一篇
《从抓包到还原签名链路:一次典型 Web 逆向中前端加密参数定位与复现实战》
下一篇
《面向中型业务的集群架构实战:从高可用部署、故障转移到容量扩缩容的系统化设计》