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

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

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

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

智能合约一旦部署,代码通常就很难修改;而资金、权限、治理逻辑又往往直接绑定在合约里。所以它和普通后端服务最大的不同,不是“写法”,而是出错成本极高。很多团队在业务开发阶段把重点放在功能上线,真正出事时才发现:审计不是“最后补一下”,而应该从设计、编码、测试到发布全程嵌入。

这篇文章我会从一个更偏“实战落地”的角度来讲:不是只列漏洞清单,而是带你搭一个从漏洞识别到自动化检测的基本流程。适合已经写过 Solidity、会用 Hardhat 或 Foundry 的中级读者。


背景与问题

智能合约安全审计常见的难点,不是“没工具”,而是:

  1. 漏洞类型多且表现隐蔽

    • 重入
    • 权限控制缺失
    • 整数边界问题
    • 外部调用返回值未检查
    • 价格预言机依赖不安全
    • 签名验证缺陷
    • 升级代理存储冲突
  2. 工具扫描结果噪声高

    • 误报不少
    • 有些逻辑型漏洞,纯静态分析抓不出来
    • 有些“看起来危险”的模式,在业务上下文里反而是合理的
  3. 团队流程不闭环

    • 只跑一次 Slither
    • 没有单元测试覆盖关键资金路径
    • 没有 CI 阻断机制
    • 部署前后缺少检查项

一句话概括:真正有效的合约审计,不是找工具,而是搭流程。


前置知识与环境准备

本文示例基于以下环境:

  • Node.js 16+
  • Hardhat
  • Solidity 0.8.x
  • Slither
  • Mythril(可选)
  • OpenZeppelin Contracts

安装步骤

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 项目即可。

如果要安装 Slither:

python3 -m pip install slither-analyzer

如果要安装 Mythril:

python3 -m pip install mythril

核心原理

智能合约安全审计,建议按下面这条链路来理解:

  1. 先看资产流

    • 钱从哪里来
    • 钱往哪里走
    • 谁能触发转账
    • 是否有重复领取、绕过校验、异常回滚等问题
  2. 再看权限流

    • owner、admin、operator 权限边界是否清晰
    • 是否存在“任意地址可调用敏感函数”
    • 升级、暂停、铸币、提币等高危操作是否受控
  3. 最后看状态流

    • 状态更新顺序是否安全
    • 外部调用前后是否可被重入
    • 是否存在依赖旧状态的竞态问题

审计流程总览图

flowchart TD
    A[需求与架构梳理] --> B[识别资产与权限边界]
    B --> C[手工代码审查]
    C --> D[静态分析 Slither]
    D --> E[单元测试与攻击用例]
    E --> F[模糊测试/属性测试]
    F --> G[修复与回归验证]
    G --> H[CI 自动化门禁]

一个常见漏洞的本质:重入

重入不是“调用外部合约”本身有问题,而是:

  • 合约在执行过程中调用了外部地址;
  • 对方在回调中再次进入当前合约;
  • 当前合约的关键状态尚未安全更新;
  • 导致重复提现吗、重复记账或状态破坏。

重入调用时序图

sequenceDiagram
    participant U as 用户/攻击者
    participant V as 脆弱合约
    participant A as 攻击合约

    U->>A: 发起攻击
    A->>V: withdraw()
    V->>A: 转账 ETH
    A->>V: fallback 重入 withdraw()
    V->>A: 再次转账 ETH
    V-->>A: 状态最终异常

实战代码(可运行)

下面我们从一个有漏洞的取款合约开始,再逐步修复,并补上测试与自动化检测。


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. 攻击合约:利用 fallback 重入

创建 contracts/Attacker.sol

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

interface IVulnerableBank {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}

