Geçen hafta (2026/06/29 - 2026/07/05) boyunca, Ethereum üzerinde yaklaşık 800.000 $ kayıpla sonuçlanan aşağıdaki önemli güvenlik olayı öne çıkmaktadır.
| Tarih | Olay | Tür | Tahmini Kayıp |
|---|---|---|---|
| 2026/07/02 | Hinkal | İş Mantığı Açığı | ~800.000 $ |
- Hinkal: Gizlenmiş havuz protokolüne yönelik çift harcama saldırısı; muhtemelen aynı mevduatın birden fazla geçerli nullifier üretmesine olanak tanıyan eski bir not formatı açığından yararlanılmıştır.
Web3 için En İyi Güvenlik Denetçisi
Lansmandan önce tasarımı, kodu ve iş mantığını doğrulayın
Haftanın Öne Çıkanı: Hinkal
Bu olayda, gizlenmiş bir havuzdaki çift harcamanın muhtemelen eski bir not formatı açığıyla mümkün kılındığı görülmektedir. Nullifier tabanlı gizlilik protokollerinde ödeme gücü, yalnızca sözleşmenin geçerli kanıtları kabul etmesine değil, devre düzeyinde nullifier bağlamasına bağlıdır.
2 Temmuz 2026'da Ethereum üzerindeki gizlenmiş havuz protokolü Hinkal, bir çift harcama saldırısıyla yaklaşık 800.000 $ değerinde varlık kaybına uğradı [1]. Muhtemel kök neden, eski not formatındaki bir açıktır; bu formatta tek bir not benzersiz bir nullifier'a sıkı sıkıya bağlı olmadığından, bir mevduatın birden fazla kez harcanmasına imkân tanınmaktadır. Proje devre uygulamasını açık kaynak olarak paylaşmamıştır ve ekip henüz ayrıntılı teknik bir analiz yayınlamamıştır. Aşağıdaki analiz; kamuya açık belgeler, gizliliği kaldırılmış istemci kodu ve zincir üstü gözlemler temel alınarak hazırlanmıştır.
Arka Plan
Protokole Genel Bakış
Hinkal, gizlenmiş bir havuz protokolüdür. Bakiyeler, düz hesap bakiyeleri yerine zincir üstündeki bir Merkle ağacındaki taahhütlerle temsil edilen notlar olarak saklanır.
Bir notu harcamak için kullanıcı, bir zk kanıtı ve bir nullifier gönderir. Sözleşme her nullifier'ı kaydeder ve tekrarını reddeder. Bir notun yalnızca tek bir nullifier üretebileceği kuralı sözleşme tarafından uygulanmaz; bu sorumluluk devreye devredilmiştir.
Aşağıdaki şema, para yatırma ve çekme akışını göstermektedir:
Not formatı (istemci) | Zincir üstü yol
|
+--------------------------+ | +-------------------------------+
| Yeni format (varsayılan):| | | transact() [kanıtla] |
| nk doğrudan Poseidon6'da | | | tüm işlemler: yatırma / |
| -> 1 taahhüt : 1 null | | | transfer / çekme |
+--------------------------+ | +-------------------------------+
--> | -->
+--------------------------+ |
| Eski format: | | +-------------------------------+
| nk yalnızca e*nk çarpımı | | | prooflessDeposit() [kanutsuz] |
| üzerinden | | | yalnızca yatırma |
| -> taahhüt başına birden | | +-------------------------------+
| fazla nullifier | |
| izin verebilir | | Her iki yatırma yolu da
+--------------------------+ | -> notu Merkle ağacına ekler
Not Formatları
Gerçek devre uygulaması açık kaynak olarak paylaşılmamıştır. Aşağıdaki analiz, Hinkal'ın kamuya açık devre tasarımı belgelerine [2] ve gizliliği kaldırılmış istemci prover koduna [3] dayanmaktadır.
Belgeler ve istemci kodu, bir isNewStyle bayrağıyla seçilen notun stealthAddress değerini oluşturmanın iki yolunu önermektedir:
-
eski:
H0 = e*Base8,H1 = (e*nk)*Base8,stealthAddress = Poseidon3(signs, H0y, H1y) -
yeni:
H1 = nk*H0,stealthAddress = Poseidon6(signs, H0y, H1y, spk0, spk1, nk)
Poseidon2,Poseidon3,Poseidon6Poseidon hash fonksiyonunun örnekleridir; sonek girdi sayısını belirtir.
Burada e rastgele bir sayı, nk gizli nullifier anahtarı ve Base8 sabit bir noktadır. H0 ve H1, Baby Jubjub eliptik eğrisindeki noktalar olduğundan her birinin bir x-koordinatı ve bir y-koordinatı vardır. signs değeri, x-koordinatlarının işaret bitlerini H0x ve H1x (2*L(H0x) + L(H1x)) olarak paketler. Eski stealthAddress, nk değerini yalnızca e*nk çarpımı aracılığıyla içerirken harcama anahtarlarını (spk0, spk1) doğrudan içermez; yeni format ise nk, spk0 ve spk1'i doğrudan Poseidon6'ya dahil eder.
Nullifier, nk'dan doğrudan hesaplanır: nullifier = Poseidon2(commitment, Poseidon2(nk, commitment)).
zkProofWorkerNode.js'den gizliliği kaldırılmış ilgili fonksiyonlar (S = Poseidon, Base8 = sabit üretici, e = rasgeleleştirme, t = nk):
// eski
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 yalnızca çarpım üzerinden)
return { H0, H1 };
};
// yeni
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 not noktasına bağlı)
return { H0, H1 };
};
// nullifier, nk'dan doğrudan türetilir
getNullifier() {
const c = this.getCommitment();
const sig = S(this.nullifyingKey, c); // Poseidon2(nk, taahhüt)
this.nullifier = S(c, sig); // Poseidon2(taahhüt, sig)
}
Bu isNewStyle bayrağı, notun stealthAddressStructure alanındaki extraRandomization adlı alanın en üst bitinde saklanır. İstemci kodu bunu extraRandomization = (isNewStyle << 255) | H0x şeklinde paketler; sözleşme ise paketlenmiş değeri çözerken getPointSign(H) = H / 2**255 ve getPointY(H) = H % 2**255 ile ayrıştırır. Kod yorumu "isNewStyle bayrağını (255. bit) devre temiz H0x koordinatını alacak şekilde soyar" şeklindedir. Bu nedenle not türünü belirlemek için en üst bit getPointSign(extraRandomization) aracılığıyla elde edilebilir.
CircomDataBuilder.sol:formBasicInput() fonksiyonundan alınan aşağıdaki kod parçası bu paketten çıkarma işlemini göstermektedir:
// CircomDataBuilder.sol:formBasicInput()
...
// isNewStyle bayrağını (255. bit) soyarak devrenin temiz H0x koordinatını almasını sağla
input[index++] = getPointY(
circomData.stealthAddressStructure.extraRandomization
); // = H0x
input[index++] = circomData.stealthAddressStructure.H0; // = H0y
...
Para yatırma işlemini oluşturan istemci kodunda bu isNewStyle bayrağı varsayılan olarak true olarak ayarlanmaktadır; dolayısıyla normal kullanıcı akışlarının hiçbir zaman eski format not oluşturmadığı görülmektedir.
// kendisi için hinkalDeposit
// Kendi çıktı UTXO'su
//@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 ile eşdeğer
})];
// başkası için 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 ile eşdeğer
})])
Bu nedenle bayrağın 0 olarak ayarlandığı eski formatlı bir yatırma işlemi, normal bir kullanıcı akışından üretilen bir şey değildir.
Para Yatırma ve Çekme Yolları
transact(), tüm işlemler için evrensel kanıt denetimli giriş noktasıdır: yatırma, özel transfer ve çekme. İstemci zincir dışında bir zk kanıtı oluşturur; transact() bunu doğrular, Merkle kökünü kontrol eder, ardından nullifier'ları ve taahhütleri yazar.
prooflessDeposit(), transact() işlevini tamamen atlayan ayrı bir zincir üstü fonksiyondur. Tokenleri kabul eder ve taahhüdü doğrudan arayanın belirttiği verilerden, kanıt gerektirmeksizin oluşturur.
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 notları için taahhüt, amount, token, stealthAddress ve timestamp değerlerinden türetilir. stealthAddress doğrudan arayanın gönderdiği değerden alınır.
commitment = hash4(
utxo.amount,
uint256(uint160(utxo.erc20Address)),
utxo.stealthAddressStructure.stealthAddress,
utxo.timeStamp
);
Güvenlik Açığı Analizi
Bu analiz, zincir üstü davranıştan ve gizliliği kaldırılmış istemci kodundan yapılan bir çıkarımdır. Gerçek devre uygulaması açık kaynak olarak paylaşılmamış olup kök neden henüz doğrulanmamıştır.
Etkilenen sözleşme Hinkal'dır (0x25e5...a826).
Muhtemel açık. Arka Plan bölümünde açıklandığı üzere, eski format nk değerini yalnızca e*nk çarpımı aracılığıyla içerirken nullifier nk'dan doğrudan türetilmektedir. Eski harcama devresi nk'yı taahhüde benzersiz biçimde bağlamıyorsa, farklı bir (e, nk) çifti aynı e*nk çarpımını (dolayısıyla aynı taahhüdü) korurken nk'yı değiştirerek aynı yaprak için her seferinde yeni bir nullifier üretebilir. Yeni format nk'yı doğrudan Poseidon6'ya dahil ederek taahhüt başına tek bir nk sabitlemiş olur. Hinkal sözleşmesi açısından her çekme geçerli görünür: kanıt doğrulanır, kök mevcuttur ve her nullifier yenidir; dolayısıyla insertNullifiers() bunu kabul eder. Sözleşme bir nullifier'ı bir yaprağa bağlayamadığından her harcamayı normal bir çekme olarak işler. Hata, zincir üstü denetimlerde değil, muhtemelen eski kanıt devresinin neyi kanıtlamasına izin verildiğindedir.
prooflessDeposit() ve saldırı yüzeyi. prooflessDeposit() fonksiyonu performProoflessDepositChecks() fonksiyonuna çağrıyı devreder; ancak zincir üstü davranışta format doğrulaması gözlemlenemez: fonksiyon eski formatlı notları reddetmiyor gibi görünmekte, sözleşme de dönüş değerini kullanmamaktadır. Bu durum, eski formatlı bir notun herhangi bir format kısıtlaması olmaksızın Merkle ağacına girmesine olanak tanıyarak yukarıda açıklanan açık için saldırı yüzeyini açmaktadır.
Saldırı Analizi
Aşağıdaki analiz, etkilenen Hinkal sözleşmesinin geçmiş işlemlerine dayanmaktadır. Saldırgan birden fazla varlık türünü (USDC, ETH vb.) hedef almıştır; burada örnek olarak USDC akışı kullanılmıştır.
-
Adım 1: Saldırgan, 0xfbedf0...8c2f11 işleminde
prooflessDeposit()aracılığıyla 100USDCyatırdı. Bu yatırımınextraRandomizationdeğerinin en üst biti 0'a (getPointSign = 0) ayarlanmış olup notun eski formatı kullandığını göstermektedir. -
Adım 2: Saldırgan bu 100
USDCeski format notuna karşıtransact()fonksiyonunu defalarca çağırdı; her seferinde aynı Merkle kökünü ancak yeni bir nullifier kullanarak çağrı başına 100USDCçekti. Bu, çift harcamadır: aynı not defalarca harcanarak yaklaşık 25.000USDCbiriktirildi. -
Adım 3: Saldırgan, 0xbf7252...d50008 işleminde başka bir
prooflessDeposit()çağrısıyla tüm 25.000USDC'yi tek bir eski format nota birleştirdi. Daha büyük bir nota konsolide ederek sonraki her çift harcama çekiminde 100 yerine 25.000USDCelde edilmesini sağladı. -
Adım 4: Saldırgan aynı süreci 25.000
USDCnotuna karşı tekrarladı; her seferinde yeni bir nullifier iletransact()çağırdı. Yatırımdan sonra saldırgan hiç ek varlık sağlamadı, ancak yatırılan 25.000USDC'nin çok üzerinde çekim yaptı.
Toplamda, tüm transact() çağrılarında yaklaşık 800.000 $ değerinde varlık ele geçirildi.

