Back to Blog

~$800K損失:Hinkal雙重支付事件 | BlockSec週報

Code Auditing
July 9, 2026
12 min read
Key Insights

在過去一週(2026/06/29 - 2026/07/05),以下重大安全事件值得關注,涉及在以太坊上損失約 $800K。

日期 事件 類型 估計損失
2026/07/02 Hinkal 業務邏輯缺陷 ~$800K
  • Hinkal:針對隱私池協議的雙重花費攻擊,可能利用了舊版票據格式缺陷,該缺陷允許同一筆存款產生多個有效的 nullifier。

Web3 最佳安全審計機構

在上線前驗證設計、程式碼與業務邏輯

每週重點:Hinkal

在此事件中,隱私池中的雙重花費很可能是由舊版票據格式缺陷所造成的。基於 nullifier 的隱私協議的償付能力取決於電路層面的 nullifier 綁定,而不僅僅是合約接受有效證明。

2026 年 7 月 2 日,以太坊上的隱私池協議 Hinkal 遭受雙重花費攻擊,約 $800K 的資產被盜 [1]。可能的根本原因是舊版票據格式存在缺陷,單一票據並未與唯一的 nullifier 緊密綁定,從而允許同一筆存款被多次花費。該專案尚未開源其電路實作,團隊也尚未發布詳細的技術分析。以下分析基於公開文件、反混淆後的客戶端程式碼以及鏈上觀察。

背景

協議概覽

Hinkal 是一個隱私池協議。餘額以票據(notes)的形式儲存,以鏈上 Merkle 樹中的承諾(commitments)表示,而非明文帳戶餘額。

要花費一個票據,使用者需提交一個 zk 證明和一個 nullifier。合約記錄每個 nullifier 並拒絕重複提交。一個票據只能產生一個 nullifier 的規則並非由合約強制執行;而是委託給電路來保證。

下圖展示了存款和提款流程:

     票據格式(客戶端)              |       鏈上路徑
                                   |
  +--------------------------+     |     +-------------------------------+
  | 新格式(預設):           |     |     | transact()(含證明)           |
  | nk 直接進入 Poseidon6    |     |     | 所有操作:存款 / 轉帳 /         |
  | -> 1 承諾 : 1 nullifier  |     |     |           提款                |
  +--------------------------+     |     +-------------------------------+
                               --> | -->
  +--------------------------+     |     
  | 舊版格式:                |     |     +-------------------------------+
  | nk 僅透過乘積 e*nk        |     |     | prooflessDeposit()(無證明)   |
  | -> 可能允許每個承諾有      |     |     | 僅限存款                      |
  |    多個 nullifier        |     |     +-------------------------------+
  +--------------------------+     |   
                                   |     對於存款,兩條路徑
                                   |         -> 票據進入 Merkle 樹

票據格式

實際的電路實作尚未開源。以下分析基於 Hinkal 的公開電路設計文件 [2] 以及反混淆後的客戶端證明程式碼 [3]

文件和客戶端程式碼提示了兩種構建票據 stealthAddress 的方式,由 isNewStyle 標誌選擇:

  • 舊版H0 = e*Base8H1 = (e*nk)*Base8stealthAddress = Poseidon3(signs, H0y, H1y)

  • 新版H1 = nk*H0stealthAddress = Poseidon6(signs, H0y, H1y, spk0, spk1, nk)

Poseidon2Poseidon3Poseidon6 均為 Poseidon 雜湊函數的實例;後綴表示輸入數量。

其中 e 是一個隨機數,nk 是秘密廢止金鑰(nullifying key),Base8 是一個固定點。由於 H0H1 是 Baby Jubjub 橢圓曲線上的點,各有 x 座標和 y 座標。signs 值打包了它們 x 座標 H0xH1x 的符號位元(2*L(H0x) + L(H1x))。舊版 stealthAddress 僅透過乘積 e*nk 包含 nk,而非直接包含花費金鑰(spk0spk1),而新版格式將 nkspk0spk1 全部納入 Poseidon6

nullifier 直接由 nk 計算:nullifier = Poseidon2(commitment, Poseidon2(nk, commitment))

以下片段從 zkProofWorkerNode.js 反混淆而來(S = Poseidon,Base8 = 固定生成元,e = 隨機化,t = nk):

// 舊版
static getRandomizedStealthPairOld = (e, t) => {
  const a  = e * (t % B) % B;            // a = e*nk
  const H0 = mulPointEscalar(Base8, e);  // H0 = e*Base8
  const H1 = mulPointEscalar(Base8, a);  // H1 = (e*nk)*Base8(nk 僅透過乘積)
  return { H0, H1 };
};

// 新版
static getRandomizedStealthPair = (e, t) => {
  const a  = t % B;                      // a = nk
  const H0 = mulPointEscalar(Base8, e);  // H0 = e*Base8
  const H1 = mulPointEscalar(H0, a);     // H1 = nk*H0(nk 與票據點綁定)
  return { H0, H1 };
};

// nullifier 直接由 nk 衍生
getNullifier() {
  const c   = this.getCommitment();
  const sig = S(this.nullifyingKey, c);  // Poseidon2(nk, commitment)
  this.nullifier = S(c, sig);            // Poseidon2(commitment, sig)
}

isNewStyle 標誌儲存在票據 stealthAddressStructure 中名為 extraRandomization 欄位的最高位元。客戶端程式碼將其打包為 extraRandomization = (isNewStyle << 255) | H0x,合約在解碼打包值時以 getPointSign(H) = H / 2**255getPointY(H) = H % 2**255 將其還原。程式碼注釋寫道「去除 isNewStyle 標誌(第 255 位元),使電路獲得乾淨的 H0x 座標」。因此,可透過 getPointSign(extraRandomization) 取得最高位元以判斷票據類型。

以下來自 CircomDataBuilder.sol:formBasicInput() 的片段展示了此解包過程:

// CircomDataBuilder.sol:formBasicInput()
    ...
    // 去除 isNewStyle 標誌(第 255 位元),使電路獲得乾淨的 H0x 座標
    input[index++] = getPointY(
        circomData.stealthAddressStructure.extraRandomization
    ); // = H0x
    input[index++] = circomData.stealthAddressStructure.H0; // = H0y
    ...

在存款構建的客戶端程式碼中,isNewStyle 標誌預設為 true,因此正常使用者流程似乎從未創建舊版票據。

// 自身的 hinkalDeposit
  // 自身輸出 UTXO
  //@hinkal/common/common/src/functions/pre-transaction/outputUtxoProcessing.mjs
	let m = [new r({
		amount: e(d + o, 0n),
		erc20TokenAddress: f,
		mintAddress: p,
		nullifyingKey: i.getShieldedPrivateKey(),
		timeStamp: s,
		spendingPublicKey: i.getSpendingKeyPair().pubSpendingBJJPoint,
		isNewStyle: !0 // 等同於 isNewStyle: true
	})];

// 為他人的 hinkalDeposit
  // @hinkal/common/common/src/data-structures/Hinkal/hinkalDeposit.mjs
    w = h.map((e, t) => [new s({
		amount: o[t],
		erc20TokenAddress: e,
		H0: [BigInt(_), BigInt(y)],
		stealthAddress: g,
		encryptionKey: b,
		isNewStyle: !0 // 等同於 isNewStyle: true
	})])

因此,將標誌設為 0 的舊版格式存款並非正常使用者流程所產生的。

存款與提款路徑

transact() 是所有操作(存款、私密轉帳和提款)的通用證明驗證入口點。客戶端在鏈下生成 zk 證明,transact() 驗證證明、檢查 Merkle 根,然後寫入 nullifier 和承諾。

