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

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

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

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

智能合约一旦部署,往往就意味着“代码即规则”。这句话听起来很酷,但也意味着另一件更现实的事:漏洞也会被永久写进链上
我第一次参与合约审计时,最大的感受不是“漏洞多高级”,而是很多问题其实都不神秘——重入、权限控制缺失、整数边界、外部调用顺序错误,这些反复出现,只是换了个业务外衣。

这篇文章不讲太多空泛概念,而是带你从一个中级开发者最需要的角度出发:

  1. 先理解智能合约审计到底在查什么;
  2. 再看几类最常见、最容易漏掉的漏洞;
  3. 然后搭一个可落地的自动化检测流程
  4. 最后给出排查清单和最佳实践,方便你自己接入项目。

背景与问题

传统 Web 服务出问题,通常还能热修复、回滚、封禁用户、恢复数据库。
但智能合约世界不同:

  • 合约部署后修改成本高,甚至不可修改
  • 资产直接由代码控制,漏洞会直接对应资金损失
  • 外部调用复杂,依赖 token、预言机、代理合约等组合关系
  • 审计不能只看“单个函数”,必须看状态变化、权限边界、调用链

审计里最常见的误区

很多团队在上线前会说:“我们跑过静态扫描了,应该没问题。”
这句话我建议保留一点警惕。因为:

  • 静态扫描能发现很多模式化问题,但业务逻辑漏洞常常抓不住
  • 单元测试覆盖高,不代表攻击路径覆盖高
  • 一些漏洞只有在“跨合约交互”“异常 token 实现”“边界输入”下才出现

所以真正实战中的安全审计,通常是三层结合:

  1. 人工审计:看业务、看权限、看状态机
  2. 静态分析:快速发现高频漏洞模式
  3. 动态测试/Fuzzing:验证异常路径和组合路径

前置知识

如果你准备跟着一起做,建议你至少熟悉:

  • Solidity 基础语法
  • EVM 调用模型
  • ERC-20 常见交互方式
  • msg.sendermsg.valuecalldelegatecall
  • 单元测试工具,例如 Hardhat 或 Foundry

如果这些概念还不稳,也没关系,本文会尽量边讲边解释。


环境准备

下面给出一个轻量但实用的审计环境。为了便于复现,我选择 Hardhat + Slither 这条组合:

  • Hardhat:编译、测试、部署本地合约
  • Slither:静态分析
  • Node.js:脚本支持
  • Solidity:示例合约

安装基础环境

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

初始化一个 JavaScript 项目即可。

再安装 Slither。它依赖 Python 环境:

pip install slither-analyzer

如果你本机没有 solc 多版本管理,可以再装一个:

pip install solc-select
solc-select install 0.8.20
solc-select use 0.8.20

核心原理

智能合约审计,不是“看有没有敏感词”,而是围绕以下几个核心问题展开:

1. 资产能否被非预期转移

最先要问的永远是:
谁能转钱?在什么条件下转?转账前后状态是否一致?

比如:

  • 用户提现时有没有先更新余额
  • 管理员权限能否被篡改
  • 外部合约回调时,能否反复进入关键逻辑

2. 状态机是否严格

很多协议类合约,其实本质是一个状态机:

  • 创建
  • 激活
  • 结算
  • 关闭

如果状态切换不严格,就会出现:

  • 重复领取奖励
  • 未开始阶段提前执行
  • 已结束任务再次提交
  • 多次初始化

3. 权限边界是否清晰

权限问题不只是 onlyOwner 有没有加。还包括:

  • 初始化函数是否可被任意人调用
  • 代理合约升级权限是否安全
  • 白名单设置是否有绕过路径
  • 管理员是否能误操作导致锁死

4. 外部交互是否可信

链上世界一个经典问题是:你调用的对象,未必按你想象工作

例如:

  • 非标准 ERC-20 不返回 bool
  • 恶意合约在回调中重新进入
  • delegatecall 修改了当前存储
  • 预言机延迟或被操纵

常见漏洞识别

下面选几类最值得优先检查的问题。

漏洞一:重入攻击

这是经典中的经典。典型错误顺序是:

  1. 先向外部地址转账
  2. 再更新内部余额

如果接收方是恶意合约,就能在回调里再次调用提现函数。

sequenceDiagram
    participant U as 用户/攻击合约
    participant V as 漏洞合约
    U->>V: withdraw()
    V->>U: call{value: amount}
    U->>V: fallback 中再次调用 withdraw()
    V-->>U: 重复转账

漏洞二:权限控制缺失

比如管理员函数没加限制:

function setOwner(address newOwner) external {
    owner = newOwner;
}

这类漏洞不复杂,但后果非常严重。

漏洞三:未检查外部调用返回值

即使 Solidity 0.8 以后很多边界更安全了,外部调用仍然要明确检查:

  • call
  • token transfer
  • token transferFrom

如果不检查结果,可能出现“逻辑上成功、实际上失败”。

漏洞四:业务逻辑漏洞

这是静态分析最难搞定的一类。比如:

  • 奖励可重复领取
  • 清算价格窗口不合理
  • 抵押率判断顺序错误
  • 初始化函数可重复执行

