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

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

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

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

智能合约一旦部署,很多时候就像“写进石头里”。代码有 bug,不是简单发个补丁就完事,尤其涉及资产转移时,漏洞往往直接等于损失。

我自己第一次系统做合约审计时,最大的感受不是“漏洞多难找”,而是没有流程比不会工具更危险。只靠肉眼扫代码,容易漏;只迷信工具,也会被误报带偏。本文就从实战角度,把一个中级开发者能真正落地的审计流程串起来:先识别常见漏洞,再把静态分析、测试、CI 自动化串成一条线


背景与问题

智能合约安全审计的难点,通常不在“知道有哪些漏洞”,而在:

  1. 漏洞模式多,但上下文差异很大

    • 重入、权限控制、整数边界、预言机操纵、闪电贷组合攻击……
    • 同样是 call,有时是危险点,有时只是必要的外部交互。
  2. 代码正确,不代表业务安全

    • 合约逻辑没有语法问题,但经济模型可能可被操纵。
    • 比如奖励计算、清算阈值、价格源依赖。
  3. 人工审计容易受经验偏差影响

    • 新手容易只盯着 reentrancy
    • 老手有时会忽略“初始化失误”“事件缺失”“升级存储冲突”这类低级但致命的问题
  4. 上线前没有自动化门禁

    • 本地看着没问题,合并代码时引入回归
    • 缺少 lint、静态分析、单元测试、属性测试、gas 基线检查

所以,比较靠谱的做法不是“找一个神奇工具”,而是建立一个分层审计模型

  • 第一层:人工建模
  • 第二层:静态工具扫描
  • 第三层:测试与攻击复现
  • 第四层:CI 自动化拦截

前置知识

如果你准备跟着做,建议先具备这些基础:

  • 会看 Solidity 0.8.x 合约
  • 知道 ERC20 / Ownable / AccessControl 的常见用法
  • 用过 Node.js 和 npm
  • 对 Hardhat 或 Foundry 至少熟悉一个

本文示例尽量选 Hardhat,原因很简单:上手快、生态全、做自动化也顺手


环境准备

先准备一个最小项目:

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

再安装审计相关工具:

npm install --save-dev solhint
npm install --save-dev @openzeppelin/contracts
pip3 install slither-analyzer

如果你本机没有 Python 环境,slither 可以先放到 CI 容器里执行,本文后面也会给自动化示例。


核心原理

1. 审计到底在看什么

我一般会把审计拆成四个问题:

  • 钱能不能被偷走
  • 权限能不能被绕过
  • 状态会不会错乱
  • 系统能不能被卡死或滥用

这四个问题,基本可以映射到大部分漏洞类别。

2. 常见漏洞识别框架

下面这张图可以作为审计时的“脑内检查表”。

flowchart TD
    A[开始审计] --> B[识别资产流]
    B --> C[识别权限边界]
    C --> D[识别外部调用]
    D --> E[识别状态更新顺序]
    E --> F[识别价格/时间/随机数依赖]
    F --> G[识别升级与初始化逻辑]
    G --> H[构建攻击路径]
    H --> I[静态分析与测试验证]

3. 常见漏洞类型与判断方法

重入攻击

典型特征:

  • 先执行外部调用
  • 再更新余额或状态
  • 外部对象可控

危险模式:

(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] -= amount;

正确思路通常是:

  • 先更新状态,再外部调用
  • 或使用重入锁
  • 或改为 pull payment 设计

权限控制缺失

常见表现:

  • 管理函数没加 onlyOwner
  • 初始化函数可被重复调用
  • 使用 tx.origin 做鉴权

整数边界与精度问题

Solidity 0.8+ 已有溢出检查,但风险还在:

  • 强制 unchecked
  • 小数换算错误
  • 奖励分配时先除后乘导致精度损失

拒绝服务(DoS)

比如:

  • 遍历超大数组导致 gas 用尽
  • 单个恶意地址阻塞批量结算
  • 依赖外部合约返回值,导致核心流程被卡住

时间、价格与随机数依赖

区块链上这些值都不是绝对可信:

  • block.timestamp 可被小范围操纵
  • blockhash 不是安全随机数
  • 单一预言机价格可能被短时操纵

用一个最小案例带你走一遍

下面我故意写一个有问题的合约,包含两个很典型的漏洞:

  1. withdraw 可重入
  2. setRewardRate 没有权限控制

