在過去一週(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*Base8,H1 = (e*nk)*Base8,stealthAddress = Poseidon3(signs, H0y, H1y) -
新版:
H1 = nk*H0,stealthAddress = Poseidon6(signs, H0y, H1y, spk0, spk1, nk)
Poseidon2、Poseidon3、Poseidon6均為 Poseidon 雜湊函數的實例;後綴表示輸入數量。
其中 e 是一個隨機數,nk 是秘密廢止金鑰(nullifying key),Base8 是一個固定點。由於 H0 和 H1 是 Baby Jubjub 橢圓曲線上的點,各有 x 座標和 y 座標。signs 值打包了它們 x 座標 H0x 和 H1x 的符號位元(2*L(H0x) + L(H1x))。舊版 stealthAddress 僅透過乘積 e*nk 包含 nk,而非直接包含花費金鑰(spk0、spk1),而新版格式將 nk、spk0 和 spk1 全部納入 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**255 和 getPointY(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 票據,承諾由 amount、token、stealthAddress 和 timestamp 衍生。stealthAddress 直接來自呼叫者。
commitment = hash4(
utxo.amount,
uint256(uint160(utxo.erc20Address)),
utxo.stealthAddressStructure.stealthAddress,
utxo.timeStamp
);
漏洞分析
此分析是基於鏈上行為和反混淆後的客戶端程式碼所作的推斷。實際的電路實作尚未開源,根本原因有待確認。
受影響的合約為 Hinkal(0x25e5...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()存入 100USDC。此次存款的extraRandomization最高位元為 0(getPointSign = 0),表明該票據使用舊版格式。 -
步驟 2:攻擊者針對這個 100
USDC舊版票據反覆呼叫transact(),每次使用相同的 Merkle 根但提供新的 nullifier,每次呼叫提取 100USDC。這就是雙重花費:同一票據被多次花費,累計約 25,000USDC。 -
步驟 3:攻擊者在交易 0xbf7252...d50008 中透過另一次
prooflessDeposit()呼叫,將所有 25,000USDC整合到一個舊版票據中。透過整合到更大的票據,後續每次雙重花費提款可獲得 25,000USDC而非 100USDC。 -
步驟 4:攻擊者對 25,000
USDC票據重複相同流程,每次以新的 nullifier 呼叫transact()。存款後,攻擊者未再注入任何資產,卻提取了遠超存入的 25,000USDC。
所有 transact() 呼叫合計,共約 $800K 的資產被盜。

結論
Hinkal 合約接受了個別有效的證明,但由於舊版票據格式存在缺陷,單一票據很可能被多次兌現。合約的鏈上檢查(證明驗證、nullifier 唯一性)均通過,但無法偵測到同一票據正在產生多個有效的 nullifier。團隊已暫停所有鏈上的 Hinkal 智能合約,並承諾全額賠償所有受影響用戶 [1]。
針對 Hinkal 具體情況,緩解措施包括:完全停用舊版票據格式路徑、在 prooflessDeposit() 中強制執行票據格式驗證(例如拒絕 isNewStyle == 0 的票據),以及進行專項電路審計以確認 nullifier 的一對一綁定。
對於一般基於 nullifier 的隱私協議,不變量是相同的:每個票據必須透過單一明確定義的衍生方式映射到恰好一個 nullifier。該綁定必須在電路層面強制執行,因為合約只能檢查 nullifier 之前是否出現過,而無法確認它是否是給定票據的唯一有效 nullifier。
參考資料
[1] Hinkal Protocol 事後更新:https://x.com/hinkal_protocol/status/2073136163880149417
[3] 客戶端程式碼:https://github.com/Hinkal-Protocol/Hinkal-API-Enclave ](https://github.com/Hinkal-Protocol/Hinkal-API-Enclave)



