За прошедшую неделю (2026/06/29 - 2026/07/05) был зафиксирован следующий значимый инцидент безопасности с потерями около $800K в сети Ethereum.
| Дата | Инцидент | Тип | Оценочные потери |
|---|---|---|---|
| 2026/07/02 | Hinkal | Уязвимость бизнес-логики | ~$800K |
- Hinkal: Атака двойного расходования на протокол с экранированным пулом, вероятно, использующая уязвимость устаревшего формата нот, которая позволяла одному депозиту генерировать несколько действительных нуллификаторов.
Best Security Auditor for Web3
Validate design, code, and business logic before launch
Главный инцидент недели: Hinkal
В данном инциденте двойное расходование в экранированном пуле стало возможным вероятно из-за уязвимости устаревшего формата нот. Платёжеспособность в протоколах приватности на основе нуллификаторов зависит от привязки нуллификаторов на уровне схемы, а не только от того, принимает ли контракт действительные доказательства.
2 июля 2026 года протокол Hinkal с экранированным пулом на Ethereum был опустошён приблизительно на $800K в активах в результате атаки двойного расходования [1]. Вероятная первопричина — уязвимость устаревшего формата нот, при которой одна нота не привязана жёстко к уникальному нуллификатору, что позволяет тратить один депозит многократно. Проект не опубликовал исходный код реализации схемы, а команда пока не опубликовала детальный технический анализ. Приведённый ниже анализ основан на публичной документации, деобфусцированном клиентском коде и наблюдениях в блокчейне.
Предыстория
Обзор протокола
Hinkal — это протокол с экранированным пулом. Балансы хранятся в виде нот, представленных в качестве обязательств в дереве Меркла на блокчейне, а не в виде обычных балансов счетов.
Чтобы потратить ноту, пользователь публикует zk-доказательство и нуллификатор. Контракт записывает каждый нуллификатор и отклоняет любое повторение. Правило, согласно которому одна нота может породить только один нуллификатор, не применяется контрактом; оно делегируется схеме.
На диаграмме ниже показан процесс депозита и вывода средств:
Формат нот (клиент) | Путь в блокчейне
|
+--------------------------+ | +-------------------------------+
| Новый формат (по умолч.):| | | transact() [с доказательством]|
| nk непосредственно в | | | все операции: депозит / |
| Poseidon6 | | | перевод / вывод |
| -> 1 обязательство : | | +-------------------------------+
| 1 нуллификатор | |
+--------------------------+ |
--> | -->
+--------------------------+ |
| Устаревший формат: | | +-------------------------------+
| nk только через | | | prooflessDeposit() |
| произведение e*nk | | | [без доказательства] |
| -> может допускать | | | только депозит |
| несколько нуллифика- | | +-------------------------------+
| торов на обязательство| |
+--------------------------+ | Для депозитов оба пути
| -> нота входит в дерево Меркла
Форматы нот
Фактическая реализация схемы не является открытым исходным кодом. Следующий анализ основан на публичной документации по дизайну схемы Hinkal [2] и деобфусцированном клиентском коде пруверa [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 — секретный ключ нуллификации, а 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 хранится в старшем бите поля под названием extraRandomization в структуре stealthAddressStructure ноты. Клиентский код упаковывает его как 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, что закрепляло бы один nk за одним обязательством. Для контракта Hinkal каждый вывод выглядит действительным: доказательство проходит, корень существует, и каждый нуллификатор новый, поэтому insertNullifiers() принимает его. Контракт не может связать нуллификатор с листом, поэтому обрабатывает каждое расходование как обычный вывод. Ошибка не в проверках на блокчейне, а, вероятно, в том, что схема устаревшего доказательства допускает доказывать.
prooflessDeposit() и поверхность атаки. Функция prooflessDeposit() делегирует выполнение performProoflessDepositChecks(), однако в поведении в блокчейне не наблюдается валидации формата: функция, по всей видимости, не отклоняет ноты устаревшего формата, и контракт не использует её возвращаемое значение. Это позволяет устаревшей ноте попасть в дерево Меркла без каких-либо ограничений по формату, открывая поверхность атаки для описанного выше дефекта.
Анализ атаки
Следующий анализ основан на исторических транзакциях уязвимого контракта Hinkal. Злоумышленник атаковал несколько типов активов (USDC, ETH и др.); здесь в качестве примера используется поток USDC.
-
Шаг 1: Злоумышленник внёс 100
USDCчерезprooflessDeposit()в транзакции 0xfbedf0...8c2f11.extraRandomizationэтого депозита имеет старший бит, установленный в 0 (getPointSign = 0), что указывает на использование нотой устаревшего формата. -
Шаг 2: Злоумышленник многократно вызывал
transact()против этой устаревшей ноты на 100USDC, каждый раз с одним и тем же корнем дерева Меркла, но свежим нуллификатором, выводя по 100USDCза вызов. Это и есть двойное расходование: одна и та же нота была потрачена многократно, накопив приблизительно 25 000USDC. -
Шаг 3: Злоумышленник консолидировал все 25 000
USDCв одну устаревшую ноту через ещё один вызовprooflessDeposit()в транзакции 0xbf7252...d50008. Консолидировав средства в более крупную ноту, каждый последующий вывод при двойном расходовании приносил бы 25 000USDCвместо 100. -
Шаг 4: Злоумышленник повторил тот же процесс против ноты на 25 000
USDC, каждый раз вызываяtransact()со свежим нуллификатором. После депозита злоумышленник больше не вносил активы, однако вывел значительно больше, чем внесённые 25 000USDC.
В общей сложности приблизительно $800K в активах было выведено в ходе всех вызовов transact().

Заключение
Контракт Hinkal принимал индивидуально действительные доказательства, однако одна нота, по всей видимости, была многократно погашена из-за уязвимости устаревшего формата нот. Все проверки контракта на блокчейне (верификация доказательств, уникальность нуллификаторов) прошли успешно, но они не могли обнаружить, что одна и та же нота генерировала несколько действительных нуллификаторов. Команда с тех пор приостановила все смарт-контракты Hinkal на всех блокчейнах и взяла на себя обязательство полностью возместить ущерб всем пострадавшим пользователям [1].
Применительно непосредственно к Hinkal меры по устранению включают полное отключение пути с устаревшим форматом нот, применение валидации формата нот в prooflessDeposit() (например, отклонение нот с isNewStyle == 0) и проведение специализированного аудита схемы для подтверждения однозначной привязки нуллификаторов.
Для протоколов приватности на основе нуллификаторов в целом инвариант одинаков: каждая нота должна соответствовать ровно одному нуллификатору через единственный чётко определённый способ вывода. Эта привязка должна применяться на уровне схемы, поскольку контракт может лишь проверить, что нуллификатор ещё не встречался, но не то, является ли он единственным действительным нуллификатором для данной ноты.
Ссылки
[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)



