返回 SC 笔记
SC Day 65

手动审计练习#1 - 审计一个Token合约

### 1. 手动审计的价值

2026-06-15
第三阶段:安全审计
manual-audittoken-contractvulnerability-analysisaudit-reportERC20

日期: 2026-06-15 方向: Solidity / Audit 阶段: 第三阶段:安全审计 标签: #manual-audit #token-contract #vulnerability-analysis #audit-report #ERC20


今日目标

类型内容
学习掌握手动审计的系统化流程:从代码阅读到写出完整审计报告
实操对一个包含5+漏洞的Token合约进行完整手动审计
产出一份完整的审计报告(含所有发现、严重性评级、修复建议)

核心概念

1. 手动审计的价值

自动化工具(Slither、Mythril、Aderyn)能捕捉到大量已知模式的漏洞,但手动审计是不可替代的。原因如下:

  • 业务逻辑漏洞:自动化工具无法理解合约的业务意图,只有人类审计员能判断"代码做的事"和"代码应该做的事"之间的差距
  • 跨合约交互:复杂的合约间调用关系、状态依赖,工具难以完整建模
  • 经济模型缺陷:Token通胀/通缩机制设计、费用计算中的精度问题,需要审计员具备业务理解
  • 权限模型审查:owner/admin的特权操作是否合理,需要人为判断

2. 系统化审计流程

一个专业的手动审计流程包含以下阶段:

Phase 1: 准备阶段(Scoping)

在开始阅读代码之前,先回答这些问题:

1. 这个合约的目的是什么?(Token、DEX、借贷、NFT...)
2. 用户有哪些?(普通用户、管理员、合约...)
3. 核心资产流是什么?(谁转Token给谁、什么时候转、多少)
4. 外部依赖有哪些?(调用了哪些外部合约)
5. 有没有文档或规范可以参考?

Phase 2: 代码阅读(Code Review)

系统化的代码阅读方法——不要随机跳着看,要有策略:

策略一:自顶向下(Top-Down)

1. 先看合约的继承关系和整体结构
2. 看 state variables(存储了什么数据)
3. 看 constructor/initializer(初始化逻辑)
4. 看外部可调用函数(external/public),这是攻击面
5. 看内部函数(internal/private),理解实现细节
6. 看 modifier 和 access control
7. 看 event 定义(是否能正确追踪关键操作)

策略二:按功能模块(Feature-Based)

1. 识别核心功能模块(如:铸造、转账、销毁、暂停)
2. 逐个模块审查,确保每个模块的完整性
3. 检查模块间的交互是否安全

策略三:按攻击面(Attack-Surface-Based)

1. 列出所有 external/public 函数
2. 对每个函数问:谁能调用?输入是什么?能造成什么后果?
3. 重点关注涉及资金转移的函数

Phase 3: 逐行分析(Line-by-Line Analysis)

对每一行代码进行如下检查:

// 对于每个函数,检查:
// [ ] 访问控制:谁能调用?权限是否正确?
// [ ] 输入验证:参数是否经过校验?边界条件?
// [ ] 状态变更:是否遵循 CEI 模式?(Checks-Effects-Interactions)
// [ ] 重入风险:是否有外部调用?调用后是否修改了状态?
// [ ] 整数运算:是否可能溢出/下溢?(0.8.x 自动检查,但 unchecked 块要注意)
// [ ] 返回值:外部调用的返回值是否检查了?
// [ ] Event:关键操作是否发出了事件?
// [ ] Gas:是否有 unbounded loop?

Phase 4: 编写审计报告(Report Writing)

每个发现(Finding)遵循以下模板:

### [S/H/M/L/I-序号] 发现标题

**严重性**: Critical / High / Medium / Low / Informational
**文件**: contracts/Token.sol
**行号**: L42-L55

**描述**:
[详细描述这个问题是什么]

**影响**:
[这个问题会导致什么后果?谁会受影响?损失多大?]

**复现步骤**:
1. 步骤一
2. 步骤二
3. ...

**推荐修复**:
[给出具体的代码修改建议]

严重性分级标准

级别定义示例
Critical可直接导致资金损失,无需特殊条件任何人可铸造无限Token
High可导致资金损失或严重功能异常,需要特定条件Owner可无限铸造并卖出
Medium可导致功能异常或潜在资金风险费率设置无上限
Low不影响核心功能,但属于代码质量问题缺少事件发出
Informational建议性改进代码风格、Gas优化

代码实战

待审计合约:MomoToken

以下是一个"看起来正常"的ERC20 Token合约,但隐藏了7个安全问题。请先尝试自己找出来,然后对照下方的审计报告。

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