漏洞合约

// contracts/VulnerableBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

    constructor() {
        owner = msg.sender;
    }

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

    function setRewardRate(uint256 newRate) external {
        rewardRate = newRate;
    }

    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 rewardOf(address user) external view returns (uint256) {
        return balances[user] * rewardRate / 100;
    }
}

实战代码(可运行)

这一部分我们做三件事:

  1. 编写攻击合约复现重入
  2. 写测试证明漏洞存在
  3. 修复合约并重新验证

第一步:攻击合约

// 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 target;
    uint256 public attackAmount;

    constructor(address _target) {
        target = IVulnerableBank(_target);
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "need at least 1 ether");
        attackAmount = 1 ether;

        target.deposit{value: 1 ether}();
        target.withdraw(1 ether);
    }

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

第二步:编写 Hardhat 测试

// test/vulnerableBank.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("VulnerableBank Audit Demo", function () {
  let bank, attacker, owner, user, evil;

  beforeEach(async function () {
    [owner, user, evil] = await ethers.getSigners();

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

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

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

  it("should allow unauthorized reward rate change", async function () {
    await bank.connect(evil).setRewardRate(999);
    expect(await bank.rewardRate()).to.equal(999n);
  });

  it("should be vulnerable to reentrancy", async function () {
    const bankAddress = await bank.getAddress();

    await evil.sendTransaction({
      to: await attacker.getAddress(),
      value: ethers.parseEther("1"),
    });

    const before = await ethers.provider.getBalance(bankAddress);

    await attacker.connect(evil).attack({ gasLimit: 1_000_000 });

    const after = await ethers.provider.getBalance(bankAddress);

    expect(after).to.be.lessThan(before);
  });
});

运行:

npx hardhat test

如果环境正常,你会看到测试通过,说明漏洞确实能被复现。这一步非常关键:审计不是“猜风险”,而是“验证风险”


修复版本

接下来给出修复后的实现。这里用了三个策略:

  • 为敏感函数加 onlyOwner
  • 采用 checks-effects-interactions 顺序
  • 增加简单重入锁
// contracts/SafeBank.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SafeBank {
    mapping(address => uint256) public balances;
    uint256 public rewardRate = 10;
    address public owner;
    bool private locked;

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

    modifier nonReentrant() {
        require(!locked, "reentrant call");
        locked = true;
        _;
        locked = false;
    }

    constructor() {
        owner = msg.sender;
    }

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

    function setRewardRate(uint256 newRate) external onlyOwner {
        require(newRate <= 100, "rate too high");
        rewardRate = newRate;
    }

    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 rewardOf(address user) external view returns (uint256) {
        return balances[user] * rewardRate / 100;
    }
}

第三步:修复验证测试

// test/safeBank.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("SafeBank Audit Fix Demo", function () {
  let bank, attacker, owner, user, evil;

  beforeEach(async function () {
    [owner, user, evil] = await ethers.getSigners();

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

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

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

  it("should block unauthorized reward rate change", async function () {
    await expect(
      bank.connect(evil).setRewardRate(999)
    ).to.be.revertedWith("not owner");
  });

  it("should resist reentrancy attack", async function () {
    await evil.sendTransaction({
      to: await attacker.getAddress(),
      value: ethers.parseEther("1"),
    });

    await expect(
      attacker.connect(evil).attack({ gasLimit: 1_000_000 })
    ).to.be.reverted;
  });
});

运行:

npx hardhat test

自动化检测流程搭建

现在进入真正实用的部分:把“发现漏洞”变成“每次提交都自动检查”。

推荐的流水线分层

flowchart LR
    A[代码提交] --> B[Solhint 语法与规范检查]
    B --> C[Hardhat Compile]
    C --> D[单元测试]
    D --> E[Slither 静态分析]
    E --> F[覆盖率与关键路径检查]
    F --> G[人工复核高危项]

1. 代码规范检查:Solhint

新增配置:

// .solhint.json
{
  "extends": "solhint:recommended",
  "rules": {
    "func-visibility": "warn",
    "avoid-low-level-calls": "warn",
    "not-rely-on-time": "warn"
  }
}

执行:

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

这个阶段主要抓“坏味道”,例如:

  • 低级调用
  • 依赖时间戳
  • 可见性不清
  • 命名与风格问题

它不是漏洞扫描器,但很适合做第一道门。


