Back to Blog

~$800K Perdidos: Gasto Duplo na Hinkal | BlockSec Semanal

Code Auditing
July 9, 2026
12 min read
Key Insights

Durante a semana passada (29/06/2026 - 05/07/2026), o seguinte incidente de segurança notável é destacado, envolvendo aproximadamente $800K em perdas na Ethereum.

Data Incidente Tipo Perda Estimada
02/07/2026 Hinkal Falha de Lógica de Negócio ~$800K
  • Hinkal: Um ataque de gasto duplo em um protocolo de pool protegido (shielded pool), provavelmente explorando uma falha no formato de nota legado que permitia que o mesmo depósito produzisse múltiplos nullifiers válidos.

Best Security Auditor for Web3

Validate design, code, and business logic before launch

Destaque da Semana: Hinkal

Neste incidente, um gasto duplo em um pool protegido foi provavelmente viabilizado por uma falha no formato de nota legado. A solvência em protocolos de privacidade baseados em nullifier depende da vinculação do nullifier no nível do circuito, não apenas do contrato aceitar provas válidas.

Em 2 de julho de 2026, o Hinkal, um protocolo de pool protegido na Ethereum, teve aproximadamente $800K em ativos drenados por meio de um ataque de gasto duplo [1]. A causa raiz provável é uma falha no formato de nota legado, onde uma única nota não está rigidamente vinculada a um nullifier único, permitindo que um depósito seja gasto múltiplas vezes. O projeto não abriu o código-fonte de sua implementação de circuito, e a equipe ainda não publicou uma análise técnica detalhada. A análise abaixo é baseada em documentação pública, código cliente desofuscado e observações on-chain.

Contexto

Visão Geral do Protocolo

O Hinkal é um protocolo de pool protegido. Os saldos são armazenados como notas, representadas por compromissos em uma árvore de Merkle on-chain, em vez de saldos de conta simples.

Para gastar uma nota, o usuário publica uma prova zk e um nullifier. O contrato registra cada nullifier e rejeita qualquer repetição. A regra de que uma nota só pode produzir um nullifier não é aplicada pelo contrato; ela é delegada ao circuito.

O diagrama abaixo mostra o fluxo de depósito e saque:

     Formato da nota (cliente)     |       Caminho on-chain
                                   |
  +--------------------------+     |     +-------------------------------+
  | Novo formato (padrão):   |     |     | transact() [com prova]        |
  | nk diretamente no        |     |     | todas as operações: depósito  |
  | Poseidon6                |     |     | / transferência / saque       |
  | -> 1 commit : 1 null     |     |     +-------------------------------+
  +--------------------------+     |
                               --> | -->
  +--------------------------+     |
  | Formato legado:          |     |     +-------------------------------+
  | nk somente via produto   |     |     | prooflessDeposit() [sem prova]|
  | e*nk                     |     |     | somente depósito              |
  | -> pode permitir múltiplos|    |     +-------------------------------+
  |    nullifiers por commit |     |
  +--------------------------+     |     Para depósitos, ambos os caminhos
                                   |         -> nota entra na árvore de Merkle

Formatos de Nota

A implementação real do circuito não foi aberta ao público. A análise a seguir é baseada na documentação pública de design de circuito do Hinkal [2] e no código provador cliente desofuscado [3].

A documentação e o código cliente sugerem duas formas de construir o stealthAddress de uma nota, selecionadas por um sinalizador isNewStyle:

  • legado: H0 = e*Base8, H1 = (e*nk)*Base8, stealthAddress = Poseidon3(signs, H0y, H1y)

  • novo: H1 = nk*H0, stealthAddress = Poseidon6(signs, H0y, H1y, spk0, spk1, nk)

Poseidon2, Poseidon3, Poseidon6 são todas instâncias da função hash Poseidon; o sufixo indica o número de entradas.

Aqui e é um número aleatório, nk é a chave de anulação secreta, e Base8 é um ponto fixo. Como H0 e H1 são pontos na curva elíptica Baby Jubjub, cada um tem uma coordenada x e uma coordenada y. O valor signs agrupa os bits de sinal de suas coordenadas x H0x e H1x (2*L(H0x) + L(H1x)). O stealthAddress legado inclui nk apenas pelo produto e*nk, não pela chave de gasto (spk0, spk1) diretamente, enquanto o novo formato coloca nk, spk0 e spk1 todos no Poseidon6.

O nullifier é calculado diretamente a partir de nk: nullifier = Poseidon2(commitment, Poseidon2(nk, commitment)).

As funções relevantes, desofuscadas de zkProofWorkerNode.js (S = Poseidon, Base8 = gerador fixo, e = aleatorização, t = nk):

// legado
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 apenas via o produto)
  return { H0, H1 };
};

// novo
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 vinculado ao ponto da nota)
  return { H0, H1 };
};

// nullifier é derivado de nk diretamente
getNullifier() {
  const c   = this.getCommitment();
  const sig = S(this.nullifyingKey, c);  // Poseidon2(nk, commitment)
  this.nullifier = S(c, sig);            // Poseidon2(commitment, sig)
}

Este sinalizador isNewStyle é armazenado no bit mais significativo de um campo chamado extraRandomization na stealthAddressStructure da nota. O código cliente o empacota como extraRandomization = (isNewStyle << 255) | H0x, e o contrato o desempacota com getPointSign(H) = H / 2**255 e getPointY(H) = H % 2**255 ao decodificar o valor empacotado. O comentário no código diz "remover o sinalizador isNewStyle (bit 255) para que o circuito receba a coordenada H0x limpa". Portanto, o bit mais significativo pode ser obtido via getPointSign(extraRandomization) para determinar o tipo da nota.

O trecho a seguir de CircomDataBuilder.sol:formBasicInput() mostra esse desempacotamento:

// CircomDataBuilder.sol:formBasicInput()
    ...
    // remover o sinalizador isNewStyle (bit 255) para que o circuito receba a coordenada H0x limpa
    input[index++] = getPointY(
        circomData.stealthAddressStructure.extraRandomization
    ); // = H0x
    input[index++] = circomData.stealthAddressStructure.H0; // = H0y
    ...

No código cliente de criação de depósito, este sinalizador isNewStyle é definido como true por padrão, portanto os fluxos normais de usuário aparentemente nunca criam uma nota legada.

// hinkalDeposit para si mesmo
  // UTXO de saída própria
  //@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 // equivalente a isNewStyle: true
	})];

// hinkalDeposit para outro
  // @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 // equivalente a isNewStyle: true
	})])

Um depósito no estilo legado com o sinalizador definido como 0 não é, portanto, algo produzido por um fluxo normal de usuário.

Caminhos de Depósito e Saque

transact() é o ponto de entrada universal verificado por prova para todas as operações: depósitos, transferências privadas e saques. O cliente gera uma prova zk off-chain, e transact() a verifica, confere a raiz de Merkle, depois registra nullifiers e compromissos.

prooflessDeposit() é uma função on-chain separada que ignora completamente o transact(). Ela aceita tokens e constrói o compromisso diretamente a partir de dados especificados pelo chamador, sem exigir uma prova.

    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)
        );
    }

Para notas ERC-20, um compromisso é derivado de amount, token, stealthAddress e timestamp. O stealthAddress vem diretamente do chamador.

      commitment = hash4(
          utxo.amount,
          uint256(uint160(utxo.erc20Address)),
          utxo.stealthAddressStructure.stealthAddress,
          utxo.timeStamp
      );

Análise de Vulnerabilidade

Esta análise é uma inferência baseada no comportamento on-chain e no código cliente desofuscado. A implementação real do circuito não foi aberta ao público, e a causa raiz ainda precisa ser confirmada.

O contrato afetado é o Hinkal (0x25e5...a826).

