在過去一週(2026/08/31 - 2026/09/06),我們觀察到 4 起安全事件,估計總損失約為 $9.4M。
| 日期 | 事件 | 類型 | 估計損失 |
|---|---|---|---|
| 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 模組內的二元選擇權邏輯遭到利用,造成約 $4.8M 的 USDC 損失。Injective 是一個將訂單簿交易所直接構建於鏈本身之中的第一層鏈,因此受影響的程式碼是節點軟體的一部分,而不是某人部署的合約。一個持有 INJ(Injective 的原生代幣)的保險基金,最終被綑綁到一個以 USDC 計價的二元選擇權市場,原因是這兩個識別碼發生了碰撞,而依賴保險基金運作的結算路徑從未比對過兩者。攻擊者在這樣的市場中與自己的子帳戶進行交易,人為製造出一個資金缺口,該協定隨後用價值僅為一分錢零頭的 INJ 餘額來填補這一缺口。每個倉位都獲得了全額退款,而攻擊者取出的金額遠超其存入的金額。
背景
Injective 的 exchange 模組列出了二元選擇權市場,這是對「是」或「否」結果進行的完全抵押的押注。任何人都可以通過支付上市費用來上市一個市場,並選擇解析該市場的預言機以及到期與結算的時間戳。預言機的命名由提供者(provider)加上符號(symbol)組成:成為提供者需要經過治理投票,而符號則可以是上市者提供的任意字串。交易者首先將如 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 模組本身是一個單一的總帳戶(omnibus bank account),其各市場、各基金的餘額都是原始整數記帳。
漏洞分析
存在缺陷的組件是 injective-core 中 exchange 模組內的二元選擇權處理邏輯,該問題已在提交 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。
碰撞的這對物件是通過調整欄位邊界的位置構造出來的,同時保持拼接後的位元組完全相同。基金的 ticker 與 quoteDenom(X 與 inj)拼出了市場的 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。
- 步驟 1:在區塊
181024772的一筆原子交易中,攻擊者創建了發生碰撞的以INJ計價的保險基金與以USDC計價的二元選擇權市場,向該基金注入12,744,000,000個原始單位的INJ(價值約$0.000000063),並向三個子帳戶存入共計30,267.02 USDC。這筆微不足道的存款金額經過精心設計,使其原始整數恰好與攻擊者計劃製造的缺口相符。每個子帳戶都恰好獲得其後續所需的保證金:
| 子帳戶 | 存款(USDC) |
資助的保證金 |
|---|---|---|
037d |
1,593.001593 |
以 0.10 買入 15,930 的 BUY,鎖定 1,593(步驟 2) |
037c |
14,337.001593 |
以 0.10 賣出 15,930 的 SELL,鎖定 14,337(步驟 2) |
037e |
14,337.014337 |
以 0.90 買入 15,930 的 BUY,鎖定 14,337(步驟 3) |
| 總計 | 30,267.017523 |
- |
-
步驟 2:在同一筆交易中,
037d以0.10下了一筆買入15,930的 BUY 訂單,037c則以0.10下了一筆賣出15,930的 SELL 訂單。EndBlocker 將它們撮合成一個攜帶1,593保證金的037d的 LONG 倉位,以及一個攜帶14,337保證金的037c的 SHORT 倉位。訂單簿上的總保證金為1.0Q = 15,930,正好等於市場資金池所持有的金額,因此此時的訂單簿與任何正常的完全抵押市場並無差別。 -
步驟 3:兩個區塊、1.1 秒之後,在區塊
181024774的交易 0x012c17...2af694 中,037d以0.90、margin = 0平倉其多頭倉位,並收到1,593 + (0.90 - 0.10) * 15,930 = 14,337。這筆支付來自前來開倉者037e所鎖定的保證金——037e以0.90下了一筆買入15,930的 BUY 並鎖定了14,337。此時已實現的利潤0.8Q = 12,744存放在037d資金池之外的可用餘額中,而剩餘的兩個倉位的保證金仍留在帳面上:負債為1.8Q = 28,674,而資金池仍只持有1.0Q。 -
步驟 4:結算是由市場自身的時鐘驅動,而非由某筆交易觸發。在每個區塊開始時,該模組會找出任何結算時間戳已過的市場,並將其整體結算,而不是按倉位逐一結算。本例在市場創建後 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一樣——這正是攻擊者在步驟 3 中已經取出的那筆0.8Q。 -
步驟 5:
PayDeficitFromInsuranceFund()通過從碰撞基金中轉出12,744,000,000個原始單位的INJ,並將12,744 USDC計入市場資金池,來彌補這一缺口。由於缺口被報告為已補足,剩餘倉位在社會化虧損(socialized-loss)機制中應被扣減的份額就此被跳過,每個倉位都獲得了全額保證金退款。這個缺口本身對任何人來說都不需要付出成本:它是由市場的保險基金補足,或者,若不能補足,則由扣減機制承擔。而使這一切變得有利可圖的原因,在於綑綁到該市場的基金持有的是價值微不足道的INJ零頭,而非USDC。 -
步驟 6:在區塊
181024803的交易 0xcb33ad...152eff 中,攻擊者取出了43,010,985,663個原始單位的USDC,即43,010.99 USDC,而先前存入的僅為30,267.02 USDC。淨獲利為12,743.97 USDC,從第一筆交易到最後一筆交易大約經過了 21 秒。
上述循環只是一個具有代表性的回合。攻擊者在長達 19 小時的時間窗口內,創建了 299 個生命週期極短的二元選擇權市場,重複了這一循環,每個市場都綑綁到一個被配置為永不發布價格的預言機,且到期與結算時間戳僅相差幾秒 [2]。這些回合的淨獲利累計起來,正是本事件中損失的約 $4.8M。
結論
本事件將一次識別碼碰撞與支付路徑上缺失的面額檢查結合在了一起。一個基金本應支持與其身份相匹配的市場。但將身份欄位首尾相接拼合而成的 ID,已不再記錄某一欄位在何處結束、下一欄位在何處開始,因此兩組不同的欄位可能產生相同的 ID,而這種配對就將一個基金綑綁到了一個它實際上並不匹配的市場上。下游沒有任何機制能夠捕捉到這種錯配,因為依賴保險基金來填補市場缺口的那條路徑只比對金額,從不比對面額。攻擊者將這兩個缺陷結合起來使用,在自己控制的市場中製造出一個虛假的缺口,用一筆價值僅有一分錢零頭的基金餘額來結算它,並帶著資金池根本從未持有過的全額保證金退款離場。
更普遍地說,帶有意義的識別碼應當由一種保留結構的編碼方式派生而來,對每一個可變長度的欄位都應有明確的分隔符或長度前綴,這樣才能確保兩組不同的欄位組合不會映射到同一個摘要值。
本週其他事件
Ankr FLOW
2026/08/31,Ankr 在 Flow EVM 上的流動性質押服務遭到利用。Ankr 針對已質押的 FLOW(該網路的原生代幣)發行兩種不同的代幣,每種代幣都通過其自身的入口點來鑄造。其中一條路徑曾被關閉,但通往同一邏輯的第二個入口卻跳過了執行該暫停狀態的檢查,而該路徑上的換算比率在此期間已經變得陳舊過時。攻擊者以遠低於相同代幣可贖回價值的成本,通過該路徑鑄造代幣,隨後將這一價差通過 Ankr 的贖回緩衝池、一個 Uniswap V3 資金池以及 MORE Markets 借貸協定循環放大。約 15.5M WFLOW(包裝後的 FLOW)——當時價值約 $410K——從 MORE Markets 儲備中被抽走,攻擊者在扣除滑點後實際獲利約 $246K [3]。
背景
Ankr FLOW 是 Flow EVM 上的一項流動性質押服務。FlowStakingPool 將 FLOW 轉發至 Cadence 進行驗證者質押,並通過兩種代幣來代表所產生的倉位:非重新計算基準(non-rebasing)的憑證代幣 ankrFLOW,以及重新計算基準(rebasing)的收益代幣 aFLOWEVMb,後者本身是由 ankrFLOW 提供支持的。
這兩種代幣擁有各自獨立的入口點。憑證路徑通過 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(效率模式)分類:即一組被預期價格會同步變動的資產,一旦借款人開啟該分類,就可套用更高的 LTV。
漏洞分析
存在缺陷的合約是 FlowStakingPool(0xfe81...287a),它根據從 InternetBondRatioFeed(0x3201...de38f)讀取的比率,鑄造憑證代幣 ankrFLOW(0x1b97...14bdb)與收益代幣 aFLOWEVMb(0xd6fd...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。
-
步驟 1:攻擊者通過在兩條路徑之間進行一次來回操作,籌得了啟動資金。他們從 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作為周轉資金保留下來。 -
步驟 2:攻擊者重複進行了 50 次 Uniswap V3 交換操作。每個循環都通過帶有價格限制的交換,將
ankrFLOW換成WFLOW,在交換的回調函式中,通過將FLOW路由經stakeBondsWithCode()與unlockShares(),即時鑄造出欠給資金池的ankrFLOW,然後解包WFLOW輸出以供下一循環使用。在這 50 次循環中,資金池共收到~38,634,755.38 ankrFLOW,並支付出~46,265,167.78 WFLOW,使攻擊者的餘額從~998.75 FLOW增加到~7,631,411.14 FLOW。 -
步驟 3:攻擊者通過收益路徑轉換了
~38,601.95 FLOW,並將所得的ankrFLOW通過unstakeCerts()贖回,抽走了FlowStakingPool當時仍持有的~46,316.57 FLOW贖回緩衝資金,並增加了~7,714.62 FLOW。 -
步驟 4:攻擊者轉向 MORE Markets。他們啟用了 e-mode 分類 1(「包裝原生代幣」),將
ankrFLOW與WFLOW視為相關聯的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%。
步驟 4 中的抵押品所帶來的回報,超過了鑄造它的成本:在收益路徑上,每 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 上的一個自有做市商(proprietary market maker)AMM——遭到利用,損失約 $2.47M,涵蓋 212 次成功的交換,分散於 USDC、USDT、HYPE、cbBTC、CASH 以及另外十三種代幣之中 [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:
// Semantic reconstruction of the code the program runs for a swap; construct_transfer()
// stands for fn_45f20() and fn_46bf8(), and the allowlist fn_49740() is never reached.
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);
// Unchecked caller inputs replace the value that was checked.
output_instruction.program_id = caller.token_program_b.key;
input_instruction.program_id = caller.token_program_a.key;
// Each invoke() hands the transfer instruction to whichever program the caller named.
invoke(output_instruction)?;
invoke(input_instruction)?;
其中 TOKENKEG_ID 表示 TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA。因此,通過驗證的值,永遠不會是實際被執行的值。更加雪上加霜的是,該程式在輸入 CPI 返回之後,並不驗證金庫實際收到的金額,因此一次未轉移任何代幣就返回成功的 CPI 調用,也會被接受為已付款。
攻擊分析
本事件由 212 筆成功的攻擊交易組成。以下分析基於交易 4pBV1G...T3bf,作為其中一個具有代表性的範例。
-
步驟 1:攻擊者以名義輸入
4,957.497101 USDC調用swap,Aquifer 據此計算出輸出為195,849.433667 KMNO。對於KMNO這一環,他們提供了 Tokenkeg;對於USDC這一環,他們提供了自己的程式DMBpPM...NRgb68,並附帶一個偽造的輸入帳戶9gsKJc...——這個由該程式所擁有的帳戶,以 165 個字節仿造出一個攜帶USDCMint、攻擊者權限及u64::MAX餘額的 SPL Token Account。 -
步驟 2:輸出 CPI 到達 Tokenkeg,將
195,849.433667 KMNO從 Aquifer 的KMNO金庫轉出到攻擊者的 Token AccountEUjkGc...,該帳戶由簽署者7fTe9p...4gRk7J所控制。

- 步驟 3:輸入 CPI 到達
DMBpPM...NRgb68,帶著USDC轉帳形式的資料。該程式返回了成功,卻沒有將任何USDC從偽造的來源9gsKJc...移入 Aquifer 真實的USDC金庫7ULN1Y...。

- 步驟 4: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 |
上述數字僅描述此範例交易,並非本事件的總損失。
結論
根本原因在於結算路徑上未經驗證的調用者輸入,而非價格或預言機操縱:swap 路徑先驗證了一個 Token Program 常數,然後在調用該指令之前,又用調用者提供的值將其替換掉,因此究竟由哪個程式來執行本應向 Aquifer 付款的那筆轉帳,實際上完全取決於調用者的選擇。由於金庫沒有轉帳後的餘額檢查,一個未實際移動代幣卻返回成功的程式便足以滿足「已付款」的條件,而另一個方向的轉帳卻真實地轉出了資產。
每一次 CPI 都應綑綁到擁有所轉移 Mint 的那個 Token Program,該程式應從 Mint Account 中解析得出,而不是取自調用者提供的帳戶;同時應在輸入轉帳的前後都讀取金庫的餘額,使得該次交換除非金庫確實收到了報價金額,否則就會被回退(revert)。
Notional Finance
2026/09/03-09/04(UTC 時間),Ethereum 上的 Notional Finance V1 遭到利用,損失約 $1.73M,以 69,257.37 DAI 和 1,658,524.86 USDC 的形式被抽走。在允許某個帳戶承擔債務之前,該協定會對該帳戶所持有及所欠的一切進行估值,而該路徑上的一次不安全的數值轉換,將一筆數額恰到好處的債務歸零。因此,該檢查放行了一個負債已消失的帳戶,而它所創造出的那筆大額債權,則完好無損地留在攻擊者控制的另一個合約上。攻擊者把時間安排得恰到好處,讓這筆偽造的債權在協定的下一個到期日(UTC 午夜)到期,並在幾分鐘後進行結算,取出了該協定當時仍持有的 DAI 和 USDC。
背景
Notional Finance V1 是 Ethereum 上的一個固定利率借貸協定。它以 fCash 來代表在預先確定的到期日發生的現金流:CASH_RECEIVER 是一個正的倉位,有權在到期時收取資產;CASH_PAYER 則是一個負的倉位,有義務在到期時支付資產。每個帳戶的 fCash 及其他倉位都被記錄在其 Portfolio 中,其中每項資產由其現金組(cash group)加上到期日共同標識;現金組決定了該資產以何種貨幣結算。單一資產的名義金額(notional),即其到期時所結算的數額,是一個 uint128。
ERC1155Trade.safeTransferFrom() 在兩個帳戶之間創建一個 fCash 配對。此調用形式上類似於 ERC-1155 轉帳,但實際上沒有任何資產發生轉移:它調用 Portfolios.mintfCashPair() 來創建兩個相互抵銷的倉位——一個給接收方的正倉位,以及一個等額的、給付款方的負倉位。每一方都通過 _upsertAsset() 寫入相應的 Portfolio 中,該函式僅在現金組與到期日都匹配的情況下,才會將新倉位合併到現有項目中,並通過經 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(),該函式在結果超出範圍時會回退(revert),而前一次轉換仍是一次直接轉型,會靜默地截斷數值。
第二,整數除法可能將小額債務向下捨去為零。除以 er.rateDecimals 與 baseDecimals 的運算會截去餘數,因此對於足夠小的債務,同樣會被估值為 0,並在自由抵押額計算中被忽略。
攻擊分析
以下分析基於交易 0xe1589a...25d60a。

-
步驟 1:於 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的債務在經過兩次整數除法之後無法保留下來,因此被估值為零,於是檢查得以通過。 -
步驟 2:攻擊者再次調用
safeTransferFrom(),鑄造出第二個配對,數量為uint128.max(340,282,366,920,938,463,463,374,607,431,768,211,455)。這個配對同樣重用了cashGroupId = 2,但攜帶的到期時間戳為1796256000(2026 年 12 月 3 日 UTC 00:00,即另一個開放的到期日),並將其正向的一側發送到另一個不同的接收方合約。負向的一側再次落在攻擊者的合約上,緊挨著步驟 1 中的那一筆。單次鑄造永遠不能超過2^128 - 1,總是比造成截斷的那個數值少一個單位,因此要達到該數值需要兩個倉位。不同的到期日使這兩筆保持在各自獨立的條目中,避開了本會在合併時發生回退的、經過檢查的加法運算;然而共同的現金組仍然使它們在估值時落入同一貨幣位置——在此,步驟 1 中的那一單位將總額恰好推升至-2^128。 -
步驟 3:在抵押品檢查期間,Escrow 代理調用了
convertBalancesToETH()。累計後的負餘額恰好達到-2^128,被傳入_convertToETH(),並被截斷為零,因此該帳戶以 ETH 計價的負債被報告為零。 -
步驟 4:由於這筆債務已在抵押品記帳中消失,第二個接收方合約所持有的大額正向 fCash 倉位,便被該協定視為可用抵押品。仍在同一筆交易之內,它以此為抵押品又鑄造了另外兩個 fCash 配對,並將其正向部分交給另外兩個接收方合約:
cashGroupId = 2(以DAI結算)下的69,257.37,以及cashGroupId = 3(以USDC結算)下的1,658,524.86。這兩個數字均來自攻擊者在交易開始時對 Escrow 所調用的balanceOf,因此每筆債權的規模都恰好對應 Escrow 實際持有的餘額。兩者的到期時間都設定為1788480000,即 73 秒之後。 -
步驟 5:於 2026 年 9 月 4 日 UTC 時間 00:01:35,即上述到期日之後 95 秒,攻擊者在交易 0xc3f3e3...a24efa 中結算了這兩筆已到期的債權,從 Escrow 中取出了
~69,257.37 DAI和~1,658,524.86 USDC。這些資金後續被轉發至0x8aaf...3be6。
結論
鑄造路徑沒有施加任何名義金額限制,而抵押品估值中的兩個算術缺陷各自讓一項償付能力檢查得以通過:捨入運算讓一個一無所有的帳戶承擔了它的第一筆負債,隨後一次靜默的縮窄轉換又將一筆數額恰到好處的債務估值為零,於是第二項檢查放行了一個實質上已嚴重資不抵債的帳戶。接收方合約上這筆偽造的正向倉位,隨後被視為可用抵押品,這使得攻擊者能夠將其拆分成與 Escrow 餘額相匹配的多筆債權,並取出它當時仍持有的 DAI 與 USDC。
帶符號的餘額在進行任何縮窄轉換之前都必須先進行範圍檢查,而一次無法表示其輸入值的轉換,應當回退(revert)而非靜默截斷。更普遍地說,償付能力檢查絕不應將一筆非零的債務報告為零,無論這個結果是由於轉型還是由於捨入步驟所導致的。若對同一函式後續已經使用過的經檢查轉型加以套用,或者對單次配對鑄造所能創造的名義金額設置上限,任何一項單獨的措施,都足以阻止這次攻擊的發生。
參考資料
[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 萬美元的資金,並保障了數十億美元的加密貨幣資產安全。
-
官方 Twitter 帳號:https://twitter.com/BlockSecTeam



