新闻详情

新闻详情

首页 / 资讯中心 / 详情

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

发布时间:2026/7/10 22:57:25
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个commitment1个 | | | | | nullifier | | ------------------------------- -------------------------- | -- | -- -------------------------- | | 旧版 | | ------------------------------- | nk 仅通过乘积 e*nk 传入 | | | prooflessDeposit()【无证明】 | | - 每个commitment可能允许 | | | 仅限存款 | | 多个nullifier | | ------------------------------- -------------------------- | | 两条存款路径最终都会使 | note进入Merkle树Note格式电路的实际实现代码未开源。以下分析基于Hinkal公开的电路设计文档和反混淆后的客户端证明代码。文档和客户端代码显示构建note的stealthAddress可能有两种方式由isNewStyle标志选择旧版legacyH0 e*Base8H1 (e*nk)*Base8stealthAddress Poseidon3(signs, H0y, H1y)新版newH1 nk*H0stealthAddress Poseidon6(signs, H0y, H1y, spk0, spk1, nk)Poseidon2、Poseidon3、Poseidon6均为Poseidon哈希函数的实例后缀数字表示输入参数的个数。其中e为随机数nk为秘密的nullifying keyBase8为固定基点。由于H0和H1是Baby Jubjub椭圆曲线上的点各有x坐标和y坐标。signs值打包了它们x坐标H0x和H1x的符号位2*L(H0x) L(H1x)。旧版stealthAddress仅通过乘积e*nk间接包含nk而非直接纳入spending keyspk0、spk1新版则将nk、spk0和spk1一并写入Poseidon6。nullifier直接从nk计算nullifier Poseidon2(commitment, Poseidon2(nk, commitment))。以下是从zkProofWorkerNode.js反混淆得到的相关函数S PoseidonBase8 固定生成器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)*Base8nk仅通过乘积传入 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*H0nk直接绑定到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 / 2strong255和getPointY(H) H % 2/strong255将其拆分。代码注释写道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 prooftransact()负责验证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类型的notecommitment由amount、token、stealthAddress和timestamp派生。其中stealthAddress直接来自调用者。commitment hash4( utxo.amount, uint256(uint160(utxo.erc20Address)), utxo.stealthAddressStructure.stealthAddress, utxo.timeStamp );漏洞分析以下分析基于链上行为和反混淆后的客户端代码推断。电路的实际实现未开源根本原因有待确认。受影响的合约为Hinkal0x25e5...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最高位为0getPointSign 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。
网站建设 高端定制 企业官网