Back to Blog

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

Code Auditing
July 9, 2026
10 min read
Key Insights

지난 한 주(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
뉴스레터 - 2026년 6월
Security Insights

뉴스레터 - 2026년 6월

이 월간 보고서는 2026년 6월의 3대 보안 사고를 다루며, 총 확인 손실액은 약 2,200만 달러입니다. 정교한 허니팟 공격으로 미확인 토큰 허용량을 악용해 JaredFromSubway의 MEV 봇에서 약 1,500만 달러가 유출됐습니다. 두 레거시 Aztec 롤업 배포에서 증명 정산 경계 취약점으로 약 435만 달러가 손실됐습니다. SecondFi의 Ed25519 구현 결함으로 지갑 개인키가 노출되어 374개 지갑에서 약 240만 달러가 유출됐습니다. 세 사고 모두 표면상 온전해 보였지만 실제로는 적용되지 않은 보안 보장이라는 공통 패턴을 공유합니다.

~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리
Security Insights

~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리

이 주간 블록체인 보안 리포트는 2026년 6월 22~28일 발생한 주요 사건 2건을 다루며, 이더리움과 카르다노에서 약 410만 달러의 피해가 확인됐습니다. Taiko 브릿지 공격은 노출된 SGX 서명 키와 디버그 엔클레이브를 거부하지 못한 증명 정책 결함을 이용해 악성 증명자를 등록하고 L2 상태 증명을 위조했습니다. SecondFi 지갑은 Ed25519 논스 도출 시 비밀 입력이 제거되는 결함으로 공개 트랜잭션 데이터만으로 개인 키 복구가 가능했습니다.

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리
Security Insights

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리

이 주간 블록체인 보안 보고서는 2026년 6월 15일~21일을 다루며, 이더리움과 BNB 체인에서 3건의 주요 사고가 발생해 약 $18.3M의 손실이 발생했습니다. jaredFromSubway 사건은 MEV 봇이 차익거래를 위해 신뢰할 수 없는 제3자 컨트랙트에 자산을 승인한 역방향 승인 공격으로, 가짜 래퍼 토큰과 스왑 풀을 이용해 약 $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