/**
 * @title MomoToken
 * @notice A fee-on-transfer token with minting, burning, and pause functionality.
 * @dev Implements ERC20 standard with additional features.
 */
contract MomoToken {
    string public name;
    string public symbol;
    uint8 public decimals = 18;
    uint256 public totalSupply;

    address public owner;
    address public feeRecipient;
    uint256 public feePercent; // basis points, e.g., 100 = 1%
    bool public paused;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    // Blacklist
    mapping(address => bool) public blacklisted;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

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

    modifier whenNotPaused() {
        require(!paused, "Paused");
        _;
    }

    constructor(
        string memory _name,
        string memory _symbol,
        uint256 _initialSupply,
        address _feeRecipient,
        uint256 _feePercent
    ) {
        name = _name;
        symbol = _symbol;
        owner = msg.sender;
        feeRecipient = _feeRecipient;
        feePercent = _feePercent;
        _mint(msg.sender, _initialSupply);
    }

    /// @notice Transfer tokens with fee deduction
    function transfer(address to, uint256 amount) external whenNotPaused returns (bool) {
        require(!blacklisted[msg.sender], "Sender blacklisted");
        require(to != address(0), "Transfer to zero address");

        uint256 fee = (amount * feePercent) / 10000;
        uint256 netAmount = amount - fee;

        balanceOf[msg.sender] -= amount;
        balanceOf[to] += netAmount;
        balanceOf[feeRecipient] += fee;

        emit Transfer(msg.sender, to, netAmount);
        // BUG: fee transfer event missing

        return true;
    }

    /// @notice Transfer tokens from an approved address
    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) external whenNotPaused returns (bool) {
        require(!blacklisted[from], "Sender blacklisted");

        uint256 currentAllowance = allowance[from][msg.sender];
        require(currentAllowance >= amount, "Insufficient allowance");

        allowance[from][msg.sender] = currentAllowance - amount;

        // Duplicated transfer logic (should use internal function)
        uint256 fee = (amount * feePercent) / 10000;
        uint256 netAmount = amount - fee;

        balanceOf[from] -= amount;
        balanceOf[to] += netAmount;
        balanceOf[feeRecipient] += fee;

        emit Transfer(from, to, netAmount);

        return true;
    }

    /// @notice Approve spender
    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    /// @notice Owner can mint new tokens to any address
    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }

    /// @notice Any holder can burn their own tokens
    function burn(uint256 amount) external {
        require(balanceOf[msg.sender] >= amount, "Insufficient balance");
        balanceOf[msg.sender] -= amount;
        totalSupply -= amount;
        emit Transfer(msg.sender, address(0), amount);
    }

    /// @notice Owner can burn tokens from any address
    function burnFrom(address from, uint256 amount) external onlyOwner {
        balanceOf[from] -= amount;
        totalSupply -= amount;
        emit Transfer(from, address(0), amount);
    }

    /// @notice Set the fee percentage (basis points)
    function setFeePercent(uint256 _feePercent) external onlyOwner {
        feePercent = _feePercent;
    }

    /// @notice Set the fee recipient
    function setFeeRecipient(address _feeRecipient) external onlyOwner {
        feeRecipient = _feeRecipient;
    }

    /// @notice Pause/unpause transfers
    function setPaused(bool _paused) external onlyOwner {
        paused = _paused;
    }

    /// @notice Add/remove address from blacklist
    function setBlacklist(address account, bool status) external onlyOwner {
        blacklisted[account] = status;
    }

    /// @notice Transfer ownership
    function transferOwnership(address newOwner) external onlyOwner {
        owner = newOwner;
    }

    function _mint(address to, uint256 amount) internal {
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }
}

完整审计报告

审计概要

项目详情
合约名MomoToken
审计范围MomoToken.sol (单文件)
Solidity版本^0.8.20
审计日期2026-06-15
审计员Manual Review

发现汇总

编号标题严重性
H-01Owner可通过无限制mint铸造任意数量TokenHigh
H-02burnFrom无需授权即可销毁任意用户TokenHigh
H-03feePercent无上限,可设为100%导致用户资金全部被截取High
M-01transferOwnership缺少zero address检查,可导致合约失控Medium
M-02feeRecipient可设为零地址,导致Token被永久销毁Medium
L-01transfer函数中feeRecipient的Transfer事件缺失Low
L-02transferFrom中blacklist未检查接收方(to)和调用方(msg.sender)Low
I-01transfer和transferFrom存在重复逻辑,应抽取内部函数Informational
I-02approve存在前端竞态攻击(front-running)风险Informational

H-01: Owner可通过无限制mint铸造任意数量Token