Sonuç
Hinkal sözleşmesi tekil olarak geçerli kanıtları kabul etmiş; ancak tek bir not, muhtemelen eski not formatındaki bir açık nedeniyle defalarca kullanılmıştır. Sözleşmenin zincir üstü denetimleri (kanıt doğrulama, nullifier benzersizliği) hepsini geçmiş, ancak aynı notun birden fazla geçerli nullifier ürettiğini tespit edememiştir. Ekip, tüm zincirlerdeki Hinkal akıllı sözleşmelerini duraklatmış ve etkilenen tüm kullanıcılara tam geri ödeme yapılacağını taahhüt etmiştir [1].
Hinkal özelinde alınabilecek önlemler; eski not formatı yolunu tamamen devre dışı bırakmayı, prooflessDeposit() fonksiyonunda not formatı doğrulamasını uygulamayı (örn. isNewStyle == 0 olan notları reddetmek) ve bire bir nullifier bağlamasını doğrulamak amacıyla özel bir devre denetimi yaptırmayı kapsamaktadır.
Nullifier tabanlı gizlilik protokolleri için genel kural aynıdır: her not, tek ve iyi tanımlanmış bir türetim yoluyla tam olarak bir nullifier'a eşlenmelidir. Bu bağlama devre düzeyinde uygulanmalıdır; zira sözleşme yalnızca bir nullifier'ın daha önce görülüp görülmediğini kontrol edebilir, belirli bir not için tek geçerli nullifier olduğunu doğrulayamaz.
Kaynaklar
[1] Hinkal Protokolü olay sonrası güncelleme: https://x.com/hinkal_protocol/status/2073136163880149417
[2] Devre tasarımı belgeleri: https://hinkal-team.gitbook.io/hinkal/technical-description/circuits/swapper-m#id-3.-correct-nullifiers-per-input
[3] İstemci tarafı kodu: https://github.com/Hinkal-Protocol/Hinkal-API-Enclave ](https://github.com/Hinkal-Protocol/Hinkal-API-Enclave)



