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

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

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

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

智能合约一旦部署,往往就意味着“代码即规则”。这句话听起来很酷,但安全视角下它还有后半句:一旦写错,修复成本通常远高于传统应用。很多团队以为“合约能编译、测试能过”就差不多了,真正上线后才发现,重入、权限配置错误、价格预言机依赖、整数处理、升级代理存储冲突这些问题,随便一个都可能直接变成链上资金事故。

这篇文章我不打算只讲“有哪些漏洞”,而是从一个更实用的角度来带你走一遍:

  1. 怎么理解审计的核心思路;
  2. 怎么用一个小型合约样例识别典型漏洞;
  3. 怎么搭建一条自动化检测流水线
  4. 怎么把“工具扫描”升级为“可持续审计流程”。

如果你已经会写 Solidity,或者至少能读懂基础合约代码,这篇内容会比较适合你。


背景与问题

智能合约安全审计和传统代码审计有几个明显不同:

  • 不可逆性强:很多链上操作不可回滚;
  • 资金属性强:漏洞常常直接对应资产损失;
  • 攻击面独特:链上状态、外部调用、MEV、预言机、治理流程都可能成为入口;
  • 执行环境约束多:Gas、EVM 语义、delegatecall、storage layout 等都很关键。

实际项目里,常见问题并不总是“高级漏洞”,反而经常是下面这些基础错误:

  • 提现函数存在重入风险;
  • onlyOwner 权限漏加;
  • 使用 tx.origin 做鉴权;
  • 外部调用顺序错误;
  • 代理升级后存储槽冲突;
  • 依赖不可信预言机价格;
  • 没有限制管理操作的时间锁或多签;
  • 关键参数可被任意修改。

很多团队一开始只想“跑一遍 Slither”,然后期待工具帮忙找出所有问题。现实是:工具能帮你缩小范围,但不能代替审计推理。真正有效的做法,是把“人工分析 + 静态分析 + 动态测试 + 规则化流程”结合起来。


前置知识

阅读本文前,最好具备以下基础:

  • Solidity 基础语法;
  • msg.sendermsg.valuecalldelegatecall 的基本概念;
  • Hardhat 或 Foundry 的基础使用;
  • 知道什么是 ERC20、Ownable、Reentrancy。

如果你还没搭过环境,也不用担心,下面我会给出一套能直接跑起来的最小示例。


环境准备

本文示例使用以下工具组合:

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

安装 Node 环境

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

推荐用 Python 虚拟环境安装:

python3 -m venv venv
source venv/bin/activate
pip install slither-analyzer

可选安装 Mythril

pip install mythril

核心原理

审计智能合约,我通常会按下面这条主线思考:

  1. 资产在哪里
    谁持有资金、谁能转移资金、哪些状态变量影响资金流向。

  2. 权限在哪里
    谁能调用关键函数,角色是否隔离,是否存在中心化风险。

  3. 外部依赖在哪里
    是否依赖外部合约、预言机、跨合约调用、代理升级逻辑。

  4. 状态转换是否安全
    调用前后状态是否一致,有没有可被抢跑、重入、绕过的窗口。

  5. 异常路径是否考虑到了
    外部调用失败怎么办、转账失败怎么办、暂停机制是否可用。

可以把它理解成一个简单的审计模型:

flowchart TD
    A[识别资产] --> B[梳理权限]
    B --> C[分析外部调用]
    C --> D[检查状态变更顺序]
    D --> E[验证异常与边界条件]
    E --> F[形成漏洞结论与修复建议]

常见漏洞的底层逻辑

1. 重入攻击

当合约向外部地址转账或调用外部合约时,对方可以在回调里再次进入当前合约。如果你的状态更新发生在外部调用之后,就可能被重复提取资产。

核心错误模式通常是:

  • 先转账
  • 后更新余额

而正确模式应该是:

  • 先检查
  • 再更新状态
  • 最后进行外部交互

也就是经典的 Checks-Effects-Interactions

2. 权限控制错误

尤其常见的是:

  • 忘记加 onlyOwner
  • 管理员可直接改关键参数但没有时间锁
  • 使用 tx.origin 鉴权
  • 合约初始化函数可被重复调用

3. 业务逻辑漏洞

这种最难靠通用工具发现。比如:

  • 抵押率检查漏了某条路径;
  • 清算条件计算错误;
  • 手续费公式因精度问题可被套利;
  • 投票权快照逻辑不严谨。

