Back to Blog

~$800K Perdidos: Doble Gasto en Hinkal | BlockSec Semanal

Code Auditing
July 9, 2026
12 min read
Key Insights

Durante la semana pasada (2026/06/29 - 2026/07/05), se destaca el siguiente incidente de seguridad notable, con pérdidas de aproximadamente $800K en Ethereum.

Fecha Incidente Tipo Pérdida estimada
2026/07/02 Hinkal Fallo en lógica de negocio ~$800K
  • Hinkal: Un ataque de doble gasto en un protocolo de pool blindado, que probablemente explotó un fallo en el formato de nota heredado que permitía que el mismo depósito produjera múltiples anuladores válidos.

El mejor auditor de seguridad para Web3

Valida el diseño, el código y la lógica de negocio antes del lanzamiento

Destacado semanal: Hinkal

En este incidente, un doble gasto en un pool blindado probablemente fue posible por un fallo en el formato de nota heredado. La solvencia en los protocolos de privacidad basados en anuladores depende del enlace del anulador a nivel de circuito, no solo de que el contrato acepte pruebas válidas.

El 2 de julio de 2026, Hinkal, un protocolo de pool blindado en Ethereum, fue drenado por aproximadamente $800K en activos mediante un ataque de doble gasto [1]. La causa raíz probable es un fallo en el formato de nota heredado, donde una única nota no está estrechamente vinculada a un anulador único, lo que permite que un mismo depósito sea gastado múltiples veces. El proyecto no ha publicado como código abierto su implementación de circuito, y el equipo aún no ha publicado un análisis técnico detallado. El análisis que sigue se basa en documentación pública, código de cliente desofuscado y observaciones en cadena.

Contexto

Descripción general del protocolo

Hinkal es un protocolo de pool blindado. Los saldos se almacenan como notas, representadas por compromisos en un árbol de Merkle en cadena, en lugar de saldos de cuentas simples.

Para gastar una nota, el usuario publica una prueba zk y un anulador. El contrato registra cada anulador y rechaza cualquier repetición. La regla de que una nota solo puede producir un anulador no es aplicada por el contrato; se delega al circuito.

El diagrama a continuación muestra el flujo de depósito y retiro:

     Formato de nota (cliente)     |       Ruta en cadena
                                   |
  +--------------------------+     |     +-------------------------------+
  | Nuevo formato (defecto): |     |     | transact() [con prueba]       |
  | nk directamente en       |     |     | todas las ops: depósito /     |
  | Poseidon6                |     |     | transferencia / retiro        |
  | -> 1 commit : 1 null     |     |     +-------------------------------+
  +--------------------------+     |
                               --> | -->
  +--------------------------+     |
  | Formato heredado:        |     |     +-------------------------------+
  | nk solo vía producto e*nk|     |     | prooflessDeposit() [sin       |
  | -> puede permitir varios |     |     | prueba] solo depósito         |
  |    anuladores por commit |     |     +-------------------------------+
  +--------------------------+     |
                                   |     Para depósitos, ambas rutas
                                   |         -> la nota entra al árbol de Merkle

Formatos de nota

La implementación real del circuito no ha sido publicada como código abierto. El siguiente análisis se basa en la documentación pública de diseño de circuitos de Hinkal [2] y el código de prover de cliente desofuscado [3].

La documentación y el código de cliente sugieren dos formas de construir la stealthAddress de una nota, seleccionadas por un indicador isNewStyle:

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

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

Poseidon2, Poseidon3, Poseidon6 son todas instancias de la función hash Poseidon; el sufijo indica el número de entradas.

Aquí e es un número aleatorio, nk es la clave de anulación secreta y Base8 es un punto fijo. Como H0 y H1 son puntos en la curva elíptica Baby Jubjub, cada uno tiene una coordenada x y una coordenada y. El valor signs empaqueta los bits de signo de sus coordenadas x H0x y H1x (2*L(H0x) + L(H1x)). La stealthAddress heredada incluye nk solo a través del producto e*nk, no la clave de gasto (spk0, spk1) directamente, mientras que el nuevo formato coloca nk, spk0 y spk1 todos en Poseidon6.