prooflessDeposit() 是一個獨立的鏈上函數,完全繞過 transact()。它接受代幣並直接根據呼叫者指定的資料構建承諾,無需提供證明。

    function prooflessDeposit(
        address[] calldata erc20Addresses,
        uint256[] calldata amounts,
        uint256[] calldata tokenIds,
        StealthAddressStructure[] calldata stealthAddressStructures
    ) public payable nonReentrant {
        hinkalHelper.performProoflessDepositChecks(
            erc20Addresses,
            amounts,
            tokenIds,
            stealthAddressStructures
        );

        bytes memory returnData = address(hinkalInLogic).functionDelegateCall(
            abi.encodeWithSelector(
                hinkalInLogic.handleProoflessDeposit.selector,
                erc20Addresses,
                amounts,
                tokenIds,
                stealthAddressStructures
            )
        );

        UTXO[] memory utxoSet = abi.decode(returnData, (UTXO[]));
        uint256 length = utxoSet.length;

        OnChainCommitment[] memory commitmentArray = new OnChainCommitment[](
            length
        );

        for (uint256 i = 0; i < length; i++) {
            commitmentArray[i] = createCommitment(utxoSet[i]);
        }

        insertCommitments(
            new uint256[][](0),
            new bytes[][](0),
            commitmentArray,
            new bool[](0)
        );
    }

對於 ERC-20 票據,承諾由 amounttokenstealthAddresstimestamp 衍生。stealthAddress 直接來自呼叫者。

      commitment = hash4(
          utxo.amount,
          uint256(uint160(utxo.erc20Address)),
          utxo.stealthAddressStructure.stealthAddress,
          utxo.timeStamp
      );

漏洞分析

此分析是基於鏈上行為和反混淆後的客戶端程式碼所作的推斷。實際的電路實作尚未開源,根本原因有待確認。

受影響的合約為 Hinkal0x25e5...a826)。

可能的缺陷。 如背景部分所述,舊版格式僅透過乘積 e*nk 包含 nk,而 nullifier 直接由 nk 衍生。若舊版花費電路未將 nk 唯一地綁定到承諾,則不同的 (e, nk) 組合可在保持相同乘積 e*nk(從而保持相同承諾)的同時改變 nk,每次為同一葉節點產生新的 nullifier。新格式將 nk 直接放入 Poseidon6,這將為每個承諾固定唯一的 nk。對於 Hinkal 合約而言,每次提款看起來都是有效的:證明通過、根存在、每個 nullifier 都是新的,因此 insertNullifiers() 接受它。合約無法將 nullifier 與葉節點關聯,因此將每次花費視為正常提款結算。此漏洞不在鏈上檢查中,而很可能在舊版證明電路被允許證明的內容上。

prooflessDeposit() 與攻擊面。 prooflessDeposit() 函數委託給 performProoflessDepositChecks(),但在鏈上行為中未觀察到格式驗證:該函數似乎不拒絕舊版格式的票據,合約也不使用其返回值。這使得舊版票據能夠在沒有任何格式限制的情況下進入 Merkle 樹,為上述缺陷開放了攻擊面。

攻擊分析

以下分析基於受影響的 Hinkal 合約的歷史交易記錄。攻擊者針對多種資產類型(USDC、ETH 等);此處以 USDC 流程為例。

  • 步驟 1:攻擊者在交易 0xfbedf0...8c2f11 中透過 prooflessDeposit() 存入 100 USDC。此次存款的 extraRandomization 最高位元為 0(getPointSign = 0),表明該票據使用舊版格式。

  • 步驟 2:攻擊者針對這個 100 USDC 舊版票據反覆呼叫 transact(),每次使用相同的 Merkle 根但提供新的 nullifier,每次呼叫提取 100 USDC。這就是雙重花費:同一票據被多次花費,累計約 25,000 USDC

  • 步驟 3:攻擊者在交易 0xbf7252...d50008 中透過另一次 prooflessDeposit() 呼叫,將所有 25,000 USDC 整合到一個舊版票據中。透過整合到更大的票據,後續每次雙重花費提款可獲得 25,000 USDC 而非 100 USDC

  • 步驟 4:攻擊者對 25,000 USDC 票據重複相同流程,每次以新的 nullifier 呼叫 transact()。存款後,攻擊者未再注入任何資產,卻提取了遠超存入的 25,000 USDC

