⭐ 用法:不要一次读完。写到哪一类代码,就翻到对应那一节。 每一条后面的
[N]是事故档案的篇号——看不懂某一条为什么重要,就去读那一篇。
第一阶段 · 写代码时
A. 外部调用与控制流
□ A1 每个函数从上到下扫一遍,标出所有【外部调用】那几行 [1]
对每一行问:这之后控制权会离开我吗?
□ A2 ⭐ 所有状态更新排在所有外部调用【之前】(CEI) [1][20]
⚠️ 这是第一道防线,重入锁只是第二道 ——
因为锁可能因为编译器 bug 而失效
□ A3 重入锁覆盖【所有读写同一份状态的入口】, [1][2]
而不是只加在"看起来危险"的那个函数上
□ A4 ⭐ 对每一对共享同一把锁的函数做交叉重入测试 [20]
(n 个函数 = n² 对,写个循环)
□ A5 view 函数也要检查:它读的状态会不会被分两步更新? [3]
⚠️ 凡是形如 A/B 的对外读数都要过一遍
□ A6 在回调里读取调用方的状态 ⟹ 一律假设它不自洽 [3]
□ A7 ⭐ 不要用 .transfer() 的 2300 Gas 限制防重入 [1]
用逻辑防,不要用 Gas 额度防
B. 权限与身份
□ B1 ⭐⭐ 把所有 external/public 函数列成一张表: [4]
函数名 | 谁能调 | 改了什么 | 出错的最坏后果
⚠️ 填不出某一行 ⟹ 那里漏了访问控制
□ B2 初始化函数:一次性标记 + 参数校验 + 部署时原子完成 [4][7]
用 OpenZeppelin 的 Initializable,不要手写
□ B3 ⭐⭐ 实现合约构造函数里调 _disableInitializers() [5]
□ B4 ⚠️ 实现合约里【不能有】selfdestruct 和任意 delegatecall [5]
部署前扫一遍字节码确认
□ B5 代理设置实现地址时校验 code.length > 0 [5]
□ B6 ⭐⭐ 持有特权的合约【不能】同时充当通用调用转发器 [6]
校验身份和执行调用要拆成两个身份,执行方零权限
□ B7 调用的"目标"和"方法"必须来自你自己的代码,不能来自输入 [6]
⚠️ 不要从用户字符串拼函数选择器 ——
4 字节选择器可以被暴力碰撞
□ B8 白名单,不要黑名单 [6]
□ B9 权限变更 / 升级 / 预言机地址变更 ⟹ 时间锁 + 事件 [6][17]
□ B10 ⭐ 所有授权【默认带过期时间】,续期需要主动操作 [22]
□ B11 不要用 tx.origin 做身份检查 [4]
□ B12 签名必须覆盖会影响执行结果的【每一个字节】: [6][10]
nonce / chainId / 合约地址 / 过期时间 / 调用哪个函数
C. 数值
□ C1 ⭐ 先检查每一项,再聚合,聚合过程中也要检查 [8][9]
⚠️ 任何"先聚合、后校验"的地方,聚合本身就是攻击面
□ C2 ⭐⭐ Solidity 0.8 【不检查】显式类型转换 [9]
uint128(x) 越界静默截断 ⟹ 用 SafeCast
□ C3 unchecked 块和内联汇编里的算术,逐个人工复核 [9]
□ C4 ⭐ 业务范围检查独立于类型范围 [8]
类型不溢出 ≠ 数值合法
□ C5 手写的边界检查必须测 阈值 和 阈值±1 [11]
⚠️ 离边界远的用例无法区分"对"和"差三个数量级"
□ C6 ⭐ 常量要"算出来",不要写死十六进制魔法数 [11]
1 << 192 一眼能核对,一串掩码不能
□ C7 ⭐⭐ 舍入方向:所有零头都留在协议里 [19]
deposit 向下 / mint 向上 / withdraw 向上 / redeem 向下
□ C8 跨代币算术先归一化精度,把基准写进变量名 [8]
⚠️ USDC 6 位、WBTC 8 位、WETH 18 位
□ C9 ⭐ 零值不能是有效值 [7]
mapping 对不存在的 key 返回零值 ⟹ 零值必须表示"不存在"
用带 exists 位的结构体更彻底
□ C10 enum 的第 0 个成员留给 Invalid / Unknown [7]
D. 外部数据与依赖
□ D1 ⭐⭐ 永远不读现货价 [12]
任何"读一次就能得到"的链上价格都可被操纵
□ D2 ⭐ 内部记账变量作为唯一真相,不要用 balanceOf [15]
⚠️ 任何人都能给你转账,你无法拒绝
□ D3 address(this).balance 可被 selfdestruct 强制改变 [15]
□ D4 多源 + 偏离检测,且数据源必须【底层真独立】 [14]
⚠️ 五个源都从同一个 API 取数 = 一个源
□ D5 ⭐⭐ 异常时暂停,而不是照单全收 [14]
合约必须能表达"我现在不知道价格"这个状态
返回错值比拒绝返回危险得多
□ D6 ⭐ 清算要求"持续 N 个区块低于清算线" [14]
过滤瞬时尖刺;N 的取值是显式取舍,不是默认值
□ D7 分档清算,不要一次清完 [14]
□ D8 ⭐ 上架资产前算:把它的价格推到 2 倍要多少钱? [12][16]
⚠️ 小于你的敞口 ⟹ 不要上架,无论用多好的预言机
□ D9 自己的代币不能做自己的抵押品 [16]
□ D10 ⭐ 清算可行性验证:清算人拿到抵押品后卖得掉吗? [16]
卖不掉 ⟹ 清算只是一个无法执行的承诺
□ D11 上架代币的兼容性审查(八条) [2]
回调 / 手续费 / rebase / 返回值 / 黑名单 /
多入口 / 小数位 / ⭐ 可升级
□ D12 ⭐⭐ 允许别人指定代币地址 = 允许任意代码在关键路径执行 [2]
E. 经济与博弈
□ E1 ⭐⭐ 对每一个"按余额/持仓/份额计权"的地方问: [12][17]
"如果调用者在这一个区块内拥有无限资金,会怎样?"
⟹ 价格、投票权、份额、奖励快照、费率档位、
任何"持有 X 才能做 Y"的门槛
□ E2 ⭐ 任何在单个区块内可被改变的量, [12]
不能单独决定资金的去向
□ E3 ⭐⭐ 投票权快照的时点必须【早于】提案创建 [17]
⟹ 闪电贷因此完全失效
□ E4 ⭐ 通过 ≠ 执行,中间必须有时间锁 [17]
□ E5 应急通道只能"做减法"(暂停、撤权), [17]
⚠️ 不能转移资产、不能升级实现
□ E6 ⭐⭐ 任何可能让账户变差的操作,结束前强制健康检查 [18]
⚠️ 用架构强制(统一路由),不要靠每个函数自己记得
□ E7 ⭐ "只影响自己"是错觉 —— [18]
有共享风险池时,账户状态是公共状态
□ E8 递增的激励要问:"谁能人为制造触发条件?成本多少?" [18]
□ E9 清算折扣封顶 + 深度不健康的账户延迟一个区块才可清算 [18]
□ E10 ⭐ 金库防第一存款人:虚拟份额偏移 + require(shares > 0) [19]
第二阶段 · 部署前
□ P1 ⭐ 锁定编译器版本到补丁号,写进部署记录 [20]
合约地址 | 编译器版本 | commit | 编译参数
□ P2 ⭐⭐ 可复现构建:重新编译,与链上字节码逐字节比对 [20]
同时防住:环境不一致 / 部署脚本被篡改 / 部署了别的代码
□ P3 CI 里把 deprecation 警告设为【错误】 [21]
□ P4 ⭐⭐ 初始化参数逐项校验,尤其是零值 [7]
零根 / 零地址 / threshold=0 / threshold>成员数
□ P5 ⭐⭐ 负向测试:本该失败的操作是不是真的失败 [7]
□ 未证明的消息 ⟹ 必须 revert
□ 伪造的证明 ⟹ 必须 revert
□ 零值参数 ⟹ 必须 revert
□ 别人的证明 ⟹ 必须 revert
⚠️ 正向测试只证明功能没坏,只有负向测试证明防线还在
□ P6 ⭐ 构造的负例,不只是随机的负例 [10]
随机乱改的假证明会失败,精心构造的不会
□ P7 ⭐ 不变量测试(每条不到十行,覆盖面极大):
□ totalSupply == Σ balances [9]
□ ⭐ 存入后立即取出,必须亏钱 [13][19]
□ 往返(AMM / 桥 / 质押)必须亏
□ 自我清算永不获利 [18]
□ Σ 所有头寸价值 ≤ 池子实际余额 [11]
□ ⭐ fuzz 的初始状态必须包含【空】 [19]
□ P8 ⭐ 按"每行代码控制多少资金"分配审计时间 [10]
而不是按代码总行数
□ P9 自定义函数比标准函数花更多时间 [9]
⚠️ 名字带 batch/multi/bulk/optimized 的单独审
□ P10 ⭐ fork 代码时,列出原作者做过但没写下来的假设 [2]
第三阶段 · 上线后(⚠️ 最容易被忽略的一段)
F. 密钥与签名流程
□ F1 ⭐⭐ 算一遍多签的【实际门槛】: [22]
按"共享攻击面"给密钥分组,
实际门槛 = 覆盖 N 把密钥所需的最少组数
⚠️ Ronin 名义 5/9,实际 1/1
□ F2 跨组织 / 跨地域 / 跨设备品牌 / 跨密钥方案 [22]
□ F3 ⚠️ 逐条检查有没有"代签 / 委托 / 备用"旁路 [22]
□ F4 ⭐⭐ 冷钱包只能转【白名单地址】 [25]
□ F5 ⭐⭐ 禁止 delegatecall(operation = 1) [23][25]
⚠️ 日常转账永远不需要它。用守卫模块强制。
⭐ 这一行配置能挡住 14.6 亿美元的攻击链
□ F6 大额转出走时间锁 [25]
⭐ 冷钱包本来就不需要实时性,代价几乎为零
□ F7 单笔 / 单日限额 [7][21]
□ F8 ⭐⭐ 签名前用【独立通道】校验 calldata [23]
从链上读原始参数 → 独立工具解析 → 自己算 EIP-712 哈希
→ 与硬件钱包屏幕逐字符比对
⚠️ "独立"= 不是同一台设备、不是同一个前端、
不是同一批开发者维护的工具
□ F9 签名在专用隔离设备上进行 [24]
不上网 / 不收邮件 / 只读介质启动 / 与办公网隔离
□ F10 多人校验要用【不同的工具】 [24]
⚠️ 都用同一个前端 = 把同一个错误看了多遍
G. 监控与响应
□ G1 ⭐⭐ 资金余额每分钟核对 [22]
几十行代码,把发现时间从【六天】缩短到【一分钟】
⟹ 全档案投入产出比最高的一条
□ G2 ⭐ 监控跑在【独立的】基础设施上 [25]
□ G3 告警能叫醒人(电话/短信,不是邮件) [22]
□ G4 ⭐⭐ 有一个【不需要多签】就能按的暂停键 [7]
⚠️ 需要多签才能按的暂停 ≈ 没有暂停
□ G5 ⭐ 每季度演练一次 [26]
没演练过的流程等于不存在
□ G6 ⭐⭐ 回答这个问题:凌晨三点谁按暂停键? [26]
他怎么被叫醒?他有权限吗?多久能完成?
□ G7 ⭐⭐ 把"检测时间"当成一个指标来跟踪和公布 [26]
"现在发生一笔异常转账,我们多久发现?"
H. 流程与人
□ H1 ⭐⭐ 任何【计划外】事件必须被记录和解释 [24]
⚠️ "签名失败了,重签一下" ⟹ 视为安全事件
⭐ 把默认反应从"再试一次"改成"先搞清楚"
□ H2 维护一份异常台账 [24]
⭐ 同一现象出现第三次而根因栏还是空的 ⟹ 必须处理
□ H3 季度权限复核,输出一张表,逐行签字 [22]
⭐ 对每一条问"现在撤销它,谁会抱怨?"答不上来 ⟹ 撤销
□ H4 ⭐ 每份公开的事故复盘发布后,逐条问"我们有没有同样的问题" [24]
⚠️ Radiant 出事时,WazirX 的复盘已经公开三个月
□ H5 反钓鱼培训 + 定期发假钓鱼邮件测试 [22]
□ H6 ⭐ 假设开发者【会】被钓鱼成功 —— [22][25]
然后问"那之后会发生什么"
□ H7 ⭐⭐ 假设终端已被攻破,然后设计 [24][25]
不要依赖"人会看仔细",也不要依赖"设备是干净的"
⭐ 如果只能做十件事
按"每单位成本挡掉的损失"排序:
1. ⭐⭐ 禁止 delegatecall + 冷钱包白名单 [25]
一行配置,对应本档案最大的单起损失
2. ⭐⭐ 资金余额每分钟核对 + 能叫醒人的告警 [22]
几十行代码,把"六天"变成"一分钟"
3. ⭐⭐ 一个不需要多签就能按的暂停键 + 提款限额 [7]
唯一在"你还没反应过来"时起作用的机制
4. ⭐⭐ CEI —— 状态更新排在所有外部调用之前 [1]
5. ⭐⭐ 把所有 external/public 函数列成权限表 [4]
填不出的那一行就是漏洞
6. ⭐⭐ 永远不读现货价 [12]
7. ⭐⭐ 负向测试 + 那几条不变量 [7][13][19]
每条不到十行,覆盖面极大
8. ⭐⭐ 签名前用独立通道校验 calldata [23]
9. ⭐⭐ 授权默认带过期时间 + 季度权限复核 [22]
10. ⭐⭐ 算一遍多签的实际门槛 [22]
大多数团队算完会吓一跳
⭐⭐ 注意这十条里,只有第 4、6、7 条是"写代码"。
其余七条都在代码之外——而这正是事故档案的损失分布告诉你的事。