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

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

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

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

智能合约一旦部署,上链代码几乎不可更改;而一旦出事,损失又往往是“真金白银”。所以合约安全审计不是锦上添花,而是上线前最该优先排的工作之一。

这篇文章我会按“先理解风险,再手动识别,再接自动化工具,最后收敛为流程”的顺序,带你走一遍实战。读完你应该能:

  • 看懂几类高频智能合约漏洞的触发机制
  • 用一个可运行的小项目复现实战问题
  • 搭一个基础自动化检测流程
  • 知道哪些报警值得重视,哪些只是“工具噪音”

背景与问题

很多团队第一次做合约安全,容易掉进两个极端:

  1. 只靠人工看代码
    容易遗漏边界条件,特别是状态变化与外部调用交织时。

  2. 只跑自动化工具
    工具能发现模式化问题,但很难理解业务约束,比如“这个函数本来就应该只允许特定角色在某个时间窗口调用”。

我自己实际做审计时,最常见的现实问题通常不是“完全不会”,而是下面这些:

  • 代码逻辑能跑,但权限控制不完整
  • 使用了 calldelegatecall 却没有理解上下文
  • 依赖 block.timestamptx.origin 做关键判断
  • 升级代理、初始化函数、管理员权限这些“工程问题”比数学错误更容易出事故
  • 自动化工具跑出来一堆结果,团队不会分级,也不会验证真假阳性

所以一个更靠谱的思路是:

人工审计负责理解业务和攻击面,自动化检测负责做批量扫描与回归检查。


前置知识

如果你已经有 Solidity 基础,这部分可以快速略过。建议至少具备以下知识:

  • Solidity 基本语法
  • EVM 调用模型:calldelegatecallstaticcall
  • 事件、存储布局、可见性修饰符
  • 常见开发框架:Hardhat 或 Foundry
  • 基本测试能力:单元测试、断言、脚本运行

环境准备

本文示例使用 Hardhat + Solidity + Slither。环境如下:

  • Node.js 16+
  • Python 3.8+
  • Solidity 0.8.x
  • Hardhat
  • Slither
  • solc-select(方便切换编译器)

1)初始化 Hardhat 项目

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

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

2)安装 Slither

pip install slither-analyzer
pip install solc-select
solc-select install 0.8.20
solc-select use 0.8.20

核心原理

合约审计的核心不是“背漏洞清单”,而是建立一套稳定的判断框架。通常我会按下面四个维度看:

  1. 权限边界

    • 谁能调用?
    • 谁能升级?
    • 谁能暂停、铸币、提取资产?
  2. 状态安全

    • 状态更新是否早于外部调用?
    • 是否存在重入、竞态、重复初始化?
  3. 资金安全

    • 资金流向是否可追踪?
    • 是否可能锁死、被盗、被错误分配?
  4. 业务一致性

    • 代码是否满足协议规则?
    • 是否存在通缩/手续费代币兼容性问题?
    • 是否考虑异常 token 行为?

一张总览图:审计流程怎么走

flowchart TD
    A[阅读协议文档与威胁模型] --> B[识别核心资产与关键权限]
    B --> C[人工审查关键合约]
    C --> D[静态分析工具扫描]
    D --> E[编写PoC与测试复现]
    E --> F[修复与二次验证]
    F --> G[固化为CI自动化流程]

常见漏洞识别思路

下面这几类,是实战里高频又必须熟悉的。

1)重入攻击

典型特征:

  • 合约先执行外部调用
  • 外部调用期间攻击者回调原函数
  • 状态尚未更新,导致重复提取

错误模式通常像这样:

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

正确思路通常是:

  • 先更新状态,再外部调用
  • 使用 ReentrancyGuard
  • 优先采用“拉取式提取”(pull payment)

2)权限控制缺失

例如:

  • 敏感函数没有 onlyOwner
  • 初始化函数可被重复调用
  • 升级函数暴露给普通用户
  • 使用 tx.origin 而不是 msg.sender

tx.origin 的问题在于:它反映的是整条调用链最初发起者,不适合做权限判断。

3)整数与边界问题

Solidity 0.8+ 已内置溢出检查,但边界错误仍然很多,比如:

  • 除法精度丢失
  • 份额计算顺序不对
  • 手续费先除后乘导致结果错误
  • for 循环 gas 过高引发 DoS 风险

4)随机数不安全

直接使用:

  • block.timestamp
  • blockhash
  • block.prevrandao(虽更好,但依然不能简单用于高价值强随机)

如果奖励很大,攻击者和打包者就有动机操控结果。

5)拒绝服务与资金锁定