El anulador se calcula directamente a partir de nk: nullifier = Poseidon2(commitment, Poseidon2(nk, commitment)).

Las funciones relevantes, desofuscadas de zkProofWorkerNode.js (S = Poseidon, Base8 = generador fijo, e = aleatorización, t = nk):

// heredado
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 solo vía el producto)
  return { H0, H1 };
};

// nuevo
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 al punto de nota)
  return { H0, H1 };
};

// el anulador se deriva de nk directamente
getNullifier() {
  const c   = this.getCommitment();
  const sig = S(this.nullifyingKey, c);  // Poseidon2(nk, commitment)
  this.nullifier = S(c, sig);            // Poseidon2(commitment, sig)
}

Este indicador isNewStyle se almacena en el bit más significativo de un campo llamado extraRandomization en la stealthAddressStructure de la nota. El código de cliente lo empaqueta como extraRandomization = (isNewStyle << 255) | H0x, y el contrato lo desempaqueta con getPointSign(H) = H / 2**255 y getPointY(H) = H % 2**255 al decodificar el valor empaquetado. El comentario del código dice "eliminar el indicador isNewStyle (bit 255) para que el circuito obtenga la coordenada H0x limpia". Por lo tanto, el bit más significativo puede obtenerse mediante getPointSign(extraRandomization) para determinar el tipo de nota.

El siguiente fragmento de CircomDataBuilder.sol:formBasicInput() muestra este desempaquetado:

// CircomDataBuilder.sol:formBasicInput()
    ...
    // eliminar el indicador isNewStyle (bit 255) para que el circuito obtenga la coordenada H0x limpia
    input[index++] = getPointY(
        circomData.stealthAddressStructure.extraRandomization
    ); // = H0x
    input[index++] = circomData.stealthAddressStructure.H0; // = H0y
    ...

En el código de cliente que construye el depósito, este indicador isNewStyle se establece en true por defecto, por lo que los flujos normales de usuario aparentemente nunca crean una nota heredada.

// hinkalDeposit para uno mismo
  // UTXO de salida propio
  //@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 otro
  // @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
	})])

Un depósito en formato heredado con el indicador establecido en 0 no es algo producido por un flujo normal de usuario.

Rutas de depósito y retiro

transact() es el punto de entrada universal verificado por prueba para todas las operaciones: depósitos, transferencias privadas y retiros. El cliente genera una prueba zk fuera de cadena, y transact() la verifica, comprueba la raíz de Merkle, luego escribe los anuladores y los compromisos.

prooflessDeposit() es una función en cadena separada que omite transact() por completo. Acepta tokens y construye el compromiso directamente a partir de datos especificados por el llamante, sin requerir una prueba.

    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, un compromiso se deriva de amount, token, stealthAddress y timestamp. La stealthAddress proviene directamente del llamante.

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

Análisis de vulnerabilidad

Este análisis es una inferencia a partir del comportamiento en cadena y el código de cliente desofuscado. La implementación real del circuito no ha sido publicada como código abierto, y la causa raíz está por confirmarse.

El contrato afectado es Hinkal (0x25e5...a826).

El defecto probable. Como se describe en el Contexto, el formato heredado incluye nk solo a través del producto e*nk, mientras que el anulador se deriva de nk directamente. Si el circuito de gasto heredado no vincula nk de forma única al compromiso, entonces un par (e, nk) diferente puede preservar el mismo producto e*nk (y por tanto el mismo compromiso) mientras cambia nk, produciendo un anulador nuevo para la misma hoja cada vez. El nuevo formato coloca nk directamente en Poseidon6, lo que fijaría un único nk por compromiso. Para el contrato Hinkal, cada retiro parece válido: la prueba pasa, la raíz existe y cada anulador es nuevo, por lo que insertNullifiers() lo acepta. El contrato no puede vincular un anulador a una hoja, por lo que liquida cada gasto como un retiro normal. El fallo no está en las verificaciones en cadena, sino probablemente en lo que el circuito de prueba heredado tiene permitido demostrar.

