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

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

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

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

智能合约一旦部署,改起来比传统后端麻烦得多。很多团队在功能联调阶段看起来一切正常,真到了主网、遇到恶意调用、闪电贷、复杂授权链路时,问题才开始暴露。
我自己做过几次合约审计后,一个很深的感受是:安全审计不是“最后扫一扫”,而是把漏洞识别方法、测试习惯、自动化工具和上线流程串起来

这篇文章不只讲“有哪些漏洞”,更会带你搭一条能实际跑起来的智能合约安全检测流程。目标读者默认已经会写一些 Solidity,知道 Hardhat 或 Foundry 的基本用法。


背景与问题

智能合约的安全问题,有几个和传统 Web 系统非常不一样的地方:

  1. 不可逆
    • 链上交易一旦确认,资金转移通常无法撤回。
  2. 公开透明
    • 源码、ABI、交易行为都可能被逆向分析。
  3. 对抗性环境
    • 不是“用户误操作”,而是有人专门盯着你设计里的边界条件。
  4. 组合性强
    • 合约之间会互调,DeFi 场景里还会叠加预言机、借贷、兑换、治理。

因此,审计不能只盯着某一行代码,而要同时看:

  • 单函数逻辑是否安全
  • 状态变更顺序是否合理
  • 权限模型是否可绕过
  • 外部调用是否可重入
  • 数值计算是否会造成经济损失
  • 自动化工具能否持续兜底

很多团队的问题不是“完全没做安全”,而是:

  • 只跑了静态扫描,没做动态验证
  • 只看单个漏洞,没有形成流程
  • 本地能过,CI/CD 里没集成
  • 上线前一次性人工审计,之后代码迭代没人看

这就是本文要解决的问题。


前置知识

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

  • Solidity 0.8+ 语法
  • 了解 msg.sendermsg.valuecall
  • 会使用 npm / node
  • 能执行基本测试命令

如果你熟悉 Hardhat,会更容易跟着跑起来。本文示例也会尽量控制复杂度,方便你本地复现。


环境准备

本文用一套偏实用的组合:

  • Node.js 18+
  • Hardhat
  • OpenZeppelin Contracts
  • Slither:静态分析
  • Mythril:符号执行/漏洞检测补充

1)初始化项目

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

选择一个基础 JavaScript 项目即可。

2)安装 Slither

Slither 依赖 Python 环境,推荐用 pipx 或虚拟环境安装。

pip install slither-analyzer

3)安装 Mythril

pip install mythril

如果你的环境比较“挑剔”,建议把 Python 工具放进虚拟环境,不然版本冲突很常见。我第一次装 Mythril 时就被依赖卡了半天。


核心原理

智能合约审计,实践中通常会组合三层手段:

  1. 人工代码审阅
    • 理解业务逻辑、权限边界、资金流向
  2. 静态分析
    • 不运行代码,通过 AST / IR / CFG 识别危险模式
  3. 动态测试与对抗验证
    • 单元测试、模糊测试、攻击合约复现

你可以把它理解为:

flowchart TD
    A[需求与业务理解] --> B[人工审阅关键逻辑]
    B --> C[静态分析 Slither]
    C --> D[单元测试/攻击复现]
    D --> E[CI 自动化拦截]
    E --> F[上线前人工复核]

常见漏洞关注面

中级开发者最容易遇到的,通常是这几类:

  • 重入攻击
  • 权限控制缺失
  • 不安全的外部调用
  • 整数边界与精度问题
  • 拒绝服务(DoS)
  • 时间戳/区块变量误用
  • 签名校验缺陷
  • 升级代理存储冲突

这篇教程重点挑最常见、最有代表性的几种来讲,并把它们串进自动化流程里。


漏洞识别思路:先看“钱怎么流”,再看“谁能调”

我一般审计一个合约,第一轮会先不急着读细节,而是问三个问题:

  1. 这个合约里,资产从哪里进、到哪里出
  2. 哪些函数能改关键状态?谁有权限调用
  3. 外部调用发生在什么位置?状态更新是在前还是在后

这个顺序非常重要。因为大量严重漏洞,本质上就两类:

  • 调用顺序有问题
  • 权限边界没封住

下面用一个最经典的例子来说明。


实战代码(可运行)