4. 升级代理风险

代理模式的关键点不只是“能升级”,而是:

  • 初始化是否安全;
  • 实现合约是否可被直接调用;
  • storage layout 是否兼容;
  • 升级权限是否足够稳妥。

审计流程长什么样

一个比较实用的安全审计流程,可以抽象成下面这样:

flowchart LR
    A[阅读需求和协议文档] --> B[手工建模资产与权限]
    B --> C[静态分析扫描]
    C --> D[单元测试与攻击测试]
    D --> E[模糊测试/符号执行]
    E --> F[人工复核误报与漏报]
    F --> G[形成修复建议]
    G --> H[回归验证]

如果你是团队内部做持续安全检查,这条链路里最容易落地自动化的是:

  • 编译检查
  • 测试执行
  • 静态分析
  • 关键规则扫描
  • 审计报告输出

实战代码(可运行)

下面我们做一个小型演示:先故意写一个存在重入漏洞的银行合约,再写攻击合约复现问题,最后再给出修复版本。

1. 漏洞合约:VulnerableBank.sol

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 ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");

        balances[msg.sender] = 0;
    }

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

这里的漏洞非常典型:先转账,再清零余额


2. 攻击合约:Attacker.sol

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.sender == owner, "Not owner");
        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);
    }

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

这个攻击合约在收到转账时,会通过 receive() 再次调用 withdraw(),从而反复取款。


3. 测试脚本:reentrancy.js

test/reentrancy.js 中写入:

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

describe("Reentrancy attack demo", function () {
  it("Should drain funds from 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();

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

如果环境没问题,你会看到攻击测试成功,说明合约确实能被重入抽干。


漏洞调用过程分析

为了更直观看到问题,我们用时序图表示:

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

    U->>A: attack(1 ether)
    A->>B: deposit(1 ether)
    A->>B: withdraw()
    B-->>A: call 转账 1 ether
    A->>B: receive() 中再次调用 withdraw()
    B-->>A: 再次转账
    A->>B: 重复进入直到资金耗尽

这里最危险的一点不是“用了 call”,而是外部调用发生时,余额状态还没更新。很多初学者容易误会成“call 一定不安全”,其实不完全对。call 本身是工具,关键在于你的状态变更顺序和防护机制。


修复版本

1. 使用 ReentrancyGuard + 正确顺序

contracts/SafeBank.sol 中写入:

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

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

contract SafeBank 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 ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }

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

这里做了两件事:

  • 先把用户余额置零;
  • 加上 nonReentrant 防护。

很多时候,只改顺序就已经能挡住这类攻击;但对于资金函数,我还是建议明确加上重入保护,代码意图更清晰,后续维护也更稳。


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 SafeBank = await ethers.getContractFactory("SafeBank", deployer);
    const bank = await SafeBank.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 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"));
  });
});

运行:

npx hardhat test

自动化检测流程搭建

接下来进入这篇文章更关键的部分:怎么把审计经验流程化

如果只是个人学习,跑几条命令就够了;但如果你希望团队内能长期执行,建议把检查拆成以下层次:

  1. 编译层:确保代码可编译;
  2. 单元测试层:验证预期逻辑;
  3. 安全静态扫描层:找出已知模式漏洞;
  4. 高风险规则层:针对项目自定义规则;
  5. 持续集成层:每次提交自动执行。

1. 最小自动化脚本

先在 package.json 中配置脚本:

{
  "name": "contract-audit-demo",
  "version": "1.0.0",
  "scripts": {
    "compile": "npx hardhat compile",
    "test": "npx hardhat test",
    "slither": "slither . --exclude-dependencies --print human-summary",
    "audit:all": "npm run compile && npm run test && npm run slither"
  },
  "devDependencies": {
    "@nomicfoundation/hardhat-toolbox": "^5.0.0",
    "hardhat": "^2.22.0"
  },
  "dependencies": {
    "@openzeppelin/contracts": "^5.0.0"
  }
}

然后执行:

npm run audit:all

2. Slither 检测

直接运行:

slither . --exclude-dependencies

你一般会看到类似如下告警:

  • reentrancy vulnerabilities
  • low level calls
  • missing zero address validation
  • costly operations in loop
  • dead code

这里要注意:Slither 的价值是快速提示,不是自动下结论。比如“低级调用”不等于漏洞,“可能重入”也要结合上下文分析。

3. Mythril 补充符号执行

myth analyze contracts/VulnerableBank.sol --solv 0.8.20

Mythril 更偏向路径探索和符号执行,适合辅助发现一些运行时问题。但它的速度、误报和环境兼容性有时不如静态分析稳定,所以更适合作为补充,而不是唯一依赖。


用 GitHub Actions 做持续审计

如果你的代码在 GitHub 上,最简单的自动化落地方式就是 CI。

创建 .github/workflows/security.yml

name: Smart Contract Security Checks

on:
  push:
    branches: [ main, master ]
  pull_request:

jobs:
  security:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: Install Node deps
        run: npm install

      - name: Compile
        run: npx hardhat compile

      - name: Test
        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: Run Slither
        run: slither . --exclude-dependencies --fail-high

这个配置做了几件很实用的事:

  • 代码提交就自动编译;
  • 自动执行测试;
  • 自动运行 Slither;
  • 如果发现高危问题,可以直接让 CI 失败。

对中小团队来说,这已经能显著减少“低级安全错误混进主分支”的概率。


逐步验证清单

实际落地时,我建议你按这个顺序执行,不容易乱:

第一步:手工过一遍核心函数

重点看:

  • 存款、提款、转账、铸造、销毁;
  • 管理员函数;
  • 升级函数;
  • 参数修改函数;
  • 依赖外部合约的调用点。

第二步:画出资产流和权限流

哪怕只是纸上画一下,也很有用。很多漏洞在“看图”时比“看代码”时更明显。

第三步:跑静态工具

先看高危告警,再看中危,不要一上来处理全部提示。

第四步:写攻击测试

这一点特别重要。
如果一个问题你不能用测试复现,往往说明你对它的理解还不够深。

第五步:修复后做回归验证

  • 原漏洞是否已失效;
  • 正常业务是否还可用;
  • 修复是否引入新的副作用。

常见坑与排查

这部分我尽量讲得接地气一点,因为很多问题真的是“工具没报,但线上会炸”。

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

0.8+ 默认有溢出检查,这很好,但不代表你就不用考虑:

  • 精度截断;
  • 除法向下取整;
  • 不同 token decimals 混算;
  • 手续费计算顺序错误。

排查建议:把金额计算单独写测试,尤其是边界值、极小值、极大值。


坑 2:把 onlyOwner 当成万能安全方案

onlyOwner 只是权限控制,不是治理安全。

如果 owner 私钥丢失、被盗,或者 owner 本身是单点地址,系统依然很危险。特别是这些函数:

  • 修改手续费;
  • 提取协议资金;
  • 升级实现合约;
  • 设置预言机地址。

排查建议

  • 关键操作加时间锁;
  • 管理员权限走多签;
  • 将紧急暂停和升级权限分离。

坑 3:工具扫不出业务漏洞

这是最典型的误区。
例如一个借贷协议中,健康因子计算少乘了一个精度参数,Slither 很可能不会直接告诉你“这里可被恶意清算”。

排查建议

  • 对业务公式建立 invariants;
  • 写基于场景的攻击测试;
  • 关键公式做交叉验证。

坑 4:代理合约升级后变量错位

这个坑我见过不止一次。
实现合约新增变量时,如果 storage layout 不兼容,原有状态可能被覆盖,后果非常难排查。

排查建议

  • 使用标准升级框架;
  • 维护 storage gap;
  • 每次升级前比较 storage layout;
  • 不要随便调整变量顺序。

坑 5:以为测试通过就安全

测试只能证明“你覆盖到的场景没问题”,不能证明“所有场景都安全”。尤其是:

  • 回调攻击;
  • 恶意 token;
  • 非标准 ERC20;
  • 极端 Gas 行为;
  • 多合约交互边界。

排查建议

  • 增加恶意合约测试;
  • 增加 revert 场景测试;
  • 对外部依赖做 mock 和异常模拟。

安全/性能最佳实践

安全和性能在链上经常是一起考虑的,因为 Gas 成本本身会影响可用性和攻击面。

安全最佳实践

1. 遵守 Checks-Effects-Interactions

在任何涉及外部调用的函数里,优先检查:

  • 是否先完成参数校验;
  • 是否先更新内部状态;
  • 是否最后才调用外部地址。

2. 关键路径使用成熟库

例如:

  • Ownable
  • AccessControl
  • ReentrancyGuard
  • Pausable

不要为了“代码短一点”手搓一套权限系统,很多事故就是从这里开始的。

3. 尽量减少信任假设

