
几个月前我在复盘某异构链项目的安全通告时又看到了 COIN Attacks 这个代号。第一反应是这又是哪个代币合约被薅羊毛了等我把报告附带的源码路径打开才意识到问题完全不在合约层。COIN 在该项目里是最底层的资产对象整套攻击围绕铸造、转账、查询三个入口展开形成的影响归结起来有三类内存型拒绝服务、账本锁定、状态回滚阻塞。源码审计做完之后我最大的感受是——这类漏洞不依赖复杂的密码学突破而是藏在“输入解析”和“状态记账”没有形成统一校验的缝隙里。这篇文章把完整的分析过程写出来包括我圈定攻击面的方式、三个高危模式、三条典型利用路径的源码级推演以及最终补丁怎么落、回归怎么验。无论你是做链底层开发、智能合约审计还是跨链桥、节点运维都可以直接把后面的检查清单拿去当参照。1. 先弄清 COIN Attacks 的攻击面追踪一枚币的完整生命周期1.1 审计中的“币”是什么不是合约是账本语义很多人看到 COIN 这个词第一反应是 ERC-20 那样的代币合约于是审计重点不自觉就放到了approve/transferFrom这类接口上。实际上 COIN Attacks 命名的对象在项目 X 的源码里非常朴素它只是一个底层资产结构体负责描述“谁拥有多少可流通资产”这件事。它既可以是链上原生币的账本也可以被上层合约映射引用。一旦把视角切到账本语义审计范围就完全变了。你要追踪的不再是某个合约函数的输入输出而是从结构体创建、余额初始化、转账扣除、查询返回、销毁回收这一整条生命周期。项目 X 里这个结构体大致长这样type Coin struct { Symbol string // 资产符号 Owner string // 当前持有者地址 Amount *big.Int // 数量 Nonce uint64 // 防重放序号 }只看这个结构体你会觉得没问题。但源码审计最怕的就是“结构体看起来正常行为不正常”。真正的危险藏在处理这个结构体的函数顺序里项目 X 的代码把所有入口都集中到了modules/coin/目录下分别对应mint.go、transfer.go、query.go。表面上职责清晰实际上每个文件都在独立操作同一个状态库没有一个统一的总入口。1.2 源码层面的三条入口mint、transfer、query我先画了一张粗糙的调用关系图把项目 X 里所有能改变或读取 COIN 数据的地方列出来。核心入口是三个铸造入口mint.go负责创建新的 Coin 记录通常由出块逻辑或治理模块触发转账入口transfer.go负责改变 Owner 和 Amount是普通交易最频繁接触的路径查询入口query.go负责按 Owner、Symbol 或者状态快照返回余额信息。这个入口设计本身合理但问题在于每个文件都直接读写同一个StateDB。也就是说转账模块可以往状态库里塞任意 Owner 的记录查询模块并不知道这条记录的合法性边界。最要命的是 mint 入口和 transfer 入口没有共享同一套金额校验工具导致“铸造时校验过的规则在转账时可能被绕过去”。审计时不要只盯着“哪个函数暴露给外部了”要看每个入口是否都回到同一个校验内核。项目 X 的 COIN 三入口属于典型反例三个入口各自为政等于给了攻击者三次试探机会。我在后续章节中会反复用到这三个入口因为它们正是 COIN Attacks 里三条利用路径的触发点。2. 失衡的根源COIN 输入解析与状态校验的分离2.1 反序列化完成不代表校验通过项目 X 的转账入口实现非常典型很多从 Web 开发转过来的工程师一眼就能看懂func (h *CoinHandler) HandleTransfer(raw []byte) ([]byte, error) { req : TransferReq{} if err : json.Unmarshal(raw, req); err ! nil { return nil, err } from : state.GetAccount(req.From) to : state.GetAccount(req.To) amount, _ : new(big.Int).SetString(req.Amount, 10) if from.Balance.Cmp(amount) 0 { return nil, ErrInsufficientBalance } from.Balance.Sub(from.Balance, amount) to.Balance.Add(to.Balance, amount) return state.Commit() }看起来逻辑是完整的先反序列化再查账户然后校验余额最后做加减。但如果你以审计视角逐行看会发现一个严重的时间差问题json.Unmarshal完成之后req.Amount已经进入内存此时没有人校验它是不是正数、是不是超过了系统上限、是不是合法的十进制格式。直到from.Balance.Cmp(amount)这一步才第一次碰金额而且只校验了“够不够扣”完全没有校验“这个金额值本身是否合法”。big.Int.SetString有个隐藏行为只要字符串能被解析成合法的数值格式负数也能被装进amount里。也就是说攻击者传一个-100SetString会成功from.Balance.Cmp(amount) 0大概率不会触发后续from.Balance.Sub(from.Balance, amount)就变成了“给 from 加 100”而to.Balance.Add(to.Balance, amount)变成了“给 to 减 100”。一笔普通的转账请求在金额语义颠倒的情况下可以直接造成账本失衡。这里要强调一个审计认知反序列化解决的是“格式是否合法”不解决“业务是否合法”。很多攻击面就藏在两者之间的空档里。2.2 记账逻辑缺少总额守恒约束项目 X 的问题还不止金额校验缺失。即使在金额完全正常的情况下它的记账逻辑也缺少一个全局不变量inputTotal必须等于outputTotal。正常理解下from 减少多少to 就应该增加多少。但项目 X 的转账代码里from.Balance.Sub和to.Balance.Add是两个独立的操作中间没有任何东西保证它们操作同一个amount。如果上层调用的时候不小心传了两个不同字段比如从req.Amount读了扣减数量又从req.Memo.Amount读了增加数量只要两个值不同账本就悄悄倾斜了。// 反例扣减和增加来自不同的数据源 deduct : new(big.Int).SetString(req.Amount, 10) add : new(big.Int).SetString(req.Meta.Amount, 10) from.Balance.Sub(from.Balance, deduct) to.Balance.Add(to.Balance, add)这在普通业务系统里可能只是一个 bug但在区块链状态机里状态倾斜意味着账本不可信。一旦某个区块里出现了这类交易后续所有依赖该状态的验证都会跟着错。更麻烦的是状态库不会自动发现“减了 10 却加了 11”它只知道执行了指令。真正的修法是引入一个总额守恒函数在每次状态变更前后计算总供应量任何一笔交易如果让总供应量发生变化直接报错。这个不变量看起来简单但项目 X 在最初的实现里完全没有。2.3 定位问题时我发现的三类高危模式经历这次审计后我把 COIN 相关的问题源提取成了三个非常容易识别的高危模式供以后快速排查同类问题第一金额值域没有统一入口。任何模块都可以构造big.Int代码里散落着大量new(big.Int).SetString(...)调用却没有一个统一的ValidateAmount函数。审计时只要搜到多个地方在做金额解析基本上可以判定这个项目存在值域不统一风险。第二关键字段缺少来源绑定。转账的 to、amount 等字段只存在于请求体里没有和交易签名绑定。攻击者改掉请求体里的 to 地址签名验证逻辑完全不知情。这不是密码学问题是数据完整性设计问题。第三查询路径没有上限控制。query.go里存在直接遍历状态库并返回全部记录的函数比如按 Owner 枚举名下所有 Coin。一旦攻击者让某个 Owner 名下出现大量 Coin 记录节点就会因为一次查询而内存暴涨。这个模式本质上和 SQL 里的无 limit 全表扫描是同一类问题但在链上代码里经常被忽略。这三个模式不是凭空猜的它们分别在后面的三类利用路径中反复出现。3. 源码级推演三类典型利用路径与根因对照3.1 路径一无界查询引发内存型资源耗尽先看查询入口。项目 X 的query.go里有一个函数用来枚举某个地址名下的资产简化后大概是这样的func (s *StateDB) GetCoinsByOwner(owner string) ([]*Coin, error) { result : []*Coin{} iter : s.db.Iterator([]byte(coin: owner :)) for iter.Next() { coin : parseCoin(iter.Value()) result append(result, coin) } return result, nil }这个函数有两个问题。第一个是完全没有分页 limit第二个是任何调用者都能直接触发它。在项目 X 的网络里P2P 消息也可以附带查询请求攻击者构造一个饿汉式调用让出块节点反复调用GetCoinsByOwner节点就不得不把所有 Coin 记录读取到内存再拼装成返回结果。实际攻击效果是内存线性增长而攻击者消耗的成本只是海量小请求。矿工或者出块节点一旦内存耗尽会进入 OOM 重启循环即使不重启出块间隔也会被显著拉长整条链的有效性随之下降。复现这个路径最直接的证据是状态库里存在大量 Coin 记录而GetCoinsByOwner没有 limit 也没有游标。修复时只需要改成“必须传 limit且 limit 有上限超过部分通过游标继续取”就能阻断。这里没有任何花哨技巧纯粹是查询函数缺少边界控制。3.2 路径二金额语义绕过引发账本锁定再回到转账入口。前面说了big.Int.SetString可以接受负数。项目 X 的共识层在打包交易时会把转账事件的 from、to、amount 全部写进区块。攻击者提交一笔amount -100的转账如果节点在交易池阶段没有拦截区块里就会存在一笔“from 增加、to 减少”的反向转账。看起来只是余额方向反了影响远比想象中严重。因为 DEX、借贷协议或者其他上层模块在读取状态时会按照“Coin Owner 余额非负”这个假设做校验。一旦某个账户余额因为反向转账变成负数相关协议会判定账本无效进而拒绝后续的交易。此时节点为了达成共识只能不断回滚依赖该账户状态的交易最终形成链级阻塞。如果把视角再放大一点同类攻击也可以使用“金额为零但数量巨大”的方式。项目 X 的状态库每写入一条 Coin 记录都要占用存储空间和默克尔树计算资源攻击者用零金额交易批量刷记录不需要真的改变任何人的余额就能推高整个状态树大小。这种变体本质上是“金额校验缺失 无界写入”的组合同样应该归入账本锁定或者存储耗尽一类。3.3 路径三提交失败但内存已改引发状态回滚阻塞第三类路径藏在state.Commit()的行为里。项目 X 在转账时先改内存中的from.Balance和to.Balance最后才调用Commit()。如果Commit()失败代码直接返回错误但内存中的余额已经把“改了一半”的状态留下来了。from.Balance.Sub(from.Balance, amount) to.Balance.Add(to.Balance, amount) if err : state.Commit(); err ! nil { return nil, err // 内存已被修改没有回滚 }这个模式下攻击者不需要提供任何恶意金额只需要让 Commit 在某一笔交易上必然失败比如构造一个重复的 Nonce触发数据库唯一约束冲突。每次失败之后节点内存里的余额仍处于中间态后续运行同一个交易处理流程的节点却会因为数据库状态不同得到不同的结果。于是出现“同一笔交易在不同节点上产生不同余额”的分叉。任何依赖该账户余额出块的操作都要反复检查最终拖慢共识。这个问题的根源不在转账金额而在“先改内存、后写库、失败不回滚”的执行顺序。三类路径的根因对比如下路径触发入口根因主要影响修复方向路径一query.go查询无 limit、无游标内存耗尽、出块延迟强制分页路径二transfer.go金额值域未校验余额负值、账本失效统一金额校验路径三transfer.go store提交失败不回滚内存节点状态不一致事务化状态更新注意以上内容全部基于防御视角的源码复盘。真正修复时重点不是研究攻击者怎么构造请求而是让状态机在任何异常输入下都保持不变量。4. 补丁怎么做从根因出发的修复与回归4.1 校验收敛在 COIN 层做统一的金额合理性检查修复不是简单在每个入口加一行if amount.Sign() 0。如果只是这样补后面还会有人绕过。正确做法是把所有金额校验、记账操作收敛到一个统一的 Coin 内核里让外部只能调用这个内核不能直接操作 StateDB 的余额字段。我重构后的思路是func (s *StateDB) Transfer(from, to string, amount *big.Int, nonce uint64) error { if amount nil || amount.Sign() 0 { return ErrInvalidAmount } if amount.Cmp(maxCoinAmount) 0 { return ErrAmountTooLarge } if from to { return ErrSameAccount } fromAcc : s.GetAccount(from) toAcc : s.GetAccount(to) if fromAcc.Balance.Cmp(amount) 0 { return ErrInsufficientBalance } fromAcc.Balance.Sub(fromAcc.Balance, amount) toAcc.Balance.Add(toAcc.Balance, amount) if s.totalSupplyChanged() { return ErrSupplyChanged } if err : s.db.Update(func(tx *DBTx) error { tx.SetBalance(from, fromAcc.Balance) tx.SetBalance(to, toAcc.Balance) return nil }); err ! nil { return err } return nil }这个实现有几个关键点首先金额校验放在最前面值域问题一次性拦截。amount.Sign() 0直接挡住负数和零值maxCoinAmount挡住超长字符串膨胀出的巨型数字。其次转账过程被包装成一个不可拆分的方法外部无法先改from再改to。这保证了扣减和增加永远来自同一个amount变量。再次totalSupplyChanged()是一个很简单的全局守恒检查如果这笔交易让整个网络流通量发生变化直接失败。它的计算成本可以通过缓存总供应量来摊销。最后状态库写入使用了事务包装。内存值可以先算好但真正的持久化必须在一个事务里原子提交。数据库层失败时内存中的余额不会对外可见也就不会出现脏中间态。4.2 回归测试矩阵与上线观察点把补丁写完只是第一步回归验证才是真正容易出问题的地方。我给项目 X 设计的测试矩阵覆盖了这些用例测试用例预期结果说明正常转账A 转 B 100成功供应量不变最基础回归A 转 B 0拒绝零值必须拦截A 转 B -1拒绝负数必须拦截A 转 B 超大数字拒绝超过上限必须拦截A 转 A 100拒绝自转账往往掩盖逻辑错误重复 Nonce拒绝防重放要生效转给不存在的地址拒绝或自动创建但需显式标注避免无界写状态并发转账同一账户总余额守恒验证事务隔离查询不带 limit拒绝无界查询彻底关闭上线时我习惯观察四个指标节点内存曲线、出块时间、状态树根哈希稳定性、交易池积压数量。前两个对应路径一后两个对应路径二和路径三。如果补丁后还有内存缓慢上涨多半是查询接口的 limit 参数没有真正透传到 P2P 层。提示回归测试里最容易漏掉的是“金额超大”这一项。很多项目测试了负数和零值却忘记测一个 1024 位长度的数字字符串结果big.Int解析成功循环计算时直接把 CPU 占满。这种问题在联调环境很难触发但在公网一定有人试。5. 由 COIN Attacks 沉淀下来的币值审计清单5.1 五条可以直接抄的检查规则这次审计做完我把 COIN 相关源码的检查点收敛成了五条规则。之后的任何链项目只要涉及资产结构体我都先用这套规则扫一遍所有金额必须来自同一个校验函数。搜代码里出现SetString、FromInt64、Bytes()的位置只要金额转换不在统一入口先记一个风险点。转账模块不允许直接操作余额字段。任何外部入口都只能调用类似Transfer、BalanceOf的封装方法不能把state.GetAccount返回的对象直接改。查询函数必须强制分页。没有 limit 的查询一旦暴露给 RPC 或 P2P 消息就是内存炸弹。limit 上限要看单条 Coin 记录的大小和节点可用内存通常几百条以内比较安全。状态提交必须事务化。内存计算和数据库写入不能分开失败了一定要有回滚或者 WAL 日志保证一致性。全局供应量是最高层不变量。任何交易执行前后都不能让总供应量发生变化除非明确是铸造或销毁交易。这套规则在项目 X 上跑了一遍直接把三个入口的问题全暴露出来。后来我又拿它扫了一个 EVM 兼容链的 gas 代币模块照样能发现问题。它不挑语言核心是“币值语义不能散落在各处”。5.2 从 COIN 扩展把“金额语义”做成通用审计视角最后想分享一个方法论上的收获。COIN Attacks 虽然名字里带 COIN但它本质上是一类“金额语义”问题。你可以把它扩展到积分系统、稳定币模块、游戏道具、跨境结算系统——只要代码里有余额、有扣减、有增加、有查询就一定有输入解析和状态记账的边界。每当我看到一个系统反复出现“余额对不上”的问题我不会先去查某一次具体的运算有没有写错而是去查金额从请求到内存、从内存到状态库、从状态库到共识结果这中间经过了几个函数有没有任何一步可以绕过校验。这次项目 X 给我的教训是不要迷信“反序列化后马上做余额比较”这种写法。格式合法和业务合法是两回事余额够不扣和金额值合法也是两回事。真正稳固的实现必须把金额的合法值域、转账户对、总供应量守恒全部收敛到同一个内核再让所有入口共用这个内核。后来我再遇到类似的安全通告已经不会被 COIN Attacks 这种代号唬住了。先把状态机的入口画出来再按“铸造、转账、查询、销毁”一条条过90% 的问题都会在溯源过程中自己浮出来。这也算是这次源码分析给我留下的最值钱的习惯。