我们先故意写一个有漏洞的银行合约,再写攻击合约复现重入。

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 value");
        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;
    address public owner;
    uint256 public attackAmount;

    constructor(address _bank) {
        bank = IVulnerableBank(_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: 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)测试脚本复现漏洞

创建 test/reentrancy.js

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

describe("Reentrancy attack demo", function () {
  it("Attacker should drain the vulnerable bank", 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 user.sendTransaction({
      to: await bank.getAddress(),
      value: 0
    }).catch(() => {});

    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.equal(ethers.parseEther("6"));
  });
});

4)运行测试

npx hardhat test

如果环境正常,你会看到攻击成功:攻击者用 1 ETH 递归提走了合约里的更多资金。


重入攻击到底是怎么发生的

很多人知道“重入”这个词,但一到真实代码就看不出来。核心点其实很朴素:

  • 合约调用 msg.sender.call(...)
  • 对方是个合约,不是普通地址
  • 对方在 receive()fallback() 中再次回调你的 withdraw
  • 而你自己的余额状态还没扣减

过程可以画成这样:

sequenceDiagram
    participant U as Attacker
    participant A as AttackerContract
    participant B as VulnerableBank

    U->>A: attack() with 1 ETH
    A->>B: deposit(1 ETH)
    A->>B: withdraw(1 ETH)
    B->>A: call{value:1 ETH}
    A->>B: re-enter withdraw(1 ETH)
    B->>A: call{value:1 ETH}
    A->>B: repeat until drained
    B-->>A: balances updated too late

这就是为什么安全里一直强调 Checks-Effects-Interactions

  1. 先检查条件
  2. 再更新内部状态
  3. 最后做外部交互

修复版本:先改顺序,再加防护

创建 contracts/SafeBank.sol

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

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        require(msg.value > 0, "zero value");
        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;
    }
}

这里做了两件事:

  • 先扣余额,再转账
  • 增加 nonReentrant 作为二次保险

我的经验是:不要把 ReentrancyGuard 当作唯一修复手段
更底层的修复,永远是状态更新顺序正确。


再看一个高频问题:权限控制不严

很多漏洞不靠复杂攻击,而是“某个不该公开的函数居然 anyone can call”。

不安全示例

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

contract BadVault {
    address public owner;
    uint256 public totalFunds;

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        totalFunds += msg.value;
    }

    // 漏洞:任何人都能调用
    function emergencyWithdraw() external {
        payable(msg.sender).transfer(address(this).balance);
    }
}

这类问题在审计里其实很常见,尤其是:

  • 忘记加 onlyOwner
  • 升级函数没做管理员控制
  • 初始化函数可重复调用
  • “内部运维函数”误设为 external

安全版本

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

contract GoodVault {
    address public owner;

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {}

    function emergencyWithdraw(address payable to) external onlyOwner {
        to.transfer(address(this).balance);
    }
}

自动化检测流程搭建

到这里,我们已经有了“怎么看漏洞”的基础。下面开始把它变成团队能复用的流程。

目标是形成这样一条流水线:

flowchart LR
    A[编写/修改合约] --> B[Solidity 编译]
    B --> C[单元测试]
    C --> D[静态分析 Slither]
    D --> E[补充符号执行 Mythril]
    E --> F[人工审阅高风险结果]
    F --> G[合并代码/发布]

第一步:保证基本测试可跑

先在 package.json 里加脚本:

{
  "scripts": {
    "test": "hardhat test",
    "compile": "hardhat compile"
  }
}

执行:

npm run compile
npm run test

第二步:接入 Slither

在项目根目录执行:

slither .

常见输出会包含:

  • reentrancy 风险
  • low-level call 使用
  • 未检查返回值
  • 未初始化状态变量
  • 权限问题提示

如果你只想看高价值结果,可以:

slither . --exclude-informational --exclude-low

第三步:对关键合约跑 Mythril

myth analyze contracts/VulnerableBank.sol --solv 0.8.20

Mythril 更偏符号执行,对复杂路径有帮助,但速度通常比静态扫描慢,所以我建议:

  • PR 阶段:优先跑 Slither
  • 发布前:对核心资金合约补跑 Mythril

第四步:接入 GitHub Actions

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

name: Contract Security Checks

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

jobs:
  test-and-scan:
    runs-on: ubuntu-latest

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

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

      - name: Install Node dependencies
        run: npm ci

      - name: Compile
        run: npx hardhat compile

      - name: Test
        run: npx hardhat test

      - 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 . --exclude-informational --exclude-low

这份配置先做到最关键的事情:

  • 编译不过,不让合并
  • 测试不过,不让合并
  • Slither 扫出高等级问题,人工必须处理

如果你们团队已经有更成熟的流程,可以再加:

  • 覆盖率阈值
  • PR 评论机器人
  • SARIF 结果上报
  • 主网部署前的强制审批

如何理解自动化工具的边界

这一点非常重要。很多团队第一次接安全工具时,容易有两个极端:

极端一:迷信工具

认为只要 Slither 没报错,就安全了。
其实不是。工具对这几类问题往往不够好:

  • 复杂经济攻击
  • 业务层权限绕过
  • 多合约组合风险
  • 预言机操纵与价格依赖
  • 闪电贷驱动的状态异常

极端二:完全不用工具

这也不现实。人工审计很贵,而且重复劳动非常多。

更合理的方法是:

  • 工具负责高频、标准化、可重复的问题
  • 人工负责理解业务与判断攻击面
  • 测试负责把真实攻击路径跑出来

常见坑与排查

下面是我在实际项目里经常见到的坑,很多不是“理论漏洞”,而是“工具接入后跑不起来”。

1)Slither 扫描失败,提示找不到编译信息

现象:

slither .

报错类似无法解析编译输出。

原因:

  • Hardhat 没编译
  • 项目依赖未安装完整
  • 编译器版本与合约声明不一致

排查方式:

npx hardhat compile

先确认编译通过,再执行 Slither。

如果有多个 Solidity 版本,检查 hardhat.config.js

require("@nomicfoundation/hardhat-toolbox");

module.exports = {
  solidity: {
    compilers: [
      { version: "0.8.20" },
      { version: "0.8.19" }
    ]
  }
};

2)测试能过,但主网环境仍然危险

常见原因:

  • 测试里只覆盖正常路径
  • 没有模拟恶意合约调用
  • 没有测试边界金额和异常回滚场景

建议:

至少补三类测试:

  • 正常用户路径
  • 恶意调用路径
  • 权限绕过与极端输入

比如针对提款函数,不只要测“能成功提”,还要测:

  • 重复提取
  • 提 0
  • 提超过余额
  • 合约地址作为调用方
  • 回调失败时状态是否一致

3)使用 transfer/send 误以为天然安全

以前很多教程会说 transfer 因为 gas 限制更安全,但这套经验现在不能机械套用了。
现代 Solidity 实践里,很多项目会使用 call,因为兼容性更强。

关键不在于你用 transfer 还是 call,而在于:

  • 是否先更新状态
  • 是否做了重入防护
  • 是否检查返回值
  • 是否预期对方是任意合约

4)代理合约升级后存储错位

如果你们用 UUPS 或 Transparent Proxy,审计一定要关注:

  • 新老版本状态变量顺序
  • 是否保留 storage gap
  • 初始化函数是否只能执行一次

这个问题很隐蔽,因为单元测试可能没暴露,但一升级主网就会把状态读乱。


5)把“onlyOwner”当成万能权限模型

真实项目里,光有 owner 远远不够。常见问题:

  • owner 私钥泄露就是全盘失守
  • 权限过于集中
  • 没有 timelock
  • 没有多签保护

如果合约管理的是大额资金,建议最少做到:

  • 关键函数交给多签
  • 变更操作设置延迟
  • 高风险动作输出事件日志

安全/性能最佳实践

这一节给出能直接落地的建议,不讲空话。

安全最佳实践

1)遵循 CEI 模式

即:

  • Checks
  • Effects
  • Interactions

尤其是有资金转移的函数,先改状态,再外部调用。

2)默认把外部调用视为不可信

包括:

  • call
  • delegatecall
  • 第三方协议接口调用
  • ERC777 / ERC721 / ERC1155 回调

只要发生外部交互,就要问自己一句:
如果对方恶意回调,会发生什么?

3)权限最小化

不要让一个管理员承担所有危险操作。可拆分为:

  • 参数配置角色
  • 暂停角色
  • 升级角色
  • 资金提取角色

