背景与问题
智能合约一旦部署到链上,最大的特点不是“自动执行”,而是“很难回滚”。这也是为什么很多团队在功能开发跑通后,真正危险的阶段才刚开始:安全审计。
我自己做合约排查时,最常见的场景不是“写不出来”,而是:
- 功能看起来没问题,但存在可利用的资金风险
- 单元测试都绿了,上链后却被重入、权限配置或价格操纵击穿
- 修一个漏洞时,又顺手引入了新的业务缺陷
这篇文章不打算只列漏洞清单,而是按**“现象复现 → 定位路径 → 修复方案 → 最佳实践”**来走一遍。主线放在 Solidity 中最常见、也最容易在审计里遇到的几类问题:
- 重入攻击
- 权限控制缺失
tx.origin误用- 整数/业务边界问题
- 外部调用返回值与 DoS 风险
目标读者默认有一定 Solidity 基础,知道合约、函数、modifier、事件这些概念,但还没系统做过安全排查。
背景案例:为什么“能跑”不等于“安全”
先说一个很典型的误区:很多开发者把“测试通过”理解成“合约安全”。
实际上二者差很远:
- 功能测试关注的是“预期路径”
- 安全审计关注的是“非预期路径”
比如一个提款函数,正常用户调用当然能成功;但攻击者会思考:
- 能不能在余额扣减前重复调用?
- 能不能通过另一个合约伪装成用户?
- 能不能利用 gas 限制让别人提现失败?
- 能不能通过异常返回值让逻辑悄悄失效?
所以安全审计本质上是在回答两个问题:
- 谁可以调用?
- 调用过程中,状态和资产会不会被异常改变?
核心原理
在 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(...)
这些地方都要问一句:外部调用失败怎么办?回调会不会影响当前状态?
第二步:找关键状态写入顺序
例如:
- 余额扣减
- 债务更新
- 股份销毁
- 白名单变更
- 所有权变更
核心不是看“有没有写”,而是看“先写还是后写”。
第三步:找权限函数
像下面这种函数都要重点盯:
setAdminmintupgradeTopausewithdrawAllrescueTokens
这些函数如果权限模型有问题,后果通常比普通 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
止血方案
如果线上已经发现异常:
- 立即暂停提现入口(前提是有 pause 机制)
- 关闭高风险外部调用
- 快照资产状态,评估受影响账户
- 如果是可升级合约,尽快升级逻辑并补回归测试
2. 权限控制缺失或过宽
现象
- 任意地址都能调用管理函数
- 配置参数被恶意改写
- 合约资金可被直接转走
根因
忘记加 onlyOwner、角色控制过于粗糙,或者初始化逻辑可被二次调用。
有问题的模式
function emergencyWithdrawAll(address payable to) external {
to.transfer(address(this).balance);
}
修复方式
- 明确角色:
owner、admin、operator - 敏感函数必须加访问控制
- 高权限操作增加事件、延迟执行、多签治理
实用建议
很多团队只做了“有管理员”,但没做“管理员误操作防护”。审计时别只看能不能挡住攻击者,也要看能不能挡住自己人失误。
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+ 已经默认检查整数溢出,但这不代表“数值问题”消失了。
常见表现
- 手续费四舍五入误差导致资金长期偏移
- 抵押率计算先除后乘,结果精度丢失
- 价格预言机单位不统一
排查点
- 乘除顺序是否合理
- 是否统一使用
1e18、1e6等精度 - 最小值、最大值、零值是否做校验
- 是否可能被“尘埃金额”反复利用
安全修复的设计思路
修复漏洞不是“把报错补掉”那么简单,更重要的是避免修出新问题。下面这张状态图能帮助理解一个安全提现流程。
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. 保留止血机制,但别让它变后门
pause、rescueToken、emergencyWithdraw 这些函数确实能救命,但前提是:
- 权限足够严格
- 行为可审计
- 有事件记录
- 最好受多签控制
否则“紧急函数”本身就会成为最大的风险点。
一份可执行的审计排查清单
如果你要对一个 Solidity 项目做一次快速安全体检,可以按这个顺序来:
flowchart LR
A[找资产相关函数] --> B[找外部调用点]
B --> C[检查状态更新顺序]
C --> D[检查权限修饰器]
D --> E[检查零地址/边界值]
E --> F[检查循环与gas风险]
F --> G[补攻击测试与回归测试]
对应的简化问题清单:
- 钱从哪进、从哪出?
- 谁能改钱、改参数、改权限?
- 外部调用之前是否已更新状态?
- 是否用了
tx.origin? - 是否存在无限循环或批量操作?
- 是否检查返回值和异常路径?
- 是否有暂停与应急方案?
- 修复后有没有对应测试?
总结
智能合约安全审计最怕两种情况:
- 只看语法和编译,不看攻击路径
- 只会背漏洞名词,不会顺着资金流和状态流去排查
这篇文章用一个简单银行合约串起了几类高频问题:重入、权限缺失、tx.origin 误用、返回值处理、DoS 和边界问题。如果你要把它变成实际工作方法,我建议就记住这三件事:
- 先找资产流,再找权限流,最后看状态流
- 凡是外部调用,都默认它可能失败或恶意回调
- 修复漏洞后,一定补攻击测试,不要只补业务测试
边界条件也要说清楚:本文聚焦的是 Solidity 常见基础漏洞排查,不展开预言机操纵、MEV、治理攻击、跨链桥等更复杂议题。但只要你把文中的排查思路用熟,已经能覆盖相当一部分日常审计场景。
如果你正在维护线上合约,我的建议很直接:
- 先把高风险函数列出来
- 逐个检查权限和外部调用顺序
- 给关键路径补一组“攻击型测试”
- 能上成熟安全库的,尽量别手写
很多事故并不是因为漏洞“太高级”,而是因为最基础的问题没人真正复盘过。安全审计这件事,越早做,越便宜。