
退款如何变成提款机NEAR Intents 380 万美元漏洞的代码级复盘9月30日NEAR Intents 在 BNB Chain 上的金库被分五笔提走约 387 万美元。官方把原因指向 Omni 充提基础设施与 Intents 合约的交互缺陷。多家审计机构没有发现这个问题。AlexAuroraDev 也确认标准审计没有识别出攻击者使用的特定操作模式和动作序列。这不是一个“随便扫一眼就能看出来”的漏洞。它藏在退款逻辑、日志大小限制、跨链 backing 和内部账本之间。下面从代码开始拆。事件轮廓损失约 387 万美元发生在 BNB Chain 金库。五笔提款持续约六小时。资金跨六条链转移大部分换成 BTC进入四个钱包。其中约 80.2 万美元流向 KuCoin 充值地址。攻击者后来归还全部 380 万美元NEAR Intents 给了 48 小时期限。但真正要看的是退款函数怎么变成武器。漏洞代码在哪里问题出在contracts/defuse/src/contract/tokens/mod.rs183 到 199 行附近。这个函数负责处理 Omni Bridge 的存款事件把存款记到intents.near内部账本。核心缺陷是退款金额被available_balance限制而不是当前这笔deposit_amount。当存款后续失败合约会按攻击者已有余额退款不是按实际存入金额退款。漏洞逻辑等价代码如下// 漏洞版退款边界用了 available_balance而不是 deposit_amountfnresolve_deposit_refund(account:AccountId,available_balance:Balance,deposit_amount:Balance,memo:str,)-ResultRefundEvent,Error{letrefund_amountavailable_balance;// 攻击者已有余额letrefund_eventRefundEvent{account_id:account.clone(),amount:refund_amount,memo:memo.to_string(),};Ok(refund_event)}问题很直接deposit_amount传进来了但没被用。正确做法应该把退款上限压到这笔存款// 修复版退款不能超过触发退款的这笔存款fnresolve_deposit_refund(account:AccountId,available_balance:Balance,deposit_amount:Balance,memo:str,)-ResultRefundEvent,Error{letrefund_amountstd::cmp::min(available_balance,deposit_amount);ifrefund_amountdeposit_amount{returnErr(Error::RefundExceedsDeposit);}letrefund_eventRefundEvent{account_id:account.clone(),amount:refund_amount,memo:memo.to_string(),};Ok(refund_event)}效果退款事件在结构上被这笔存款约束。攻击者就算账上有大额历史余额也不能通过退款路径把它放大成提款能力。攻击为什么成立一步一步拆第 1 步提前积累大额辅助代币余额。攻击者用的是一种自定义辅助代币。他在 NEAR 侧积累了相当规模的余额。这笔余额在 Intents 内部账本里是一笔 credit后面可以被动用。第 2 步在 BNB Chain 做小额存款。一个新钱包往金库打 10 USDT然后另一个钱包提 10 USDT再提 11 USDT整个过程约一小时。这些动作看起来像普通用户不会立刻触发监控。第 3 步触发退款事件引用那笔大额已有余额。因为 resolver 用available_balance限制退款攻击者可以构造一笔下游失败的存款事件让合约发出远大于实际存入额的退款。退款用高精度代币计价。第 4 步退款事件超过 NEAR 日志大小限制 11 字节。NEAR 的日志上限是 16,384 字节。大十进制金额把退款事件撑爆超出 11 字节Intents deposit resolver 失败。这个失败是关键转折。Omni Bridge 已经把存款 backing 退回给攻击者但之前成功入账的 Intents credit 还在。第 5 步用幸存的内部 credit 从 BNB 金库提真实资产。此时内部余额大于剩余 backing。攻击者走正常提款路径五次从 BNB Chain 的 HOT Bridge 金库抽走约 386.5 万美元 USDT。第 6 步跨六条链转移资金。约五分之一通过 NEAR Intents 自己 swap其中还包括 750 BNB 还回被盗金库。大部分赃款最终变成 BTC进入四个钱包约 80.2 万美元到 KuCoin 充值地址。缺少回滚为什么状态没被撤销BlockSec 把事件归类为 “Improper Refund Validation and Missing Rollback”。这个分类抓到了后半段问题。只有退款校验缺陷不一定灾难前提是退款失败时内部 credit 会回滚。原来的设计是deposit resolver 先给用户内部余额记账再调用接收账户的通知回调。当退款事件超过日志限制、resolver 失败时之前成功入账的 credit 没有被回滚。系统里没有一条状态反转路径说如果退款解析失败内部 credit 必须恢复到存款前状态。回滚机制大致应该长这样// 给内部账本记账后尝试通知和退款解析letcredit_resultintents_ledger.credit(account,amount);ifletErr(e)resolve_refund_event(...){// 回滚下游解析失败就撤销这次 creditintents_ledger.debit(account,amount)?;returnErr(Error::RefundResolutionFailed(e));}没有这段回滚攻击者的内部余额就保持虚增。即使 BNB Chain 上的真实 backing 已经被 Omni Bridge 退回账本里仍然显示他有可提款余额。审计为什么没发现审计漏掉它不是因为审计太粗心而是因为看错了抽象层。审计通常盯智能合约内部逻辑权限控制、算术安全、重入保护、标准代币流程。它们没有建模三层系统之间的交互BNB Chain 上的真实资产、NEAR 上的 HOT 代币表示、intents.near里的用户内部余额。漏洞住在层与层之间的缝隙。合约代码单独看没有明显错误。它要同时满足几个条件才会爆日志大小限制、大十进制退款金额、缺少回滚、Omni Bridge 独立的 backing 退回逻辑。没有哪家审计被要求在所有对抗条件下追踪完整路径。NEAR 创始人 Illia Polosukhin 随后宣布生态会在发布流程里加入形式化验证目标是数学证明代码在所有可能输入下都正确。形式化验证能抓这类 bug因为“退款金额不得超过存款金额”可以写成形式化不变量。人工审计很难可靠抓跨层运营 bug。真正的修复需要什么NEAR 后来打了补丁。intents.near在暂停前后代码哈希一致说明修复落在 resolver 层不需要整份合约升级。但工程教训比一行代码更深。第一退款校验必须由存款额约束而不是可用余额。这是立刻生效的代码级修复直接消掉攻击面。第二存款解析流程必须有回滚路径。退款事件失败内部 credit 必须回滚。否则系统会进入内部余额超过真实 backing 的不一致状态。第三跨系统会计不变量要持续验证。三层模型必须保持一个不变量总内部余额永远不超过总真实 backing。独立验证流程应该在任何提款签名前检查这一点。BlockSec 说得很准下游签名和执行不能基于错误的上游信息签名前和放款前都要独立检查。代码之外开发者该检查什么所有退款和补偿路径金额边界是什么是不是只看用户已有余额失败路径会不会留下已记账但没有 backing 的 credit跨链入账和出账是否共享同一状态机有没有补偿事务日志、事件、序列化有没有大小上限大数和高精度代币会不会触发边界审计范围有没有覆盖跨合约、跨链、跨账本交互有没有包含对抗序列上线前有没有验证全局不变量内部负债 真实资产关键不变量有没有形式化验证或运行时断言监控有没有关注“小额存提之后出现大额退款或提款”的模式总结攻击者归还了全部 380 万美元。但真正的教训不是追回。一个退款函数任何 bridge 或 vault 系统里最普通的代码因为信任了错误的数字又没有能力撤销自己的错误最后变成了多链盗窃入口。代码中最危险的部分往往并非复杂的密码学或花哨的跨链消息格式而是看似无害的 min 校验错误以及失败分支后缺失的 debit。NEAR Intents 此次 380 万美元事件正是这一点的注脚。