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

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

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

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

智能合约一旦部署,往往就意味着“代码即规则”。规则写错了,损失通常不是页面报个错那么简单,而是真金白银直接没了。很多团队在上线前会做功能测试,却低估了安全审计的复杂度:能跑通,不等于足够安全

这篇文章我会从实战视角,带你走一遍智能合约安全审计流程:先识别高频漏洞,再用工具把检测流程自动化,最后把“人工经验”尽可能沉淀成可重复执行的流水线。文章面向已经有一定 Solidity 和工程经验的读者,重点不在概念堆砌,而是“怎么落地”。


背景与问题

智能合约安全审计和传统 Web 安全有几个很不一样的地方:

  1. 部署后很难修复
    即使能通过代理升级,也会引入更多权限与存储布局风险。

  2. 攻击面更偏业务逻辑
    很多漏洞不是单纯语法问题,而是状态机设计、权限模型和资产流转路径出了问题。

  3. 攻击者可以无限次试错
    你的合约是公开的,字节码和 ABI 都可分析,攻击者有足够时间构造交易序列。

  4. 损失可被快速自动化放大
    MEV、闪电贷、批量调用等机制,让一个小漏洞可以在几秒内变成系统性事故。

所以,真正有效的审计流程,通常不是“跑一个扫描器看结果”,而是下面这三层叠加:

  • 规则层:常见漏洞识别
  • 语义层:业务逻辑与权限流分析
  • 工程层:自动化检测、回归和发布门禁

先看一个审计流程全景图。

flowchart TD
    A[需求与资产梳理] --> B[威胁建模]
    B --> C[代码静态扫描]
    C --> D[人工审计]
    D --> E[单元测试与属性测试]
    E --> F[Fuzz/符号执行]
    F --> G[修复与复测]
    G --> H[CI门禁与发布]

前置知识与环境准备

如果你准备边看边做,建议本地准备如下环境:

  • Node.js 18+
  • npm 或 pnpm
  • Solidity 0.8.x
  • Hardhat
  • Slither(需要 Python 3)
  • 可选:Mythril、Echidna

初始化项目

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

选择创建一个 JavaScript 项目,然后安装 OpenZeppelin:

npm install @openzeppelin/contracts

如果要装 Slither:

python3 -m pip install slither-analyzer

核心原理

做智能合约安全审计时,我通常会先抓四个核心问题:

1. 资产能否被越权转移

这是最直接的问题。比如:

  • onlyOwner 是否缺失
  • 管理员是否可被错误替换
  • 签名验证是否可伪造
  • delegatecall 是否可被恶意利用

2. 状态更新顺序是否安全

典型场景是重入攻击。如果你先转账,后更新状态,就给了攻击者在回调中再次进入函数的机会。

经典原则是:

  • Checks
  • Effects
  • Interactions

也就是先检查条件,再更新状态,最后和外部合约交互。

3. 数值与边界是否被正确处理

虽然 Solidity 0.8 以后内置了整数溢出检查,但仍有很多边界问题:

  • 精度丢失
  • 除零
  • 舍入偏差
  • 单位混用(wei / ether)
  • 时间戳依赖

4. 业务状态机是否闭环

很多严重漏洞不是“某一行代码错了”,而是流程没设计完整:

  • 是否允许重复领取奖励
  • 是否允许未初始化就执行关键操作
  • 是否允许在错误阶段提现
  • 是否支持“部分完成”却未正确记账

下面这张图可以帮助你理解“从代码问题到资金风险”的映射。

flowchart LR
    A[代码缺陷] --> B[状态异常]
    B --> C[权限绕过/余额错乱]
    C --> D[资产转移异常]
    D --> E[资金损失]

常见漏洞识别

下面列几个审计中最常见、也最值得优先检查的问题。

重入攻击

当合约向外部地址转账或调用外部合约时,如果自身状态尚未更新,攻击者就可能在回调中重复调用。

访问控制缺失

比如本应只有管理员能执行的函数,被写成了 public 且没有鉴权。

tx.origin 误用

tx.origin 很容易在跨合约调用中被利用,权限判断应优先使用 msg.sender

未检查低级调用返回值

calldelegatecallstaticcall 返回值如果不检查,合约可能误以为调用成功。

DoS 与 gas 风险

例如在单个函数里遍历一个可能无限增长的数组,后期就可能因 gas 不足而不可调用。

伪随机数

使用 block.timestampblockhash 等链上变量生成随机数,在高价值场景中基本都不安全。

签名重放

如果签名消息没有包含链 ID、合约地址、nonce 等上下文,就可能被跨链或跨场景重放。


实战代码(可运行)

下面我们故意写一个有漏洞的合约,用来演示审计思路和自动化检测。

1)脆弱示例合约

文件:contracts/VulnerableBank.sol

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

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

    constructor() {
        owner = msg.sender;
    }

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

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

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

        balances[msg.sender] -= amount;
    }

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

