Back to Blog

~$800K 손실: Hinkal 이중 지불 사건 | BlockSec 위클리

Code Auditing
July 9, 2026
10 min read
Key Insights
  • 이 보고서에는 이더리움에서 발생한 주목할 만한 보안 사고 1건이 포함되어 있으며, 약 $800K의 손실이 발생했습니다.

  • 실드 풀 프로토콜인 Hinkal은 이중 지출 공격으로 인해 약 $800K가 탈취되었습니다: 레거시 노트 형식의 결함으로 인해 단일 예치금에서 여러 개의 널리파이어(nullifier)를 도출하는 것이 가능했던 것으로 추정됩니다.

  • 널리파이어 기반 프라이버시 프로토콜의 경우, 지급 능력은 계약이 유효한 증명을 검증하는 것에만 의존하는 것이 아니라, 노트와 널리파이어 간의 회로 수준 바인딩에 달려 있습니다.

지난 한 주(2026/06/29 - 2026/07/05) 동안 이더리움에서 약 $800K의 손실을 기록한 주목할 만한 보안 사고가 발생했습니다.

날짜 사고 유형 추정 손실
2026/07/02 Hinkal 비즈니스 로직 결함 ~$800K
  • Hinkal: 실드 풀 프로토콜에 대한 이중 지출 공격으로, 동일한 예치금이 여러 개의 유효한 널리파이어를 생성할 수 있도록 허용하는 레거시 노트 형식 결함을 악용한 것으로 추정됩니다.

Best Security Auditor for Web3

Validate design, code, and business logic before launch

주간 하이라이트: Hinkal

이번 사고에서 실드 풀의 이중 지출은 레거시 노트 형식 결함에 의해 가능했던 것으로 추정됩니다. 널리파이어 기반 프라이버시 프로토콜의 지급 능력은 컨트랙트가 유효한 증명을 수락하는지 여부가 아니라, 회로 수준의 널리파이어 바인딩에 달려 있습니다.

2026년 7월 2일, 이더리움의 실드 풀 프로토콜인 Hinkal이 이중 지출 공격을 통해 약 $800K 상당의 자산을 탈취당했습니다 [1]. 유력한 근본 원인은 레거시 노트 형식의 결함으로, 단일 노트가 고유한 널리파이어에 엄격하게 바인딩되지 않아 하나의 예치금이 여러 번 지출될 수 있었습니다. 해당 프로젝트는 회로 구현을 오픈소스로 공개하지 않았으며, 팀은 아직 상세한 기술적 분석을 발표하지 않았습니다. 아래 분석은 공개 문서, 난독화 해제된 클라이언트 코드, 그리고 온체인 관찰을 기반으로 합니다.

배경

프로토콜 개요

Hinkal은 실드 풀 프로토콜입니다. 잔액은 일반 계정 잔액이 아닌 온체인 머클 트리의 커밋먼트로 표현되는 노트 형태로 저장됩니다.

노트를 지출하려면 사용자가 zk 증명과 널리파이어를 게시해야 합니다. 컨트랙트는 각 널리파이어를 기록하고 중복을 거부합니다. 하나의 노트가 하나의 널리파이어만 생성할 수 있다는 규칙은 컨트랙트가 강제하지 않으며, 회로에 위임됩니다.

아래 다이어그램은 예치 및 출금 흐름을 보여줍니다:

     노트 형식 (클라이언트)          |       온체인 경로
                                   |
  +--------------------------+     |     +-------------------------------+
  | 새 형식 (기본값):         |     |     | transact() [증명 포함]        |
  | nk가 Poseidon6에 직접    |     |     | 모든 작업: 예치 / 전송 /      |
  | -> 1 commit : 1 null     |     |     |           출금               |
  +--------------------------+     |     +-------------------------------+
                               --> | -->
  +--------------------------+     |     
  | 레거시 형식:              |     |     +-------------------------------+
  | nk는 곱 e*nk를 통해서만  |     |     | prooflessDeposit() [증명 없음]|
  | -> 커밋당 여러            |     |     | 예치만 가능                   |
  |    널리파이어 허용 가능   |     |     +-------------------------------+
  +--------------------------+     |   
                                   |     예치의 경우, 두 경로 모두
                                   |         -> 노트가 머클 트리에 진입

노트 형식

실제 회로 구현은 오픈소스로 공개되지 않았습니다. 다음 분석은 Hinkal의 공개 회로 설계 문서 [2]와 난독화 해제된 클라이언트 프루버 코드 [3]를 기반으로 합니다.

