手动审计练习#1 - 审计一个Token合约
### 1. 手动审计的价值
日期: 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-01 | Owner可通过无限制mint铸造任意数量Token | High |
| H-02 | burnFrom无需授权即可销毁任意用户Token | High |
| H-03 | feePercent无上限,可设为100%导致用户资金全部被截取 | High |
| M-01 | transferOwnership缺少zero address检查,可导致合约失控 | Medium |
| M-02 | feeRecipient可设为零地址,导致Token被永久销毁 | Medium |
| L-01 | transfer函数中feeRecipient的Transfer事件缺失 | Low |
| L-02 | transferFrom中blacklist未检查接收方(to)和调用方(msg.sender) | Low |
| I-01 | transfer和transferFrom存在重复逻辑,应抽取内部函数 | Informational |
| I-02 | approve存在前端竞态攻击(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价格归零
复现步骤:
- Owner调用
mint(ownerAddress, 1_000_000_000e18)铸造10亿Token - Owner在DEX上卖出所有铸造的Token
- 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
- 用户资产可以被强制没收
- 这是一个严重的信任风险,等同于"冻结+没收"权限
复现步骤:
- 用户Alice持有 1000 Token
- Owner调用
burnFrom(alice, 1000) - 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
描述:
transfer 和 transferFrom 中有大段完全相同的转账+扣费逻辑。这违反了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标准的已知问题,不是这个合约特有的,但建议提供 increaseAllowance 和 decreaseAllowance 作为安全替代。
推荐修复:
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;
}
关键要点总结
审计方法论要点
- 先理解再审:在开始逐行阅读前,先搞清楚合约要做什么
- 系统化检查:按照checklist逐项检查,不要靠"灵感"
- 攻击者思维:对每个函数问"如果我是攻击者,我怎么利用这个函数"
- 中心化风险是重点:Owner权限过大是Token合约中最常见也最严重的问题
- 不要放过"看起来正常"的代码:很多漏洞藏在最平常的逻辑里
本次审计发现的问题模式
| 模式 | 发现 | 通用教训 |
|---|---|---|
| 权限过大 | H-01, H-02 | Owner权限应有明确边界和约束 |
| 缺少输入验证 | H-03, M-01, M-02 | 每个setter函数都要验证输入范围 |
| 事件不完整 | L-01 | 所有状态变更都应有对应事件 |
| 检查不全面 | L-02 | 权限/黑名单检查要覆盖所有相关角色 |
| 代码重复 | I-01 | DRY原则减少维护风险 |
常见误区
误区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 | 社区审计发现,学习真实案例 |