contract Attacker {
    IVulnerableBank public bank;
    uint256 public attackAmount;

    constructor(address _bank) {
        bank = IVulnerableBank(_bank);
    }

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

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

3. 编写测试复现漏洞

创建 test/reentrancy.js

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

describe("Reentrancy Attack Demo", function () {
  it("should drain vulnerable bank", async function () {
    const [deployer, user, attackerEOA] = await ethers.getSigners();

    const Bank = await ethers.getContractFactory("VulnerableBank");
    const bank = await Bank.deploy();
    await bank.waitForDeployment();

    await bank.connect(user).deposit({ value: ethers.parseEther("5") });

    const Attacker = await ethers.getContractFactory("Attacker");
    const attacker = await Attacker.connect(attackerEOA).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

如果测试通过,说明漏洞已被成功利用。


修复方案:Checks-Effects-Interactions + ReentrancyGuard

1. 修复后的安全合约

创建 contracts/SafeBank.sol

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

import "@openzeppelin/contracts/security/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;
    }
}

2. 修复测试

创建 test/safeBank.js

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

describe("SafeBank", function () {
  it("should prevent reentrancy", async function () {
    const [deployer, user, attackerEOA] = await ethers.getSigners();

    const Bank = await ethers.getContractFactory("SafeBank");
    const bank = await Bank.deploy();
    await bank.waitForDeployment();

    await bank.connect(user).deposit({ value: ethers.parseEther("5") });

    const Attacker = await ethers.getContractFactory("Attacker");
    const attacker = await Attacker.connect(attackerEOA).deploy(await bank.getAddress());
    await attacker.waitForDeployment();

    await expect(
      attacker.connect(attackerEOA).attack({ value: ethers.parseEther("1") })
    ).to.be.reverted;

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

从“单点修复”到“审计清单”

真实项目里,重入只是一个切口。下面是我更推荐的人工审计检查顺序。

1. 权限控制

重点检查:

  • 是否所有管理函数都加了 onlyOwner / AccessControl
  • owner 是否可以任意提走用户资产
  • 是否存在初始化函数被重复调用
  • 是否存在代理升级权限泄露

有问题的示例:

function setTreasury(address newTreasury) external {
    treasury = newTreasury;
}

任何人都能改资金归集地址,这是高危问题。

安全写法:

function setTreasury(address newTreasury) external onlyOwner {
    require(newTreasury != address(0), "zero address");
    treasury = newTreasury;
}

2. 外部调用与返回值检查

ERC20 并不总是行为一致。有些 Token 不返回 bool,有些失败会 revert,有些静默失败。建议统一使用 OpenZeppelin 的 SafeERC20

不推荐:

token.transfer(to, amount);

推荐:

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

3. 价格与时间依赖

很多 DeFi 合约喜欢直接信链上池子价格,但瞬时价格很容易被闪电贷操纵。如果你的逻辑依赖价格,至少要检查:

  • 是否使用 TWAP
  • 是否有价格上下界保护
  • 是否有预言机停摆兜底
  • 是否允许管理员手工暂停

4. 签名校验与重放攻击

如果合约通过签名授权执行操作,务必检查:

  • 是否包含 chainId
  • 是否包含 nonce
  • 是否限定签名用途
  • 是否使用 EIP-712

一个常见坑是:签名消息没带 nonce,导致同一签名可重复使用


自动化检测流程搭建

手工审计很重要,但不能只靠人眼。下面我们把自动化流程搭起来。

自动化检测分层图

flowchart LR
    A[源码提交] --> B[格式与编译检查]
    B --> C[单元测试]
    C --> D[静态分析 Slither]
    D --> E[安全规则扫描]
    E --> F[攻击回归测试]
    F --> G[允许合并]

1. 基础目录结构建议

contract-audit-demo/
├── contracts/
├── test/
├── scripts/
├── slither.config.json
├── hardhat.config.js
├── package.json
└── .github/workflows/security.yml

2. 运行 Slither

先编译:

npx hardhat compile

然后执行:

slither . --filter-paths "node_modules|test"

你可能会看到类似输出:

  • reentrancy vulnerability
  • low level calls
  • missing zero-address validation
  • functions that send ether to arbitrary destinations

注意:不要把扫描结果当最终结论。工具告诉你“哪里值得看”,不是直接给你审计报告。


3. 配置 GitHub Actions

创建 .github/workflows/security.yml

name: Smart Contract Security Check

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test-and-scan:
    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: Install Node dependencies
        run: npm install

      - name: Run tests
        run: npx hardhat test

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

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

      - name: Compile contracts
        run: npx hardhat compile

      - name: Run Slither
        run: slither . --filter-paths "node_modules|test"

这样每次提交代码时,测试和扫描都会自动执行。最直接的收益是:把低级错误挡在合并前


4. 增加攻击回归测试

这是很多团队最容易忽略的一步。修完漏洞后,如果没有把 PoC 测试保留下来,下次重构时它很可能又回来。

建议把测试分三类:

  1. 正常业务路径
  2. 异常输入路径
  3. 攻击路径

比如:

  • 重入攻击
  • 重复初始化
  • 非 owner 调敏感函数
  • 零地址配置
  • 超额提款
  • 价格被操纵时的边界行为

逐步验证清单

当你要审一个中小型合约时,可以按这个清单走:

第一步:先画边界

  • 哪些函数会动钱?
  • 哪些函数会改权限?
  • 哪些函数会调外部合约?
  • 哪些状态变量是核心账本?

第二步:快速手审

  • 敏感函数有没有权限限制?
  • 更新状态和外部调用顺序是否合理?
  • 参数有没有 zero address / 边界检查?
  • 事件是否完整,便于审计追踪?

第三步:工具扫描

  • Slither 发现了哪些高危点?
  • 这些高危点是误报还是实锤?
  • 哪些 warning 值得补测试?

第四步:攻击测试

  • 能不能构造恶意合约重入?
  • 能不能绕过权限?
  • 能不能重复执行签名操作?
  • 能不能利用极端状态让合约锁死?

第五步:修复后回归

  • 原漏洞 PoC 是否已失败?
  • 正常业务流程是否未被破坏?
  • gas 是否显著变差?
  • 是否引入新的可用性问题?

常见坑与排查

下面这些坑,我在合约审查里见得非常多。

坑 1:以为 Solidity 0.8 解决了所有整数问题

0.8 之后默认检查溢出,但这不代表“数值安全”就结束了。你仍然要关注:

  • 精度截断
  • 除零
  • 价格换算顺序错误
  • 单位混淆(wei / ether / token decimals)

排查方法:

uint256 value = amount * price / 1e18;

看起来没问题,但如果 amountprice 精度不统一,结果会偏得离谱。


坑 2:tx.origin 用错

不安全示例:

require(tx.origin == owner, "not owner");

攻击者可以诱导 owner 通过恶意合约发起交易,从而绕过预期逻辑。权限判断应使用 msg.sender


坑 3:升级代理的存储布局冲突

如果你用了 UUPS 或 Transparent Proxy,一定要注意:

  • 新版本不能随意调整状态变量顺序
  • 继承层次变化可能影响 storage layout
  • initializer 只能执行一次

这个坑不像重入那么“炸得快”,但一旦出问题,数据会直接坏掉,非常难救。


坑 4:把 calldelegatecall 当普通函数调用

尤其是 delegatecall,它在当前合约上下文执行目标代码。只要调用目标不受控,就可能直接导致权限接管或存储污染。

排查重点:

  • 调用目标地址是否可被任意设置
  • 是否白名单校验
  • 是否真的需要 delegatecall

坑 5:误信静态分析“没报错就安全”

我自己踩过一个坑:某个合约静态分析结果很干净,但后面通过测试发现签名消息缺 nonce,导致可重复领取奖励。
这类问题属于业务逻辑漏洞,工具很难完全识别,所以必须补:

  • 属性测试
  • 对抗性测试
  • 场景化审查

安全/性能最佳实践

安全和性能在智能合约里常常需要平衡。下面给一组实用建议。

安全最佳实践

  1. 采用成熟库

    • OpenZeppelin 的权限、Token、ReentrancyGuard 不要自己重复造轮子
  2. 遵循 CEI 模式

    • Checks
    • Effects
    • Interactions
  3. 最小权限原则

    • 管理员权限尽量拆分
    • 高危操作加多签控制
    • 升级权限与资金权限分离
  4. 关键路径必须有测试

    • 存款
    • 提款
    • 清算
    • 升级
    • 暂停/恢复
  5. 记录完整事件

    • 安全事故追踪时,事件日志非常重要

性能最佳实践

  1. 避免不必要的存储写入

    • SSTORE 最贵,能缓存到内存就缓存
  2. 减少循环中的链上操作

    • 大数组遍历容易超 gas
    • 可考虑分批处理或 Pull 模式
  3. 错误信息适度精简

    • 自定义错误比长字符串更省 gas

示例:

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

error NotOwner();
error ZeroAddress();

contract Config {
    address public owner;
    address public treasury;

    constructor() {
        owner = msg.sender;
    }

    function setTreasury(address _treasury) external {
        if (msg.sender != owner) revert NotOwner();
        if (_treasury == address(0)) revert ZeroAddress();
        treasury = _treasury;
    }
}
  1. 安全优先于省 gas
    • 不要为了几百 gas 去删除关键校验
    • 特别是权限、签名、金额边界检查

一个可落地的审计流程模板

如果你在团队里要推动流程,我建议至少做到下面这个版本:

开发阶段

  • 使用 OpenZeppelin 基础组件
  • 编写单元测试和异常路径测试
  • PR 模板要求说明权限变更与资金流变更

提测阶段

  • 人工审查核心逻辑
  • Slither 静态分析
  • 编写攻击 PoC

发布前

  • 核验部署参数
  • 核验初始化状态
  • 核验 owner / multisig / treasury 地址
  • 核验预言机和外部依赖地址

发布后

  • 保留监控脚本
  • 跟踪异常事件
  • 准备暂停/熔断预案
  • 修复后进行回归审计

总结

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

  • 一种是只靠经验手看,流程很散;
  • 另一种是完全迷信工具,觉得“扫描通过就上线”。

更稳妥的做法是把它拆成三层:

  1. 人工理解业务与资产边界
  2. 工具做静态检测和持续集成
  3. 测试覆盖攻击路径并长期回归

如果你现在就想开始落地,我建议先做三件事:

  • 给资金相关函数补一轮攻击测试
  • 在 CI 里接入 Hardhat test + Slither
  • 统一引入 OpenZeppelin 的权限与安全组件

最后给一个边界条件:自动化流程能显著提升下限,但它替代不了高质量的人工审计。尤其是 DeFi、治理、签名授权、升级代理这类强业务耦合场景,逻辑漏洞往往比语法漏洞更危险。工具负责“扫”,工程流程负责“挡”,而真正的安全能力,还是来自你对业务状态流、权限流、资产流的持续拆解。


分享到:

上一篇
《从 0 到 1 搭建企业级开源项目治理流程:许可证合规、依赖审计与社区协作实战》
下一篇
《Spring Boot 3 实战:基于 Spring Security 与 JWT 的前后端分离鉴权体系搭建与权限控制》