这个合约有两个明显问题:

  • withdraw 里先转账后扣余额,存在重入风险
  • emergencyWithdrawAll 使用了 tx.origin 做权限判断

2)攻击合约

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

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

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

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

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

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

3)测试复现漏洞

文件:test/vulnerableBank.js

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

describe("VulnerableBank", function () {
  it("should be drained by reentrancy 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());
    expect(bankBalance).to.equal(0n);
  });
});

运行测试:

npx hardhat test

如果环境正常,你会看到这个测试通过,意味着这个银行合约真的被“吸干”了。


漏洞修复

接下来我们把它修掉,顺手把权限控制也改正确。

文件:contracts/SafeBank.sol

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

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeBank is Ownable, ReentrancyGuard {
    mapping(address => uint256) public balances;

    constructor() Ownable(msg.sender) {}

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

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

        balances[msg.sender] -= amount;

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

    function emergencyWithdrawAll(address payable to) external onlyOwner {
        require(to != address(0), "zero address");
        to.transfer(address(this).balance);
    }
}

修复点很明确:

  • 使用 nonReentrant
  • 先更新余额,再执行外部调用
  • 使用 onlyOwner 替代 tx.origin
  • 管理员提现目标地址显式传入并校验

自动化检测流程搭建

很多团队的问题不是“不知道漏洞”,而是“知道,但没法稳定执行”。所以接下来重点是把审计能力流程化。

1)静态分析:Slither

在项目根目录运行:

slither . --print human-summary

或者指定合约:

slither contracts/VulnerableBank.sol

Slither 常能发现:

  • 重入风险
  • tx.origin 使用
  • 未初始化状态变量
  • 高复杂度函数
  • 未受保护的敏感函数

如果你要在 CI 里直接使用:

slither . --fail-high --fail-medium

这样在发现高危或中危问题时,命令会直接失败。


2)测试与覆盖率

除了单元测试,建议至少覆盖这三类测试:

  • 正常流程测试
  • 权限边界测试
  • 异常输入测试

示例:test/safeBank.js

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

describe("SafeBank", function () {
  it("should allow normal deposit and withdraw", async function () {
    const [owner, user] = await ethers.getSigners();

    const Bank = await ethers.getContractFactory("SafeBank", owner);
    const bank = await Bank.deploy();
    await bank.waitForDeployment();

    await bank.connect(user).deposit({
      value: ethers.parseEther("2")
    });

    await bank.connect(user).withdraw(ethers.parseEther("1"));

    const left = await bank.balances(user.address);
    expect(left).to.equal(ethers.parseEther("1"));
  });

  it("should restrict emergencyWithdrawAll to owner", async function () {
    const [owner, user] = await ethers.getSigners();

    const Bank = await ethers.getContractFactory("SafeBank", owner);
    const bank = await Bank.deploy();
    await bank.waitForDeployment();

    await expect(
      bank.connect(user).emergencyWithdrawAll(user.address)
    ).to.be.reverted;
  });
});

3)把检测接入 CI

一个简单但实用的思路是:

  1. 安装依赖
  2. 编译合约
  3. 运行测试
  4. 运行静态分析
  5. 关键规则未通过则阻止合并

下面是一个 GitHub Actions 示例。

文件:.github/workflows/security.yml

name: Smart Contract Security Check

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test-and-scan:
    runs-on: ubuntu-latest

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

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

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

      - name: Install Node dependencies
        run: npm ci

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

      - name: Compile
        run: npx hardhat compile

      - name: Test
        run: npx hardhat test

      - name: Slither Scan
        run: slither . --fail-high --fail-medium

整个自动化流程可以概括成下面这样:

sequenceDiagram
    participant Dev as 开发者
    participant Git as Git仓库
    participant CI as CI流水线
    participant Tool as Slither/Tests
    participant Reviewer as 审计者

    Dev->>Git: 提交合约代码
    Git->>CI: 触发检查
    CI->>Tool: 编译、测试、静态扫描
    Tool-->>CI: 输出风险结果
    CI-->>Reviewer: 生成报告/阻断合并
    Reviewer-->>Dev: 修复建议

逐步验证清单

如果你想自己带团队落地,我建议按这个顺序来,不容易乱。

第一步:先梳理资产与权限

列清楚:

  • 哪些函数会转移资产
  • 哪些角色可以调用
  • 谁能升级、暂停、改参数
  • 哪些外部协议被信任

第二步:人工过一遍高风险模式

重点看:

  • 外部调用前后状态变更
  • 权限修饰器是否完整
  • 签名验证是否包含 nonce / deadline / chainId
  • 循环和数组长度是否可能失控

第三步:补测试

至少补齐:

  • 非法调用者测试
  • 极值金额测试
  • 重复调用测试
  • 回滚路径测试

第四步:接入自动扫描

推荐最小组合:

  • Hardhat test
  • Slither

如果项目金额较大,再考虑加入:

  • Echidna 属性测试
  • Mythril 符号执行
  • Foundry fuzzing

第五步:把规则变成门禁