严重性: High 文件: MomoToken.sol 行号: L105-L107

描述: mint 函数没有任何铸造上限(minting cap)。Owner可以随时铸造任意数量的Token。虽然在某些场景下Owner铸造权是正常的,但对于一个可交易的Token来说,无上限的铸造权意味着Owner可以在不经通知的情况下大量增发Token,稀释所有持有者的价值。

这是典型的中心化风险 (Centralization Risk),也是Rug Pull的常见手段之一。

影响:

  • Owner可铸造大量Token并在市场上抛售(Rug Pull)
  • 所有Token持有者的资产被稀释
  • 严重时可导致Token价格归零

复现步骤:

  1. Owner调用 mint(ownerAddress, 1_000_000_000e18) 铸造10亿Token
  2. Owner在DEX上卖出所有铸造的Token
  3. Token价格暴跌,其他持有者蒙受损失

推荐修复:

uint256 public constant MAX_SUPPLY = 100_000_000 * 10**18; // 1亿上限

function mint(address to, uint256 amount) external onlyOwner {
    require(totalSupply + amount <= MAX_SUPPLY, "Exceeds max supply");
    _mint(to, amount);
}

或者,更彻底的做法是移除 mint 函数,在 constructor 中一次性铸造所有Token。


H-02: burnFrom无需授权即可销毁任意用户Token

严重性: High 文件: MomoToken.sol 行号: L116-L120

描述: burnFrom 函数仅检查调用者是否为 owner,但未检查 allowance。Owner可以在未经用户授权的情况下销毁任意用户的Token。

标准的 ERC20 burnFrom 应该要求调用者拥有足够的 allowance,并在销毁后扣减 allowance。

// 当前实现 — 问题代码
function burnFrom(address from, uint256 amount) external onlyOwner {
    balanceOf[from] -= amount;  // 直接扣减,未检查allowance
    totalSupply -= amount;
    emit Transfer(from, address(0), amount);
}

影响:

  • Owner可以无条件销毁任何用户的Token
  • 用户资产可以被强制没收
  • 这是一个严重的信任风险,等同于"冻结+没收"权限

复现步骤:

  1. 用户Alice持有 1000 Token
  2. Owner调用 burnFrom(alice, 1000)
  3. Alice的Token全部被销毁,且Alice没有任何授权操作

推荐修复:

function burnFrom(address from, uint256 amount) external {
    uint256 currentAllowance = allowance[from][msg.sender];
    require(currentAllowance >= amount, "Insufficient allowance");
    allowance[from][msg.sender] = currentAllowance - amount;

    require(balanceOf[from] >= amount, "Insufficient balance");
    balanceOf[from] -= amount;
    totalSupply -= amount;
    emit Transfer(from, address(0), amount);
}

H-03: feePercent无上限,可设为100%导致用户资金全部被截取

严重性: High 文件: MomoToken.sol 行号: L123-L125

描述: setFeePercent 函数没有对 _feePercent 设置上限。Owner可以将费率设为 10000 (100%) 甚至更高。当费率为100%时,所有转账的金额将全部作为fee发送给 feeRecipient,接收方收到0 Token。如果设为超过10000,netAmount = amount - fee 会因为 Solidity 0.8.x 的溢出检查而 revert,导致所有转账被拒绝(相当于暂停合约)。

// 当前实现 — 无上限
function setFeePercent(uint256 _feePercent) external onlyOwner {
    feePercent = _feePercent;  // 可设为任意值
}

影响:

  • 设为10000:所有转账的Token全部被截取到feeRecipient
  • 设为>10000:所有转账revert,Token被冻结
  • Owner可以先将feeRecipient设为自己,然后提高费率,截取所有流通Token

推荐修复:

uint256 public constant MAX_FEE = 1000; // 最高10%

function setFeePercent(uint256 _feePercent) external onlyOwner {
    require(_feePercent <= MAX_FEE, "Fee too high");
    uint256 oldFee = feePercent;
    feePercent = _feePercent;
    emit FeeUpdated(oldFee, _feePercent);
}

M-01: transferOwnership缺少zero address检查

严重性: Medium 文件: MomoToken.sol 行号: L140-L142

描述: transferOwnership 没有检查 newOwner 是否为零地址。如果 owner 误操作传入 address(0),合约的 owner 将变为零地址,导致所有 onlyOwner 函数永久不可调用。

function transferOwnership(address newOwner) external onlyOwner {
    owner = newOwner;  // 如果newOwner是address(0),owner将永久丢失
}

影响:

  • Owner权限永久丢失
  • mint、burnFrom、setFeePercent、setPaused、setBlacklist等功能全部不可用
  • 如果合约处于paused状态且owner丢失,Token将永久被冻结

