返回博客

约940万美元损失:Injective、Aquifer遭利用攻击 | BlockSec Weekly

Code Auditing
2026年9月11日
阅读约 30 分钟
核心要点
  • 本周发生四起事件,在 Injective、Solana、Ethereum 和 Flow EVM 上造成了约 940 万美元的损失。

  • Injective、Aquifer 和 Notional Finance 的漏洞利用均无需价格操纵,也无需闪电贷。每一起都只是让协议自身的账务系统生成了一个从未真实存在的数字:在 Injective 上,一个标识符与某市场相冲突的保险基金,用一种价值仅为几分之一美分的不同代币的未经检查余额,结算了一笔虚构的 12,744 USDC 缺口;而在 Notional Finance 上,一笔精确为 -2^128 的债务被估值为零。

  • 在四个案例中的三个里,协议其实已经写好了正确的检查,只是没有放在真正关键的路径上。Aquifer 部署了一个 Token Program 白名单,但其交换入口点从未调用它;Ankr FLOW 用暂停修饰符保护了一个质押入口点,却让其对应的另一个入口点保持开放;Notional Finance 在某个函数中的一次转换使用了带检查的强制转换,但其上方的转换仍是原始的强制转换。

在过去一周(2026/08/31 - 2026/09/06),我们观察到 4 起安全事件,估计总损失约为 940 万美元。

日期 事件 类型 估计损失
2026/08/31 Ankr FLOW 有缺陷的状态验证 ~$410K
2026/08/31 Aquifer 有缺陷的输入验证 ~$2.47M
2026/08/31 Injective 缺失的计价单位验证 ~$4.8M
2026/09/03 Notional Finance 不安全的类型转换 ~$1.73M

Web3 领域最佳安全审计方

在上线前验证设计、代码与业务逻辑

本周焦点:Injective

本事件因其攻击链的复杂性和损失规模而成为本周焦点:必须先让两个独立的缺陷相互吻合,才能触发一次结算支付。这两个缺陷都存在于链本身的交易所逻辑中,而非某个应用合约中。它展示了一个由多个字段直接拼接、不加分隔符组成的标识符,是如何在无声无息中将两个本不应有关联的对象合并起来的,以及当结算路径从未检查一个基金所持有的计价单位是否与其所支持的市场一致时,这种合并会造成多大的代价。

2026/08/31,Injective 的 exchange 模块内的二元期权逻辑被利用,损失约 480 万美元的 USDC。Injective 是一条 layer-1 链,将订单簿交易所直接内置于链本身,因此受影响的代码是节点软件的一部分,而不是某人部署的合约。一个持有 INJ(Injective 的原生代币)的保险基金,由于两个标识符发生了碰撞,最终被绑定到了一个以 USDC 计价的二元期权市场上,而依赖保险基金进行结算的路径从未对这两者进行比较。攻击者在这样一个市场中与自己的子账户进行交易,制造出一笔亏空,协议随后用价值不到一分钱的 INJ 余额来弥补这笔亏空。所有仓位都得到了全额退款,攻击者提取的金额远远超过了他们的存入金额。

背景

Injective 的 exchange 模块列出了二元期权市场,这些市场是对是/否结果的完全抵押押注。任何人都可以通过支付上架费用来上架一个市场,选择用于结算的预言机,以及到期和结算的时间戳。预言机由一个提供方加一个符号组成:成为提供方需要经过治理投票,而符号则是上架者提供的任意字符串。交易者首先将报价代币(例如 USDC)存入一个子账户,然后通过下单来选择方向。BUY 押注事件会发生,锁定 P * Q 作为保证金;SELL 押注事件不会发生,锁定 (1 - P) * Q,其中 Q 是合约数量,P 是范围在 [0, 1] 之间的入场价格。因此,押注更可能发生结果的一方需要锁定更大的保证金。EndBlocker 是链在每个区块末尾运行的钩子,会将同价的一个 BUY 和一个 SELL 匹配成一个 LONG 仓位和一个 SHORT 仓位。由于这两笔锁定金额之和恒等于 Q,一个刚匹配完成的账本在结构上必然是全额出资的。

到期时,预言机发布一个位于 [0, 1] 之间的结算价格 S,每个仓位从市场资金池中获得 margin ± (S - entry) * Q 的支付。参与者之间的支付严格为零和,且每笔支付都以零为下限,因此仓位永远不会变为负数,也无需清算。仓位也可以通过下一个 margin = 0 的反向订单提前平仓,这会释放其保证金以及已实现的利润,这部分利润是从新开仓者锁定的保证金中支付的。每个仓位的保证金始终保持在开仓时锁定的数值,因此在提前平仓之后,账本上的保证金总额就不再需要与资金池相加吻合。

一个没有任何提供方发布符号价格的市场,到结算时根本没有价格可用。该模块为这种情况设有一个回退机制:结算会进入由 getBinaryOptionsSocializedLossDataWithRefundFlag() 实现的退款路径,该路径会解散市场而不是进行正常结算。每个仓位都会简单地退回其保证金,因为该路径是按各自的入场价格来平仓每一笔仓位的,所以没有仓位会记录利润或亏损——只要账本仍然持有与仓位所声明的相符的资产即可。退款资金来源于市场余额,若有缺口,则由与该市场关联的保险基金来弥补。

市场和保险基金是两个独立的对象,各自由自己的消息创建,并各自携带一个计价单位:市场以某种代币计价,而基金持有其创建时所使用的代币。它们通过身份来配对:一个基金支持的是其 ID 与自身 ID 相同的市场。这两个 ID 都是由身份字段计算出的 keccak256 摘要。exchange 模块本身是一个统一的总账户,其各个市场和各个基金的余额都是原始的整数记账。

漏洞分析

存在缺陷的组件是 injective-coreexchange 模块的二元期权处理逻辑,该问题已在提交 b994d6b6 [1] 中得到修复。两个相互衔接的缺陷使得一个持有某种计价单位的基金能够支持一个以另一种计价单位计价的市场。

缺陷一:标识符是从未加分隔符的拼接字符串中派生出来的。 在市场上架时计算市场 ID 的 NewBinaryOptionsMarketID(),以及计算新基金意图支持的市场 ID 的 CreateInsuranceFund()(对于 expiry = BinaryOptionsExpiryFlag = -2 的情况),二者都是从同一个表达式派生出该 ID 的:

return crypto.Keccak256Hash([]byte((BINARY_OPTIONS_MARKET_ID_PREFIX +
    oracleType.String() + ticker + quoteDenom + oracleSymbol + oracleProvider)))

其中没有字段分隔符,也没有长度前缀,因此字段之间的边界在被哈希的字节中不留下任何痕迹。CreateInsuranceFund() 将保险基金自身的字段映射到这些位置上,其中 oracle_base 占据 oracleSymbol 的位置,oracle_quote 占据 oracleProvider 的位置。因此,一个基金元组和一个市场元组可以产生逐字节相同的原像,同时以不同方式将这些字节分配到各个字段中,此时这两个对象便共享同一个 ID。注册流程接受这个共享 ID 作为它们之间的关联,因此一个基金可以成为某个市场的保险基金,而它实际持有的却不是该市场的 quoteDenom

缺陷二:支付款从未与市场的计价单位进行核对。 PayDeficitFromInsuranceFund() 会按基金所持有的计价单位将原始代币从基金中转出,然后将同样的原始整数记入市场余额,其间从未比较过 insuranceFund.DepositDenom 与市场的报价计价单位。由于该模块的记账方式是纯粹的整数,一枚原始单位的 INJ 和一枚原始单位的 USDC 在这条路径上是无法区分的,尽管同一个整数所代表的价值实际相差约 10^11 倍。修复提交在流入和流出两端都添加了此路径原本缺失的检查,从而使一个基金只能支持以其所持计价单位计价的市场:

同一提交还在 Injective 主网上硬性禁用了二元期权的交易和结算功能,从而消除了退款路径作为攻击面的可能性。

攻击分析

以下所有步骤都由同一个钱包通过其自己的三个子账户(...037c...037d...037e)执行,Q = 15,930

这对相互碰撞的对象是通过调整字段边界的位置、同时保持拼接后的字节完全相同来构造的。基金的 tickerquoteDenomXinj)拼出了市场的 ticker Xinj,而基金的 oracle_base(一个合约地址和一个预言机符号粘合而成)拼出了市场的 quoteDenom 紧接着市场的 oracleSymbol

拼接中的位置 基金 (MsgCreateInsuranceFund) 市场 (MsgInstantBinaryOptionsMarketLaunch)
前缀 -BINARY-OPTIONS-MARKET- -BINARY-OPTIONS-MARKET-
oracleType.String() Provider Provider
ticker X Xinj
quoteDenom inj erc20:0xa00C...235a
oracleSymbol erc20:0xa00C...235aNO_PRICE_FOR_REFUND...297,由基金的 oracle_base 填入,两部分被打包填入同一个字段 NO_PRICE_FOR_REFUND...297
oracleProvider Frontrunner,由基金的 oracle_quote 填入 Frontrunner

两者都解析为 0x9793a39f82993cdedb0c82c614102d5aba2451f29709dc9a6f7df191020b4efc。名为 NO_PRICE_FOR_REFUND 的预言机被配置为永远不会发布价格,这就迫使结算走向退款路径。

以下分析基于交易 0x6ae9cb...51dcf8

  • 第一步:在区块 181024772 的一笔原子交易中,攻击者创建了这对相互碰撞的对象——以 INJ 计价的保险基金和以 USDC 计价的二元期权市场,向基金注入了 12,744,000,000 原始单位的 INJ(价值约 $0.000000063),并向三个子账户存入了共计 30,267.02 USDC。这笔零碎存款的数额经过精心设计,使其原始整数正好与攻击者计划制造的亏空相匹配。每个子账户都收到了它随后所需的恰好数量的保证金:
子账户 存入金额 (USDC) 所支持的保证金
037d 1,593.001593 0.10 价格买入(BUY)15,930 份,锁定 1,593(第二步)
037c 14,337.001593 0.10 价格卖出(SELL)15,930 份,锁定 14,337(第二步)
037e 14,337.014337 0.90 价格买入(BUY)15,930 份,锁定 14,337(第三步)
总计 30,267.017523 -
  • 第二步:在同一笔交易中,037d0.10 的价格下了一笔 15,930 份的 BUY 单,037c0.10 的价格下了一笔 15,930 份的 SELL 单。EndBlocker 将它们匹配成一个持有 1,593 保证金的 037d 的 LONG 仓位,和一个持有 14,337 保证金的 037c 的 SHORT 仓位。账本上的总保证金为 1.0Q = 15,930,恰好等于市场资金池所持有的金额,所以此时的账本与任何正常的全额抵押市场无法区分。

  • 第三步:两个区块外,1.1 秒后,在区块 181024774 的交易 0x012c17...2af694 中,037d0.90 的价格且 margin = 0 平掉了它的多头仓位,收到了 1,593 + (0.90 - 0.10) * 15,930 = 14,337。该支付款是从新开仓者 037e 所锁定的保证金中支付的,037e0.90 的价格下了一笔 15,930 份的 BUY 单,锁定了 14,337。已实现利润 0.8Q = 12,744 现在存在于 037d 的可用余额中,位于市场资金池之外,而剩余两个仓位的保证金仍留在账本上:此时的负债合计为 1.8Q = 28,674,而资金池仍只持有 1.0Q

  • 第四步:结算是由市场自身的时钟驱动的,而不是由某笔交易触发的。在每个区块开始时,该模块会挑选出所有结算时间戳已经过去的市场,并将其作为一个整体来结算,而不是逐个仓位结算。这个市场在创建 18 秒后触发结算,此时账本上仍有两个仓位:037c 的 SHORT 和 037e 的 LONG,各持有 14,337 的保证金。预言机始终保持沉默,因此退款路径计算出的负债为 1.8Q = 28,674,而理想化资产仅为 1.0Q = 15,930,报告出的亏空为 0.8Q = 12,744。这个亏空只存在于退款路径的记账中。在单一价格 S 下,每一方的支付款只取决于 S,而与其入场价格无关:空头方获得 (1 - S) * Q,多头方获得 S * Q,两者之和恰好等于资金池中的 Q。而退款路径却按各自的入场价格来支付,因此两个入场价格无法相互抵消:空头方获得了 (1 - 0.10) * Q,多头方获得了 0.90 * Q,各为 14,337。空头方收回了其全部保证金,就好像价格从未偏离 0.10 一样,这正是攻击者在第三步中已经提取过的那 0.8Q

  • 第五步:PayDeficitFromInsuranceFund() 通过从那个相互碰撞的基金中转出 12,744,000,000 原始单位的 INJ,并向市场资金池记入 12,744 USDC,来消除这笔亏空。既然该亏空已被报告为已弥补,针对剩余仓位的社会化亏损扣减便被跳过,每个仓位都获得了其全额保证金的退款。这笔亏空本身对任何人来说都不该造成损失:它本应由市场的保险基金弥补,若基金不足则通过扣减来弥补。而使这一切变得有利可图的关键在于,绑定到该市场的基金持有的是 INJ 零碎金额,而不是 USDC

  • 第六步:在区块 181024803 的交易 0xcb33ad...152eff 中,攻击者提取了 43,010,985,663 原始单位的 USDC,即 43,010.99 USDC,而此前存入的金额仅为 30,267.02 USDC。净收益为 12,743.97 USDC,从第一笔交易到最后一笔交易大约耗时 21 秒。

上述循环只是一个代表性回合。攻击者在 19 小时的窗口内,针对 299 个短期存在的二元期权市场重复了这一操作,每个市场都关联一个被配置为永不发布价格的预言机,且到期和结算时间戳之间仅相隔几秒 [2]。这些回合的净收益累计约达本次事件损失的 480 万美元。

结论

本次事件将标识符碰撞与支付路径上缺失的计价单位检查结合在了一起。基金本应支持与其身份相匹配的市场。但是,将身份字段首尾相连拼接而构造出的 ID 已不再记录一个字段结束、下一个字段开始的位置,因此两组不同的字段集合可能产生相同的 ID,而这种配对会将一个基金绑定到一个它实际上并不匹配的市场上。下游没有任何环节能捕捉到这种不匹配,因为那条依赖保险基金来弥补市场亏空的路径只比较金额,从不比较计价单位。攻击者利用这两个缺陷共同作用,在自己控制的市场内制造出一笔虚假的亏空,用价值不到一分钱的基金余额来结算它,然后带着资金池从未真正持有过的全额保证金退款离场。

更普遍地说,一个承载语义信息的标识符应该由保持结构的编码方式派生出来,对每个可变长度的字段都使用明确的分隔符或长度前缀,以确保两组不同的字段元组不可能映射到同一个摘要值。

开始使用 Phalcon Explorer

深入交易细节,做出明智决策

立即免费试用

本周更多事件

Ankr FLOW

2026/08/31,Ankr 在 Flow EVM 上的流动性质押服务遭到利用。Ankr 针对质押的 FLOW(该网络的原生代币)发行了两种不同的代币,每种代币都通过各自独立的入口来铸造。其中一条路径已被关闭,但存在第二条通往同一逻辑的路径,该路径绕过了本应强制执行暂停状态的检查,而在此期间该路径上的转换汇率已经过时了。攻击者通过该路径以远低于同类代币可赎回价格的成本进行铸造,然后将这一价差通过 Ankr 的赎回缓冲池、一个 Uniswap V3 资金池以及借贷协议 MORE Markets 进行循环套利。约 15.5M WFLOW(包装后的 FLOW),按当时价值约 41 万美元,从 MORE Markets 的储备中被抽走,攻击者在扣除滑点后实际获利约 24.6 万美元 [3]