문서와 클라이언트 코드는 isNewStyle 플래그로 선택되는 노트의 stealthAddress를 구성하는 두 가지 방법을 제시합니다:

  • 레거시: 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은 고정 포인트입니다. H0H1은 Baby Jubjub 타원 곡선 위의 점이므로 각각 x좌표와 y좌표를 가집니다. signs 값은 x좌표 H0xH1x의 부호 비트를 압축합니다(2*L(H0x) + L(H1x)). 레거시 stealthAddressnk를 곱 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 플래그는 노트의 stealthAddressStructure에 있는 extraRandomization이라는 필드의 최상위 비트에 저장됩니다. 클라이언트 코드는 이를 extraRandomization = (isNewStyle << 255) | H0x로 압축하며, 컨트랙트는 압축된 값을 디코딩할 때 getPointSign(H) = H / 2**255getPointY(H) = H % 2**255로 분리합니다. 코드 주석에는 "회로가 깨끗한 H0x 좌표를 받을 수 있도록 isNewStyle 플래그(비트 255)를 제거"라고 적혀 있습니다. 따라서 최상위 비트는 getPointSign(extraRandomization)을 통해 가져와 노트 유형을 판별할 수 있습니다.

다음 CircomDataBuilder.sol:formBasicInput()의 코드 스니펫은 이 압축 해제를 보여줍니다:

// CircomDataBuilder.sol:formBasicInput()
    ...
    // 회로가 깨끗한 H0x 좌표를 받을 수 있도록 isNewStyle 플래그(비트 255)를 제거
    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를 변경하여 동일한 리프에 대해 매번 새로운 널리파이어를 생성할 수 있습니다. 새 형식은 nkPoseidon6에 직접 넣어 커밋먼트당 하나의 nk를 고정합니다. Hinkal 컨트랙트 입장에서 모든 출금은 유효해 보입니다: 증명이 통과되고, 루트가 존재하며, 각 널리파이어가 새롭기 때문에 insertNullifiers()가 이를 수락합니다. 컨트랙트는 널리파이어를 리프와 연결할 수 없으므로 각 지출을 정상적인 출금으로 처리합니다. 버그는 온체인 검사가 아니라 레거시 증명 회로가 증명할 수 있는 것에 있을 가능성이 높습니다.

prooflessDeposit()과 공격 표면. prooflessDeposit() 함수는 performProoflessDepositChecks()에 위임하지만, 온체인 동작에서 형식 검증은 관찰되지 않습니다: 함수가 레거시 형식 노트를 거부하는 것으로 보이지 않으며, 컨트랙트도 반환 값을 사용하지 않습니다. 이로 인해 레거시 노트가 형식 제한 없이 머클 트리에 진입할 수 있어, 위에서 설명한 결함에 대한 공격 표면이 열립니다.

공격 분석

다음 분석은 영향을 받은 Hinkal 컨트랙트의 과거 트랜잭션을 기반으로 합니다. 공격자는 여러 자산 유형(USDC, ETH 등)을 표적으로 삼았으며, 여기서는 USDC 흐름을 예시로 사용합니다.

  • 1단계: 공격자는 트랜잭션 0xfbedf0...8c2f11에서 prooflessDeposit()을 통해 100 USDC를 예치했습니다. 이 예치의 extraRandomization은 최상위 비트가 0으로 설정되어 있어(getPointSign = 0), 노트가 레거시 형식을 사용함을 나타냅니다.

  • 2단계: 공격자는 이 100 USDC 레거시 노트에 대해 transact()를 반복적으로 호출했으며, 매번 동일한 머클 루트를 사용하되 새로운 널리파이어를 제시하여 호출당 100 USDC를 출금했습니다. 이것이 이중 지출입니다: 동일한 노트가 여러 번 지출되어 약 25,000 USDC가 누적되었습니다.

  • 3단계: 공격자는 트랜잭션 0xbf7252...d50008에서 또 다른 prooflessDeposit() 호출을 통해 25,000 USDC 전체를 단일 레거시 노트로 통합했습니다. 더 큰 노트로 통합함으로써, 이후 각 이중 지출 출금에서 100 USDC 대신 25,000 USDC를 얻을 수 있었습니다.

  • 4단계: 공격자는 25,000 USDC 노트에 대해 동일한 과정을 반복하며, 매번 새로운 널리파이어로 transact()를 호출했습니다. 예치 후 공격자는 추가 자산을 공급하지 않았음에도 예치된 25,000 USDC보다 훨씬 많은 금액을 출금했습니다.