推荐修复: 建议采用两步转移(Two-Step Transfer)模式:

address public pendingOwner;

function transferOwnership(address newOwner) external onlyOwner {
    require(newOwner != address(0), "Zero address");
    pendingOwner = newOwner;
    emit OwnershipTransferInitiated(owner, newOwner);
}

function acceptOwnership() external {
    require(msg.sender == pendingOwner, "Not pending owner");
    emit OwnershipTransferred(owner, msg.sender);
    owner = msg.sender;
    pendingOwner = address(0);
}

M-02: feeRecipient可设为零地址

严重性: Medium 文件: MomoToken.sol 行号: L128-L130

描述: setFeeRecipient 和 constructor 均未检查 _feeRecipient 是否为零地址。如果 feeRecipient 被设为 address(0),每次 transfer 时收取的 fee 会被发送到零地址,相当于被永久销毁(但不会更新 totalSupply),导致合约的余额追踪出现偏差。

影响:

  • Fee Token被发送到address(0),永久锁死
  • totalSupply 不会减少,但实际可流通量减少
  • balanceOf[address(0)] 会不断增长,干扰数据统计

推荐修复:

function setFeeRecipient(address _feeRecipient) external onlyOwner {
    require(_feeRecipient != address(0), "Zero address");
    feeRecipient = _feeRecipient;
}

L-01: Fee转账的Transfer事件缺失

严重性: Low 文件: MomoToken.sol 行号: L63-L72

描述: 在 transfer 函数中,fee 部分被转给 feeRecipient,但只发出了 Transfer(msg.sender, to, netAmount) 事件,缺少了fee部分的 Transfer(msg.sender, feeRecipient, fee) 事件。这意味着:

  • 链上的 Transfer 事件记录不完整
  • 区块浏览器、钱包、索引工具(如 The Graph)无法追踪fee流向
  • 用户看到的转账记录金额与实际余额变化不一致

transferFrom 有同样的问题。

推荐修复:

emit Transfer(msg.sender, to, netAmount);
if (fee > 0) {
    emit Transfer(msg.sender, feeRecipient, fee);
}

L-02: transferFrom中blacklist检查不完整

严重性: Low 文件: MomoToken.sol 行号: L76-L97

描述: transferFrom 只检查了 from 是否被blacklisted,但没有检查 to(接收方)和 msg.sender(调用方)。同样,transfer 只检查了 msg.sender 但没有检查 to

如果blacklist的目的是阻止特定地址参与Token流通,那么应该同时检查发送方和接收方。

推荐修复:

function transfer(address to, uint256 amount) external whenNotPaused returns (bool) {
    require(!blacklisted[msg.sender], "Sender blacklisted");
    require(!blacklisted[to], "Recipient blacklisted");
    // ...
}

function transferFrom(address from, address to, uint256 amount) external whenNotPaused returns (bool) {
    require(!blacklisted[from], "Sender blacklisted");
    require(!blacklisted[to], "Recipient blacklisted");
    require(!blacklisted[msg.sender], "Caller blacklisted");
    // ...
}

I-01: transfer和transferFrom存在重复逻辑

严重性: Informational

描述: transfertransferFrom 中有大段完全相同的转账+扣费逻辑。这违反了DRY(Don't Repeat Yourself)原则,增加了维护成本和出错风险。如果未来修改fee逻辑,需要在两个地方同时修改,容易遗漏。

推荐修复:

function _transfer(address from, address to, uint256 amount) internal {
    require(!blacklisted[from], "Sender blacklisted");
    require(!blacklisted[to], "Recipient blacklisted");
    require(to != address(0), "Transfer to zero address");

    uint256 fee = (amount * feePercent) / 10000;
    uint256 netAmount = amount - fee;

    balanceOf[from] -= amount;
    balanceOf[to] += netAmount;

    emit Transfer(from, to, netAmount);

    if (fee > 0) {
        balanceOf[feeRecipient] += fee;
        emit Transfer(from, feeRecipient, fee);
    }
}

I-02: approve存在前端竞态攻击风险

严重性: Informational

描述: 标准的 approve 函数存在已知的 front-running 问题。当用户想要将 allowance 从 N 改为 M 时,矿工/MEV bot可以在 approve 交易执行前先使用旧的 N 额度,然后在 approve 执行后再使用新的 M 额度,从而使用 N+M 的总额度。

这是ERC20标准的已知问题,不是这个合约特有的,但建议提供 increaseAllowancedecreaseAllowance 作为安全替代。

推荐修复:

function increaseAllowance(address spender, uint256 addedValue) external returns (bool) {
    allowance[msg.sender][spender] += addedValue;
    emit Approval(msg.sender, spender, allowance[msg.sender][spender]);
    return true;
}

function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool) {
    uint256 currentAllowance = allowance[msg.sender][spender];
    require(currentAllowance >= subtractedValue, "Decreased below zero");
    allowance[msg.sender][spender] = currentAllowance - subtractedValue;
    emit Approval(msg.sender, spender, allowance[msg.sender][spender]);
    return true;
}