所有 transact() 呼叫合計,共約 $800K 的資產被盜。

結論

Hinkal 合約接受了個別有效的證明,但由於舊版票據格式存在缺陷,單一票據很可能被多次兌現。合約的鏈上檢查(證明驗證、nullifier 唯一性)均通過,但無法偵測到同一票據正在產生多個有效的 nullifier。團隊已暫停所有鏈上的 Hinkal 智能合約,並承諾全額賠償所有受影響用戶 [1]。 針對 Hinkal 具體情況,緩解措施包括:完全停用舊版票據格式路徑、在 prooflessDeposit() 中強制執行票據格式驗證(例如拒絕 isNewStyle == 0 的票據),以及進行專項電路審計以確認 nullifier 的一對一綁定。

對於一般基於 nullifier 的隱私協議,不變量是相同的:每個票據必須透過單一明確定義的衍生方式映射到恰好一個 nullifier。該綁定必須在電路層面強制執行,因為合約只能檢查 nullifier 之前是否出現過,而無法確認它是否是給定票據的唯一有效 nullifier。

開始使用 Phalcon Explorer

深入分析交易,做出明智決策

立即免費試用

參考資料

[1] Hinkal Protocol 事後更新:https://x.com/hinkal_protocol/status/2073136163880149417

[2] 電路設計文件:https://hinkal-team.gitbook.io/hinkal/technical-description/circuits/swapper-m#id-3.-correct-nullifiers-per-input

[3] 客戶端程式碼:https://github.com/Hinkal-Protocol/Hinkal-API-Enclave ](https://github.com/Hinkal-Protocol/Hinkal-API-Enclave)

開始使用 Phalcon Security

偵測每一個威脅,發出重要警報,並阻擋攻擊。

立即免費試用
Sign up for the latest updates
電子報 - 2026年6月
Security Insights

電子報 - 2026年6月

本月報告涵蓋2026年6月三起最大安全事件,確認損失合計約2200萬美元。一次精密蜜罐攻擊利用未受檢查的代幣授權,從JaredFromSubway的MEV機器人抽走約1500萬美元。兩個舊版Aztec rollup部署因證明結算邊界漏洞損失約435萬美元。SecondFi的Ed25519實現缺陷暴露錢包私鑰,導致374個錢包共損失約240萬美元。三起事件共同模式:表面完整但從未真正執行的安全保證。

~$400萬損失:Taiko、SecondFi遭攻擊 | BlockSec週報
Security Insights

~$400萬損失:Taiko、SecondFi遭攻擊 | BlockSec週報

本週區塊鏈安全報告涵蓋2026年6月22-28日兩起重大事件,以太坊和Cardano確認損失約410萬美元。Taiko橋接漏洞結合了外洩的SGX飛地簽名密鑰與不完整的認證策略,未能拒絕調試飛地,使攻擊者註冊惡意證明者並偽造L2狀態證明。SecondFi錢包漏洞源於Ed25519隨機數派生中的密碼學實現缺陷,移除了秘密輸入,可從Cardano公開交易數據離線恢復私鑰。

~$18M損失:jaredFromSubway、Aztec及更多 | BlockSec每週快報
Security Insights

~$18M損失:jaredFromSubway、Aztec及更多 | BlockSec每週快報

本週區塊鏈安全報告涵蓋2026年6月15日至21日,以太坊與BNB Chain共發生3起重大事件,總損失約1830萬美元。重點分析兩起事件:jaredFromSubway事件揭示反向授權攻擊模式——MEV機器人主動將資產授權給不可信第三方合約套利,攻擊者構造假包裝代幣與交易池,發出真實事件但從未消耗授權額度,損失約1500萬美元。Aztec三日內第二次漏洞:逃生艙ZK電路中`old_data_root`兩個見證值缺少相等約束,致使攻擊者可用偽造Merkle樹證明虛假票據所有權並通過鏈上驗證。

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit