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

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

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

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

智能合约一旦部署,往往就很难“热修复”。这和普通后端服务很不一样:Web 服务挂了还能回滚、补丁、限流,但合约里的 bug,可能直接变成链上永久资产风险。
所以,安全审计不是上线前的形式化动作,而是开发流程的一部分

这篇文章我会按“能上手”的思路来讲,不只列漏洞名词,而是带你从:

  1. 识别典型漏洞;
  2. 编写最小可复现合约;
  3. 用自动化工具做静态分析;
  4. 用测试验证风险;
  5. 搭建一条可复用的审计流水线。

文章默认读者对 Solidity、EVM 和常见开发工具有基础了解。


背景与问题

智能合约安全的难点,不在于“漏洞列表很多”,而在于它同时具备以下特点:

  • 代码公开:攻击者可以反复阅读你的逻辑;
  • 资金直接绑定:漏洞不是报错,而是资产被转走;
  • 执行环境受限:Gas、调用栈、回退逻辑都会影响安全;
  • 权限和状态强耦合:一个小的状态变量更新顺序问题,就可能造成严重后果;
  • 第三方依赖复杂:代理合约、预言机、ERC20 实现差异,都会带来额外风险。

很多团队刚开始做审计时,容易陷入两个误区:

误区一:只靠人工读代码

人工审计很重要,但纯人工会遇到几个问题:

  • 容易遗漏边界分支;
  • 重复性检查效率低;
  • 不同审计人员标准不一致;
  • 对历史回归问题缺乏机制保障。

误区二:只跑工具,不理解结果

像 Slither、Mythril、Echidna 这类工具很强,但它们输出的是线索,不是最终结论。
我自己第一次跑 Slither 的时候,输出了一屏 warning,看着很吓人,后来才发现不少是误报,真正危险的是其中一个低调的重入路径。

所以更现实的方式是:

人工建模 + 工具筛查 + 测试验证 + 持续集成


前置知识与环境准备

为了让代码能直接跑起来,这里采用 Hardhat + Solidity + Slither 的组合。

环境

  • Node.js 16+
  • npm 或 pnpm
  • Python 3.8+
  • Solidity 0.8.x
  • Hardhat
  • Slither

安装步骤

mkdir contract-audit-demo
cd contract-audit-demo

npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat

选择创建一个 JavaScript 项目后,再安装 Slither:

pip install slither-analyzer

项目结构大致如下:

contract-audit-demo/
├── contracts/
├── test/
├── scripts/
├── hardhat.config.js
└── package.json

核心原理

在实战前,先统一一下审计视角。一个比较好用的切入方式是把审计拆成四层:

  1. 资产流:钱从哪里来,到哪里去;
  2. 权限流:谁可以调用什么函数;
  3. 状态流:状态在调用前、中、后如何变化;
  4. 外部交互:是否依赖外部合约、预言机、代币返回值。

审计流程总览

flowchart TD
    A[明确业务与资产模型] --> B[识别关键函数与权限边界]
    B --> C[人工代码走查]
    C --> D[静态分析 Slither]
    D --> E[编写单元测试与攻击测试]
    E --> F[回归修复验证]
    F --> G[接入 CI 自动化]

常见漏洞的底层原因

智能合约里很多漏洞,本质上不是“语法问题”,而是状态机设计错误
比如:

  • 重入:状态更新发生在外部调用之后;
  • 权限绕过:关键函数缺少访问控制;
  • 整数处理失误:虽然 0.8+ 默认检查溢出,但业务逻辑仍可能算错;
  • DoS:依赖循环、依赖外部返回、依赖某个参与者配合;
  • 不安全随机数:把 block.timestampblockhash 当作强随机源。

一个简单的安全分析模型

classDiagram
    class ContractAudit {
      +资产入口
      +资产出口
      +权限角色
      +关键状态变量
      +外部调用点
    }

    class RiskPoint {
      +重入
      +权限缺失
      +价格操纵
      +拒绝服务
      +升级风险
    }

    ContractAudit --> RiskPoint : 映射分析

这个模型的好处是:你在拿到一个陌生合约时,不会直接陷进实现细节,而是先问:

  • 谁能存钱?
  • 谁能提钱?
  • 提钱前后余额如何变化?
  • 调用了谁?
  • 失败会不会卡死全局流程?

实战代码(可运行)

这一部分我们故意写一个存在重入漏洞的合约,然后用攻击合约和工具把问题跑出来。

1. 漏洞合约

新建 contracts/VulnerableBank.sol

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

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

    function deposit() external payable {
        require(msg.value > 0, "zero amount");
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "insufficient balance");

        // 漏洞点:先转账,后更新余额
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "transfer failed");

        balances[msg.sender] -= amount;
    }

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

这里的核心问题非常经典:

  • call 会把控制权交给对方合约;
  • 如果对方在 fallback/receive 里再次调用 withdraw
  • 而当前余额还没扣减,就会重复提款。

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;
    address public owner;
    uint256 public attackAmount = 1 ether;

    constructor(address _bank) {
        bank = IVulnerableBank(_bank);
        owner = msg.sender;
    }

    function attack() external payable {
        require(msg.sender == owner, "not owner");
        require(msg.value >= attackAmount, "need more ether");

        bank.deposit{value: attackAmount}();
        bank.withdraw(attackAmount);
    }

    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);
    }
}

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 {
        require(msg.value > 0, "zero amount");
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "insufficient balance");

        // 修复点 1:先更新状态
        balances[msg.sender] -= amount;

        // 修复点 2:再外部调用
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "transfer failed");
    }

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

这里用了两层修复思路:

  • Checks-Effects-Interactions
  • 重入锁

实际生产环境中,我更建议优先考虑 OpenZeppelin 的 ReentrancyGuard,避免自己手写锁逻辑时出细节问题。


编写测试并验证漏洞

新建 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();

    // 普通用户先存入 5 ETH,制造池子
    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();

    // 发起攻击,先存 1 ETH 再反复提取
    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.equal(ethers.parseEther("6"));
  });

  it("should block exploit 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;

    const bankBalance = await ethers.provider.getBalance(await safeBank.getAddress());
    expect(bankBalance).to.equal(ethers.parseEther("5"));
  });
});

运行测试:

npx hardhat test

如果一切正常,你会看到:

  • VulnerableBank 被成功打穿;
  • SafeBank 中攻击回滚。

攻击过程时序图

sequenceDiagram
    participant A as Attacker
    participant B as VulnerableBank

    A->>B: deposit(1 ETH)
    A->>B: withdraw(1 ETH)
    B-->>A: call{value: 1 ETH}
    A->>B: receive() 中再次 withdraw(1 ETH)
    B-->>A: 再次转账
    Note over B: 余额尚未更新,重复提款成立

使用 Slither 做自动化静态检测

1. 基础运行

在项目根目录执行:

slither .

如果环境正常,Slither 会尝试解析 Hardhat 工程,并给出检测结果。

你通常会看到类似输出:

  • reentrancy vulnerabilities
  • low level calls
  • missing zero address validation
  • naming / visibility / optimization 建议

2. 关注重点而不是“全量告警”

中级开发者最容易踩的坑是:把工具输出当成漏洞结论
更合理的做法是按优先级筛选:

高优先级

  • Reentrancy
  • Arbitrary external call
  • Access control
  • Delegatecall misuse
  • Unchecked return values(尤其是老旧 ERC20)

中优先级

  • DoS with failed call
  • Unbounded loop
  • Timestamp dependence
  • Weak randomness

低优先级

  • 命名风格
  • 可见性优化
  • Gas 微优化

3. 输出审计报告线索

我建议把工具结果整理成统一表格,而不是直接贴原始日志。

检测项风险级别是否确认说明
Reentrancy in withdraw先外部调用后更新余额
Low-level call usage需结合重入上下文确认
Missing events可观测性问题,不直接构成漏洞

搭建自动化检测流程

真正好用的审计流程,不是“某天跑一次工具”,而是每次提交都能检查。

推荐流水线结构

flowchart LR
    A[git commit] --> B[Solidity 编译]
    B --> C[单元测试]
    C --> D[覆盖率检查]
    D --> E[Slither 静态分析]
    E --> F[审计结果归档]
    F --> G[人工复核高危项]

一个简化版 package.json 脚本

{
  "scripts": {
    "compile": "hardhat compile",
    "test": "hardhat test",
    "audit:slither": "slither .",
    "check": "npm run compile && npm run test && npm run audit:slither"
  }
}

执行:

npm run check

GitHub Actions 示例

新建 .github/workflows/audit.yml

name: Contract Audit Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  security-check:
    runs-on: ubuntu-latest

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

      - name: Setup Node
        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: Compile
        run: npx hardhat compile

      - name: Test
        run: npx hardhat test

      - name: Slither
        run: slither .

这条流水线虽然不复杂,但已经能覆盖三类问题:

  • 编译错误;
  • 回归逻辑问题;
  • 一部分静态安全风险。

逐步验证清单

如果你打算把这套流程迁到自己的项目,可以按下面的顺序执行:

第一步:梳理业务面

  • 资产流是否闭环?
  • 权限角色是否明确?
  • 升级入口是否受控?
  • 是否依赖外部价格源或第三方 token?

第二步:手工检查关键函数

重点扫这些函数:

  • deposit
  • withdraw
  • claim
  • mint
  • burn
  • upgradeTo
  • setAdmin
  • emergencyWithdraw

第三步:运行自动化检测

  • Hardhat compile
  • Hardhat test
  • Slither static analysis

第四步:为每个高危点补测试

比如发现重入风险,不是只修代码,而是要补:

  • 正常提款测试
  • 攻击路径测试
  • 修复回归测试

第五步:纳入 CI

确保后续每次 PR 都会自动触发。


常见坑与排查

这一节我想讲一些“工具会跑,但结果不一定好懂”的坑。

1. 误把 transfer 当成绝对安全方案

过去很多文章会建议用 transfer 防重入,因为它限制 2300 gas。
但现在这已经不是推荐做法,原因包括:

  • Gas 成本变化可能导致兼容性问题;
  • 无法覆盖更广泛的安全语义;
  • 更好的方案是状态先更新 + 重入保护。

建议:优先使用 call,但必须配合安全模式。


2. 只看单个函数,不看跨函数状态

有些重入不是发生在同一个函数里,而是:

  • 在 A 函数回调中调用 B 函数;
  • B 函数也依赖相同状态;
  • 最终形成“跨函数重入”。

排查方法

  • 列出所有外部调用点;
  • 标记这些调用点之前哪些状态尚未落账;
  • 查看回调后能否进入其他敏感函数。

3. ERC20 返回值处理不一致

不是所有 ERC20 都严格按标准实现。有些:

  • 返回 bool
  • 有些不返回值
  • 有些直接 revert

如果直接调用 token.transfer(...),可能出现兼容性问题。

建议:使用 OpenZeppelin SafeERC20


4. 升级代理的存储布局问题

这类问题在业务测试中不一定能第一时间暴露,但上线后非常危险。
常见表现:

  • 新版本变量顺序改了;
  • 覆盖旧存储槽;
  • 导致管理员地址、余额映射异常。

排查方法

  • 升级合约前做 storage layout diff;
  • 禁止随意调整已存在变量顺序;
  • 预留 storage gap。

5. 工具报了“重入”,但其实是误报

静态分析对外部调用非常敏感,只要看到:

  • 状态变量读写;
  • 外部 call;
  • 资金转移;

就可能提示风险。

但误报常见于:

  • 只读回调;
  • 已做锁保护;
  • 外部调用无法控制回调路径。

我的建议是
别急着忽略,先回答三个问题:

  1. 回调是否可控?
  2. 状态是否已更新?
  3. 是否存在跨函数二次进入?

只要这三个问题里有一个答不上来,就别把它当误报。


安全/性能最佳实践

安全和性能在智能合约里不是完全对立,但优先级一定是安全先于 gas 优化

1. 先建立最小安全基线

我认为每个资金类合约至少要做到:

  • 权限控制清晰;
  • 外部调用最小化;
  • 关键状态先更新;
  • 核心流程可暂停;
  • 关键操作有事件日志;
  • 高危路径有回归测试。

2. 优先采用成熟组件

不要为了“更轻量”自己重写一套:

  • Ownable
  • AccessControl
  • ReentrancyGuard
  • SafeERC20
  • Pausable

成熟库不是绝对无风险,但比手搓稳定得多。

3. 测试要覆盖“对抗性场景”

很多项目测试只验证“正常用户会成功”,但攻击者关心的是“边界条件会怎样”。

建议至少补这些测试:

  • 重复调用
  • 极值输入
  • 回调攻击
  • 权限绕过
  • 暂停后行为
  • 升级后兼容性

4. 把审计前移到开发期

最有效的方式不是上线前集中修 bug,而是:

  • 开发时就写安全注释;
  • 提交前自动跑基础检查;
  • PR 中明确“新增外部调用点”和“新增权限点”。

5. 不要过度依赖单一工具

一个实用的组合是:

  • Slither:静态分析,快;
  • Hardhat/Foundry 测试:逻辑验证;
  • Echidna 或 Fuzzing:发现边界异常;
  • 人工审计:业务理解和误报确认。

边界条件也要说清楚:
如果你的合约涉及复杂 DeFi 机制,比如闪电贷、价格预言机、多合约协同、代理升级,那么仅靠本文这套基础流水线是不够的,还需要更深入的形式化验证、经济攻击建模和主网分叉测试。


总结

智能合约审计真正有价值的地方,不是“记住多少漏洞名词”,而是形成一套稳定的方法:

  1. 先建模:看资产、权限、状态、外部交互;
  2. 再识别:优先关注重入、权限、外部调用、DoS;
  3. 再验证:用测试把漏洞和修复都跑出来;
  4. 最后固化:接入 CI,让检查持续发生。

如果你现在就要落地,我建议从下面三件事开始:

  • 给资金类函数补一遍攻击测试;
  • 在 CI 里加入 hardhat test + slither .
  • 对所有外部调用点做一次“状态是否先更新”的人工复核。

这三步不花哨,但非常有效。
而且我自己的经验是,很多严重问题并不是藏得多深,往往只是因为团队一直没有把“审计流程”真正放进开发流程里。只要这件事做起来,安全水平会比单次突击审计提升得更明显。


分享到:

上一篇
《Spring Boot 中基于 Spring Cache 与 Redis 构建高可用多级缓存的实战指南》
下一篇
《Web3 中级实战:从零搭建基于钱包登录与链上签名的去中心化身份认证系统》