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

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

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

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

智能合约最大的特点,是“上线即公开、部署后难改、资金直接受影响”。这也意味着,传统 Web 开发里一些还能靠热修复补救的问题,到了链上很可能就变成真金白银的事故。

这篇文章我会按“实战审计”的思路来讲,不只列漏洞名词,而是带你从漏洞识别一路走到自动化检测流程搭建。读完后,你至少能完成下面几件事:

  • 看懂几类高频智能合约漏洞的成因
  • 用可运行示例复现典型问题
  • 用工具把手工审计经验固化成自动化流程
  • 建立一个适合中小团队的审计基线

背景与问题

很多团队做合约安全时,最容易陷入两个误区:

  1. 只靠人工看代码
  2. 只跑工具,不理解漏洞原理

前者效率低、容易漏;后者报告很多,但无法判断真实风险。真正可落地的做法,应该是:

  • 先理解漏洞模式
  • 再结合代码结构做人工确认
  • 最后把高频检查固化进 CI/CD

智能合约审计到底在审什么?

从实务上看,主要审下面几类问题:

  • 资产安全:会不会被盗、被锁死、被重复提取
  • 权限安全:管理员、升级权限、铸币权限是否失控
  • 业务逻辑安全:价格计算、奖励发放、状态流转是否可被绕过
  • 可用性与 DoS 风险:是否会因某个地址、某笔交易导致核心功能卡死
  • 代码实现安全:重入、整数问题、低级调用返回值忽略等

如果把审计流程画成一张图,大致是这样:

flowchart TD
    A[需求与业务梳理] --> B[威胁建模]
    B --> C[代码静态检查]
    C --> D[人工审计高风险模块]
    D --> E[测试与攻击路径验证]
    E --> F[自动化规则固化]
    F --> G[CI/CD 持续扫描]
    G --> H[修复回归与复审]

前置知识与环境准备

本文默认你已经了解:

  • Solidity 基础语法
  • 合约部署与调用流程
  • Remix / Hardhat 的基本使用

推荐环境

为了让示例更贴近真实项目,我建议用 Hardhat:

  • Node.js 16+
  • npm 或 pnpm
  • Hardhat
  • Solidity 0.8.x
  • Slither
  • Mythril(可选)
  • solhint(可选)

初始化项目

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

选择一个 JavaScript 项目模板即可。

再安装审计相关工具:

npm install --save-dev solhint
pip3 install slither-analyzer mythril

核心原理

先说一句我自己的经验:不要背漏洞定义,要背“攻击者能改什么状态、抢在什么时候调用、让谁失去控制”。这样看代码会快很多。

下面挑几类最常见、也最值得自动化覆盖的漏洞来讲。

1. 重入攻击

当合约在更新自身状态前,先调用外部地址,攻击者就可能在回调中再次进入原函数,重复执行敏感逻辑。

典型风险点:

  • call
  • send
  • transfer 的误用
  • ERC777、带回调的 token
  • 跨合约调用后再更新余额

重入攻击过程

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

    U->>V: withdraw()
    V->>A: call.value(amount)
    A->>V: 再次调用 withdraw()
    V->>A: 再次转账
    V-->>U: 状态最终才更新

2. 权限控制缺失或设计不当

常见问题包括:

  • 敏感函数没加 onlyOwner
  • 使用 tx.origin 做鉴权
  • 多角色权限边界混乱
  • 初始化函数可重复调用
  • 升级合约实现地址可被随意修改

这类问题往往比重入更“业务化”,但危害一点不小。尤其在代币合约、治理合约、桥接合约中,权限失控经常直接导致全量资产风险。

3. 低级调用返回值未检查

Solidity 低级调用如 call 返回 (bool success, bytes memory data)。如果忽略 success,逻辑上可能“看起来执行成功”,实际却没有完成预期操作。

风险包括:

  • 账务状态已更新,但资金未转出
  • 批量分发中部分失败未感知
  • 外部系统状态不一致

4. 拒绝服务(DoS)

常见模式:

  • 在循环中给大量地址转账
  • 单个恶意地址回退导致整批流程失败
  • 动态数组过长导致 gas 超限
  • 必须依赖某个外部合约成功响应

