In der vergangenen Woche (2026/06/29 - 2026/07/05) wird der folgende bemerkenswerte Sicherheitsvorfall vorgestellt, der einen Verlust von rund 800.000 USD auf Ethereum verursachte.
| Datum | Vorfall | Typ | Geschätzter Verlust |
|---|---|---|---|
| 2026/07/02 | Hinkal | Fehler in der Geschäftslogik | ~800.000 USD |
- Hinkal: Ein Double-Spend-Angriff auf ein Shielded-Pool-Protokoll, der wahrscheinlich einen Fehler im Legacy-Notenformat ausnutzte, der es ermöglichte, dass dieselbe Einzahlung mehrere gültige Nullifier erzeugte.
Best Security Auditor for Web3
Validate design, code, and business logic before launch
Wochenrückblick: Hinkal
Bei diesem Vorfall wurde ein Double-Spend in einem Shielded Pool wahrscheinlich durch einen Fehler im Legacy-Notenformat ermöglicht. Die Solvenz in nullifierbasierten Datenschutzprotokollen hängt von der Nullifier-Bindung auf Schaltkreisebene ab, nicht nur davon, dass der Vertrag gültige Beweise akzeptiert.
Am 2. Juli 2026 wurde Hinkal, ein Shielded-Pool-Protokoll auf Ethereum, durch einen Double-Spend-Angriff um rund 800.000 USD an Vermögenswerten geleert [1]. Die wahrscheinliche Ursache ist ein Fehler im Legacy-Notenformat, bei dem eine einzelne Note nicht eindeutig an einen einzigen Nullifier gebunden ist, sodass eine Einzahlung mehrfach ausgegeben werden kann. Das Projekt hat seine Schaltkreisimplementierung nicht als Open Source veröffentlicht, und das Team hat noch keine detaillierte technische Analyse publiziert. Die folgende Analyse basiert auf der öffentlichen Dokumentation, de-obfuskiertem Client-Code und On-Chain-Beobachtungen.
Hintergrund
Protokollübersicht
Hinkal ist ein Shielded-Pool-Protokoll. Guthaben werden als Notes gespeichert, die durch Commitments in einem On-Chain-Merkle-Baum dargestellt werden, anstatt als gewöhnliche Kontoguthaben.
Um eine Note auszugeben, sendet der Benutzer einen zk-Beweis und einen Nullifier. Der Vertrag zeichnet jeden Nullifier auf und lehnt jede Wiederholung ab. Die Regel, dass eine Note nur einen Nullifier erzeugen kann, wird nicht durch den Vertrag erzwungen, sondern an den Schaltkreis delegiert.
Das folgende Diagramm zeigt den Einzahlungs- und Auszahlungsablauf:
Notenformat (Client) | On-Chain-Pfad
|
+------------------------------+ | +-------------------------------+
| Neues Format (Standard): | | | transact() [mit Beweis] |
| nk direkt in Poseidon6 | | | alle Ops: Einzahlung / |
| -> 1 Commit : 1 Nullifier | | | Transfer / Auszahlung |
+------------------------------+ | +-------------------------------+
--> | -->
+------------------------------+ |
| Legacy-Format: | | +-------------------------------+
| nk nur über Produkt e*nk | | | prooflessDeposit() [kein |
| -> erlaubt möglicherweise | | | Beweis] nur Einzahlung |
| mehrere Nullifier pro | | +-------------------------------+
| Commitment | |
+------------------------------+ | Bei Einzahlungen führen beide
| Pfade dazu, dass die Note in
| den Merkle-Baum eingetragen wird
Notenformate
Die eigentliche Schaltkreisimplementierung wurde nicht als Open Source veröffentlicht. Die folgende Analyse basiert auf Hinkals öffentlicher Dokumentation zum Schaltkreisdesign [2] und dem de-obfuskierten Client-Prover-Code [3].
Die Dokumentation und der Client-Code legen zwei Möglichkeiten nahe, die stealthAddress einer Note zu erstellen, ausgewählt durch ein isNewStyle-Flag:
-
Legacy:
H0 = e*Base8,H1 = (e*nk)*Base8,stealthAddress = Poseidon3(signs, H0y, H1y) -
Neu:
H1 = nk*H0,stealthAddress = Poseidon6(signs, H0y, H1y, spk0, spk1, nk)
Poseidon2,Poseidon3,Poseidon6sind allesamt Instanzen der Poseidon-Hashfunktion; das Suffix bezeichnet die Anzahl der Eingaben.
Hier ist e eine Zufallszahl, nk ist der geheime Nullifying-Key und Base8 ist ein fester Punkt. Da H0 und H1 Punkte auf der elliptischen Baby-Jubjub-Kurve sind, hat jeder eine x-Koordinate und eine y-Koordinate. Der Wert signs packt die Vorzeichenbits ihrer x-Koordinaten H0x und H1x (2*L(H0x) + L(H1x)). Die Legacy-stealthAddress enthält nk nur durch das Produkt e*nk, nicht direkt den Spending-Key (spk0, spk1), während das neue Format nk, spk0 und spk1 alle in Poseidon6 einbettet.
Der Nullifier wird direkt aus nk berechnet: nullifier = Poseidon2(commitment, Poseidon2(nk, commitment)).
Die relevanten Funktionen, de-obfuskiert aus zkProofWorkerNode.js (S = Poseidon, Base8 = fester Generator, e = Randomisierung, t = nk):
// Legacy
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 nur über das Produkt)
return { H0, H1 };
};
// Neu
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 an den Notenpunkt gebunden)
return { H0, H1 };
};
// Nullifier wird direkt aus nk abgeleitet
getNullifier() {
const c = this.getCommitment();
const sig = S(this.nullifyingKey, c); // Poseidon2(nk, commitment)
this.nullifier = S(c, sig); // Poseidon2(commitment, sig)
}
Dieses isNewStyle-Flag wird im obersten Bit eines Feldes namens extraRandomization in der stealthAddressStructure der Note gespeichert. Der Client-Code packt es als extraRandomization = (isNewStyle << 255) | H0x, und der Vertrag trennt es wieder mit getPointSign(H) = H / 2**255 und getPointY(H) = H % 2**255 beim Dekodieren des gepackten Wertes. Der Code-Kommentar lautet „strip the isNewStyle flag (bit 255) so the circuit gets the clean H0x coordinate". Daher kann das oberste Bit über getPointSign(extraRandomization) abgerufen werden, um den Notentyp zu bestimmen.
Der folgende Ausschnitt aus CircomDataBuilder.sol:formBasicInput() zeigt dieses Entpacken:
// 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
...
Im Client-Code zur Erstellung von Einzahlungen ist dieses isNewStyle-Flag standardmäßig auf true gesetzt, sodass normale Benutzerabläufe offenbar nie eine Legacy-Note erstellen.
// hinkalDeposit für sich selbst
// Eigene Output-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 // entspricht isNewStyle: true
})];
// hinkalDeposit für andere
// @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 // entspricht isNewStyle: true
})])
Eine Einzahlung im Legacy-Format mit auf 0 gesetztem Flag wird daher nicht durch einen normalen Benutzerablauf erzeugt.
Einzahlungs- und Auszahlungspfade
transact() ist der universelle, beweisgecheckte Einstiegspunkt für alle Operationen: Einzahlungen, private Transfers und Auszahlungen. Der Client erzeugt einen zk-Beweis außerhalb der Kette, und transact() verifiziert ihn, prüft die Merkle-Wurzel und schreibt dann Nullifier und Commitments.
prooflessDeposit() ist eine separate On-Chain-Funktion, die transact() vollständig umgeht. Sie akzeptiert Token und erstellt das Commitment direkt aus vom Aufrufer angegebenen Daten, ohne einen Beweis zu benötigen.
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)
);
}
Bei ERC-20-Notes wird ein Commitment aus amount, token, stealthAddress und timestamp abgeleitet. Die stealthAddress stammt direkt vom Aufrufer.
commitment = hash4(
utxo.amount,
uint256(uint160(utxo.erc20Address)),
utxo.stealthAddressStructure.stealthAddress,
utxo.timeStamp
);
Schwachstellenanalyse
Diese Analyse ist eine Schlussfolgerung aus dem On-Chain-Verhalten und dem de-obfuskierten Client-Code. Die eigentliche Schaltkreisimplementierung wurde nicht als Open Source veröffentlicht, und die Grundursache ist noch zu bestätigen.
Der betroffene Vertrag ist Hinkal (0x25e5...a826).
Der wahrscheinliche Fehler. Wie im Hintergrund beschrieben, enthält das Legacy-Format nk nur durch das Produkt e*nk, während der Nullifier direkt aus nk abgeleitet wird. Wenn der Legacy-Spend-Schaltkreis nk nicht eindeutig an das Commitment bindet, kann ein anderes (e, nk)-Paar dasselbe Produkt e*nk (und damit dasselbe Commitment) bewahren, während nk geändert wird, wodurch jedes Mal ein neuer Nullifier für dasselbe Blatt erzeugt wird. Das neue Format setzt nk direkt in Poseidon6, was einen nk pro Commitment festlegt. Für den Hinkal-Vertrag sieht jede Auszahlung gültig aus: Der Beweis besteht, die Wurzel existiert und jeder Nullifier ist neu, sodass insertNullifiers() ihn akzeptiert. Der Vertrag kann einen Nullifier nicht mit einem Blatt verknüpfen, daher wickelt er jede Ausgabe als normale Auszahlung ab. Der Fehler liegt nicht in den On-Chain-Prüfungen, sondern wahrscheinlich darin, was der Legacy-Beweis-Schaltkreis beweisen darf.
prooflessDeposit() und die Angriffsfläche. Die Funktion prooflessDeposit() delegiert an performProoflessDepositChecks(), aber im On-Chain-Verhalten ist keine Formatvalidierung erkennbar: Die Funktion lehnt offenbar keine Notes im Legacy-Format ab, und der Vertrag verwendet ihren Rückgabewert nicht. Dies ermöglicht es einer Legacy-Note, ohne Formatbeschränkung in den Merkle-Baum einzugehen und die oben beschriebene Angriffsfläche zu eröffnen.
Angriffsanalyse
Die folgende Analyse basiert auf den historischen Transaktionen des betroffenen Hinkal-Vertrags. Der Angreifer zielte auf mehrere Vermögensarten (USDC, ETH usw.) ab; der USDC-Ablauf wird hier als Beispiel verwendet.
-
Schritt 1: Der Angreifer zahlte 100
USDCüberprooflessDeposit()in der Transaktion 0xfbedf0...8c2f11 ein. DieextraRandomizationdieser Einzahlung hat ihr oberstes Bit auf 0 gesetzt (getPointSign = 0), was darauf hinweist, dass die Note das Legacy-Format verwendet. -
Schritt 2: Der Angreifer rief
transact()wiederholt gegen diese Legacy-Note mit 100USDCauf, jedes Mal mit derselben Merkle-Wurzel, aber einem neuen Nullifier, und zog bei jedem Aufruf 100USDCab. Dies ist der Double-Spend: Dieselbe Note wurde viele Male ausgegeben und häufte rund 25.000USDCan. -
Schritt 3: Der Angreifer konsolidierte alle 25.000
USDCin einer einzigen Legacy-Note über einen weiterenprooflessDeposit()-Aufruf in der Transaktion 0xbf7252...d50008. Durch die Konsolidierung in eine größere Note würde jede nachfolgende Double-Spend-Auszahlung 25.000USDCanstatt 100 einbringen. -
Schritt 4: Der Angreifer wiederholte denselben Prozess gegen die 25.000-
USDC-Note und rieftransact()jedes Mal mit einem neuen Nullifier auf. Nach der Einzahlung lieferte der Angreifer nie mehr Vermögenswerte nach, zog jedoch weit mehr als die eingezahlten 25.000USDCab.
Insgesamt wurden über alle transact()-Aufrufe hinweg rund 800.000 USD an Vermögenswerten abgezogen.

Fazit
Der Hinkal-Vertrag akzeptierte einzeln gültige Beweise, dennoch wurde eine einzelne Note höchstwahrscheinlich viele Male eingelöst, da ein Fehler im Legacy-Notenformat vorlag. Die On-Chain-Prüfungen des Vertrags (Beweisverifizierung, Nullifier-Eindeutigkeit) bestanden alle, konnten aber nicht erkennen, dass dieselbe Note mehrere gültige Nullifier erzeugte. Das Team hat inzwischen alle Hinkal-Smart-Contracts auf allen Chains pausiert und sich verpflichtet, alle betroffenen Nutzer vollständig zu entschädigen [1].
Für Hinkal im Speziellen umfassen Gegenmaßnahmen die vollständige Deaktivierung des Legacy-Notenformat-Pfads, die Durchsetzung einer Notenformat-Validierung in prooflessDeposit() (z. B. Ablehnung von Notes mit isNewStyle == 0) sowie die Durchführung eines dedizierten Schaltkreis-Audits zur Bestätigung der Eins-zu-eins-Nullifier-Bindung.
Für nullifierbasierende Datenschutzprotokolle im Allgemeinen gilt dieselbe Invariante: Jede Note muss durch eine einzige, klar definierte Ableitung auf genau einen Nullifier abgebildet werden. Diese Bindung muss auf Schaltkreisebene erzwungen werden, da der Vertrag nur prüfen kann, ob ein Nullifier zuvor gesehen wurde, nicht aber, ob er der einzige gültige Nullifier für eine bestimmte Note ist.
Referenzen
[1] Hinkal Protocol Post-Incident-Update: https://x.com/hinkal_protocol/status/2073136163880149417
[2] Dokumentation zum Schaltkreisdesign: https://hinkal-team.gitbook.io/hinkal/technical-description/circuits/swapper-m#id-3.-correct-nullifiers-per-input
[3] Clientseitiger Code: https://github.com/Hinkal-Protocol/Hinkal-API-Enclave ](https://github.com/Hinkal-Protocol/Hinkal-API-Enclave)