这类问题常常不体现在语法层,而体现在条件组合里。


实战代码(可运行)

下面我用一个小例子演示:先写一个存在重入漏洞的合约,再写攻击合约和修复版,最后用测试验证。

示例 1:存在漏洞的银行合约

新建 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(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;
    }
}

这里的关键问题是:先转账,后扣余额


示例 2:攻击合约

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

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

示例 3:修复版合约

新建 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 {
        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;
    }
}

修复点有两个:

  1. 使用 Checks-Effects-Interactions 顺序
  2. 增加 nonReentrant

这两步最好一起做,不建议只靠其中一个。


测试验证

新建 test/reentrancy.js

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

describe("Reentrancy Audit 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();

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

  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

如果环境正常,你会看到:

  • VulnerableBank 被攻击成功
  • SafeBank 攻击失败

审计流程怎么搭:从人工检查到自动化检测

实际项目里,审计不该靠“某个专家看一遍”。更靠谱的是建立一个持续执行的检测链路。

flowchart TD
    A[代码提交] --> B[格式化与编译]
    B --> C[单元测试]
    C --> D[静态分析 Slither]
    D --> E[Fuzz/属性测试]
    E --> F[人工审计清单复核]
    F --> G[审计报告与修复验证]

第一步:编译和基础测试

先确保最基本的事情成立:

  • 能编译
  • 单元测试通过
  • 核心流程有测试
  • 失败路径有测试

第二步:静态分析

运行 Slither:

slither contracts/VulnerableBank.sol

或者对整个项目跑:

slither .

Slither 常能发现:

  • 重入风险
  • 未初始化存储指针
  • 错误的可见性
  • 危险的低级调用
  • 常量可优化项
  • 死代码

不过要注意,Slither 的结果不能“全信也不能无视”。
我的习惯是把结果分成三类:

  1. 必须修:高危、明确可利用
  2. 需人工确认:可能误报
  3. 工程优化项:不影响安全但值得整理

第三步:增加属性测试 / Fuzzing

单元测试是“我预设输入去测”;Fuzzing 是“工具帮我乱试边界”。

如果你使用 Foundry,这一步会更顺手;如果仍在 Hardhat,也可以结合其他工具。核心思路是给出不变量,例如:

  • 合约总资产不应凭空减少
  • 非 owner 不能执行管理员操作
  • 用户提取金额不能超过自己的净存款
  • 初始化函数只能成功一次

第四步:人工审计清单

这一层不能省。自动化工具抓不到所有业务漏洞。

推荐按下面几个维度检查:

  • 权限
  • 资金流
  • 状态迁移
  • 外部调用
  • 数学边界
  • 升级/初始化
  • 紧急暂停与恢复策略

自动化检测脚本示例

如果你希望把流程接进 CI,可以先写一个简单脚本。

新建 scripts/audit.sh

#!/usr/bin/env bash
set -e

echo "==> compile"
npx hardhat compile

echo "==> test"
npx hardhat test

echo "==> slither"
slither . || true

echo "==> audit pipeline done"

给执行权限:

chmod +x scripts/audit.sh
./scripts/audit.sh

这里我故意把 slither . || true 保留了。原因很实际:

  • 在团队早期接入阶段,静态分析可能报很多历史问题
  • 如果直接让 CI 因全部告警失败,团队容易立刻把工具关掉
  • 更合理的做法是:先接入、再分级治理

等你们告警收敛后,再逐步改成“高危失败即阻断”。


审计视角下的代码检查框架

为了避免“看着看着就漏”,我建议固定用一套框架。

classDiagram
    class AuditChecklist {
        +权限控制
        +资金流向
        +状态机完整性
        +外部调用安全
        +数学与边界
        +升级与初始化
        +事件与可观测性
    }

    class Permission {
        +onlyOwner
        +role based access
        +init guard
    }

    class FundFlow {
        +deposit withdraw
        +accounting consistency
        +unexpected token behavior
    }

    class ExternalCall {
        +call delegatecall
        +reentrancy
        +return value check
    }

    AuditChecklist --> Permission
    AuditChecklist --> FundFlow
    AuditChecklist --> ExternalCall

你可以把它理解成一个“不会漏大项”的脑内模板。


常见坑与排查

这部分非常实战,我尽量写得接地气一点。

坑 1:以为 Solidity 0.8+ 就不会有整数问题

确实,0.8+ 默认带溢出检查,很多老问题少了。
但这不等于数学安全就万事大吉。你仍要检查:

  • 精度损失
  • 除法截断
  • 价格换算顺序
  • 费率计算时的舍入偏差

尤其是 DeFi 合约,精度错误最后会变成套利入口。

排查建议:

  • 用极小值、极大值测试
  • 用不整除数值测试
  • 检查先乘后除还是先除后乘

坑 2:只看 ETH 转账,不看 Token 行为差异

很多人把 ERC-20 当成“和 ETH 一样”。这是典型踩坑点。

现实里你会遇到:

  • transfer 不返回 bool
  • fee-on-transfer token
  • rebasing token
  • 黑名单 token
  • 回调型 token

排查建议:

  • 不要假设 token 行为绝对标准
  • 资金核算以“实际到账”为准
  • 使用成熟安全库做 token 交互

坑 3:升级合约把初始化暴露了

代理模式中最危险的问题之一是:

  • 实现合约未禁用初始化
  • 代理初始化函数能被重复调用
  • 升级权限控制不严

这类问题往往一旦被利用,直接就是控制权丢失。

排查建议:

  • 检查 initializer / reinitializer
  • 检查实现合约构造中是否做禁用初始化
  • 升级函数必须加严格权限控制

坑 4:以为测试通过就代表安全

我自己就踩过这个坑。
单元测试通常覆盖的是“正常业务路径”,而攻击发生在“异常组合路径”。

排查建议:

  • 给每个资金函数写失败路径测试
  • 尝试跨函数组合调用
  • 针对边界状态写 invariant

坑 5:把告警全当误报

静态工具确实会误报,但“全部忽略”通常比“多看几眼”危险得多。

更好的方式:

  • 为每条告警打标签:高危 / 待确认 / 忽略
  • 在 PR 中写清楚忽略依据
  • 对重复误报建立团队规则,而不是口头跳过

安全/性能最佳实践

这一节我按“真正值得落地”的方式来总结。

1. 遵守 CEI 模式

即:

  1. Checks:先校验条件
  2. Effects:先修改内部状态
  3. Interactions:最后与外部交互

这不是万能公式,但对多数提现、领取、结算逻辑都非常重要。


2. 关键函数加重入保护

尤其是这些函数:

  • 提现
  • 领取奖励
  • 清算
  • 任意外部调用后再写状态的函数

如果合约复杂,建议优先使用成熟库里的重入保护实现。


3. 权限最小化

不要让 owner 拥有过多不可逆能力。
更稳妥的做法是:

  • 分角色授权
  • 敏感操作增加 timelock
  • 升级与资金提取权限分离
  • 对高危操作发事件并留审计痕迹

4. 对外部依赖保持“不信任”假设

包括:

  • token
  • 预言机
  • 回调接收者
  • 第三方协议合约

任何外部依赖都要假设它可能:

  • 失败
  • 延迟
  • 回调攻击
  • 返回异常值

5. 先做可观测性,再做排障

很多项目出问题后第一反应是:日志太少,根本不知道哪一步错了。

建议关键路径都发事件:

  • 存款
  • 提现
  • 权限变更
  • 升级
  • 紧急暂停
  • 参数修改

事件不是直接提升安全,但能大幅提升排查效率。


6. 自动化检测要“持续运行”,而不是上线前跑一次

真正有效的流程是:

  • 每次 PR 自动编译与测试
  • 每次合并自动静态分析
  • 每个版本发布前跑完整审计清单
  • 高危修改必须人工复核

安全不是某次活动,而是一条流水线。


逐步验证清单

如果你准备在自己的项目里落地,可以直接按这份清单走:

基础层

  • 合约可稳定编译
  • 核心业务路径有单元测试
  • 失败路径有测试
  • 关键状态变更有事件

安全层

  • 提现/领取逻辑检查重入
  • 管理员函数检查权限
  • 初始化函数检查是否只能执行一次
  • 外部调用结果有检查
  • token 交互考虑非标准行为

工程层

  • 接入静态分析工具
  • CI 自动跑测试
  • 告警有分级处理
  • 修复后有回归测试

发布层

  • 高危改动进行人工审计
  • 升级流程有回滚或暂停策略
  • 部署参数经过二次核对
  • 审计结论有留档

一个更实用的落地建议

如果你是中型团队,不要一上来就追求“全自动、全覆盖、零误报”。这通常会把流程做得很重,最后没人用。

更现实的路线是:

  1. 先把测试与 Slither 接进 CI
  2. 把高频漏洞清单固化到 code review
  3. 对资金相关模块做重点人工审计
  4. 逐步补属性测试和更深的动态分析

也就是说,先追求“稳定执行”,再追求“极致完美”。


总结

智能合约安全审计的关键,不在于记住多少漏洞名词,而在于建立一套稳定的方法:

  • 资金流、权限、状态机、外部调用四个维度看代码
  • 用静态分析快速抓高频问题
  • 用测试和 Fuzzing 验证边界路径
  • 用人工审计识别业务逻辑漏洞
  • 把这些动作沉淀成自动化流程,而不是临上线前突击

如果你刚开始搭审计流程,我建议先完成三件事:

  1. 给资金相关函数补全失败路径测试
  2. 在 CI 中接入 Slither
  3. 建立一份团队统一的审计检查表

做到这三步,你们的合约安全基线通常就会比“只靠经验看代码”稳很多。

安全这件事没有银弹,但有方法。方法一旦固定下来,很多看似复杂的问题,其实都能被提前发现。


分享到:

上一篇
《Java 中基于 CompletableFuture 与线程池的异步任务编排实战与性能优化-309》
下一篇
《从浏览器 DevTools 到脚本复现:中级开发者实战拆解 Web 逆向中的签名参数生成逻辑》