5. 价格与时间依赖

比如:

  • 直接使用区块时间做关键随机数
  • 直接依赖可操纵价格源
  • 没有对预言机价格做新鲜度和边界检查

这一类在 DeFi 中尤其高发。审计时不能只盯 Solidity 语法,还要看协议假设是否成立。


实战代码(可运行)

下面我们从一个故意写得不安全的合约开始,边看边改。

示例 1:存在重入漏洞的 Ether Bank

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() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

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

        balances[msg.sender] = 0;
    }

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

攻击合约

contracts/Attacker.sol 中写入:

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

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

contract Attacker {
    IVulnerableBank public bank;
    address public owner;

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

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

    function attack() external payable {
        require(msg.value >= 1 ether, "Need at least 1 ether");
        bank.deposit{value: 1 ether}();
        bank.withdraw();
    }

    function collect() external {
        require(msg.sender == owner, "Not owner");
        payable(owner).transfer(address(this).balance);
    }
}

Hardhat 测试复现漏洞

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", 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());
    expect(bankBalance).to.equal(0n);
  });
});

执行:

npx hardhat test

如果一切正常,你会看到测试通过,说明攻击成功把合约里的 Ether 抽干了。


修复版本:Checks-Effects-Interactions

最经典的修复方案是 CEI 模式:先检查、再更新状态、最后与外部交互。

contracts/SafeBank.sol 中写入:

// 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() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // 先更新状态
        balances[msg.sender] = 0;

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

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

不过仅靠 CEI 还不够保险。更稳妥的方式是叠加 ReentrancyGuard

使用 OpenZeppelin 的重入锁

安装依赖:

npm install @openzeppelin/contracts

合约代码:

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

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

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

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

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

        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

实战代码(第二部分):权限控制与 tx.origin 问题

很多初学者会觉得 tx.origin 能识别“真实发起人”,所以更安全。实际上它经常被钓鱼合约利用。

错误示例

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

contract BadAuth {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function withdrawAll(address payable to) external {
        require(tx.origin == owner, "Not owner");
        to.transfer(address(this).balance);
    }

    receive() external payable {}
}

如果 owner 被诱导去调用攻击合约,而攻击合约再转调 withdrawAlltx.origin 仍然是 owner,校验就会通过。

正确示例

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

contract GoodAuth {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

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

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

    receive() external payable {}
}

自动化检测流程搭建

讲完漏洞原理,重点来了:如何把这些经验变成可重复执行的流程。

我的建议是分三层:

  1. 语法/规范层:solhint
  2. 静态分析层:Slither
  3. 测试验证层:Hardhat + 单元测试/攻击测试

一套实用的审计流水线

flowchart LR
    A[开发提交代码] --> B[solhint 规范检查]
    B --> C[Slither 静态分析]
    C --> D[Hardhat 单元测试]
    D --> E[攻击场景测试]
    E --> F[人工复核高危告警]
    F --> G[合并或阻断发布]

1. 配置 solhint

创建 .solhint.json

{
  "extends": "solhint:recommended",
  "rules": {
    "compiler-version": ["error", "^0.8.20"],
    "func-visibility": ["error", { "ignoreConstructors": true }],
    "avoid-tx-origin": "error",
    "not-rely-on-time": "warn"
  }
}

运行:

npx solhint "contracts/**/*.sol"

它更像“代码规范守门员”,对低级错误和团队统一风格很有帮助。

2. 使用 Slither 做静态分析

直接运行:

slither .

你通常会看到类似输出:

  • reentrancy vulnerabilities
  • low-level calls
  • uninitialized state variables
  • arbitrary from in transferFrom
  • missing zero-address validation

Slither 的价值在于:快、规则多、适合集成 CI。缺点是可能有误报,所以一定要人工确认。

3. 用测试把漏洞“打出来”

真正有说服力的审计,不是截图一份报告,而是能写出攻击路径测试。

建议至少覆盖:

  • 正常业务流程
  • 边界输入
  • 权限绕过尝试
  • 恶意合约交互
  • 批量/极端 gas 场景

4. 在 GitHub Actions 中自动执行

新建 .github/workflows/audit.yml

