過去1週間(2026/06/29 - 2026/07/05)において、Ethereumで約80万ドルの損失を伴う以下の重要なセキュリティインシデントが発生しました。
| 日付 | インシデント | 種別 | 推定損失 |
|---|---|---|---|
| 2026/07/02 | Hinkal | ビジネスロジックの欠陥 | ~$800K |
- Hinkal: シールドプールプロトコルに対するダブルスペンド攻撃。同一のデポジットから複数の有効なヌリファイアを生成できるレガシーノートフォーマットの欠陥が悪用された可能性が高い。
Web3のベストセキュリティ監査機関
ローンチ前に設計・コード・ビジネスロジックを検証
今週のハイライト:Hinkal
本インシデントでは、シールドプールにおけるダブルスペンドがレガシーノートフォーマットの欠陥によって引き起こされた可能性が高い。ヌリファイアベースのプライバシープロトコルにおける支払い能力の保証は、コントラクトが有効な証明を受け入れることだけでなく、回路レベルでのヌリファイアバインディングに依存している。
2026年7月2日、Ethereum上のシールドプールプロトコルであるHinkalが、ダブルスペンド攻撃により約80万ドル相当の資産を流出させた [1]。根本原因として考えられるのは、レガシーノートフォーマットの欠陥であり、単一のノートが一意のヌリファイアに強く結び付けられていないため、1つのデポジットを複数回スペンドできる状態になっていた。プロジェクトは回路実装をオープンソース化しておらず、チームはいまだ詳細な技術的説明を公開していない。以下の分析は、公開ドキュメント、難読化解除されたクライアントコード、およびオンチェーンの観察に基づいている。
背景
プロトコル概要
Hinkalはシールドプールプロトコルである。残高はオンチェーンのマークルツリー内のコミットメントとして表現されるノートとして保存され、通常のアカウント残高ではない。
ノートをスペンドするには、ユーザーはzk証明とヌリファイアを送信する。コントラクトは各ヌリファイアを記録し、繰り返しを拒否する。1つのノートが1つのヌリファイアのみを生成できるというルールはコントラクトによって強制されない。これは回路に委ねられている。
以下の図はデポジットと引き出しのフローを示している:
ノートフォーマット(クライアント) | オンチェーンパス
|
+--------------------------+ | +-------------------------------+
| 新フォーマット(デフォルト):| | | transact()【証明あり】 |
| nkを直接Poseidon6に使用 | | | 全操作:デポジット/転送/ |
| -> 1コミット : 1ヌリファイア| | | 引き出し |
+--------------------------+ | +-------------------------------+
--> | -->
+--------------------------+ |
| レガシーフォーマット: | | +-------------------------------+
| nkはe*nkの積のみで使用 | | | prooflessDeposit()【証明なし】 |
| -> 1コミットに対して複数 | | | デポジットのみ |
| のヌリファイアが許容 | | +-------------------------------+
| される可能性あり | |
+--------------------------+ | デポジットの場合、両パスとも
| -> ノートはマークルツリーに追加される
ノートフォーマット
実際の回路実装はオープンソース化されていない。以下の分析は、Hinkalの公開回路設計ドキュメント [2] および難読化解除されたクライアント証明者コード [3] に基づいている。
ドキュメントとクライアントコードから、isNewStyleフラグによって選択される2種類のノートのstealthAddress構築方法が示唆されている:
-
レガシー:
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は秘密のヌリファイアキー、Base8は固定点である。H0とH1はBaby Jubjub楕円曲線上の点であるため、それぞれx座標とy座標を持つ。signsの値はそれらのx座標H0xおよびH1xの符号ビット(2*L(H0x) + L(H1x))をパックしたものである。レガシーのstealthAddressにはnkが積e*nkを介してのみ含まれ、スペンディングキー(spk0、spk1)は直接含まれない。一方、新フォーマットではnk、spk0、spk1がすべてPoseidon6に入力される。
ヌリファイアは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 };
};
// ヌリファイアは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()はその証明を検証し、マークルルートを確認した後、ヌリファイアとコミットメントを書き込む。
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)である。
欠陥の可能性。 背景で説明したとおり、レガシーフォーマットではnkが積e*nkのみを介して含まれるが、ヌリファイアはnkから直接導出される。レガシーのスペンド回路がnkをコミットメントに一意に結び付けていない場合、異なる(e, nk)のペアが同じ積e*nk(つまり同じコミットメント)を保ちながらnkを変えることで、同一のリーフに対して毎回新しいヌリファイアを生成できる。新フォーマットではnkが直接Poseidon6に入力されるため、コミットメントごとに1つのnkが固定される。Hinkalコントラクトから見ると、すべての引き出しは有効に見える:証明はパスし、ルートは存在し、各ヌリファイアは新しいため、insertNullifiers()はそれを受け入れる。コントラクトはヌリファイアをリーフに結び付けることができないため、各スペンドを通常の引き出しとして処理する。バグはオンチェーンのチェックにあるのではなく、おそらくレガシー証明回路が何を証明できるかに起因する。
prooflessDeposit()とアタックサーフェス。 prooflessDeposit()関数はperformProoflessDepositChecks()に処理を委譲しているが、オンチェーンの挙動からフォーマット検証は観察されない:この関数はレガシーフォーマットのノートを拒否せず、コントラクトもその戻り値を使用していないようである。これにより、フォーマット制限なしにレガシーノートがマークルツリーに挿入でき、上記の欠陥に対するアタックサーフェスが生じる。
攻撃分析
以下の分析は、影響を受けたHinkalコントラクトの過去のトランザクションに基づいている。攻撃者は複数の資産タイプ(USDC、ETHなど)をターゲットにしており、ここではUSDCのフローを例として使用する。
-
ステップ1:攻撃者はトランザクション 0xfbedf0...8c2f11 において
prooflessDeposit()経由で100USDCをデポジットした。このデポジットのextraRandomizationは最上位ビットが0(getPointSign = 0)であり、ノートがレガシーフォーマットを使用していることを示している。 -
ステップ2:攻撃者はこの100
USDCのレガシーノートに対してtransact()を繰り返し呼び出し、毎回同じマークルルートに対して新しいヌリファイアを使用し、1回の呼び出しにつき100USDCを引き出した。これがダブルスペンドであり、同一のノートが何度もスペンドされ、合計で約25,000USDCが蓄積された。 -
ステップ3:攻撃者はトランザクション 0xbf7252...d50008 において別の
prooflessDeposit()呼び出しにより、25,000USDCすべてを1つのレガシーノートに統合した。より大きなノートに統合することで、以降のダブルスペンド引き出しでは100USDCではなく25,000USDCずつ取得できるようになった。 -
ステップ4:攻撃者は25,000
USDCのノートに対して同じプロセスを繰り返し、毎回新しいヌリファイアでtransact()を呼び出した。デポジット後、攻撃者は追加の資産を一切供給せず、それでもデポジットした25,000USDCをはるかに超える金額を引き出した。
すべてのtransact()呼び出し合計で、約80万ドル相当の資産が流出した。

結論
Hinkalコントラクトは個別には有効な証明をすべて受け入れたが、レガシーノートフォーマットの欠陥により、単一のノートが何度も換金された可能性が高い。コントラクトのオンチェーンチェック(証明検証、ヌリファイアの一意性)はすべてパスしたが、同一のノートが複数の有効なヌリファイアを生成していることを検出できなかった。チームはその後、全チェーンにわたるすべてのHinkalスマートコントラクトを一時停止し、影響を受けたすべてのユーザーへの全額補償を約束した [1]。
Hinkal固有の緩和策としては、レガシーノートフォーマットのパスを完全に無効化すること、prooflessDeposit()でノートフォーマットの検証を強制すること(例:isNewStyle == 0のノートを拒否する)、そして一対一のヌリファイアバインディングを確認するための専用回路監査の実施が挙げられる。
ヌリファイアベースのプライバシープロトコル全般において、不変条件は同じである:各ノートは単一の明確に定義された導出によって正確に1つのヌリファイアにマッピングされなければならない。そのバインディングは回路レベルで強制される必要がある。なぜなら、コントラクトはヌリファイアがまだ見られていないことを確認できるだけで、それが特定のノートに対して唯一の有効なヌリファイアであるかどうかは確認できないからである。
参考文献
[1] Hinkalプロトコルのインシデント後アップデート: 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)