例如:

  • 一个失败的外部转账阻塞整个批处理
  • 遍历超大数组导致函数永远执行不完
  • 资金提取依赖某个永远可能失败的接收方

用一个最小示例复现漏洞

下面我们做一个有意写错的“银行合约”,复现重入问题,再给出修复版。


实战代码(可运行)

漏洞合约: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");

        // 漏洞点:先转账,再更新余额
        (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;
    }
}

攻击合约: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);
    }

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

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

修复版合约:contracts/SafeBank.sol

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

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

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

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

        // 先更新状态,再外部调用
        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;
    }
}

测试代码: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();

    // 正常用户先存入 5 ETH,制造可被盗资金池
    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();

    // 攻击者投入 1 ETH 发动攻击
    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"));
  });

  it("should block attack on SafeBank", async function () {
    const [deployer, user, attackerEOA] = await ethers.getSigners();

    const SafeBank = await ethers.getContractFactory("SafeBank", deployer);
    const safeBank = await SafeBank.deploy();
    await safeBank.waitForDeployment();

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

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

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

运行测试

npx hardhat test

攻击调用过程图

很多人第一次学重入时,代码能看懂,但调用栈绕不清。这个时序图能帮助你一下子看明白。

sequenceDiagram
    participant U as 攻击者EOA
    participant A as Attacker
    participant B as VulnerableBank

    U->>A: attack(1 ETH)
    A->>B: deposit(1 ETH)
    A->>B: withdraw(1 ETH)
    B-->>A: call{value:1 ETH}()
    A->>B: receive() 中再次调用 withdraw(1 ETH)
    B-->>A: 再次转账
    Note over B: 因为余额尚未扣减,重复提取成功

自动化检测流程搭建

人工复现一遍漏洞后,下一步就该把“能力”变成“流程”。这里给一个适合中小团队落地的基础版本。

1)先跑静态分析

在项目根目录执行:

slither .

如果工程较复杂,也可以指定路径:

slither contracts/VulnerableBank.sol

对于上面的漏洞合约,Slither 通常会提示重入风险、低级调用等问题。

2)建议输出机器可读结果

slither . --json slither-report.json

这样后续可以接 CI、做结果归档或质量门禁。

3)在 CI 中自动执行

以 GitHub Actions 为例,创建 .github/workflows/security.yml

name: Smart Contract Security Check

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.10"

      - name: Install Node deps
        run: npm install

      - name: Install Slither
        run: |
          pip install slither-analyzer
          pip install solc-select
          solc-select install 0.8.20
          solc-select use 0.8.20

      - name: Compile
        run: npx hardhat compile

      - name: Run tests
        run: npx hardhat test

      - name: Run Slither
        run: slither . --json slither-report.json

4)把人工审计清单纳入提交流程

自动化不是替代人,而是提醒人。比较实用的做法是每次 PR 都附一份检查清单:

  • 是否新增外部调用?
  • 是否新增管理员权限?
  • 是否引入升级入口?
  • 是否处理异常 token 行为?
  • 是否有数组遍历导致 gas 风险?
  • 是否更新了测试覆盖关键路径?

自动化流程全景图

flowchart LR
    A[开发提交代码] --> B[Hardhat编译]
    B --> C[单元测试]
    C --> D[Slither静态分析]
    D --> E[人工复核高危项]
    E --> F[修复与回归测试]
    F --> G[合并上线]

逐步验证清单

如果你想自己完整做一遍,建议按这个顺序:

  1. 初始化 Hardhat 项目
  2. 写入 VulnerableBank.sol
  3. 写入 Attacker.sol
  4. 写入测试脚本
  5. 本地执行测试,确认攻击成功
  6. 切换为 SafeBank.sol
  7. 再次运行测试,确认攻击失败
  8. 执行 slither .
  9. 查看报警结果并对照代码理解
  10. 把 Slither + 测试接入 CI

这个过程很重要,因为只看文章“知道”漏洞,和自己跑通“真正理解”漏洞,中间差很多。


常见坑与排查

坑 1:Slither 跑不起来

常见原因:

  • solc 版本不匹配
  • Hardhat 配置与本地编译器版本不一致
  • Python 环境安装不完整

排查方式:

solc --version
python --version
npx hardhat compile
slither .

建议先保证 Hardhat 能编译,再去跑 Slither。


坑 2:测试里攻击没成功

我见过最多的是这几种情况:

  • 银行合约里没有足够的可盗资金
  • receive() 没写对
  • 攻击金额和判断条件不一致
  • Solidity 版本或测试断言写法不兼容

