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

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

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

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

智能合约一旦部署,往往就很难“后悔”。传统后端服务写错了还能热修复,合约写错了,轻则资金冻结,重则资产被盗、协议失信。也正因为如此,安全审计不是上线前“走个流程”,而是研发流程的一部分

这篇文章我会从一个更偏实战的角度来讲:不是只列漏洞清单,而是带你从漏洞识别走到自动化检测流程搭建。如果你已经写过一点 Solidity,想把“会写合约”提升到“能做基础安全审计”,这篇会比较适合你。


背景与问题

很多团队在做合约安全时,容易掉进两个误区:

  1. 过于依赖人工审计
    经验丰富的审计员确实重要,但单靠人眼扫代码,成本高、覆盖有限,而且难以融入日常 CI/CD。

  2. 过于迷信工具扫描
    扫描器能抓到不少模式化问题,但业务逻辑漏洞、权限设计缺陷、经济模型攻击面,工具通常只能给“提示”,不能替你思考。

更现实的问题是:

  • 合约模块越来越多:代币、质押、治理、升级代理、预言机交互……
  • 一次发布涉及多份合约,依赖复杂
  • 漏洞不再只是“重入”这么单一,而是代码缺陷 + 状态机设计错误 + 权限边界失控的组合

所以一个实用的审计流程,应该至少回答三个问题:

  • 看什么:常见漏洞有哪些,怎么快速识别?
  • 怎么测:如何把关键检测变成自动化?
  • 怎么落地:如何接入团队开发流程,减少回归风险?

前置知识

阅读本文前,建议你至少具备这些基础:

  • 会看 Solidity 合约
  • 知道 msg.sendertx.origincalldelegatecall 的基本含义
  • 用过 Hardhat 或 Foundry 之一
  • 对 ERC20 / Ownable / ReentrancyGuard 有基础了解

如果这些还不熟,也没关系,文章里的代码会尽量写得直白一些。


环境准备

下面的示例使用 Hardhat + Solidity + Slither。你可以在 Linux / macOS 环境运行,Windows 建议使用 WSL。

1)初始化项目

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

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

2)安装 OpenZeppelin 合约库

npm install @openzeppelin/contracts

3)安装 Slither

Slither 是非常实用的静态分析工具。

python3 -m pip install slither-analyzer

验证安装:

slither --version

核心原理

智能合约安全审计,本质上是在做三层检查:

  1. 代码层:有没有典型危险写法
    比如重入、整数边界、低级调用返回值未检查、权限缺失

  2. 状态层:合约状态变更是否满足预期
    比如提现前后余额是否一致、状态机是否能被越权跳转

  3. 系统层:跨合约交互是否存在攻击面
    比如外部调用、价格操纵、代理升级、角色错配

我平时做审计,通常会按下面这个顺序推进:

flowchart TD
    A[明确业务目标与资产流向] --> B[梳理权限与角色]
    B --> C[识别外部调用点]
    C --> D[检查状态更新顺序]
    D --> E[运行静态分析工具]
    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 / onlyRole
  • 升级函数、铸币函数、参数配置函数权限过大
  • 初始化函数可被重复调用

这类漏洞往往比代码 bug 更“隐蔽”,因为代码本身可能能正常跑,但权限模型是错的

3. tx.origin 误用

如果你用 tx.origin 做鉴权,攻击者可以通过中间合约诱导用户发起交易,从而绕过预期限制。

错误示例:

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

应使用:

require(msg.sender == owner, "not owner");

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

例如:

target.call(data);

如果不检查返回值,外部调用失败时可能不会回滚,导致状态不一致。

5. DoS 与 gas 风险

比如在一个函数里遍历大型数组并进行转账,随着数据增长,函数可能永远无法成功执行。

这类问题在测试阶段不容易暴露,因为测试数据量通常太小。


用一个脆弱合约做实战

下面我们故意写一个有问题的“银行合约”,用于演示审计与修复流程。

脆弱合约:contracts/VulnerableBank.sol

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

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

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        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 emergencyWithdrawAll(address payable to) external {
        require(tx.origin == owner, "not owner");
        to.transfer(address(this).balance);
    }

    receive() external payable {}
}

这个合约至少有两个明显问题:

  • withdraw 存在重入风险
  • emergencyWithdrawAll 使用了 tx.origin

攻击合约:复现重入漏洞

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 bankAddress) {
        bank = IVulnerableBank(bankAddress);
    }

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

实战代码(可运行)

我们用 Hardhat 写一个攻击测试,把漏洞真实跑出来。

test/vulnerableBank.js

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

describe("VulnerableBank", function () {
  it("should be drained by reentrancy attack", 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: ethers.parseEther("5")
    });

    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

如果一切正常,你会看到攻击成功,银行合约资金被抽干。

这里我特别提醒一个容易踩坑的点:
很多人在测试里既直接给合约转账,又调用 deposit,结果没想清楚哪些余额进了 mapping,哪些只是进了合约总余额。审计时一定要区分“链上 ETH 余额”和“内部记账余额”。


修复版本:按检查-生效-交互顺序改造

我们来修复这个合约。

contracts/SafeBank.sol

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

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

contract SafeBank is ReentrancyGuard, Ownable {
    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 balance");

        balances[msg.sender] -= amount;

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

    function emergencyWithdrawAll(address payable to) external onlyOwner {
        require(to != address(0), "zero address");

        (bool ok, ) = to.call{value: address(this).balance}("");
        require(ok, "transfer failed");
    }

    receive() external payable {}
}

这个版本里有三个关键变化:

  1. withdraw 增加 nonReentrant
  2. 状态更新先于外部调用
  3. 管理员鉴权改为 onlyOwner,不再使用 tx.origin

自动化检测流程搭建

接下来是重点:怎么把审计经验自动化

我的建议是把自动化检测拆成三层:

  • 第 1 层:编译与格式化
  • 第 2 层:静态分析
  • 第 3 层:单元测试 + 攻击测试

自动化流程图

flowchart LR
    A[代码提交] --> B[Solidity编译]
    B --> C[单元测试]
    C --> D[静态分析 Slither]
    D --> E[安全回归测试]
    E --> F[允许合并]

使用 Slither 做静态分析

在项目根目录运行:

slither .

对于上面的脆弱合约,Slither 通常会提示类似风险:

  • reentrancy
  • dangerous usage of tx.origin
  • low-level calls

如果你想输出更聚焦的信息,可以先列检测器:

slither --list-detectors

再运行指定检测器:

slither . --detect reentrancy-eth,tx-origin

如何看 Slither 报告

这里有个经验:不要把工具报告当判决书,要把它当线索源

比如:

  • 报告说可能重入
    你要回到函数里看:是否真的在外部调用前后修改了关键状态?

  • 报告说存在低级调用
    你要判断:这是必要设计,还是没做结果检查?

  • 报告说复杂度高
    你要思考:这是不是意味着测试覆盖需要加强?


用 GitHub Actions 接入 CI

如果你的代码托管在 GitHub,可以加一个最基础的自动化工作流。

.github/workflows/security.yml

name: security-check

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

      - name: Install Node dependencies
        run: npm install

      - name: Run Hardhat tests
        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 . --detect reentrancy-eth,tx-origin,unchecked-lowlevel

这个版本不算豪华,但已经能拦住很多低级错误了。


从人工审计到半自动审计的思路

如果你在团队里推动安全流程,我建议用下面这套分层办法:

sequenceDiagram
    participant Dev as 开发
    participant CI as CI流水线
    participant Tool as 静态分析工具
    participant Auditor as 审计人员

    Dev->>CI: 提交合约代码
    CI->>Tool: 编译与静态分析
    Tool-->>CI: 输出风险报告
    CI->>Dev: 反馈基础问题
    Dev->>Auditor: 提交待审版本
    Auditor->>Auditor: 业务逻辑/权限模型审查
    Auditor-->>Dev: 输出审计建议

这里最重要的不是“全部自动化”,而是把适合自动化的部分尽量前置,把人工时间留给更难的问题:

  • 权限边界是否合理
  • 经济激励是否能被操纵
  • 跨合约协作是否可能被绕过
  • 升级与初始化流程是否安全

常见坑与排查

这一部分我尽量写得接地气一些,因为很多问题不是“不会”,而是“测试时没想到”。

坑 1:以为用了 Solidity 0.8+ 就万事大吉

0.8+ 确实默认检查整数溢出,但这只解决了其中一小类问题。
重入、权限、业务逻辑错误、外部依赖风险,都不会因为编译器版本高就自动消失。

坑 2:只测 happy path,不测攻击路径

很多测试只写:

  • 正常存款
  • 正常提现
  • 管理员正常调用

但没有写:

  • 恶意合约回调重入
  • 非管理员尝试调用
  • 余额边界值
  • 重复初始化
  • 调用失败后的状态回滚

如果没有攻击测试,测试通过其实说明不了太多。

坑 3:把“合约余额”和“用户账本余额”混为一谈

这是审计中非常高频的逻辑坑。

排查时建议每次都问自己两个问题:

  1. 当前合约链上真实持有多少 ETH / Token?
  2. 合约内部记账系统认为每个用户持有多少?

这两者不一致时,往往就是漏洞入口。

坑 4:升级代理场景下审计错对象

代理模式下,很多关键逻辑并不在代理合约本身,而在实现合约、初始化函数和存储布局。

排查重点:

  • 初始化函数是否只允许执行一次
  • 升级权限是否可控
  • 存储槽布局是否兼容
  • 是否误用 delegatecall

坑 5:静态分析报告太多,不知道先看什么

我的建议是按严重性和利用难度排序:

  1. 资金直接损失
  2. 权限接管
  3. 资金冻结
  4. 业务中断
  5. 代码规范和可维护性问题

不要一上来陷入“命名不规范”这种小问题里。


逐步验证清单

如果你准备上线一个中等复杂度的合约,我建议至少跑完这份检查单。

代码级

  • 是否存在外部调用前未更新状态的路径
  • 是否误用 tx.origin
  • 所有关键函数是否有权限控制
  • 是否检查低级调用返回值
  • 是否存在未受控的 delegatecall

状态级

  • 提现前后内部余额是否一致
  • 失败回滚后状态是否恢复
  • 重复调用是否会破坏状态机
  • 边界值输入是否安全

系统级

  • 是否依赖可被操纵的外部价格
  • 是否存在管理员单点风险
  • 升级流程是否可审计、可限制
  • 多合约交互是否有循环依赖和回调风险

自动化级

  • PR 是否自动跑测试
  • PR 是否自动跑静态扫描
  • 高危规则是否设置为阻断合并
  • 是否保留安全回归测试样例

安全/性能最佳实践

安全和性能在智能合约里经常需要一起考虑,因为 gas 成本和执行路径会影响可用性。

1. 遵循 Checks-Effects-Interactions

这是老原则,但真的有用:

  • 先校验
  • 再更新内部状态
  • 最后做外部调用

只要涉及 ETH / Token 转账,我都会先用这个顺序扫一遍。

2. 关键入口加最小权限控制

建议做到:

  • 管理函数最小化
  • 角色拆分,而不是一个 owner 管所有事
  • 对高危操作增加时间锁或多签

3. 对外部调用保持不信任

任何外部地址、外部合约、回调函数,都应该假设它“可能作恶”。

例如:

  • 调用前先更新状态
  • 调用结果必须检查
  • 必要时限制可调用目标

4. 避免大循环与不可控数组遍历

性能问题在链上就是安全问题。
因为 gas 超限会让关键函数无法执行,最终形成 DoS。

优化思路:

  • 分批处理
  • 用映射替代部分线性遍历
  • 将计算移到链下,链上只验证结果

5. 安全测试要保留“回归样本”

每修一个问题,都补一个测试。
这样未来别人改代码时,旧漏洞不容易“复活”。

我自己比较推荐的做法是:

  • 一个漏洞,对应一个最小复现测试
  • 一个修复,对应一个防回归断言

一个更实用的审计思维模型

很多人学审计时喜欢背漏洞名称,但真正有用的是“资产视角”。

你可以把每个合约都问成三件事:

  1. 钱从哪里进来?
  2. 钱怎么出去?
  3. 谁有权改变规则?

围绕这三个问题,绝大多数高危问题都能被快速逼出来。

stateDiagram-v2
    [*] --> 存款
    存款 --> 记账
    记账 --> 提现申请
    提现申请 --> 状态更新
    状态更新 --> 外部转账
    外部转账 --> [*]

如果你的实际代码顺序不是“状态更新 -> 外部转账”,那就要立刻提高警惕。


总结

智能合约安全审计,不是“把几个漏洞名词背下来”,而是建立一套能重复执行的检查方法:

  • 先理解资产流向和权限边界
  • 再检查高频漏洞模式
  • 最后把经验沉淀到测试和 CI 里

如果你是中级开发者,我建议先从这三步开始落地:

  1. 手工审计 3 类高危点
    重入、权限控制、外部调用顺序

  2. 给每个高危点补攻击测试
    不要只测正常流程,要故意写恶意合约

  3. 把 Slither + 测试接入 CI
    让低级错误在合并前暴露,而不是上线后暴露

最后说个边界条件:
自动化检测非常有价值,但它解决不了全部问题。涉及复杂经济模型、跨协议组合、代理升级、治理攻击时,仍然需要人工深度审计。

如果你把这篇文章里的示例项目真的跑一遍,再自己扩展两个漏洞场景,比如“权限缺失”和“初始化重复调用”,你的审计能力会比只看概念提升得快很多。智能合约安全这件事,最终还是要靠“看得懂 + 能复现 + 会自动拦”。


分享到:

上一篇
《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固指南》
下一篇
《Node.js 中基于 BullMQ 与 Redis 构建高可靠异步任务队列的实战指南》