nullifier 没和 note 绑死,隐私池就会被双花

📅 2026/7/10 23:04:33 👁️ 阅读次数 📝 编程学习
nullifier 没和 note 绑死,隐私池就会被双花

核心要点

  • 本周Ethereum上有1起代表性安全事件入选,涉及损失约$800K。
  • 隐私池协议Hinkal遭遇双花攻击,损失约$800K:旧版note格式的缺陷可能使攻击者从单笔存款派生出多个有效nullifier。
  • 基于nullifier的隐私协议的偿付能力取决于电路层面note与nullifier的绑定关系,而非仅依赖合约对有效证明的验证。

过去一周(2026/06/29 - 2026/07/05),以下代表性安全事件入选本期报告,涉及Ethereum上约$800K的损失。

  • Hinkal:隐私池协议遭遇双花攻击,旧版note格式的缺陷可能使同一笔存款产生多个有效nullifier。

本周看点:Hinkal

在本事件中,旧版note格式的缺陷可能使隐私池遭遇了一次双花攻击。基于nullifier的隐私协议中,偿付能力的保障依赖于电路层面的nullifier绑定,而不只是合约对有效证明的验证。

2026年7月2日,Ethereum上的隐私池协议Hinkal遭遇双花攻击,约$800K资产被盗。其可能的根本原因在于旧版note格式的缺陷:单个note未与唯一的nullifier紧密绑定,导致同一笔存款可被多次消费。项目方未开源电路实现,也尚未发布详细的技术分析报告。以下分析基于公开文档、反混淆后的客户端代码及链上观察。

背景

协议概览

Hinkal是一个隐私池协议。余额不以明文账户形式存在,而是以note的形式存储,作为commitment记录在链上Merkle树中。

要消费一个note,用户需提交zk证明和一个nullifier。合约记录每个nullifier并拒绝重复使用。"一个note只能产生一个nullifier"这一规则并非由合约强制执行,而是交由电路保证。

下图展示了存取款的整体流程:

Note格式(客户端) | 链上路径 | +--------------------------+ | +-------------------------------+ | 新版(默认): | | | transact()【含证明】 | | nk 直接进入 Poseidon6 | | | 所有操作:存款 / 转账 / 提款 | | -> 1个commitment:1个 | | | | | nullifier | | +-------------------------------+ +--------------------------+ | --> | --> +--------------------------+ | | 旧版: | | +-------------------------------+ | nk 仅通过乘积 e*nk 传入 | | | prooflessDeposit()【无证明】 | | -> 每个commitment可能允许 | | | 仅限存款 | | 多个nullifier | | +-------------------------------+ +--------------------------+ | | 两条存款路径最终都会使 | note进入Merkle树
Note格式

电路的实际实现代码未开源。以下分析基于Hinkal公开的电路设计文档和反混淆后的客户端证明代码。

文档和客户端代码显示,构建note的stealthAddress可能有两种方式,由isNewStyle标志选择:

  • 旧版(legacy)H0 = e*Base8H1 = (e*nk)*Base8stealthAddress = Poseidon3(signs, H0y, H1y)
  • 新版(new)H1 = nk*H0stealthAddress = 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,而非直接纳入spending key(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直接绑定到note的点上) 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标志存储在note的stealthAddressStructure中一个名为extraRandomization的字段的最高位。客户端代码将其打包为extraRandomization = (isNewStyle << 255) | H0x,合约在解码时通过getPointSign(H) = H / 2<strong>255getPointY(H) = H % 2</strong>255将其拆分。代码注释写道:"strip the isNewStyle flag (bit 255) so the circuit gets the clean H0x coordinate"。因此,通过getPointSign(extraRandomization)即可获取最高位来判断note类型。

以下CircomDataBuilder.sol:formBasicInput()的代码片段展示了这一拆解过程:

// CircomDataBuilder.sol:formBasicInput() ... // strip the isNewStyle flag (bit 255) so the circuit gets the clean H0x coordinate input[index++] = getPointY( circomData.stealthAddressStructure.extraRandomization ); // = H0x input[index++] = circomData.stealthAddressStructure.H0; // = H0y ...

