Back to Blog

~$800K потеряно: двойное списание в Hinkal | Еженедельник BlockSec

Code Auditing
July 9, 2026
11 min read
Key Insights

За прошедшую неделю (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() против этой устаревшей ноты на 100 USDC, каждый раз с одним и тем же корнем дерева Меркла, но свежим нуллификатором, выводя по 100 USDC за вызов. Это и есть двойное расходование: одна и та же нота была потрачена многократно, накопив приблизительно 25 000 USDC.

  • Шаг 3: Злоумышленник консолидировал все 25 000 USDC в одну устаревшую ноту через ещё один вызов prooflessDeposit() в транзакции 0xbf7252...d50008. Консолидировав средства в более крупную ноту, каждый последующий вывод при двойном расходовании приносил бы 25 000 USDC вместо 100.

  • Шаг 4: Злоумышленник повторил тот же процесс против ноты на 25 000 USDC, каждый раз вызывая transact() со свежим нуллификатором. После депозита злоумышленник больше не вносил активы, однако вывел значительно больше, чем внесённые 25 000 USDC.

В общей сложности приблизительно $800K в активах было выведено в ходе всех вызовов transact().

Заключение

Контракт Hinkal принимал индивидуально действительные доказательства, однако одна нота, по всей видимости, была многократно погашена из-за уязвимости устаревшего формата нот. Все проверки контракта на блокчейне (верификация доказательств, уникальность нуллификаторов) прошли успешно, но они не могли обнаружить, что одна и та же нота генерировала несколько действительных нуллификаторов. Команда с тех пор приостановила все смарт-контракты Hinkal на всех блокчейнах и взяла на себя обязательство полностью возместить ущерб всем пострадавшим пользователям [1]. Применительно непосредственно к Hinkal меры по устранению включают полное отключение пути с устаревшим форматом нот, применение валидации формата нот в prooflessDeposit() (например, отклонение нот с isNewStyle == 0) и проведение специализированного аудита схемы для подтверждения однозначной привязки нуллификаторов.

Для протоколов приватности на основе нуллификаторов в целом инвариант одинаков: каждая нота должна соответствовать ровно одному нуллификатору через единственный чётко определённый способ вывода. Эта привязка должна применяться на уровне схемы, поскольку контракт может лишь проверить, что нуллификатор ещё не встречался, но не то, является ли он единственным действительным нуллификатором для данной ноты.

Get Started with Phalcon Explorer

Dive into Transactions to Act Wisely

Try now for free

Ссылки

[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)

Get Started with Phalcon Security

Detect every threat, alert what matters, and block attacks.

Try now for free
Sign up for the latest updates
Информационный бюллетень — июнь 2026 г.
Security Insights

Информационный бюллетень — июнь 2026 г.

Отчёт за июнь 2026: три крупнейших инцидента с общими потерями ~$22M. Honeypot атака опустошила MEV-бот на ~$15M. Уязвимости Aztec rollup привели к потере ~$4.35M. Ошибка Ed25519 в SecondFi раскрыла ключи 374 кошельков (~$2.4M).

~$4M потеряно: эксплойты Taiko и SecondFi | Еженедельник BlockSec
Security Insights

~$4M потеряно: эксплойты Taiko и SecondFi | Еженедельник BlockSec

Отчёт за 22–28 июня 2026: два инцидента, ~$4,1М потерь. Эксплойт Taiko: утечка ключа SGX + неполная политика аттестации → подделка L2-доказательств. SecondFi: уязвимость Ed25519 (нонс без секрета) → восстановление приватных ключей Cardano.

~$18 млн потеряно: jaredFromSubway, Aztec и другие | Еженедельник BlockSec
Security Insights

~$18 млн потеряно: jaredFromSubway, Aztec и другие | Еженедельник BlockSec

Еженедельный отчёт о безопасности блокчейна (15–21 июня 2026): 3 инцидента в Ethereum и BNB Chain, ~$18,3M потерь. Атака на MEV-бот jaredFromSubway (~$15M): бот одобрял свои активы недоверенным контрактам. Злоумышленник создал фиктивные токены и пулы. Также: эксплойт Aztec — отсутствие ограничения равенства в ZK-схеме.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit