Back to Blog

~$800K损失:Hinkal双花攻击 | BlockSec周报

Code Auditing
July 9, 2026
12 min read
Key Insights

在过去一周(2026/06/29 - 2026/07/05),以下值得关注的安全事件涉及以太坊上约 80 万美元的损失。

日期 事件 类型 预估损失
2026/07/02 Hinkal 业务逻辑漏洞 ~$800K
  • Hinkal:一起针对隐私池协议的双花攻击,可能利用了旧版票据格式漏洞,使同一笔存款能够生成多个有效的 nullifier。

Web3 最佳安全审计机构

在上线前验证设计、代码和业务逻辑

本周重点:Hinkal

在此次事件中,隐私池中的双花攻击很可能源于旧版票据格式漏洞。基于 nullifier 的隐私协议的偿付能力依赖于电路层面的 nullifier 绑定,而不仅仅是合约接受有效证明。

2026 年 7 月 2 日,以太坊上的隐私池协议 Hinkal 遭受双花攻击,约 80 万美元的资产被盗 [1]。可能的根本原因是旧版票据格式存在缺陷:单个票据未与唯一的 nullifier 严格绑定,导致同一笔存款可以被多次花费。该项目尚未开源其电路实现,团队也尚未发布详细的技术分析报告。以下分析基于公开文档、反混淆后的客户端代码以及链上观察。

背景

协议概览

Hinkal 是一个隐私池协议。资产余额以**票据(notes)**的形式存储,通过链上 Merkle 树中的承诺(commitments)表示,而非明文账户余额。

用户花费一个票据时,需要提交一个零知识证明和一个 nullifier。合约会记录每个 nullifier,并拒绝重复提交。一个票据只能生成一个 nullifier 的规则并非由合约强制执行,而是委托给电路来保证。

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

     票据格式(客户端)              |       链上路径
                                   |
  +--------------------------+     |     +-------------------------------+
  | 新格式(默认):           |     |     | transact()【含证明】           |
  | nk 直接进入 Poseidon6     |     |     | 所有操作:存款 / 转账 /         |
  | -> 1 个承诺:1 个 null    |     |     |           提款                |
  +--------------------------+     |     +-------------------------------+
                               --> | -->
  +--------------------------+     |     
  | 旧格式:                  |     |     +-------------------------------+
  | 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,而非直接包含 spending key(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() 是所有操作(存款、私密转账和提款)的通用验证入口点。客户端在链下生成零知识证明,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 合约而言,每次提款看起来都是有效的:证明通过、Merkle 根存在、每个 nullifier 都是新的,因此 insertNullifiers() 全部接受。合约无法将 nullifier 与叶节点关联,所以将每次花费都作为正常提款结算。该漏洞不在于链上检查,而很可能在于旧版证明电路被允许证明的内容。

prooflessDeposit() 与攻击面。 prooflessDeposit() 函数委托给 performProoflessDepositChecks() 执行,但从链上行为来看,没有可观察到的格式验证:该函数似乎不会拒绝旧格式票据,合约也不使用其返回值。这使得旧格式票据无需任何格式限制即可进入 Merkle 树,从而为上述缺陷打开了攻击面。

攻击分析

以下分析基于受影响的 Hinkal 合约的历史交易记录。攻击者针对多种资产类型(USDC、ETH 等);此处以 USDC 流程为例进行说明。

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

  • 步骤二:攻击者针对这张 100 USDC 旧格式票据反复调用 transact(),每次使用相同的 Merkle 根但提交全新的 nullifier,每次调用提取 100 USDC。这就是双花攻击:同一张票据被多次花费,累计约 25,000 USDC

  • 步骤三:攻击者在交易 0xbf7252...d50008 中,通过另一次 prooflessDeposit() 调用将全部 25,000 USDC 合并到一张旧格式票据中。通过合并成更大面额的票据,后续每次双花提款可获得 25,000 USDC,而非 100 USDC

  • 步骤四:攻击者对这张 25,000 USDC 票据重复相同操作,每次调用 transact() 时提交全新的 nullifier。存款后,攻击者未再补充任何资产,却提取了远超所存入的 25,000 USDC

所有 transact() 调用累计共盗取约 80 万美元的资产。

结论

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日的两起安全事件,确认损失约410万美元,涉及Ethereum和Cardano两条链。Taiko桥攻击中,攻击者利用泄露的SGX enclave签名密钥与attestation策略中缺失的属性验证,注册恶意prover并在Ethereum上伪造L2状态证明。SecondFi钱包漏洞源于Ed25519 nonce派生中的密码学实现缺陷,秘密输入被遗漏,使攻击者可从公开的Cardano链上交易数据离线恢复私钥。

~$1800万损失:jaredFromSubway、Aztec等项目受损 | BlockSec周报
Security Insights

~$1800万损失:jaredFromSubway、Aztec等项目受损 | BlockSec周报

本周区块链安全报告涵盖2026年6月15日至21日,以太坊和BNB链共发生3起重大事件,总损失约1830万美元,其中2起进行详细分析。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