name: Contract Audit Pipeline

on:
  push:
    branches: [ main ]
  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: Install Node dependencies
        run: npm install

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

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

      - name: Run solhint
        run: npx solhint "contracts/**/*.sol"

      - name: Run tests
        run: npx hardhat test

      - name: Run Slither
        run: slither . || true

这里我故意把 slither . || true 保留了。为什么?因为团队初期接入时,告警可能很多,如果第一次就强制阻断,开发体验会很差。更实际的做法是:

  • 第 1 阶段:先收集报告
  • 第 2 阶段:只阻断高危规则
  • 第 3 阶段:再逐步提高门槛

逐步验证清单

如果你准备把这套流程真正用起来,可以按这个顺序做:

第一步:确认高风险函数

优先检查以下类型函数:

  • 提现
  • 铸币/销毁
  • 权限变更
  • 升级入口
  • 外部合约调用
  • 批量转账/分发
  • 清算、质押、赎回等资金路径

第二步:做状态机梳理

问自己几个问题:

  • 哪些状态只能从 A 到 B,不能反向?
  • 有没有函数能跳过中间状态?
  • 多个函数之间是否共享同一份关键余额或额度?
  • 外部调用失败时,状态是否仍然一致?

对于复杂协议,用状态图很有帮助:

stateDiagram-v2
    [*] --> Created
    Created --> Funded: deposit
    Funded --> Withdrawn: withdraw
    Funded --> Frozen: emergencyPause
    Frozen --> Funded: unpause
    Withdrawn --> [*]

第三步:补攻击测试

我一般会要求至少有这些测试:

  • 重入攻击测试
  • 非 owner 调用敏感函数测试
  • 零地址输入测试
  • 极小值/极大值测试
  • 回调失败测试
  • 批量处理 gas 边界测试

第四步:工具结果人工分级

不要看到工具报错就一律当高危。建议分级:

  • Critical:直接导致资产损失、权限接管
  • High:影响核心业务安全,利用门槛不高
  • Medium:特定条件下影响可用性或一致性
  • Low:规范、可维护性、潜在误用风险
  • Info:建议项

常见坑与排查

这一节我尽量讲“真会踩”的坑,而不是只列概念。

1. 以为 Solidity 0.8+ 就没有整数问题了

0.8 之后默认有溢出检查,确实比早期安全很多,但这不代表数值逻辑没问题。

仍然要注意:

  • 精度丢失
  • 除法向下取整
  • 不同 token 小数位不一致
  • 费率计算导致套利窗口

排查方法:

  • 给所有金额计算补单元测试
  • 特别测试 1、最小值、最大值、临界值
  • 检查是否依赖 decimals() 的假设

2. 把 transfer 当万能安全方案

以前很多文章会说 transfer 限制 2300 gas,更安全。但随着 EVM gas 成本变化,这个假设早就不稳了。现在更常见做法是:

  • 优先使用 call
  • 检查返回值
  • 配合 CEI 和重入锁

3. 忽略代理合约和初始化问题

升级合约体系中,最常见的问题之一不是函数本身,而是:

  • 初始化函数未调用
  • 初始化函数可重复调用
  • 实现合约暴露危险入口
  • 存储布局冲突

排查思路:

  • 检查是否使用 initializer
  • 检查升级权限归属
  • 检查 storage gap
  • 检查实现合约是否被错误初始化

4. 误把“工具没报错”当“代码安全”

这是我见过最多的误区。工具对语法层、模式层问题很有效,但对下面这些事常常无能为力:

  • 业务逻辑绕过
  • 经济模型攻击
  • 权限设计不合理
  • 多合约组合风险

所以一定要回到问题本身:这个协议的钱,最终沿着哪些路径流动?谁有权改变这些路径?

5. 测试只测 happy path

很多团队测试覆盖率挺高,但只测“正常使用”。这对安全价值有限。

更有效的方式是每个关键函数都问一句:

  • 如果我是恶意合约,我怎么调它?
  • 如果我连续调两次会怎样?
  • 如果外部调用失败呢?
  • 如果参数看起来合法但语义异常呢?

安全/性能最佳实践