在存款构建的客户端代码中,isNewStyle标志默认设为true,因此正常用户流程看起来不会创建旧版note。

// hinkalDeposit for self // 自身输出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 for others // @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 proof,transact()负责验证proof、检查Merkle root,然后写入nullifier和commitment。

prooflessDeposit()是链上一个独立的函数,完全绕过transact()。它直接接收代币,根据调用者指定的数据构建commitment,无需提供proof。

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类型的note,commitment由amount、token、stealthAddress和timestamp派生。其中stealthAddress直接来自调用者。

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

漏洞分析

以下分析基于链上行为和反混淆后的客户端代码推断。电路的实际实现未开源,根本原因有待确认。

受影响的合约为Hinkal(0x25e5...a826)。

可能的缺陷。如背景部分所述,旧版格式中nk仅通过乘积e*nk进入note,而nullifier则直接从nk派生。如果旧版消费电路没有将nk与commitment唯一绑定,那么不同的(e, nk)对可以保持相同的乘积e*nk(从而保持同一commitment),同时改变nk,每次为同一叶子节点生成新的nullifier。新版格式将nk直接纳入Poseidon6,从而使每个commitment只对应一个nk。对Hinkal合约而言,每笔提款看起来都是合法的:证明通过,root存在,每个nullifier都是新的,因此insertNullifiers()都会接受。合约无法将nullifier关联回特定的叶子节点,因此将每次消费都作为正常提款处理。问题可能不在于链上检查,而在于旧版证明电路所允许证明的内容。

prooflessDeposit()与攻击面。prooflessDeposit()函数将检查委托给performProoflessDepositChecks(),但从链上行为来看未观察到任何格式验证:该函数并未拒绝旧版格式的note,合约也未使用其返回值。这使得旧版note可以在不受任何格式限制的情况下进入Merkle树,为上述缺陷打开了攻击面。

攻击分析

以下分析基于受影响的Hinkal合约的历史交易记录。攻击者针对多种资产(USDC、ETH等)实施了攻击,以下以USDC流程为例。

  • Step 1:攻击者通过交易0xfbedf0...8c2f11中的prooflessDeposit()存入100 USDC。该存款的extraRandomization最高位为0(getPointSign = 0),表明该note使用旧版格式。

  • Step 2:攻击者对这个100 USDC的旧版note反复调用transact(),每次使用相同的Merkle root但不同的nullifier,每次提取100 USDC。这就是双花:同一个note被多次消费,累积约25,000 USDC。

  • Step 3:攻击者通过交易0xbf7252...d50008中的另一次prooflessDeposit()调用,将全部25,000 USDC合并为单个旧版note。通过合并为更大金额的note,后续每次双花提取可得25,000 USDC而非100。

  • Step 4:攻击者对25,000 USDC的note重复同一过程,每次调用transact()时使用新的nullifier。存款之后攻击者再未注入任何资产,但提取总额远超存入的25,000 USDC。

所有transact()调用累计盗取了约$800K资产。

图:Phalcon 资金流图,展示 Hinkal 合约与攻击者之间的转账

结论

Hinkal合约接受了单独有效的证明,然而同一个note很可能被多次赎回,原因在于旧版note格式的缺陷。合约的链上检查(proof验证、nullifier唯一性)均已通过,但无法检测同一note产生了多个有效nullifier。团队已暂停所有链上的Hinkal智能合约,并承诺全额赔付所有受影响用户。

针对Hinkal,修复措施包括:彻底禁用旧版note格式路径、在prooflessDeposit()中强制执行格式校验(例如拒绝isNewStyle == 0的note),以及进行专项电路审计以确认一对一的nullifier绑定。

对基于nullifier的隐私协议而言,不变量始终是同一个:每个note必须通过唯一确定的派生路径映射到恰好一个nullifier。这一绑定必须在电路层面强制执行,因为合约只能检查nullifier是否已被使用,无法判断它是否是某个note唯一合法的nullifier。