2026 年 8 月 31 日,Injective 停了大约 3 小时 42 分钟。官方说法是“加速网络升级”。链上研究者的说法更直接:这是一次 490 万美元的漏洞利用,验证者被迫紧急打补丁。
这次攻击没有用闪电贷,没有操纵价格,也没有部署什么第三方恶意合约。问题出在 Injective 自己的 exchange 模块,也就是链的核心代码。两个缺陷叠在一起,一个没做检查的整数比较,最后把账本算出了一个根本不该存在的数字。
先别急着看结论,我们从头拆。
事件背景:链停了,官方说升级,链上说被掏了
- Injective 是一条把订单簿交易所直接嵌进节点软件的 Layer-1。交易逻辑不是智能合约,而是链的原生代码。
- 攻击者没有从外部应用下手,而是利用了链原生 exchange 和 insurance 模块里的逻辑漏洞。
- 攻击持续了大约 19 小时,涉及 299 个即时二元期权市场。
- 最终约 490 万美元 USDC 被提取,桥到以太坊,换成了 ETH。
- 验证者在区块高度 181,027,005 停止出块,在 181,027,007 恢复。没有回滚,链就是停在那里等所有人打补丁。
- 部分验证者因为没在升级窗口内完成操作,被暂时 jailed。Coinbase 和 Coins.ph 暂停了 INJ 充提。
- 官方把这件事描述成“加速网络升级”,不是“漏洞响应”。基金会说共识没被破坏,质押的 INJ 没有风险。CEO Eric Chen 说网络用户没受影响,基金会配合了恢复。
- 链上研究员 Paddy-earthling 不买这个说法。他指出攻击用的消息来自链自己的原生 exchange 和 insurance 模块,不是外部应用代码。紧急补丁改的是协议核心代码,包括面额检查,还禁用了主网二元期权清算。
- 这个区别很重要。如果是应用合约漏洞,链的核心共识和记账模块还算稳。如果是原生 exchange 模块漏洞,那就是链自己的状态机算出了错误结果。这两件事不是一个性质。
- 截至 BlockSec 报告发布,Injective 还没有给出完整的技术复盘,也没说清楚 490 万美元损失由谁承担,生态基金怎么补。
攻击链:四步,每一步都不该连起来
第一步:造一个会碰撞的市场 ID
Injective 生成 market_id 的方式,是把五个字段直接拼起来,然后哈希:
oracleTypetickerquoteDenomoracleSymboloracleProvider
拼接时没有分隔符,也没有长度前缀。只要两个不同的市场配置拼出来是同一个字符串,它们就会得到同一个哈希,也就是同一个 market_id。
比如:
- 市场 A:
oracleType="price",ticker="BTC",quoteDenom="USDC",oracleSymbol="USD",oracleProvider="Pyth" - 市场 B:
oracleType="price",ticker="BTCUSDC",quoteDenom="",oracleSymbol="USD",oracleProvider="Pyth"
两者拼出来都是:
priceBTCUSDCUSDPyth
哈希一样,market_id 一样。协议根本分不清这是两个市场。
第二步:把 INJ 保险基金绑到 USDC 市场
攻击者创建了一个以 INJ 计价的保险基金。通过精心构造 ticker 和 oracle 相关字段,这个基金的标识符和某个以 USDC 报价的二元期权市场标识符撞在了一起。
从协议视角看,这两个东西变成了同一个对象。保险基金以为自己在给 INJ 市场兜底,实际上它已经被绑到了 USDC 市场。
第三步:自成交制造结算赤字
Injective 上的二元期权结算,是比较最终市场价格和行权价。如果出现缺口,就由保险基金来补。
攻击者控制了多个子账户,自己和自己对敲,开多和开空互相成交。这样就能制造出一个结算赤字。这个赤字并不是真实资金亏出来的,而是攻击者自己撮合出来的账面缺口。
第四步:用 INJ 结算 USDC 赤字
这一步是整个攻击最致命的地方。
结算路径从 INJ 保险基金里拿钱,去补一个以 USDC 计价的赤字。代码从来没有比较过:
insuranceFund.DepositDenommarket.QuoteDenom
它只是把 INJ 基金的原始整数余额转出去,然后把这个同样的整数记到 USDC 市场的余额上。
问题是,INJ 有 18 位小数,USDC 只有 6 位小数。一个极小金额的 INJ,价值不到一分钱,但它的原始整数在 USDC 账本上看起来就是一笔巨款。于是所有仓位都被全额退款,攻击者取出的钱远远超过他存进去的钱。
攻击者用这个方法,在 19 小时里重复操作了 299 个即时二元期权市场,最终拿走约 490 万美元 USDC。
代码分析一:市场 ID 是怎么撞车的
问题代码大概长这样:
// 有漏洞:字段之间没有分隔符
func GenerateMarketID(oracleType string, ticker string, quoteDenom string, oracleSymbol string, oracleProvider string) string {
raw := oracleType + ticker + quoteDenom + oracleSymbol + oracleProvider
return hex.EncodeToString(keccak256([]byte(raw)))
}
这段代码的问题很直白。五个字段直接相加,字段边界完全丢失。攻击者只要调整字段内容,就能让两组不同的配置生成同一个字符串,从而拿到同一个 market_id。
修复方案是给每个字段加长度前缀:
// 修复后:长度前缀编码,防止碰撞
func GenerateMarketID(oracleType string, ticker string, quoteDenom string, oracleSymbol string, oracleProvider string) string {
raw := fmt.Sprintf("%d:%s|%d:%s|%d:%s|%d:%s|%d:%s",
len(oracleType), oracleType,
len(ticker), ticker,
len(quoteDenom), quoteDenom,
len(oracleSymbol), oracleSymbol,
len(oracleProvider), oracleProvider)
return hex.EncodeToString(keccak256([]byte(raw)))
}
这样改的效果:
- 长度前缀让编码变成单射。不同的字段组合,不可能再生成同样的字节序列。
- 这跟 ASN.1、Protobuf 这类序列化格式的思路一样。它们用长度前缀,就是因为无分隔符拼接不安全。
- 只要编码没有二义性,攻击者就没法靠构造字段来制造
market_id碰撞。
代码分析二:结算路径为什么把 INJ 当 USDC
第二个缺陷在结算函数 PayDeficitFromInsuranceFund 里。漏洞版本大概是:
// 有漏洞:没有检查币种
func PayDeficitFromInsuranceFund(ctx context.Context, marketID string, deficit int64) error {
fund := getInsuranceFund(marketID)
market := getMarket(marketID)
if fund.Balance >= deficit {
// 直接把原始整数从基金转到市场
transferCoins(fund, market, deficit)
market.Deficit -= deficit
}
return nil
}
这里没有比较 fund.DepositDenom 和 market.QuoteDenom。函数把赤字当成一个没有单位的整数。基金里是 INJ,市场要的是 USDC,但代码完全不管。
结果就是,一个代表极小 INJ 的整数 12,744,000,000,000,000,000,被当成 12,744 USDC 记到了市场账上。
紧急补丁 v1.20.3-safeharbor.1 加了一个 validateInsuranceFundDenom 函数,强制检查币种是否匹配。修正后的逻辑:
// 修复后:转账前必须检查币种
func validateInsuranceFundDenom(fund InsuranceFund, market Market) error {
if fund.DepositDenom != market.QuoteDenom {
return fmt.Errorf("insurance fund denom %s does not match market quote denom %s",
fund.DepositDenom, market.QuoteDenom)
}
return nil
}
func PayDeficitFromInsuranceFund(ctx context.Context, marketID string, deficit int64) error {
fund := getInsuranceFund(marketID)
market := getMarket(marketID)
if err := validateInsuranceFundDenom(fund, market); err != nil {
return err
}
if fund.Balance >= deficit {
transferCoins(fund, market, deficit)
market.Deficit -= deficit
}
return nil
}
补丁在三个关键路径上都加了检查:
PayDeficitFromInsuranceFundTransferFullInsuranceFundBalanceMoveCoinsIntoInsuranceFund
这样改的效果:
- 转账的整数必须和赤字的单位一致。INJ 就是 INJ,USDC 就是 USDC,不能混。
- 如果币种不匹配,函数直接报错,结算不会继续。
- 协议不会再把自己账上的数字算成一个从未真实存在过的金额。
紧急补丁与停机决策
- 验证者停止出块,等所有人应用补丁。没有回滚,也没有治理投票去逆转交易。
- 官方口径是“加速网络升级”。但补丁改的是协议核心代码,不是普通应用升级。
- 部分验证者因为升级窗口问题被暂时 jailed。Coinbase 和 Coins.ph 暂停 INJ 充提。
- 基金会说共识没坏,质押 INJ 没风险。这个说法从共识层看可能成立,但从账本层看,链自己的状态机确实算错了。
- 截至 BlockSec 报告,Injective 没有发布完整技术复盘,也没解释 490 万美元损失谁承担。
三个教训
教训一:字符串拼接不是序列化格式
- 市场 ID 碰撞本来完全可以避免。
- 任何把可变长字段直接拼起来、不加分隔符或长度前缀的方案,都可能被碰撞攻击。
- 这不是理论问题。长度前缀编码就能消除这类漏洞。
- Injective 的 SDK 和公开接口暴露了字段结构,攻击者即使没有源码,也能通过逆向和试错找到碰撞面。
教训二:类型系统存在有原因,但你要用
- 结算缺陷本质上是类型混淆,藏在整数运算里面。
deficit是int64,基金余额也是int64。代码能编译,能运行,能出结果。- 但这两个整数代表的东西不一样。一个是 6 位小数的 USDC,一个是 18 位小数的 INJ。
- 在类型系统里,应该定义
type USDC int64和type INJ int64,禁止隐式转换。这样编译器就能挡住这种比较。
教训三:检查关键路径,不是检查你记得的路径
- BlockSec 在分析里提到,那一周的四起漏洞里,有三起是协议其实已经写了正确检查,但没放在关键路径上。
- Injective 的代码里可能也有面额验证。但结算路径从保险基金拿钱时,就是没调用它。
- 一个检查存在但没被调用,等于不存在。
对 Layer-1 安全意味着什么
Injective 的架构很有野心。它把交易所嵌进链里,交易逻辑作为原生节点软件运行,不是智能合约。
这个设计有好处:
- 性能更高
- gas 效率更好
- 跟链自身状态集成更紧
但它也有结构性代价:
- 当交易所逻辑属于协议本身,这里的 bug 就不是应用 bug,而是共识层 bug。
- 市场 ID 碰撞和面额不匹配都是逻辑错误,不是内存安全问题。
- 它们不需要编译器漏洞,也不需要共识失败。它们只需要一串交易,协议自己认为每一步都合法,但组合起来的效果完全不是设计意图。
- 这是最难审计的一类漏洞。因为每一步单独看都像正常操作。碰撞看起来像合法市场创建。自成交看起来像合法订单。赤字看起来像真实缺口。结算看起来像例行保险基金赔付。
- 修复本身很简单:面额检查加长度前缀标识符。难的是意识到这串操作居然可能发生。
- 这个意识花了 490 万美元,4 小时停机,还有一个 Injective 到现在都没完全回答的可信度问题。
对每个正在构建原生金融逻辑的 Layer-1 团队来说,Injective 这件事提醒很直接:协议级代码,就值得协议级审查。
不是因为它比智能合约更复杂,而是因为它一旦失败,没有可升级代理让你回滚,也没有治理投票让你逆转交易。你只有一条停摆的链,一个补丁,和屏幕上那个本来不该出现的数字。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/HK2KING/article/details/165728432