智能合约里,安全和性能有时会互相拉扯。下面给一些比较务实的建议。

安全最佳实践

1. 资金逻辑优先采用拉取模式

与其主动给用户批量打钱,不如记录可领取余额,让用户自己提取。

优点:

  • 降低批量转账 DoS 风险
  • 失败隔离更清晰
  • 更容易做审计

2. 遵循最小权限原则

  • owner 只做治理操作
  • 日常操作使用独立角色
  • 升级权限放多签
  • 紧急暂停能力与资金转移能力分离

3. 所有外部交互都当成不可信

包括:

  • 用户地址
  • ERC20 合约
  • 预言机
  • 回调合约
  • 跨链消息入口

4. 关键操作必须发事件

这不仅方便链上追踪,也方便审计、风控和事故复盘。

例如:

event Withdraw(address indexed user, uint256 amount);
event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);
event Paused(address indexed operator);

5. 给紧急场景留止血手段

例如:

  • pause/unpause
  • 提现限速
  • 白名单模式
  • 升级冻结窗口

但要注意,止血能力本身也是高权限入口,必须严格控制。

性能最佳实践

1. 避免无界循环

特别是在:

  • 大数组遍历
  • 批量分发
  • 清算列表
  • 全量用户结算

无界循环不仅贵,还容易形成 DoS。

2. 减少不必要的存储写入

SSTORE 很贵。能缓存到内存的,尽量不要反复读写存储。

3. 合理拆分函数职责

复杂函数既难审计,也难测。把“校验、记账、转账、事件”拆清楚,安全性往往会更高。

4. 对高频路径优先做 gas 分析

例如:

  • 存款/提款
  • 交易撮合
  • 奖励结算
  • 清算路径

优化时不要只盯 gas 数字,更要看会不会引入新的攻击面。


一套适合中小团队的审计落地方案

如果你所在团队资源有限,不可能每次都做顶级全面审计,我建议先把下面这套“基础盘”搭起来:

开发阶段

  • 使用 OpenZeppelin 标准库
  • 统一 Solidity 版本
  • 启用 solhint
  • 关键函数强制双人 review

提测阶段

  • 跑 Slither
  • 补攻击测试
  • 对权限图和资金流做一次人工梳理

上线前

  • 核查部署参数
  • 核查 owner/多签地址
  • 核查初始化状态
  • 做一次测试网演练

上线后

  • 监控关键事件
  • 对异常大额提取预警
  • 对升级、暂停、角色变更做告警
  • 保留应急响应流程

这套方案不花哨,但非常实用。很多事故并不是因为没有“高深审计”,而是因为最基本的上线检查都没做完。


总结

智能合约安全审计,真正难的不是记住十几种漏洞名,而是建立一套**“原理理解 + 人工验证 + 自动化落地”**的闭环。

这篇文章我们做了几件事:

  • 梳理了智能合约审计关注的核心问题
  • 重点分析了重入、权限控制、低级调用等高频漏洞
  • 用 Hardhat + Solidity 复现并修复了典型漏洞
  • 搭建了基于 solhint、Slither、测试与 CI 的自动化检测流程
  • 总结了实战中最常踩的坑和排查方法

如果你准备马上动手,我建议按这个最小路径开始:

  1. 先给现有项目跑一遍 solhintslither
  2. 把提现、权限、升级相关函数逐个补攻击测试
  3. 把高风险检查接入 CI
  4. 对主资金路径做一次人工状态流审计

最后强调一个边界条件:自动化工具能显著提高下限,但替代不了人工审计,尤其替代不了对业务逻辑和经济模型的理解。
真正靠谱的安全工作,永远不是“跑过了工具”,而是“知道系统为什么在攻击下仍然成立”。

如果你能把这套思路坚持两三个迭代,你会明显感觉到:团队讨论安全问题时,不再只是“这个函数有没有漏洞”,而是开始谈“这个协议的信任边界在哪里”。这时候,审计才算真正进入实战阶段。


分享到:

上一篇
《Java 中使用虚拟线程重构高并发 I/O 服务的实战指南》
下一篇
《安卓逆向实战:使用 Frida 定位并绕过常见 Root 检测逻辑的完整方法》