2. 静态分析:Slither

直接运行:

slither . --exclude-dependencies

如果项目基于 Hardhat 编译,通常 Slither 能识别大部分结构。它特别适合抓:

  • 重入风险
  • 未初始化状态变量
  • 受控外部调用
  • 死代码
  • 未检查返回值
  • 权限问题候选点

我在实际项目里常做的一件事是:不要追求 Slither 零告警,而是建立高危告警的处理机制。否则误报太多,团队会很快对工具失去耐心。


3. GitHub Actions 自动化

下面给一个可以直接改造的 CI 示例:

# .github/workflows/contract-security.yml
name: Contract Security Pipeline

on:
  push:
    branches: [main, develop]
  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: 20

      - 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: Lint Solidity
        run: npx solhint "contracts/**/*.sol"

      - name: Compile
        run: npx hardhat compile

      - name: Run tests
        run: npx hardhat test

      - name: Run Slither
        run: slither . --exclude-dependencies

这份流水线很基础,但已经能解决一个现实问题:把审计从“上线前临时检查”前移到“开发过程持续检查”


审计工作流怎么落地

很多团队最大的问题,不是没有工具,而是不知道先做什么、后做什么。下面这个顺序比较适合中级开发者执行。

sequenceDiagram
    participant Dev as 开发者
    participant Test as 测试框架
    participant Tool as 静态分析工具
    participant Reviewer as 审计者

    Dev->>Dev: 阅读合约与业务说明
    Dev->>Dev: 标记资产、权限、外部调用点
    Dev->>Test: 编写正常路径测试
    Dev->>Test: 编写攻击/异常路径测试
    Dev->>Tool: 运行 Solhint/Slither
    Tool-->>Dev: 输出风险候选项
    Dev->>Reviewer: 提交漏洞复现与修复说明
    Reviewer-->>Dev: 复核并确认上线风险

逐步验证清单

如果你想在项目里照着做,这份清单可以直接拿去用。

合约级检查

  • 所有改状态的管理函数都有限制权限
  • 初始化逻辑只能执行一次
  • 外部调用前是否已更新关键状态
  • 是否存在可重入路径
  • 是否依赖时间戳、区块哈希、单一价格源
  • 循环是否可能导致 gas 爆炸
  • 精度换算是否先乘后除
  • 是否有事件日志支撑链上追踪

测试级检查

  • 正常流程测试通过
  • 权限绕过测试存在
  • 边界值测试存在
  • 恶意合约交互测试存在
  • 回归测试覆盖历史漏洞

自动化级检查

  • PR 自动执行 compile
  • PR 自动执行 test
  • PR 自动执行 lint
  • PR 自动执行 slither
  • 高危告警阻止合并

常见坑与排查

这一部分我尽量说得接地气一点,因为这些问题真的很常见。

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

不是。自动溢出检查只解决了一部分问题,下面这些仍然危险:

  • 精度截断
  • 小数位不一致
  • 汇率换算顺序错误
  • unchecked 块误用

排查方法:

  • 查所有金额计算路径
  • 用 1、极小值、极大值、临界值写测试
  • 对奖励、手续费、清算比例单独做断言

坑 2:测试通过了,但其实没测到攻击路径

很多人写的“安全测试”其实只是调用一下函数,看能不能 revert。问题是,真正的攻击往往需要恶意合约配合

比如重入攻击,如果你不写 receive() 或 fallback 递归调用,根本复现不了。

排查方法:

  • 对涉及外部调用的函数,优先写攻击合约
  • 检查测试是否覆盖“调用链”而不仅是“单函数”

坑 3:Slither 报一堆 warning,不知道先修哪个

经验上可以按这个优先级分:

  1. 资产损失直接相关
  2. 权限提升相关
  3. 状态错乱相关
  4. 可用性问题
  5. 风格与低风险提示

不要一上来试图把所有 warning 全消掉。那样很容易把时间花在低价值项上。


坑 4:把 tx.origin 当作身份校验

这是老坑,但现在依然偶尔能见到。攻击者可以通过中间合约诱导用户发起调用,从而绕过你的设计意图。

建议:

  • 一律用 msg.sender 做权限判断
  • 如果是元交易或代理场景,明确使用受信转发方案

坑 5:升级合约只审实现,不审存储布局

如果你用的是代理升级模式,存储布局冲突会直接把状态写坏。这个问题工具不一定完全兜得住。

排查方法:

  • 保留 storage gap
  • 审核新增变量顺序
  • 升级前在测试环境跑迁移校验
  • 对历史状态做快照回放

安全/性能最佳实践

安全和性能在链上经常是绑在一起的。gas 过高不只是贵,很多时候还会变成可用性问题。

1. 优先采用成熟库

例如 OpenZeppelin 的:

  • Ownable
  • AccessControl
  • ReentrancyGuard
  • Pausable

不要为了“代码短一点”自己重写权限和锁。除非你非常确定自己在做什么。

示例:

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

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

contract BetterBank is Ownable, ReentrancyGuard {
    mapping(address => uint256) public balances;

    constructor(address initialOwner) Ownable(initialOwner) {}

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

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

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

2. 用“拉取”替代“批量推送”

如果你要给很多用户分发奖励,不要在一笔交易里循环给所有人转账。更好的方式是:

  • 合约记录可领取额度
  • 用户自己来领

这样好处有两个:

  • 避免 gas 超限
  • 降低因单个失败地址导致整批失败的风险

3. 外部依赖要设边界

包括但不限于:

  • 价格源异常时暂停关键功能
  • 管理操作增加 timelock
  • 对最大费率、最大单笔金额设上限
  • 紧急暂停功能要可用且权限清晰

4. 日志事件不要省

很多团队觉得事件只是“前端方便读”。其实在审计和事故排查里,事件非常有用:

  • 还原攻击路径
  • 分析参数变化
  • 追踪管理员操作
  • 支撑监控告警

5. 为关键不变量写测试

所谓不变量,简单理解就是“无论怎么调用,都不该被打破的规则”。

比如:

  • 合约总余额 >= 用户余额总和
  • 未授权账户不能改管理参数
  • 每次提款后用户余额正确减少
  • 清算后债务和抵押状态依然一致

哪怕你暂时不用高级 fuzz 工具,先把这些规则写成普通测试,也会非常有帮助。


一个更实用的审计思路:先画资产流

很多人一上来就逐行看代码,效率其实不高。我更推荐先做这件事:

  1. 资金从哪里进入
  2. 谁能改账本
  3. 谁能触发转出
  4. 哪些步骤依赖外部合约
  5. 失败时状态会不会回滚

你会发现,很多高危问题在“资产流图”层面就能看出来。

flowchart TD
    U[用户] -->|deposit| C[合约余额增加]
    C -->|记录| B[balances映射]
    A[管理员] -->|setRewardRate| P[奖励参数]
    U -->|withdraw| X{先更新状态?}
    X -->|否| R[存在重入风险]
    X -->|是| S[风险显著降低]

边界条件与适用范围

这套流程适合:

  • DeFi 小中型协议
  • 钱包、资金池、质押、奖励分发类合约
  • 团队内部建立基础安全门禁

但它不等于完整专业审计。以下情况,建议一定要引入资深安全团队:

  • 涉及复杂经济模型
  • 多合约组合与升级代理
  • 依赖预言机、清算、AMM 定价
  • TVL 高、上线影响大
  • 有跨链桥、签名验证、密码学逻辑

换句话说,本文的方法更像是:把明显的、常见的、可自动化发现的问题尽量前置消灭,而不是替代深度安全评估。


总结

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

  • 只靠人工经验,不建流程
  • 只靠自动化工具,不做业务理解

更稳妥的做法是:

  1. 先从资产、权限、外部调用三条主线读代码
  2. 针对重入、权限、边界值、DoS 等常见问题建立检查表
  3. 用攻击合约和测试把风险真正复现出来
  4. 把 Solhint、Slither、单元测试接入 CI
  5. 把高危问题的处理规则制度化,而不是靠临时救火

如果你现在就想开始落地,我建议最小可执行方案如下:

  • 今天先把现有合约补上权限与攻击路径测试
  • 本周把 solhint + hardhat test + slither 接入 PR 流程
  • 下周开始沉淀一份你们团队自己的“审计检查清单”

这样做的价值很实际:不是保证绝对安全,而是显著降低“本可避免”的低级事故概率。在链上世界,这已经很值钱了。


分享到:

上一篇
《大模型应用上线实战:从 Prompt 设计、RAG 检索到效果评测的完整落地指南》
下一篇
《大模型推理性能优化实战:从 KV Cache、量化到批处理调度的工程落地指南》