比如,如果银行里只有攻击者自己存入的 1 ETH,那即使重入成功,效果也不明显。一定要先让别的账户存入一笔钱,形成资金池。


坑 3:误把工具告警都当真

自动化工具会有真假阳性混杂的情况。你需要分级:

  • 高危:重入、未受控权限、可升级入口错误、签名验证错误
  • 中危:不安全随机数、DoS 风险、精度损失
  • 低危:代码风格、事件缺失、可读性问题

我一般建议先抓“资金直接损失”和“权限失控”两类问题,因为这两类最容易出大事故。


坑 4:只审业务代码,不审部署与初始化

实际线上事故里,很多问题不在业务逻辑,而在工程流程:

  • 代理合约初始化被抢先调用
  • 管理员地址配错
  • 多签未生效
  • 测试环境参数带到了生产环境

这个坑很隐蔽,因为代码本身“看起来没问题”,但部署方式让系统暴露了。


安全/性能最佳实践

1)遵循 Checks-Effects-Interactions

也就是:

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

这是抵御重入的基础习惯,虽然不是万能,但非常有效。


2)敏感操作必须有明确权限模型

建议区分以下角色:

  • owner
  • admin
  • operator
  • pauser
  • upgrader

不要把所有权力都堆在一个地址上。对高价值协议,最好再配多签与时间锁。


3)避免在链上做大规模遍历

像下面这种设计要特别小心:

for (uint256 i = 0; i < users.length; i++) {
    // 批量处理
}

用户一多,就可能因为 gas 不足无法执行,形成 DoS。更稳妥的办法包括:

  • 分批处理
  • 用户自行领取
  • 用 Merkle proof 做离线计算、链上验证

4)对外部 token 保持“不信任”态度

不是所有 ERC-20 都像标准里写得那么乖。要考虑:

  • 返回值不规范
  • 手续费代币
  • 黑名单代币
  • 回调行为
  • 精度不一致

如果你的协议要兼容第三方 token,最好做适配层,并且测试非标准场景。


5)把测试做成“攻击驱动”

不要只写“正常充值、正常提现”。更有价值的是:

  • 重复调用
  • 越权调用
  • 极限值输入
  • 角色切换
  • 恶意合约回调
  • 时间窗口边界
  • 升级前后存储一致性

很多漏洞就是在“本来不该有人这么调”的路径里触发的。攻击者恰恰最喜欢走这些路径。


6)自动化检测要和人工审计配合

一个比较实用的组合是:

  • 单元测试:验证功能正确性
  • 静态分析:扫描通用漏洞模式
  • 人工审计:理解业务逻辑与信任边界
  • 回归流程:修复后防止漏洞回归

如果项目资金规模大,再进一步上:

  • 模糊测试
  • 形式化验证
  • 第三方审计
  • 赏金计划

一个简化的审计思维模型

如果你拿到一个陌生合约,不知道从哪开始,我建议按下面这个顺序过:

stateDiagram-v2
    [*] --> 识别资产
    识别资产 --> 找权限入口
    找权限入口 --> 查外部调用
    查外部调用 --> 看状态更新顺序
    看状态更新顺序 --> 验证边界条件
    验证边界条件 --> 编写PoC
    编写PoC --> 输出审计结论
    输出审计结论 --> [*]

这个顺序的好处是:不会一上来就陷进细枝末节,而是先抓住最危险的地方。


总结

智能合约安全审计,真正有效的方式不是“背几个漏洞名词”,而是建立一套能反复执行的方法:

  • 先理解协议中的资产、权限、外部交互
  • 再从重入、权限、边界、DoS、随机性这些高频问题切入
  • PoC 测试确认风险是否真实可利用
  • 最后把 静态分析 + 单元测试 + CI 变成团队日常流程

如果你是中级开发者,我给你的可执行建议是:

  1. 先从一个最小漏洞样例开始,自己跑通攻击与修复
  2. 每个项目至少接入一次静态分析工具
  3. 对所有敏感函数做权限复盘
  4. 对所有外部调用检查状态更新顺序
  5. 上线前至少做一轮“攻击者视角”的测试

边界条件也要说清楚:自动化工具不能替代业务审计,而人工经验也不能替代持续扫描。真正靠谱的安全实践,往往是两者结合。

如果你现在就准备落地,最小可行方案其实很简单:Hardhat 测试 + Slither 扫描 + PR 检查清单。先把这三样做好,安全基线就已经比很多项目强不少了。


分享到:

上一篇
《前端性能实战:从代码分割、资源预加载到 Core Web Vitals 优化的系统方案》
下一篇
《Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全落地》