从“上线前检查”到“上线后盯盘”:为什么安全体系不能只做审计
很多中级开发者第一次接触区块链安全时,容易把“智能合约审计”理解成一次性交付:代码写完,找工具扫一遍,甚至请第三方出个报告,然后就安心上线。
但真实世界不是这样运作的。
我自己在做链上系统时,踩过一个很典型的坑:合约逻辑本身没明显漏洞,单元测试也过了,静态分析报告看着很干净,但上线后因为一个管理员权限配置失误,某个高风险函数在错误时间窗口被调用,差点引发资金异常。这个问题不属于传统意义上的“代码漏洞”,却是实打实的安全事件。
所以,安全不是一个动作,而是一条链路:
- 上线前:审计代码、验证权限、模拟攻击路径
- 上线中:监控关键事件、账户行为、资金流向
- 上线后:告警、止损、复盘、持续修补
这篇文章会带你从一个中级开发者能真正落地的角度,搭一套“审计 + 链上监控”的基础防护体系。重点不是堆概念,而是让你能自己跑起来。
背景与问题
在区块链项目里,常见安全问题通常分成三层:
-
合约代码层
- 重入攻击
- 整数溢出(旧版本)
- 未受控的外部调用
- 权限校验缺失
- 价格预言机依赖不安全
-
协议运行层
- 管理员私钥滥用
- 参数被异常修改
- 升级代理实现被替换
- 大额异常转账
- 闪电贷驱动的短时操纵
-
系统联动层
- 前端签名误导
- 后端风控缺失
- 告警不及时
- 没有事件追踪和审计日志
很多团队的问题不是“完全没做安全”,而是只做了其中一段。比如:
- 只做静态扫描,不做业务逻辑审计
- 只做上线前审计,不做上线后监控
- 只看转账,不看管理员操作
- 只看失败交易,不看成功但异常的交易
如果你是中级开发者,最值得建立的认知是:
审计负责减少已知风险,监控负责捕获运行时风险。二者必须联动。
前置知识
建议你至少熟悉以下内容:
- Solidity 基础语法
- Hardhat 或 Foundry 的基本使用
- 事件(event)、交易(transaction)、日志(log)的关系
- ERC-20 的转账模型
- Node.js 基础
ethers.js的基础用法
环境准备
下面的实战代码基于以下环境:
- Node.js 18+
- Hardhat
- Solidity 0.8.x
- ethers.js v6
初始化项目:
mkdir chain-security-demo
cd chain-security-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install ethers dotenv
npx hardhat
项目结构可以这样放:
chain-security-demo/
├─ contracts/
│ └─ Vault.sol
├─ scripts/
│ ├─ deploy.js
│ └─ monitor.js
├─ test/
│ └─ Vault.js
├─ .env
└─ hardhat.config.js
核心原理
我们先把整个防护体系拆开,再组合起来。
1. 上线前:智能合约审计关注什么
审计并不等于跑一个工具,而是从以下几个方向检查:
- 权限边界
- 谁能调用敏感函数?
- owner 是否可被替换?
- 多签是否生效?
- 状态变更顺序
- 是否先更新状态再转账?
- 是否存在可重入窗口?
- 外部依赖
- 是否依赖不可信合约?
- 是否依赖预言机且无保护?
- 业务不变量
- 用户余额总和是否等于池子资金?
- 手续费是否超出上限?
- 提现额度是否能绕过?
2. 上线后:链上监控盯什么
监控不是“把所有事件都打印出来”,而是挑关键风险点:
- 管理员权限变更
- 合约升级事件
- 大额提款
- 高频失败交易
- 某地址短时反复调用敏感函数
- ERC-20 异常转出
- 资金池余额骤降
3. 核心思路:用“不变量 + 事件驱动”做监控
实战里最好用两类规则:
事件驱动规则
监听链上 event,比如:
DepositWithdrawOwnershipTransferred
一旦匹配高风险模式,立刻告警。
状态校验规则
定时读取合约状态,验证不变量,比如:
totalAssets >= totalUserBalances- owner 地址是否仍在白名单
- 实现合约地址是否发生变化
这两种方式结合起来,才更稳。
整体架构图
flowchart LR
A[开发阶段] --> B[静态分析]
B --> C[单元测试/模糊测试]
C --> D[人工审计与业务规则检查]
D --> E[主网部署]
E --> F[链上事件监听]
E --> G[定时状态巡检]
F --> H[告警系统]
G --> H
H --> I[人工响应/自动止损]
风险发现与响应时序
sequenceDiagram
participant Dev as 开发者
participant Contract as 智能合约
participant Monitor as 监控服务
participant Alert as 告警通道
participant Ops as 响应人员
Dev->>Contract: 部署合约
Monitor->>Contract: 订阅事件/轮询状态
User->>Contract: 调用 withdraw()
Contract-->>Monitor: 触发 Withdraw 事件
Monitor->>Monitor: 规则判断(大额/频率异常)
alt 命中风险规则
Monitor->>Alert: 发送告警
Alert->>Ops: 通知处理
Ops->>Contract: 执行暂停/限流/下线前端
else 正常行为
Monitor-->>Monitor: 记录审计日志
end
实战:从一个简化 Vault 合约开始
我们先写一个资金存取合约。它不复杂,但足够演示:
- 审计时该看什么
- 上链后怎么监听异常提款
- 如何用简单规则告警
第一步:编写合约
contracts/Vault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
address public owner;
uint256 public totalDeposits;
mapping(address => uint256) public balances;
event Deposit(address indexed user, uint256 amount);
event Withdraw(address indexed user, uint256 amount);
event OwnerChanged(address indexed oldOwner, address indexed newOwner);
event EmergencyPaused(bool paused);
bool public paused;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
modifier whenNotPaused() {
require(!paused, "paused");
_;
}
constructor() {
owner = msg.sender;
}
function deposit() external payable whenNotPaused {
require(msg.value > 0, "zero value");
balances[msg.sender] += msg.value;
totalDeposits += msg.value;
emit Deposit(msg.sender, msg.value);
}
function withdraw(uint256 amount) external whenNotPaused {
require(amount > 0, "zero amount");
require(balances[msg.sender] >= amount, "insufficient balance");
// Checks-Effects-Interactions
balances[msg.sender] -= amount;
totalDeposits -= amount;
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "transfer failed");
emit Withdraw(msg.sender, amount);
}
function changeOwner(address newOwner) external onlyOwner {
require(newOwner != address(0), "zero address");
address oldOwner = owner;
owner = newOwner;
emit OwnerChanged(oldOwner, newOwner);
}
function setPaused(bool _paused) external onlyOwner {
paused = _paused;
emit EmergencyPaused(_paused);
}
function getVaultBalance() external view returns (uint256) {
return address(this).balance;
}
}
第二步:从审计视角检查这份合约
这份代码虽然简单,但已经能体现很多审计思路。
1. 先看权限
敏感函数有两个:
changeOwnersetPaused
它们都用了 onlyOwner,这是第一层保护。但继续问:
- owner 是 EOA 还是多签?
- owner 地址是否会误配置?
- 是否需要两步转移所有权?
如果项目真的管钱,我一般不建议直接单步切 owner,至少要有:
pendingOwneracceptOwnership
2. 再看提款逻辑
withdraw() 做了对的事:
- 先检查余额
- 再更新余额和总额
- 最后外部转账
这是经典的 Checks-Effects-Interactions,能显著降低重入风险。
3. 看不变量
理论上应该满足:
合约余额 == totalDeposits
如果某天监控发现这两个值不一致,就要高度警惕。可能原因包括:
- 合约被强制转入 ETH
- 某处记账错误
- 存在未预期资金流入/流出
4. 看暂停机制
paused 让项目在风险时刻有止血手段。这很重要。很多团队只想着“绝不出事”,但安全工程里更现实的想法是:
出事时能不能先把损失控制住。
第三步:部署脚本
scripts/deploy.js
const hre = require("hardhat");
async function main() {
const Vault = await hre.ethers.getContractFactory("Vault");
const vault = await Vault.deploy();
await vault.waitForDeployment();
const address = await vault.getAddress();
console.log("Vault deployed to:", address);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行:
npx hardhat compile
npx hardhat run scripts/deploy.js --network localhost
第四步:写测试,先把审计结论用例化
这一步非常关键。很多人审计完只是“脑子里觉得安全”,但没有把规则写成测试,后面改代码很容易回归。
test/Vault.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("Vault", function () {
async function deployFixture() {
const [owner, user1, user2] = await ethers.getSigners();
const Vault = await ethers.getContractFactory("Vault");
const vault = await Vault.deploy();
await vault.waitForDeployment();
return { vault, owner, user1, user2 };
}
it("should accept deposits", async function () {
const { vault, user1 } = await deployFixture();
await vault.connect(user1).deposit({ value: ethers.parseEther("1") });
expect(await vault.balances(user1.address)).to.equal(
ethers.parseEther("1")
);
expect(await vault.totalDeposits()).to.equal(ethers.parseEther("1"));
});
it("should allow user to withdraw own funds", async function () {
const { vault, user1 } = await deployFixture();
await vault.connect(user1).deposit({ value: ethers.parseEther("1") });
await vault.connect(user1).withdraw(ethers.parseEther("0.4"));
expect(await vault.balances(user1.address)).to.equal(
ethers.parseEther("0.6")
);
expect(await vault.totalDeposits()).to.equal(ethers.parseEther("0.6"));
});
it("should prevent non-owner from pausing", async function () {
const { vault, user1 } = await deployFixture();
await expect(
vault.connect(user1).setPaused(true)
).to.be.revertedWith("not owner");
});
it("should pause deposit and withdraw", async function () {
const { vault, owner, user1 } = await deployFixture();
await vault.connect(user1).deposit({ value: ethers.parseEther("1") });
await vault.connect(owner).setPaused(true);
await expect(
vault.connect(user1).deposit({ value: ethers.parseEther("1") })
).to.be.revertedWith("paused");
await expect(
vault.connect(user1).withdraw(ethers.parseEther("0.1"))
).to.be.revertedWith("paused");
});
it("vault balance should equal totalDeposits after normal operations", async function () {
const { vault, user1, user2 } = await deployFixture();
await vault.connect(user1).deposit({ value: ethers.parseEther("1") });
await vault.connect(user2).deposit({ value: ethers.parseEther("2") });
await vault.connect(user1).withdraw(ethers.parseEther("0.3"));
const contractAddress = await vault.getAddress();
const balance = await ethers.provider.getBalance(contractAddress);
expect(balance).to.equal(await vault.totalDeposits());
});
});
运行:
npx hardhat test
第五步:写一个最小可用的链上监控脚本
下面这个监控脚本做三件事:
- 监听
Withdraw事件 - 监听
OwnerChanged事件 - 定时检查
address(this).balance和totalDeposits是否一致
配置 .env
RPC_URL=http://127.0.0.1:8545
VAULT_ADDRESS=你的合约地址
ALERT_WITHDRAW_THRESHOLD_ETH=1
监控脚本 scripts/monitor.js
require("dotenv").config();
const { ethers } = require("ethers");
const ABI = [
"event Deposit(address indexed user, uint256 amount)",
"event Withdraw(address indexed user, uint256 amount)",
"event OwnerChanged(address indexed oldOwner, address indexed newOwner)",
"event EmergencyPaused(bool paused)",
"function totalDeposits() view returns (uint256)",
"function getVaultBalance() view returns (uint256)",
"function owner() view returns (address)",
"function paused() view returns (bool)"
];
const RPC_URL = process.env.RPC_URL;
const VAULT_ADDRESS = process.env.VAULT_ADDRESS;
const ALERT_WITHDRAW_THRESHOLD_ETH =
process.env.ALERT_WITHDRAW_THRESHOLD_ETH || "1";
if (!RPC_URL || !VAULT_ADDRESS) {
throw new Error("Missing RPC_URL or VAULT_ADDRESS in .env");
}
const provider = new ethers.JsonRpcProvider(RPC_URL);
const vault = new ethers.Contract(VAULT_ADDRESS, ABI, provider);
function alert(level, message, data = {}) {
const time = new Date().toISOString();
console.log(
`[${time}] [${level}] ${message} ${JSON.stringify(data, null, 2)}`
);
}
async function checkInvariants() {
try {
const [totalDeposits, vaultBalance, owner, paused] = await Promise.all([
vault.totalDeposits(),
vault.getVaultBalance(),
vault.owner(),
vault.paused()
]);
if (totalDeposits !== vaultBalance) {
alert("CRITICAL", "Invariant broken: totalDeposits != vaultBalance", {
totalDeposits: totalDeposits.toString(),
vaultBalance: vaultBalance.toString()
});
} else {
alert("INFO", "Invariant ok", {
totalDeposits: totalDeposits.toString(),
vaultBalance: vaultBalance.toString(),
owner,
paused
});
}
} catch (err) {
alert("ERROR", "Failed to check invariants", { error: err.message });
}
}
async function main() {
alert("INFO", "Monitor started", { address: VAULT_ADDRESS });
vault.on("Withdraw", (user, amount, event) => {
const amountEth = ethers.formatEther(amount);
alert("INFO", "Withdraw detected", {
user,
amount: amount.toString(),
amountEth,
txHash: event.log.transactionHash
});
if (Number(amountEth) >= Number(ALERT_WITHDRAW_THRESHOLD_ETH)) {
alert("HIGH", "Large withdraw detected", {
user,
amountEth,
txHash: event.log.transactionHash
});
}
});
vault.on("OwnerChanged", (oldOwner, newOwner, event) => {
alert("CRITICAL", "Owner changed", {
oldOwner,
newOwner,
txHash: event.log.transactionHash
});
});
vault.on("EmergencyPaused", (paused, event) => {
alert("HIGH", "Pause status changed", {
paused,
txHash: event.log.transactionHash
});
});
setInterval(checkInvariants, 15000);
await checkInvariants();
}
main().catch((err) => {
alert("ERROR", "Monitor crashed", { error: err.message });
process.exit(1);
});
运行:
node scripts/monitor.js
第六步:手动触发几笔交易,验证监控是否生效
你可以在本地网络里执行几次操作:
- 存款
- 小额提款
- 大额提款
- owner 变更
- 暂停合约
当监控脚本打印出对应日志,说明这套基础链路通了。
用状态图理解“合约生命周期中的安全动作”
stateDiagram-v2
[*] --> 开发中
开发中 --> 审计中: 完成编码
审计中 --> 待部署: 修复问题并复测
待部署 --> 运行中: 主网部署
运行中 --> 告警中: 监控发现异常
告警中 --> 已暂停: 执行应急开关
告警中 --> 运行中: 误报或低风险
已暂停 --> 修复中: 排查根因
修复中 --> 待部署: 完成补丁
待部署 --> 运行中: 重新上线
常见坑与排查
下面这些问题,我建议你上线前就假设自己一定会遇到。
1. 监听不到事件
现象
监控脚本启动了,但没有任何事件输出。
排查路径
- 合约地址是否正确
- ABI 是否包含正确 event 定义
- RPC 节点是否稳定
- 是不是连到了错误网络
- 事件是否真的被触发了
建议
先用区块浏览器或本地 Hardhat 控制台确认交易成功,再查监听代码。
2. totalDeposits 与链上余额不一致
现象
监控提示不变量被破坏。
可能原因
- 某人通过
selfdestruct强制向合约转入 ETH - 合约后来新增了未记账的收款逻辑
- 统计口径有误
- 提款失败回滚路径没处理好
排查方式
- 查最近区块的入账交易
- 查合约变更历史
- 对比事件日志与状态值
- 本地 fork 链复现
边界条件
这里有个很容易忽略的点:“合约余额 == 内部记账”并不是所有场景都永远成立。如果合约允许被动接收资金,或者存在利息、奖励、捐赠机制,就要调整不变量定义。
3. 只监控大额提款,漏掉分批攻击
现象
攻击者每次只提 0.9 ETH,但一分钟提了 50 次。
原因
规则太简单,只做单笔阈值。
改进思路
增加时间窗口聚合:
- 1 分钟内同地址提款次数
- 5 分钟内总提款金额
- 多地址向同一归集地址转移
这是中级开发者向更成熟风控迈进的一步:从单事件判断,升级到行为模式判断。
4. 管理员操作是合法的,但仍然危险
现象
owner 修改了参数,合约层面没报错,但实际上把系统风险拉高了。
例子
- 提高手续费到极端值
- 更换预言机地址为不可信地址
- 升级实现合约到未经审计版本
处理建议
对“合法但危险”的管理员操作也要告警,不要只盯非法调用。
5. 监控服务自己挂了
这是非常真实的坑。区块链监控如果没有自监控,等于没有。
最低要求
- 进程保活
- 日志持久化
- 节点断线重连
- 告警通道失败重试
- 启动后补扫历史区块
我见过不少团队“监控脚本一直在服务器跑着”,但实际上 RPC 断开后就再也没收到过事件。
安全/性能最佳实践
安全最佳实践
1. 把审计结论写进测试
不要让“安全规则”只存在脑子里或文档里。最有效的方式,是写成:
- 单元测试
- 属性测试
- 回归测试
这样以后代码改动,风险会自动暴露。
2. 敏感权限尽量上多签
如果合约涉及真实资金,owner 最好不是个人 EOA,而是多签钱包。
3. 给高风险函数加事件
很多监控做不起来,不是监控能力不够,而是合约设计时没把关键操作事件化。
建议敏感动作都发 event,例如:
- 参数更新
- 白名单变更
- 权限转移
- 升级执行
- 紧急暂停
4. 预设止血动作
不是所有风险都能第一时间修复,但可以第一时间止血。常见手段:
- pause
- 前端下线敏感入口
- 限制单笔/单地址额度
- 临时关闭自动化策略
5. 对“成功交易”也做风控
很多危险交易是成功执行的。不要只看 reverted transaction。
性能最佳实践
1. 不要对所有合约做高频轮询
轮询太密会增加节点压力,也会提升成本。更合理的策略是:
- 事件优先
- 状态补充校验
- 对高风险合约高频巡检
- 对低风险合约低频巡检
2. 做增量处理
监控历史区块时,记录上次处理到的 block number,避免重复扫描。
3. 告警分级
不是所有异常都要半夜把人叫醒。建议至少分:
- INFO:普通事件
- HIGH:高风险行为
- CRITICAL:需要立即响应
4. 规则要能解释
一条告警如果不能说明“为什么触发、影响范围是什么、建议动作是什么”,值班的人会非常痛苦。
逐步验证清单
如果你想把本文内容真正落地,我建议按这个顺序来:
合约侧
- 敏感函数都做了权限控制
- 关键操作都发了事件
- 资金流遵循 Checks-Effects-Interactions
- 有 pause 或其他止血机制
- 业务不变量被明确列出
测试侧
- 存款/提款正常路径覆盖
- 权限越权测试覆盖
- 暂停状态测试覆盖
- 不变量测试覆盖
- 修复过的漏洞有回归测试
监控侧
- 能监听关键事件
- 能做定时状态巡检
- 大额/高频/权限变更会告警
- 监控服务断线可恢复
- 有日志留存和简单审计轨迹
运维侧
- 有告警接收人
- 有明确应急动作
- 有升级/暂停流程
- 有复盘模板
进阶建议:中级开发者下一步该补什么
如果你已经能完成本文的基础版链路,下一阶段建议重点补这三类能力:
1. 模糊测试与属性测试
例如用 Foundry 的 fuzz test 去验证:
- 任意用户提款后余额不会为负
- 任意操作序列下总额不出现异常偏差
2. 历史区块回放
不是只监听“现在开始”的事件,而是能从某个区块高度回放,帮助你:
- 冷启动监控
- 漏报补扫
- 复盘攻击过程
3. 自动化响应
比如当命中 CRITICAL 规则时,自动触发:
- 机器人通知
- 工单创建
- 暂停建议
- 前端隐藏高风险入口
这里要注意边界:自动止血一定要谨慎。自动告警通常没问题,但自动执行链上操作,误伤成本很高。
总结
如果把区块链安全只理解为“合约审计”,那你其实只完成了一半。
更完整的做法应该是:
-
上线前做审计
- 查权限
- 查状态变更顺序
- 查业务不变量
- 用测试固化结论
-
上线后做监控
- 监听关键事件
- 巡检关键状态
- 识别异常行为模式
- 建立告警和止血流程
-
让两者联动
- 审计发现的风险点,变成监控规则
- 监控发现的异常模式,反过来补测试和修复代码
如果你现在就要动手,我建议从最小闭环开始,不要一口气做成大平台:
- 先挑一个核心合约
- 列出 3 个最关键的不变量
- 监听 3 类高风险事件
- 配 2 级告警
- 补 5 条回归测试
这套东西不花哨,但很有用。对中级开发者来说,真正的进阶不是知道更多漏洞名词,而是能把安全变成一套持续运转的工程系统。