背景

Ankr FLOW 是 Flow EVM 上的一项流动性质押服务。FlowStakingPoolFLOW 转发到 Cadence 用于验证人质押,并通过两种代币来代表所产生的仓位:不进行 rebase 的凭证代币 ankrFLOW,以及本身以 ankrFLOW 作为支撑、会进行 rebase 的持有型代币 aFLOWEVMb

这两种代币有各自独立的入口。凭证路径通过 stakeCerts()unstakeCerts() 进入 _stakeCerts()_unstakeCertsFor();持有型路径通过 stakeBonds()unstakeBonds() 进入 _stakeBonds()_unstakeBondsFor()。持有型路径的铸造功能还有第二个外部入口,stakeBondsWithCode(),它接收 Ankr 推荐计划的合作方代码,随后调用同一个内部函数 _stakeBonds()。每条路径都会从 InternetBondRatioFeed(这是发布 FLOW 与每种代币之间转换比率的合约)中读取各自对应的比率数据。

FlowStakingPool 还持有一个用于即时赎回的 FLOW 缓冲池。除该资金池之外,ankrFLOW 还在一个 Uniswap V3 的 ankrFLOW/WFLOW 资金池中进行交易,并被 MORE Markets(一个类似 Aave V3 风格的借贷协议)接受作为抵押品。MORE Markets 会根据每种资产设定的贷款价值比(LTV)来限制借款额度,并提供 e-mode(效率模式)分类:即将预期价格走势趋同的资产归为一组,一旦借款人为该分类启用 e-mode,就可以适用更高的 LTV。

漏洞分析

存在缺陷的合约是 FlowStakingPool0xfe81...287a),它根据从 InternetBondRatioFeed0x3201...de38f)读取的比率来铸造凭证代币 ankrFLOW0x1b97...14bdb)和持有型代币 aFLOWEVMb0xd6fd...f8d4a)。

两个缺陷相互叠加。首先,stakeBondsWithCode() 在调用 _stakeBonds() 时缺少 stakeBonds() 所强制执行的 bondStakingUnpaused 修饰器,因此即便该路径已被禁用,持有型代币路径依然可以被调用。其次,在 2025 年 4 月 29 日至 2026 年 8 月 27 日期间的 71 次每周比率更新批次中,只有活跃的 ankrFLOW 条目得到了刷新,导致 aFLOWEVMb 的条目一直停留在 1.0

因此,这两个比率对同一份底层质押给出了不同的定价:

方向 函数 比率 转换关系
铸造 _stakeCerts() / stakeCerts() 0.833437 1 FLOW 兑 0.833437 ankrFLOW
铸造 _stakeBonds() / stakeBondsWithCode() 1.0 1 FLOW 兑 1 aFLOWEVMb
赎回 _unstakeCertsFor() / unstakeCerts() 0.833437 1 ankrFLOW 兑 约 1.19985 FLOW
赎回 _unstakeBondsFor() / unstakeBonds() 1.0 1 aFLOWEVMb 兑 1 FLOW

由于 aFLOWEVMb 是以 ankrFLOW 作为支撑的,通过持有型路径铸造时,每存入 1 份 FLOW 就产生 1 份有支撑的 ankrFLOW,而凭证路径只产生 0.833437。而通过凭证路径进行赎回时,仍然是按每份 ankrFLOW 支付 约 1.19985 FLOW

攻击分析

以下分析基于交易 0x2b2e6e...3f66c9

  • 第一步:攻击者通过在两条路径之间进行一次往返操作来筹集启动资金。他们从 Uniswap V3 的 ankrFLOW/WFLOW 资金池闪电借入 5,000 ankrFLOW,通过 unstakeCerts() 赎回,获得约 5,999.25 FLOW,随后通过 stakeBondsWithCode() 存入 5,000.50 FLOW,铸造出以同等数量 ankrFLOW 作为支撑的 5,000.50 aFLOWEVMb,并调用 unlockShares() 释放这些 ankrFLOW 用以偿还借款及其 0.50 ankrFLOW 的手续费。约 998.75 FLOW 作为剩余的运营资金留存下来。

  • 第二步:攻击者重复进行了 50 次 Uniswap V3 兑换操作。每一轮循环都以带价格限制的方式将 ankrFLOW 兑换为 WFLOW,在兑换回调中,通过 stakeBondsWithCode()unlockShares()FLOW 转化为欠资金池的 ankrFLOW 并铸造出来偿还,然后将得到的 WFLOW 解包以用于下一轮循环。在这 50 轮循环中,该资金池共收到约 38,634,755.38 ankrFLOW,并支付出约 46,265,167.78 WFLOW,使攻击者的余额从约 998.75 FLOW 增长到约 7,631,411.14 FLOW

  • 第三步:攻击者通过持有型路径转换了约 38,601.95 FLOW,并通过 unstakeCerts() 赎回所得的 ankrFLOW,抽走了当时 FlowStakingPool 仍持有的约 46,316.57 FLOW 的赎回缓冲池资金,使自身余额增加约 7,714.62 FLOW

  • 第四步:攻击者转向 MORE Markets。他们启用了 e-mode 分类 1("包装原生代币"),该分类将 ankrFLOWWFLOW 视为相关联的 FLOW 资产,将 ankrFLOW 的 LTV 从 78.5% 提高到 97%。他们通过 stakeBondsWithCode() 存入约 7,639,125.76 FLOW,解锁相应的 ankrFLOW,将其作为抵押品供入,并借出约 5,668,483.10 WFLOW。将这笔借款解包后再通过同一路径重新存入(一轮循环借贷)又增加了等量的 ankrFLOW,使抵押品增至约 13,307,608.86 ankrFLOW,从而支撑起第二笔约 9,819,641.05 WFLOW 的借款,总债务达到约 15,488,124.15 WFLOW,约占按 约1.19985 WFLOW 预言机汇率计算出的抵押品价值的 97%。

第四步中的抵押品所返还的价值超过了铸造它所付出的成本:通过持有型路径,每 1 份 FLOW 铸造出 1 份 ankrFLOW,而预言机将该份 ankrFLOW 估值为约 1.19985 WFLOW,e-mode 又允许按其价值的 97% 借款,因此供入的约 13.31M ankrFLOW 支撑起了约 15.49M WFLOW 的债务,即每存入 1 份 FLOW 就能借出约 1.16 WFLOW。攻击者只进行了一轮循环,因为第二次借款后 WFLOW 储备已被抽空。

那笔约 15.49M WFLOW 是借款的毛额,也就是报告中所说的从 MORE Markets 储备中被抽走的 15.5M WFLOW。其中约 5.67M WFLOW 被解包并循环转化为了额外的抵押品,而不是作为流动收益留存;一旦第二笔约 9,819,641.05 WFLOW 的借款被解包,攻击者手中持有的约 9,819,641.05 FLOW 就是其最终变现的收益。按价值来源划分,该变现收益中约 7,630,412.39 FLOW 来自 Uniswap V3 资金池,约 8,713.37 FLOW 来自 FlowStakingPool,约 2,180,515.29 FLOW 来自 MORE Markets。

结论

此事件的根本原因在于:一个状态检查只守护了某个入口,却未能覆盖其姊妹入口——一条已被禁用的铸造路径仍然可以被调用,而其比率在长达十六个月的每周更新中一直未被刷新。因此,任何经由该路径转入的 FLOW 都会以凭证路径本不会提供的价格产出 ankrFLOW,攻击者将这一价差通过 Uniswap V3 资金池、质押池的赎回缓冲池以及一个借贷市场循环套利,在每一步都将廉价的 ankrFLOW 转化为 WFLOW 流动性。

暂停保护机制必须在通往被禁用逻辑的每一个入口处强制执行,而不仅仅是在预期会被调用的那一个入口;服务于一条休眠路径的比率数据源应当保持及时更新,或者被设计为在这种情况下回退(revert)。借贷协议也应避免对一种其铸造价格独立于其预言机价格设定的流动性质押代币授予过高的 e-mode LTV;同时监测铸造成本、赎回价值和预言机价格三者,有助于及早发现这种偏离。


Aquifer

2026/08/31,Aquifer——Solana 上的一个专有做市商 AMM——遭到利用,损失约 247 万美元,涉及 212 笔成功的兑换交易,分布在 USDCUSDTHYPEcbBTCCASH 及其他十三种代币中 [4]。每笔兑换都要结算为两笔方向相反的代币转账,Aquifer 允许调用方为每笔转账指定由哪个程序来执行,却从未对这一选择进行任何核实。攻击者为本应向 Aquifer 付款的那笔转账指定了一个自己控制的程序,该程序在什么都没转移的情况下报告执行成功;而另一方向的转账则通过真正的 Token Program 执行,从 Aquifer 的资金库中转出了真实的资产。

背景

Aquifer 是一个 Prop AMM,意思是由专业做市商提供自己的库存并维持买卖报价,而不是像 Uniswap V2 风格的资金池那样沿着恒定乘积曲线来为兑换定价。Aquifer 根据其报价和风险状态推算出一个价格,然后在用户的 Token Account 和自身的资金库之间完成交易结算。

在 Solana 上,Token Program 是实现转账、铸造和销毁等操作的可执行程序。Tokenkeg 是原始的 SPL Token Program,Token-2022 则是其可扩展的后继版本;每一个都管理着许多不同的代币,而不是单一资产。Mint Account 用于标识一种代币类型,并存储其供应量、精度和权限信息,而 Token Account 则为某一持有者存储针对某个 Mint 的余额;两者都由管理它们的 Token Program 所拥有。Aquifer 的资金库是由 Aquifer 的 PDA 控制的 Token Account。

交易者通过调用 Aquifer 的 swap 指令并传入该指令将涉及的账户来进行交易,其中包括为两笔转账各自指定应由哪个 Token Program 执行。Aquifer 通过跨程序调用(CPI)来转移这些代币:它构建一个转账指令,并交给由 Instruction.program_id 所指定的程序去执行。只有当正确的 Token Program 执行转账形式的指令数据时,才会真正转移 SPL 代币。

漏洞分析

存在缺陷的程序是 Aquifer(AQU1FR...Tz45)。目前没有已公开的源码与其部署的字节码相匹配,因此以下分析是从该程序的反汇编代码中还原出来的:fn_ 名称是根据代码偏移量为内部函数所标注的标签,本文中使用的所有名称(包括 swap)都并非来自开发者。该程序包含一个白名单函数 fn_49740(),该函数只接受 Tokenkeg 和 Token-2022,但在处理 swap 时的执行路径上,没有任何地方调用它。在处理一次 swap 时,程序通过 fn_45f20()fn_46bf8(),使用一个硬编码的 Tokenkeg 常量来构建两个转账指令,该常量必然能通过它们内部的检查,随后在调用之前,用调用方提供的 Token Program 覆盖每个指令的 program_id

// 这是对程序在处理一次 swap 时所运行代码的语义还原;construct_transfer()
// 代表 fn_45f20() 和 fn_46bf8(),白名单函数 fn_49740() 从未被执行到。
let checked_program = TOKENKEG_ID;

let mut output_instruction = construct_transfer(checked_program, output_accounts);
let mut input_instruction = construct_transfer(checked_program, input_accounts);

// 未经检查的调用方输入替换了原本经过检查的值。
output_instruction.program_id = caller.token_program_b.key;
input_instruction.program_id = caller.token_program_a.key;

// 每次 invoke() 都将转账指令交给调用方指定的任意程序去执行。
invoke(output_instruction)?;
invoke(input_instruction)?;

这里的 TOKENKEG_ID 指的是 TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA。因此,真正通过验证的那个值从来不是最终被执行的那个值。更严重的是,该程序在输入方 CPI 返回后并不核实资金库实际收到的金额,因此一次“成功”却什么都没转移的 CPI 会被视为已完成付款。

攻击分析

本次事件共包含 212 笔成功的攻击交易。以下分析基于交易 4pBV1G...T3bf,作为一个代表性示例。

  • 第一步:攻击者调用 swap,名义上输入 4,957.497101 USDC,Aquifer 据此计算出应输出 195,849.433667 KMNO。对于 KMNO 那一端,他们提供的是 Tokenkeg;而对于 USDC 那一端,他们提供的是自己的程序 DMBpPM...NRgb68,以及一个伪造的输入账户 9gsKJc...——这是一个由该程序所拥有的账户,其 165 字节的数据模拟了一个 SPL Token Account,携带 USDC Mint、攻击者的权限地址,以及 u64::MAX 的余额。

  • 第二步:输出方的 CPI 到达 Tokenkeg,将 195,849.433667 KMNO 从 Aquifer 的 KMNO 资金库转出,转入攻击者的 Token Account EUjkGc...,该账户由签名者 7fTe9p...4gRk7J 控制。

  • 第三步:输入方的 CPI 到达 DMBpPM...NRgb68,携带 USDC 转账形式的数据。该程序返回了成功,却没有将任何 USDC 从伪造的来源账户 9gsKJc... 转移到真正的 Aquifer USDC 资金库 7ULN1Y... 中。
  • 第四步:Aquifer 接受了两次 CPI 的返回结果,于是这次兑换以原子方式完成提交。

本次交易的余额变化如下所示。

账户 变化前 变化后 变化量
Aquifer KMNO 资金库 9BHsZp...FHSqG 604,968.018277 KMNO 409,118.584610 KMNO -195,849.433667 KMNO
攻击者 KMNO 账户 EUjkGc... 0 KMNO 195,849.433667 KMNO +195,849.433667 KMNO
Aquifer USDC 资金库 7ULN1Y... 1,620,342.341679 USDC 1,620,342.341679 USDC 0 USDC

上述数据仅描述这一笔示例交易,并非本次事件的总损失。

结论

根本原因在于结算路径上存在一个未经验证的调用方输入,而不是价格或预言机操纵:兑换路径先验证了一个 Token Program 常量,随后在调用该指令之前又将其替换成了调用方提供的值,因此由哪个程序来执行本应向 Aquifer 付款的那笔转账,其实完全由调用方自行决定。由于没有对资金库进行转账后的余额核查,一个返回成功却未转移任何代币的程序也能“满足”付款要求,而反方向的转账却真实地转移了资产。

每一次 CPI 都应绑定到拥有所涉 Mint 的那个 Token Program 上,该信息应从 Mint Account 中解析得出,而不是取自调用方提供的账户;同时应在输入方转账前后分别读取资金库的余额,从而确保只有当资金库确实收到了报价数量的资产时,兑换才能成立,否则应回退(revert)。


Notional Finance

2026/09/03 至 09/04(UTC 时间),以太坊上的 Notional Finance V1 遭到利用,损失约 173 万美元,以 69,257.37 DAI1,658,524.86 USDC 的形式被抽走。在允许某个账户举债之前,协议会对该账户所持有和所欠的一切进行估值,而该路径上一次不安全的数值转换将一笔本应规模合适的债务压缩为零。于是这项检查放行了一个负债已“凭空消失”的账户,而该账户所创造出的那笔巨额债权却完好无损地留在攻击者控制的另一个合约上。攻击者精心安排,使这笔伪造的债权恰好在协议的下一个到期日——UTC 午夜——到期,并在几分钟后完成结算,从而提走了协议当时仍持有的 DAIUSDC

背景