明确哪些问题必须阻断上线:

  • 高危未修复
  • 权限设计未确认
  • 升级存储布局未审查
  • 核心资产函数覆盖率不足

常见坑与排查

这部分很重要,因为很多工具“跑不起来”本身就会劝退团队。

坑 1:Slither 扫描失败,提示编译环境不一致

现象:

  • 扫描时报 Solidity 版本冲突
  • OpenZeppelin 依赖找不到
  • 编译器参数不匹配

排查:

  • 检查 hardhat.config.js 中的编译器版本
  • 确认 node_modules 已安装完整
  • 确认合约 pragma 与配置兼容

示例配置:

require("@nomicfoundation/hardhat-toolbox");

module.exports = {
  solidity: {
    version: "0.8.20",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  }
};

坑 2:修了重入,结果业务流程坏了

有时候引入 nonReentrant 后,某些函数之间不能再相互调用,尤其是以前写成“一个外部函数调另一个外部函数”的模式。

建议:

  • 把核心逻辑沉到 internal 私有流程
  • 暴露的 external 函数只做参数入口与权限控制

坑 3:只相信工具报告,不做业务审查

我见过不少项目,Slither 扫了一遍“没高危”,团队就觉得可以上线。实际问题出在:

  • 清算逻辑可被价格操纵
  • 奖励分配可被抢跑
  • 提现额度计算有精度漏洞

这些问题,单靠通用规则很难完全识别,必须结合协议机制审查。


坑 4:把 transfer 当成绝对安全方案

过去常说 transfer 有 2300 gas 限制,似乎天然更安全。但在现代 EVM 环境下,它并不是万能解法,反而可能造成兼容性问题。更稳妥的方式通常是:

  • 使用 call
  • 检查返回值
  • 结合重入防护和状态先更新策略

安全/性能最佳实践

安全和性能在合约里经常要一起考虑,因为 gas 成本也会反过来影响可用性。

安全最佳实践

  1. 优先复用成熟库
    如 OpenZeppelin 的 OwnableAccessControlReentrancyGuard

  2. 遵循最小权限原则
    不要让一个角色拥有过多控制权,尤其是升级、提款和参数调整权限。

  3. 关键操作增加事件日志
    方便事后追踪与链上监控。

  4. 对外部依赖做隔离
    接第三方协议时,尽量把外部交互收口到少数模块中。

  5. 设计暂停机制
    对高价值协议,pause/circuit breaker 非常有必要,但也要防止管理员滥用。

性能最佳实践

  1. 避免无界循环
  2. 减少重复 SLOAD / SSTORE
  3. 热点变量做缓存
  4. 慎用复杂链上计算
  5. 测试极端 gas 场景

下面是一个审计关注点的简化分类图:

classDiagram
    class AuditFocus {
      +AccessControl
      +Reentrancy
      +Arithmetic
      +BusinessLogic
      +GasRisk
      +UpgradeSafety
    }

一套实用的审计落地策略

如果你的团队资源有限,我建议不要一上来就追求“全自动、全覆盖”。更现实的路径是:

小团队版本

  • 手工审查核心资产函数
  • 单元测试覆盖正常/异常/权限路径
  • 接入 Slither 到 CI
  • 发布前做一次清单式复核

中型团队版本

  • 增加属性测试和 fuzz
  • 对升级代理单独建检查规则
  • 引入代码评审模板
  • 对链上事件做监控和报警

高价值协议版本

  • 内部审计 + 外部审计双轨
  • 上线前做攻击路径演练
  • 配置时间锁、多签和应急暂停
  • 建立漏洞响应与赏金机制

边界条件也要说清楚:自动化检测能大幅降低漏检率,但不可能替代人工审计。
尤其是 AMM、借贷、衍生品、质押收益类协议,真正的高危问题往往都在业务逻辑里。


总结

智能合约安全审计,最怕两种极端:

  • 只靠人工经验,不可复制
  • 只靠工具扫描,忽略业务逻辑

更稳妥的做法,是把两者结合起来:

  1. 先从资产、权限、状态机三个维度建立审计思路
  2. 针对重入、权限控制、低级调用、DoS、签名重放等常见漏洞做系统检查
  3. 用测试、静态分析和 CI 门禁把检测流程自动化
  4. 对高价值协议补充 fuzz、符号执行和外部审计

如果你刚开始搭建团队审计流程,我的建议很直接:

  • 先把最危险的函数清单列出来
  • 先把 Slither 和测试接进 CI
  • 先让每次合并都经过自动安全检查
  • 再逐步补业务逻辑审计方法和测试深度

这样做不花哨,但真的有效。而且一旦形成工程化习惯,安全审计就不再是上线前临时抱佛脚,而会变成你日常开发流程的一部分。


分享到:

上一篇
《Docker 多阶段构建与镜像瘦身实战:从构建加速到安全优化的完整方案》
下一篇
《大模型推理加速实战:从 KV Cache、量化到连续批处理的性能优化路径》