prooflessDeposit() y la superficie de ataque. La función prooflessDeposit() delega a performProoflessDepositChecks(), pero no se observa ninguna validación de formato en el comportamiento en cadena: la función no parece rechazar notas en formato heredado, ni el contrato utiliza su valor de retorno. Esto permite que una nota heredada entre en el árbol de Merkle sin ninguna restricción de formato, abriendo la superficie de ataque para el defecto descrito anteriormente.

Análisis del ataque

El siguiente análisis se basa en las transacciones históricas del contrato Hinkal afectado. El atacante apuntó a múltiples tipos de activos (USDC, ETH, etc.); aquí se utiliza el flujo de USDC como ejemplo.

  • Paso 1: El atacante depositó 100 USDC mediante prooflessDeposit() en la transacción 0xfbedf0...8c2f11. La extraRandomization de este depósito tiene su bit más significativo establecido en 0 (getPointSign = 0), lo que indica que la nota utiliza el formato heredado.

  • Paso 2: El atacante llamó a transact() repetidamente contra esta nota heredada de 100 USDC, cada vez con la misma raíz de Merkle pero un anulador nuevo, retirando 100 USDC por llamada. Este es el doble gasto: la misma nota fue gastada muchas veces, acumulando aproximadamente 25,000 USDC.

  • Paso 3: El atacante consolidó todos los 25,000 USDC en una única nota heredada mediante otra llamada a prooflessDeposit() en la transacción 0xbf7252...d50008. Al consolidar en una nota más grande, cada retiro de doble gasto posterior rendiría 25,000 USDC en lugar de 100.

  • Paso 4: El atacante repitió el mismo proceso contra la nota de 25,000 USDC, llamando a transact() con un anulador nuevo cada vez. Tras el depósito, el atacante nunca aportó más activos, pero retiró mucho más de los 25,000 USDC depositados.

En total, se drenaron aproximadamente $800K en activos a través de todas las invocaciones de transact().

Conclusión

El contrato Hinkal aceptó pruebas individualmente válidas, pero una única nota fue muy probablemente canjeada múltiples veces debido a un fallo en el formato de nota heredado. Las verificaciones en cadena del contrato (verificación de prueba, unicidad del anulador) pasaron todas, pero no pudieron detectar que la misma nota estaba produciendo múltiples anuladores válidos. El equipo ha pausado desde entonces todos los contratos inteligentes de Hinkal en todas las cadenas y se ha comprometido a reembolsar íntegramente a todos los usuarios afectados [1]. Para Hinkal específicamente, las mitigaciones incluyen deshabilitar por completo la ruta del formato de nota heredado, aplicar la validación del formato de nota en prooflessDeposit() (por ejemplo, rechazando notas con isNewStyle == 0) y realizar una auditoría dedicada del circuito para confirmar el enlace uno a uno del anulador.

Para los protocolos de privacidad basados en anuladores en general, el invariante es el mismo: cada nota debe mapearse exactamente a un anulador a través de una única derivación bien definida. Ese enlace debe ser aplicado a nivel de circuito, ya que el contrato solo puede verificar que un anulador no ha sido visto antes, no que sea el único anulador válido para una nota dada.

Comienza con Phalcon Explorer

Profundiza en las transacciones para actuar con inteligencia

Pruébalo gratis ahora

Referencias

[1] Actualización post-incidente del protocolo Hinkal: https://x.com/hinkal_protocol/status/2073136163880149417

[2] Documentación de diseño de circuitos: https://hinkal-team.gitbook.io/hinkal/technical-description/circuits/swapper-m#id-3.-correct-nullifiers-per-input

[3] Código del lado del cliente: https://github.com/Hinkal-Protocol/Hinkal-API-Enclave ](https://github.com/Hinkal-Protocol/Hinkal-API-Enclave)

Comienza con Phalcon Security

Detecta cada amenaza, alerta sobre lo que importa y bloquea los ataques.

Pruébalo gratis ahora

Best Security Auditor for Web3

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

BlockSec Audit