총 모든 transact() 호출을 통해 약 $800K 상당의 자산이 탈취되었습니다.

결론

Hinkal 컨트랙트는 개별적으로 유효한 증명을 수락했지만, 레거시 노트 형식의 결함으로 인해 단일 노트가 여러 번 상환되었을 가능성이 높습니다. 컨트랙트의 온체인 검사(증명 검증, 널리파이어 고유성)는 모두 통과되었지만, 동일한 노트가 여러 유효한 널리파이어를 생성하고 있음을 감지할 수 없었습니다. 팀은 이후 모든 체인의 Hinkal 스마트 컨트랙트를 일시 중지하고 영향을 받은 모든 사용자에게 전액 보상하겠다고 약속했습니다 [1]. Hinkal의 경우, 완화 방안에는 레거시 노트 형식 경로 완전 비활성화, prooflessDeposit()에서 노트 형식 검증 강제 적용(예: isNewStyle == 0인 노트 거부), 그리고 일대일 널리파이어 바인딩을 확인하기 위한 전담 회로 감사 수행이 포함됩니다.

널리파이어 기반 프라이버시 프로토콜 전반에 걸쳐 불변 조건은 동일합니다: 각 노트는 단일하게 잘 정의된 파생을 통해 정확히 하나의 널리파이어에 매핑되어야 합니다. 컨트랙트는 널리파이어가 이전에 확인된 적이 없다는 것만 확인할 수 있고, 그것이 주어진 노트에 대한 유일한 유효 널리파이어인지는 확인할 수 없기 때문에, 해당 바인딩은 회로 수준에서 강제되어야 합니다.

Get Started with Phalcon Explorer

Dive into Transactions to Act Wisely

Try now for free

참고 문헌

[1] Hinkal Protocol 사고 후 업데이트: 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
~$160만 손실: Moke 토큰, LpdFi 익스플로잇 | BlockSec 위클리
Security Insights

~$160만 손실: Moke 토큰, LpdFi 익스플로잇 | BlockSec 위클리

2026년 8월 3~9일, BNB 체인에서 2건의 보안 사고가 발생해 총 약 160만 달러의 손실이 발생했으며, 모두 가격 조작이 원인이었습니다. LpdFi(약 69.7만 달러)는 PancakeSwap 유동성 풀을 주문 평가와 이자 정산에 동시 사용해 공격자가 원금을 부풀리고 과도한 이자를 탈취했습니다. Moke Token(약 90.6만 달러)은 조작 가능한 현물 가격과 중복 LP 배당 회계를 결합해 MOKE를 부풀려 BNB 배당을 반복 수령했습니다.

COLDCARD 사건: 지갑의 "무작위" 시드가 무작위가 아니었을 때
Security Insights

COLDCARD 사건: 지갑의 "무작위" 시드가 무작위가 아니었을 때

COLDCARD 펌웨어의 빌드 통합 버그로 비트코인 시드 생성이 취약한 소프트웨어 RNG로 라우팅되어 지갑 시드가 오프라인 복구 가능해졌습니다. 시드 자체의 취약점이므로 펌웨어 업데이트로 해결 불가하며, 2026년 8월 7일 기준 확인된 피해는 1,405 BTC(~9,100만 달러), 비공개 추정치는 최대 2,055 BTC입니다.

~$88M 손실: COLDCARD 및 LULA 익스플로잇 | BlockSec 위클리
Security Insights

~$88M 손실: COLDCARD 및 LULA 익스플로잇 | BlockSec 위클리

2026년 7월 27일~8월 2일, 비트코인과 BNB 체인에서 약 8,800만 달러 손실을 유발한 두 건의 보안 사고가 발생했습니다. COLDCARD 사고는 하드웨어 지갑 펌웨어의 엔트로피 오류로, RNG 매크로 활성화 여부 미확인으로 결정론적 폴백이 실행되어 약 1,370 BTC(~8,800만 달러)가 탈취됐습니다. BNB 체인의 LULA 토큰은 비즈니스 로직 취약점으로 `recycle()` 함수가 악용되어 PancakeSwap V2 유동성에서 약 57만 8천 달러가 유출됐습니다.

Best Security Auditor for Web3

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

BlockSec Audit
~$800K 손실: Hinkal 이중 지불 사건 | BlockSec 위클리