지난 한 주(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은 고정 포인트입니다. 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 플래그는 노트의 stealthAddressStructure에 있는 extraRandomization이라는 필드의 최상위 비트에 저장됩니다. 클라이언트 코드는 이를 extraRandomization = (isNewStyle << 255) | H0x로 압축하며, 컨트랙트는 압축된 값을 디코딩할 때 getPointSign(H) = H / 2**255와 getPointY(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를 변경하여 동일한 리프에 대해 매번 새로운 널리파이어를 생성할 수 있습니다. 새 형식은 nk를 Poseidon6에 직접 넣어 커밋먼트당 하나의 nk를 고정합니다. Hinkal 컨트랙트 입장에서 모든 출금은 유효해 보입니다: 증명이 통과되고, 루트가 존재하며, 각 널리파이어가 새롭기 때문에 insertNullifiers()가 이를 수락합니다. 컨트랙트는 널리파이어를 리프와 연결할 수 없으므로 각 지출을 정상적인 출금으로 처리합니다. 버그는 온체인 검사가 아니라 레거시 증명 회로가 증명할 수 있는 것에 있을 가능성이 높습니다.
prooflessDeposit()과 공격 표면. prooflessDeposit() 함수는 performProoflessDepositChecks()에 위임하지만, 온체인 동작에서 형식 검증은 관찰되지 않습니다: 함수가 레거시 형식 노트를 거부하는 것으로 보이지 않으며, 컨트랙트도 반환 값을 사용하지 않습니다. 이로 인해 레거시 노트가 형식 제한 없이 머클 트리에 진입할 수 있어, 위에서 설명한 결함에 대한 공격 표면이 열립니다.
공격 분석
다음 분석은 영향을 받은 Hinkal 컨트랙트의 과거 트랜잭션을 기반으로 합니다. 공격자는 여러 자산 유형(USDC, ETH 등)을 표적으로 삼았으며, 여기서는 USDC 흐름을 예시로 사용합니다.
-
1단계: 공격자는 트랜잭션 0xfbedf0...8c2f11에서
prooflessDeposit()을 통해 100USDC를 예치했습니다. 이 예치의extraRandomization은 최상위 비트가 0으로 설정되어 있어(getPointSign = 0), 노트가 레거시 형식을 사용함을 나타냅니다. -
2단계: 공격자는 이 100
USDC레거시 노트에 대해transact()를 반복적으로 호출했으며, 매번 동일한 머클 루트를 사용하되 새로운 널리파이어를 제시하여 호출당 100USDC를 출금했습니다. 이것이 이중 지출입니다: 동일한 노트가 여러 번 지출되어 약 25,000USDC가 누적되었습니다. -
3단계: 공격자는 트랜잭션 0xbf7252...d50008에서 또 다른
prooflessDeposit()호출을 통해 25,000USDC전체를 단일 레거시 노트로 통합했습니다. 더 큰 노트로 통합함으로써, 이후 각 이중 지출 출금에서 100USDC대신 25,000USDC를 얻을 수 있었습니다. -
4단계: 공격자는 25,000
USDC노트에 대해 동일한 과정을 반복하며, 매번 새로운 널리파이어로transact()를 호출했습니다. 예치 후 공격자는 추가 자산을 공급하지 않았음에도 예치된 25,000USDC보다 훨씬 많은 금액을 출금했습니다.
총 모든 transact() 호출을 통해 약 $800K 상당의 자산이 탈취되었습니다.

결론
Hinkal 컨트랙트는 개별적으로 유효한 증명을 수락했지만, 레거시 노트 형식의 결함으로 인해 단일 노트가 여러 번 상환되었을 가능성이 높습니다. 컨트랙트의 온체인 검사(증명 검증, 널리파이어 고유성)는 모두 통과되었지만, 동일한 노트가 여러 유효한 널리파이어를 생성하고 있음을 감지할 수 없었습니다. 팀은 이후 모든 체인의 Hinkal 스마트 컨트랙트를 일시 중지하고 영향을 받은 모든 사용자에게 전액 보상하겠다고 약속했습니다 [1].
Hinkal의 경우, 완화 방안에는 레거시 노트 형식 경로 완전 비활성화, prooflessDeposit()에서 노트 형식 검증 강제 적용(예: isNewStyle == 0인 노트 거부), 그리고 일대일 널리파이어 바인딩을 확인하기 위한 전담 회로 감사 수행이 포함됩니다.
널리파이어 기반 프라이버시 프로토콜 전반에 걸쳐 불변 조건은 동일합니다: 각 노트는 단일하게 잘 정의된 파생을 통해 정확히 하나의 널리파이어에 매핑되어야 합니다. 컨트랙트는 널리파이어가 이전에 확인된 적이 없다는 것만 확인할 수 있고, 그것이 주어진 노트에 대한 유일한 유효 널리파이어인지는 확인할 수 없기 때문에, 해당 바인딩은 회로 수준에서 강제되어야 합니다.
참고 문헌
[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)