问自己几个问题:

  • 预言机一定可靠吗?
  • 管理员一定不会作恶吗?
  • 代币一定符合 ERC20 标准吗?
  • 外部合约升级后行为还一致吗?

如果答案是否定的,就需要做保护性设计。

4. 为紧急情况预留止血机制

包括但不限于:

  • 暂停开关;
  • 提现限速;
  • 白名单恢复操作;
  • 多签审批;
  • 可观测告警。

性能最佳实践

1. 减少不必要的存储读写

EVM 中 SSTORE 很贵。
如果一个值可以缓存到内存变量,就不要重复从 storage 读取。

2. 避免无界循环

如果你的函数遍历一个可能无限增长的数组,那么迟早会因为 Gas 不足而不可用。
这不只是性能问题,也会变成 DoS 风险。

3. 合理拆分批处理逻辑

发奖励、结算、批量迁移等场景,不要试图在一个交易里处理所有用户。


从“工具扫描”升级到“流程审计”

如果你想把这件事做得更专业,建议从下面三个层面持续补强:

层 1:代码级规则

建立团队自己的规则清单,比如:

  • 禁止 tx.origin 鉴权;
  • 关键函数必须带事件;
  • 资金函数必须有测试覆盖;
  • 外部调用点必须注明风险说明。

层 2:测试级安全基线

每个项目至少补齐:

  • 权限绕过测试;
  • 重入攻击测试;
  • 参数边界测试;
  • 暂停机制测试;
  • 升级兼容性测试。

层 3:流程级质量门禁

把下面几项做成 merge 前门槛:

  • 编译成功;
  • 单元测试通过;
  • 关键攻击测试通过;
  • Slither 无高危告警;
  • 高权限改动需人工复核。

你可以把它理解成一个状态流转:

stateDiagram-v2
    [*] --> 开发中
    开发中 --> 静态扫描通过
    静态扫描通过 --> 测试通过
    测试通过 --> 人工复核通过
    人工复核通过 --> 允许合并
    静态扫描通过 --> 开发中: 发现高危
    测试通过 --> 开发中: 攻击测试失败
    人工复核通过 --> 开发中: 业务逻辑存疑

审计时我会重点看的问题清单

这一段你可以直接当成实战 checklist。

资产安全

  • 用户资金如何进入和退出?
  • 是否存在重复提取路径?
  • 是否依赖外部 token 的 transfer 返回值?
  • 提现和清算逻辑是否可被抢跑?

权限安全

  • 是否有未受控的管理函数?
  • 初始化函数是否只能调用一次?
  • 升级权限是否过于集中?
  • 是否存在角色配置遗漏?

交互安全

  • 是否有外部回调风险?
  • 是否依赖恶意 token 可控行为?
  • 是否对外部调用失败做了处理?
  • 是否存在 delegatecall 风险?

业务安全

  • 价格、汇率、份额计算是否正确?
  • 边界值下会不会出现 0 值、截断或溢出式损失?
  • 奖励分发是否可被刷量?
  • 治理流程是否可被闪电贷操纵?

总结

智能合约安全审计,真正难的地方不在“记住漏洞名称”,而在于建立一套稳定的分析框架:

  • 先找资产和权限;
  • 再看外部调用和状态变化;
  • 用测试去复现风险;
  • 用工具去放大覆盖面;
  • 最后把这些动作沉淀成自动化流程。

如果你刚开始实践,我建议你先做到这 4 件事:

  1. 每个资金函数都写攻击测试
  2. 每次提交都跑静态分析和单元测试
  3. 关键权限函数统一走多签或时间锁设计
  4. 对升级、预言机、回调这三类高风险点做专项检查

最后给一个边界提醒:
自动化检测非常有用,但它更适合发现“已知模式问题”和“代码层面风险”。对于复杂 DeFi 业务逻辑、经济模型、治理攻击面,仍然需要人工建模和经验判断。也就是说,工具能帮你跑得更快,但方向还是得靠人来把握。

如果你准备把这套流程用到真实项目里,可以先从本文的最小示例开始,先把“可复现漏洞 + 可自动扫描 + 可 CI 阻断”这三件事搭起来。只要这一步走稳了,后续再扩展到模糊测试、形式化验证、升级兼容检查,就会顺畅很多。


分享到:

上一篇
《从前端加密到接口还原:中级开发者实战 Web 逆向中的请求签名分析与自动化复现》
下一篇
《Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:一致性、穿透与热点 Key 处理方案》