在过去一周(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*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,而非直接包含 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 绑定到票据点)
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() 是所有操作(存款、私密转账和提款)的通用验证入口点。客户端在链下生成零知识证明,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 合约而言,每次提款看起来都是有效的:证明通过、Merkle 根存在、每个 nullifier 都是新的,因此 insertNullifiers() 全部接受。合约无法将 nullifier 与叶节点关联,所以将每次花费都作为正常提款结算。该漏洞不在于链上检查,而很可能在于旧版证明电路被允许证明的内容。
prooflessDeposit() 与攻击面。 prooflessDeposit() 函数委托给 performProoflessDepositChecks() 执行,但从链上行为来看,没有可观察到的格式验证:该函数似乎不会拒绝旧格式票据,合约也不使用其返回值。这使得旧格式票据无需任何格式限制即可进入 Merkle 树,从而为上述缺陷打开了攻击面。
攻击分析
以下分析基于受影响的 Hinkal 合约的历史交易记录。攻击者针对多种资产类型(USDC、ETH 等);此处以 USDC 流程为例进行说明。
-
步骤一:攻击者在交易 0xfbedf0...8c2f11 中通过
prooflessDeposit()存入 100USDC。该存款的extraRandomization最高位为 0(getPointSign = 0),表明该票据使用旧格式。 -
步骤二:攻击者针对这张 100
USDC旧格式票据反复调用transact(),每次使用相同的 Merkle 根但提交全新的 nullifier,每次调用提取 100USDC。这就是双花攻击:同一张票据被多次花费,累计约 25,000USDC。 -
步骤三:攻击者在交易 0xbf7252...d50008 中,通过另一次
prooflessDeposit()调用将全部 25,000USDC合并到一张旧格式票据中。通过合并成更大面额的票据,后续每次双花提款可获得 25,000USDC,而非 100USDC。 -
步骤四:攻击者对这张 25,000
USDC票据重复相同操作,每次调用transact()时提交全新的 nullifier。存款后,攻击者未再补充任何资产,却提取了远超所存入的 25,000USDC。
所有 transact() 调用累计共盗取约 80 万美元的资产。

结论
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)