4)关键操作必须发事件

比如:

  • 管理员变更
  • 资金提取
  • 黑名单变更
  • 参数更新
  • 合约升级

这不仅是审计需要,也方便链上监控。

5)对核心逻辑写“攻击型测试”

不是只测 happy path,而是主动写:

  • 重入攻击合约
  • 伪造调用者
  • 异常 token 合约
  • 返回值不规范的 ERC20

很多漏洞在正常测试里永远不会出现。


性能与工程实践

安全不是越多修饰器越好,也要考虑 gas 和可维护性。

1)不要为“省一点 gas”牺牲安全边界

比如:

  • 去掉权限判断
  • 合并导致逻辑难审计
  • 手写复杂汇编但没人能 review

2)把高频检查放自动化里

建议至少形成这张清单:

  • 编译通过
  • 单元测试通过
  • 静态扫描通过
  • 关键合约攻击测试通过
  • 部署前人工 review

3)对告警做分级

不是所有工具提示都要一刀切拦截。建议分级:

  • High:阻塞合并
  • Medium:要求解释或修复
  • Low:记录并评估
  • Info:供人工参考

4)保留审计基线

每次发版前,记录:

  • 合约版本
  • 编译器版本
  • 依赖版本
  • 审计结果摘要
  • 已知风险与接受理由

这样后续追溯问题会轻松很多。


逐步验证清单

如果你想把本文真正落地,可以按这个顺序做:

stateDiagram-v2
    [*] --> 编译通过
    编译通过 --> 单元测试通过
    单元测试通过 --> 攻击测试通过
    攻击测试通过 --> Slither扫描完成
    Slither扫描完成 --> 高风险问题清零
    高风险问题清零 --> 发布前人工复核
    发布前人工复核 --> [*]

对应操作清单如下:

  • 本地 npx hardhat compile 成功
  • 本地 npx hardhat test 成功
  • 已复现至少一个真实漏洞案例
  • 已编写修复版并验证
  • slither . 已运行并处理高风险告警
  • 核心资金合约已做人工检查
  • GitHub Actions 已接入
  • 发布流程包含安全复核点

这份清单看起来朴素,但真能把很多低级事故挡在上线前。


一个更实用的审计视角:从函数清单到资产路径

如果你已经不满足于“看到漏洞例子”,我建议实际审计时用这个顺序:

第一步:列出高风险函数

通常包括:

  • 提款
  • 授权
  • 升级
  • 清算
  • 管理员设置
  • 外部协议调用
  • 预言机价格读取

第二步:画资金流

比如:

  • 用户资产进入哪里
  • 合约资产如何出账
  • 是否依赖外部价格
  • 是否能被第三方合约打断或回调

第三步:标记信任边界

问清楚:

  • 谁是可信管理员
  • 哪些合约地址可变
  • 哪些 token 是不可信输入
  • 是否允许任意合约调用

第四步:把这些点转成自动化测试

这是很多团队最容易忽略的一步。
审计结论如果没有沉淀成测试,下次改代码还会再犯。


总结

智能合约安全审计,真正有用的不是记住几十个漏洞名词,而是建立一个稳定的方法:

  1. 先看资产流与权限边界
  2. 用人工审阅识别核心攻击面
  3. 用测试复现真实攻击路径
  4. 用 Slither / Mythril 做自动化兜底
  5. 把安全检查接进 CI,而不是发布前临时抱佛脚

如果你刚开始落地,我建议先别追求一步到位。最小可行方案就是:

  • 先写出一个可复现漏洞的 demo
  • 再写修复版
  • 跑单元测试
  • 接入 Slither
  • 放进 GitHub Actions

只要这五步跑通,你们团队就已经从“凭感觉写安全代码”,进入“有审计闭环”的阶段了。

最后给一个边界条件提醒:
自动化流程能大幅降低常见错误,但它替代不了对业务逻辑和经济模型的理解。
尤其在 DeFi、治理、跨链桥这类高复杂场景里,人工审计依然是最后一道关键防线。


分享到:

上一篇
《自动化测试中的稳定性治理实战:从脆弱用例识别到持续反馈闭环搭建》
下一篇
《Web逆向实战:基于浏览器开发者工具定位并还原前端加密请求参数的完整方法》