关键要点总结

审计方法论要点

  1. 先理解再审:在开始逐行阅读前,先搞清楚合约要做什么
  2. 系统化检查:按照checklist逐项检查,不要靠"灵感"
  3. 攻击者思维:对每个函数问"如果我是攻击者,我怎么利用这个函数"
  4. 中心化风险是重点:Owner权限过大是Token合约中最常见也最严重的问题
  5. 不要放过"看起来正常"的代码:很多漏洞藏在最平常的逻辑里

本次审计发现的问题模式

模式发现通用教训
权限过大H-01, H-02Owner权限应有明确边界和约束
缺少输入验证H-03, M-01, M-02每个setter函数都要验证输入范围
事件不完整L-01所有状态变更都应有对应事件
检查不全面L-02权限/黑名单检查要覆盖所有相关角色
代码重复I-01DRY原则减少维护风险

常见误区

误区1:"Solidity 0.8.x自动检查溢出,所以不用担心数学问题"

0.8.x确实有内置的溢出检查,但这不意味着数学逻辑是正确的。本合约中 feePercent 可以被设为超过10000,虽然不会溢出(因为溢出检查会revert),但会导致所有转账失败——这本身就是一个漏洞。

误区2:"只要是onlyOwner就是安全的"

onlyOwner 只是限制了谁能调用,但没有限制调用了之后能做什么。如果owner的权限过大(如无限mint、无需授权burnFrom),那么onlyOwner反而是一个中心化风险的标志。

误区3:"Low severity的问题不重要"

事件缺失(L-01)看起来无害,但在实际运行中会导致:

  • 用户在钱包里看不到完整的交易记录
  • DeFi协议集成时无法正确追踪Token流动
  • 数据分析和审计时信息缺失

误区4:"审计只需要找Critical/High的漏洞"

真正的审计报告需要全面覆盖。Medium和Low级别的问题积累起来可能导致严重后果。完整的审计报告也能体现审计员的专业度。


面试关联

Q1: "你如何进行智能合约的手动审计?"

简短回答: 我遵循四阶段流程——Scoping确定范围和理解目的、Code Review系统化阅读代码、Line-by-Line Analysis逐行检查安全属性、Report Writing输出结构化报告。重点关注权限模型、资金流向、外部调用和输入验证。

详细回答: 首先我会理解合约的业务目的和用户角色,建立"应该做什么"的心理模型。然后按自顶向下的方式阅读代码:继承关系→状态变量→构造函数→外部函数→内部函数。对每个函数检查访问控制、输入验证、CEI模式、重入风险。最后用标准模板写报告,每个发现包含描述、影响、复现步骤和修复建议。

追问准备:

  • "手动审计和自动化工具的区别?" → 自动化工具擅长检测已知模式(重入、溢出),手动审计擅长发现业务逻辑漏洞和中心化风险。最佳实践是两者结合。
  • "审计一个合约大概需要多长时间?" → 取决于合约复杂度。一个简单的Token合约约2-4小时,一个完整的DeFi协议(如借贷)可能需要1-2周。

Q2: "Token合约中最常见的安全问题有哪些?"

回答: 最常见的五类问题是:(1) 中心化风险——Owner权限过大,如无限mint、无需授权burn;(2) 缺少输入验证——fee/rate参数无上限;(3) 转账逻辑缺陷——fee-on-transfer导致与DeFi协议不兼容;(4) approve竞态攻击;(5) 事件缺失导致链下系统无法追踪。

Q3: "如果你在审计中发现了Critical漏洞,你会怎么做?"

回答: 立即私下通知项目方,说明漏洞详情和影响范围,建议在修复前不要公开。如果是已部署的合约且存在实际资金风险,建议项目方通过timelock或pause功能暂停合约,同时准备修复方案。修复后应进行再次审计确认修复有效且未引入新问题。


参考资源

资源说明
Solcurity社区维护的Solidity安全检查清单
Smart Contract Security Verification Standard安全验证标准
Consensys Best Practices智能合约安全最佳实践
OpenZeppelin ERC20标准实现参考
Trail of Bits Audit Reports专业审计报告示例
Code4rena Findings社区审计发现,学习真实案例