O defeito provável. Conforme descrito no Contexto, o formato legado inclui nk apenas pelo produto e*nk, enquanto o nullifier é derivado de nk diretamente. Se o circuito de gasto legado não vincular nk de forma única ao compromisso, então um par (e, nk) diferente pode preservar o mesmo produto e*nk (e, portanto, o mesmo compromisso) enquanto muda nk, produzindo um nullifier novo para a mesma folha a cada vez. O novo formato coloca nk diretamente no Poseidon6, o que fixaria um nk por compromisso. Para o contrato Hinkal, todo saque parece válido: a prova passa, a raiz existe e cada nullifier é novo, então insertNullifiers() o aceita. O contrato não consegue vincular um nullifier a uma folha, então liquida cada gasto como um saque normal. O bug não está nas verificações on-chain, mas provavelmente no que o circuito de prova legado está autorizado a provar.

prooflessDeposit() e a superfície de ataque. A função prooflessDeposit() delega para performProoflessDepositChecks(), mas nenhuma validação de formato é observável no comportamento on-chain: a função não parece rejeitar notas no formato legado, nem o contrato usa seu valor de retorno. Isso permite que uma nota legada entre na árvore de Merkle sem qualquer restrição de formato, abrindo a superfície de ataque para o defeito descrito acima.

Análise do Ataque

A análise a seguir é baseada nas transações históricas do contrato Hinkal afetado. O atacante visou múltiplos tipos de ativos (USDC, ETH, etc.); o fluxo de USDC é usado aqui como exemplo.

  • Etapa 1: O atacante depositou 100 USDC via prooflessDeposit() na transação 0xfbedf0...8c2f11. O extraRandomization deste depósito tem seu bit mais significativo definido como 0 (getPointSign = 0), indicando que a nota usa o formato legado.

  • Etapa 2: O atacante chamou transact() repetidamente contra esta nota legada de 100 USDC, cada vez com a mesma raiz de Merkle, mas um nullifier novo, sacando 100 USDC por chamada. Este é o gasto duplo: a mesma nota foi gasta muitas vezes, acumulando aproximadamente 25.000 USDC.

  • Etapa 3: O atacante consolidou todos os 25.000 USDC em uma única nota legada por meio de outra chamada a prooflessDeposit() na transação 0xbf7252...d50008. Ao consolidar em uma nota maior, cada saque subsequente de gasto duplo renderia 25.000 USDC em vez de 100.

  • Etapa 4: O atacante repetiu o mesmo processo contra a nota de 25.000 USDC, chamando transact() com um nullifier novo a cada vez. Após o depósito, o atacante nunca forneceu mais ativos, mas sacou muito mais do que os 25.000 USDC depositados.

No total, aproximadamente $800K em ativos foram drenados em todas as invocações de transact().

Conclusão

O contrato Hinkal aceitou provas individualmente válidas, mas uma única nota foi muito provavelmente resgatada muitas vezes devido a uma falha no formato de nota legado. As verificações on-chain do contrato (verificação de prova, unicidade do nullifier) todas passaram, mas não conseguiram detectar que a mesma nota estava produzindo múltiplos nullifiers válidos. A equipe desde então pausou todos os contratos inteligentes do Hinkal em todas as redes e se comprometeu a reembolsar integralmente todos os usuários afetados [1]. Para o Hinkal especificamente, as mitigações incluem desabilitar completamente o caminho do formato de nota legado, aplicar validação do formato de nota em prooflessDeposit() (por exemplo, rejeitar notas com isNewStyle == 0) e realizar uma auditoria dedicada do circuito para confirmar a vinculação um-para-um do nullifier.

Para protocolos de privacidade baseados em nullifier em geral, o invariante é o mesmo: cada nota deve mapear para exatamente um nullifier por meio de uma única derivação bem definida. Essa vinculação deve ser aplicada no nível do circuito, já que o contrato só pode verificar se um nullifier já foi visto antes, não se ele é o único nullifier válido para uma determinada nota.

Get Started with Phalcon Explorer

Dive into Transactions to Act Wisely

Try now for free

Referências

[1] Atualização pós-incidente do Protocolo Hinkal: https://x.com/hinkal_protocol/status/2073136163880149417

[2] Documentação de design de circuito: https://hinkal-team.gitbook.io/hinkal/technical-description/circuits/swapper-m#id-3.-correct-nullifiers-per-input

[3] Código do lado cliente: 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

Best Security Auditor for Web3

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

BlockSec Audit