Notional Finance V1 是以太坊上的一个固定利率借贷协议。它使用 fCash 来表示预定到期日的现金流:CASH_RECEIVER 是一个正数仓位,享有在到期时收取资产的权利;CASH_PAYER 是一个负数仓位,负有在到期时支付资产的义务。每个账户的 fCash 及其他仓位都记录在其 Portfolio 中,其中每种资产由其 cash group 及到期日共同标识;cash group 决定了该资产将以哪种货币结算。单个资产的名义金额(即其到期时结算所用的数额)是一个 uint128 类型。

ERC1155Trade.safeTransferFrom() 会在两个账户之间创建一对 fCash。该调用的形式类似于 ERC-1155 转账,但实际上并没有任何资产发生转移:它调用 Portfolios.mintfCashPair() 来创建两个相互抵消的仓位——一个正数仓位给接收方,一个等额的负数仓位给付款方。每一方都通过 _upsertAsset() 被写入相应的 Portfolio 中,该函数只有在 cash group 和到期日都相匹配时,才会将新仓位并入已有条目,并通过经 SafeUInt128 检查的加法运算,将两个名义金额相加。

偿付能力检查是通过对付款方调用 freeCollateral() 来进行的,该函数将账户在 Escrow 中的现金余额与其 Portfolio 估值相结合,按货币逐项汇总为一个带符号的 int256。随后,每种货币的余额都会按该货币的汇率转换为 ETH 计价,其中汇率和小数位的换算都以整数除法的形式进行,最终得出的自由抵押额(free collateral)必须为非负数。到期时,fCash 仓位会通过 Escrow.portfolioSettleCash() 结算进该账户的现金余额中,而正的现金余额随后即可作为对应的底层资产从 Escrow 中提取出来。

漏洞分析

存在缺陷的合约是 ERC1155Trade 入口(0xbba8...ef08)——该合约在铸造 fCash 对时没有任何名义金额上限,以及 Escrow(0x9abd...f683)——其抵押品估值逻辑中的 _convertToETH() 存在两个算术缺陷,可能将一笔债务估值为零。

首先,一次未经检查的窄化转换可能将一笔巨额债务截断为零。该函数用一次直接的类型转换(raw cast)将 balance.abs() 直接转换为 uint128,而从未检查该值是否能容纳于目标类型之内。这两种类型之间的差距十分巨大:带符号的 int256 可以达到 2^255 - 1,而 uint128 的上限仅为 2^128 - 1。因此,一个恰好为 -2^128 的余额完全可以正常容纳于 int256 之中,balance.abs() 也能完整地得出 2^128。真正出问题的是这次类型转换本身:2^128 恰好超出了 uint128 所能容纳的范围一个单位,于是发生回绕(wrap)变为 0,导致后续的估值计算实际上操作的是一个零余额,整笔债务就从自由抵押额的计算中彻底消失了。同一个函数在稍后对计算出的 ETH 价值进行转换时,使用的是 SafeCast.toUint128()(该函数在结果超出范围时会回退),而前面这次转换却仍然是一次直接的类型转换,会悄无声息地截断数值。

其次,整数除法可能将小额债务向下舍入为零。除以 er.rateDecimalsbaseDecimals 的运算会截断余数,因此一笔足够小的债务同样会被估值为 0 并从自由抵押额中被排除在外。

攻击分析

以下分析基于交易 0xe1589a...25d60a

  • 第一步:在 2026 年 9 月 3 日 UTC 时间 23:58:47,攻击者调用 ERC1155Trade 上的 safeTransferFrom(),以 cashGroupId = 2、到期时间戳 1788480000(2026 年 9 月 4 日 UTC 00:00,比当前提前 73 秒,是协议当时开放的两个到期日中较近的一个)铸造了一对数额为 1 的 fCash。在铸造过程中,_upsertAsset() 将这笔负的 fCash 负债记录在攻击者的合约上,将等额的正数债权记录在接收方合约上,Portfolios 随即对攻击者合约进行了自由抵押额检查。该合约在任何货币下都没有余额,因此若这笔新增负债的估值为正数,检查本应失败。而正是第二个缺陷掩盖了这一点:按当时的汇率,一笔数额为 1 的债务在经过两次整数除法之后无法“存活”下来,因而被估值为零,检查也就顺利通过了。

  • 第二步:攻击者再次调用 safeTransferFrom(),铸造了第二对数额为 uint128.max340,282,366,920,938,463,463,374,607,431,768,211,455)的 fCash。这一对同样使用 cashGroupId = 2,但到期时间戳为 1796256000(2026 年 12 月 3 日 UTC 00:00,即另一个开放的到期日),并将其正数一侧发送到了另一个不同的接收方合约。负数一侧再次落在攻击者的合约上,与第一步中的那笔负债并列存在。单次铸造永远不可能超过 2^128 - 1,总是比真正触发截断的数值少一个单位,因此需要两笔仓位才能凑到这个数值。不同的到期日使这两笔负债保持在独立的条目中,从而躲过了本会在合并时触发回退的加法检查;然而相同的 cash group 仍然将它们归入同一个货币槛位进行估值,第一步中那 1 单位的负债正好把总额推到了恰好 -2^128

  • 第三步:在偿付能力检查过程中,Escrow 代理合约调用了 convertBalancesToETH()。汇总后的负余额恰好达到 -2^128,被传入 _convertToETH() 并被截断为零,因此该账户以 ETH 计价的负债被报告为零。

  • 第四步:由于这笔债务在抵押品记账中“消失”,第二个接收方合约持有的那笔巨额正数 fCash 仓位就被协议视为了可用的抵押品。仍在同一笔交易中,该合约以此作为抵押,又铸造了另外两对 fCash,并将正数一侧交给了另外两个接收方合约:一笔为 69,257.37,使用 cashGroupId = 2(以 DAI 结算),另一笔为 1,658,524.86,使用 cashGroupId = 3(以 USDC 结算)。这两个数字都来自攻击者在交易开始时对 Escrow 所做的 balanceOf 调用,因此每笔债权的数额都恰好对应 Escrow 实际持有的余额。这两笔都设定为到期时间戳 1788480000,距离当时还有 73 秒。

  • 第五步:在 2026 年 9 月 4 日 UTC 时间 00:01:35,即该到期日过后 95 秒,攻击者在交易 0xc3f3e3...a24efa 中结算了这两笔已到期的债权,从 Escrow 中提取了约 69,257.37 DAI 和约 1,658,524.86 USDC。这些资金随后被转发至 0x8aaf...3be6

结论

铸造路径没有设置任何名义金额上限,而抵押品估值中的两个算术缺陷各自都能使某一项偿付能力检查得以通过:舍入误差让一个一无所有的账户承担了它的第一笔负债,随后一次悄无声息的窄化转换将一笔数额恰好合适的债务估值为零,使得第二项检查在一个实际已严重资不抵债的账户上得以通过。接收方合约上那笔伪造的正数仓位随后被视为可用的抵押品,这使攻击者能够将其拆分为与 Escrow 余额相匹配的多笔债权,并提走了 Escrow 当时仍持有的 DAIUSDC

带符号的余额在进行任何窄化转换之前都必须先经过范围检查,而任何无法完整表示其输入值的转换都应当回退(revert),而不是进行截断。更广泛地说,一项偿付能力检查绝不应将一笔非零的债务报告为零——无论这种“消失”是由类型转换造成的,还是由舍入步骤造成的。仅仅应用该函数在后面已经使用过的那种经过检查的类型转换,或者对单次仓位铸造所能创造的名义金额设置上限,这两种做法中的任何一种,单独就足以阻止这次攻击的发生。

开始使用 Phalcon Security

侦测每一个威胁,只针对重要事件告警,并拦截攻击。

立即免费试用

参考资料

[1] https://github.com/InjectiveFoundation/injective-core/commit/b994d6b603eb53a38312e547f7bbe3c69d40495b

[2] https://mpost.io/injective-exploited-for-4-9m-via-market-id-collision-in-binary-options-settlement-logic/

[3] https://x.com/flow_blockchain/status/2094506622429307061

[4] https://bitquery.io/investigations/aquifer-solana-hack-2-5-million

关于 BlockSec

BlockSec 是一家全栈式区块链安全与加密合规服务提供商。我们打造的产品和服务,帮助客户在协议和平台的整个生命周期中,进行代码审计(涵盖智能合约、区块链及钱包)、实时拦截攻击、分析安全事件、追踪违法资金,并履行反洗钱/反恐融资(AML/CFT)义务。

BlockSec 已在多个知名会议上发表了多篇区块链安全论文,披露了多起 DeFi 应用的零日攻击,成功阻止了多起黑客攻击并为受害方挽回了超过 2000 万美元的资金,同时保障了价值数十亿美元的加密资产的安全。

订阅最新动态
超越智能合约:Web3中的域名与DNS运营安全
Security Insights

超越智能合约:Web3中的域名与DNS运营安全

合约审计只审合约本身。我们对DefiLlama TVL前100协议背后的100个域名进行了8项基于SEAL的DNS和注册商检查,共800项检查,只有一个域名全部通过。以下是大多数项目缺失的四项关键控制措施,以及它们在用户入口处为何至关重要。

Web3攻击面:渗透测试概览

Web3攻击面:渗透测试概览

加密机构承载所有传统攻击面,并叠加资金处理链。本文提供系统的实用抽象:应用、授权与签名、区块链交互、基础设施四组件模型,说明各自职责、代表实现及继承的攻击面,并将web3特有覆盖细分为五大攻击面:生产与自动化运维、签名意图、审批与提现链、资金逻辑及链上交易与已部署合约。

损失约2300万美元:Cosmos EVM与Moonwell遭利用攻击 | BlockSec Weekly
Security Insights

损失约2300万美元:Cosmos EVM与Moonwell遭利用攻击 | BlockSec Weekly

在报告期(2026/08/22-2026/08/30)内,我们统计5起区块链安全事件,损失约2270万美元;Tectonic被盗约7400万-1.195亿美元,因Cronos回滚至攻击前状态而多数被抹除。重点是六链Cosmos EVM漏洞系列(实现约570万美元),经TAC Chain溯源,共享余额同步漏洞串联下溢与上溢耗尽质押池。报告还分析了Moonwell的抵押品核算与预言机价格操纵组合攻击、Tectonic的预言机价格与凭证代币汇率操纵低流动性抵押品、Ajna清算业务逻辑缺陷,以及Solana上的Rain Card Contract Exploit Series(Ed25519签名验证绕过,涉及Avici、Tria等)。

Web3 最佳安全审计方

在上线之前验证设计、代码与业务逻辑,对标业内最高安全标准。

BlockSec 审计