# Integration Layer Issues

> **회로의 제약은 회로를 벗어나는 순간, 해석의 문제가 된다. 통합 레이어는 바로 그 해석이 보안으로 바뀌는 경계선이다.**

많은 개발자는 첫 단계인 회로 작성의 난해함이나, 마지막 단계인 증명 시스템이 요구하는 복잡한 암호학적 수학에 주로 집중한다. 그러나 이 둘을 연결하는 **통합 레이어(Integration Layer)**&#xC5D0;서도 보안사고는 빈번하게 발생한다.

회로가 규칙을 엄격히 정의한 금고이고, 증명 시스템이 그 규칙을 수학적으로 보장하는 자물쇠라면, 통합 레이어는 그 금고를 언제, 어떤 조건에서, 누구에게 열어줄지를 결정하는 운영 매뉴얼에 해당한다. 아무리 회로가 완벽하고 증명 시스템이 정교하더라도, 통합 단계에서 검증 조건이 누락되거나, 증명 위임 과정이 잘못 설계되거나, 서로 다른 증명을 느슨하게 결합한다면 시스템 전체의 안전성은 쉽게 붕괴된다.

특히 ZK 회로가 동작하는 유한체(Field)의 세계와, 스마트 컨트랙트가 동작하는 uint256 기반의 실행 환경이 만나는 지점에서는 작은 설계 실수도 치명적인 취약점으로 이어진다. 공개 입력과 상태 값의 연결 누락, 타입 해석의 불일치, 증명 간 관계를 강제하지 않는 합성 로직 등은 모두 회로 자체나 증명 시스템과는 무관하게 발생하는 통합 레이어 특유의 문제다.

이번 글에서는 회로 설계 이후, 증명 시스템으로 넘어가기 전에 반드시 점검해야 할 **통합 레이어 취약점**들을 다룬다. 스마트 컨트랙트 수준의 검증 실수부터, 증명 위임(delegation)과 증명 합성(composition) 과정에서 발생하는 설계 오류까지, ZK 시스템의 핵심 연결 고리를 위협하는 문제들을 실제 사례와 함께 살펴본다.

### 1. 스마트 컨트랙트의 입력값 및 상태 검증 부재

회로(Circuit)가 생성한 증명 자체는 수학적으로 유효하지만, 이를 수신하는 Smart Contract가 공개 입력(Public Input)에 대한 범위(Range)나 상태(State) 검증을 누락할 때 발생한다. ZK 회로는 단순히 "비밀을 알고 있다"는 수학적 사실만 증명할 뿐, 입력된 데이터가 블록체인 환경(EVM)에 적합한 형식인지(Aliasing 방지), 혹은 이전에 사용된 적이 없는 데이터인지(Double Spending 방지) 판단하지 못한다. 따라서 스마트 컨트랙트가 이 역할을 수행해야 하며, 이를 누락할 경우 시스템의 무결성이 붕괴된다.

**예시**

증명자(Prover)가 증명을 제출하는 과정에서 다음과 같은 문제가 발생할 수 있다.

1. **검증되지 않은 데이터 전달**

   ZK 회로는 입력값이 유한체 크기 p 미만이라고 가정하고 연산을 수행한다. 그러나 스마트 컨트랙트가 사용자로부터 전달받은 `uint256` 값이 실제로 p 보다 작은지 확인하지 않은 채 검증기에 전달한다면, 공격자는 p 보다 큰 값이지만 모듈러 연산 결과가 동일한 값 $$x \equiv x \pmod p$$ 을 제출하여 회로 검증을 통과할 수 있다. 이 경우 회로는 정상적인 입력으로 인식하지만, 스마트 컨트랙트는 서로 다른 값으로 처리하게 되어 논리적 불일치가 발생한다.
2. 상태 검증 누락

   시스템이 Nullifier의 중복 사용을 금지하는 규칙을 가지고 있음에도, 스마트 컨트랙트가 해당 Nullifier가 이미 사용되었는지를 확인하지 않는 경우 문제가 발생한다. 공격자는 유효한 증명과 Nullifier를 제출해 보상을 받은 뒤, 동일한 증명과 Nullifier를 다시 제출할 수 있다. 증명 자체는 여전히 유효하므로, 상태 검증이 없다면 공격자는 보상을 무한정 반복 수령할 수 있다.

**실제 사례**

* ZKDrops

  ZkDrops는 중복 수령 방지를 위해 Nullifier를 사용하는데, EVM은 256비트 정수를 다루는 반면 SNARK 회로는 그보다 작은 스칼라 필드(약 254비트) 위에서 모듈러 연산을 수행한다는 차이가 있다. 스마트 컨트랙트가 제출된 Nullifier 값이 SNARK 스칼라 필드 범위(p)보다 작은지 검증하는 로직을 누락했다. 공격자는 필드 크기보다 큰 값(x)과 모듈러 연산된 값($$x \pmod p$$)을 각각 제출하여, 회로 검증은 통과하면서도 스마트 컨트랙트에서는 서로 다른 키로 인식되게 만들어 에어드랍을 이중으로 수령할 수 있었다.

  ```solidity
  /// @notice verifies the proof, collects the airdrop if valid, and prevents this proof from working again.
  function collectAirdrop(bytes calldata proof, bytes32 nullifierHash) public {
  	// nullifierHash가 이미 사용되었는지 확인한다.
    // 하지만 여기서 nullifierHash의 값이 SNARK 필드 범위 내에 있는지는 확인하지 않는다.
    // 스마트 컨트랙트는 'A'와 'A + p'를 서로 다른 값(bytes32)으로 취급한다.
    // 따라서 공격자는 두 값을 각각 제출하여 여기서 두 번 모두 통과할 수 있다.
  	require(!nullifierSpent[nullifierHash], "Airdrop already redeemed");

  	uint[] memory pubSignals = new uint[](3);
  	pubSignals[0] = uint256(root);
  	pubSignals[1] = uint256(nullifierHash);
  	pubSignals[2] = uint256(uint160(msg.sender));
  	require(verifier.verifyProof(proof, pubSignals), "Proof verification failed");
  	nullifierSpent[nullifierHash] = true;
  	airdropToken.transfer(msg.sender, amountPerRedemption);
  }
  ```

  이를 막기 위해 함수 시작 부분에 다음과 같은 범위 확인 코드가 추가되었다.

  ```solidity
  require(uint256(nullifierHash) < SNARK_FIELD, "Nullifier is not within the field");
  ```
* **Tornado Cash - Nullifier Check**

  Tornado Cash는 상태 검증을 올바르게 수행한 대표적인 사례이다. `Tornado.sol`의 `withdraw` 함수에서는 출금 요청 시 Nullifier가 이미 사용되었는지를 먼저 확인하고, 증명 검증이 완료된 이후에 해당 Nullifier를 사용 처리한다. 이 과정을 통해 이중 지불을 효과적으로 방지한다.

  ```solidity
  function withdraw(
      bytes calldata _proof,
      bytes32 _root,
      bytes32 _nullifierHash,
      address payable _recipient,
      address payable _relayer,
      uint256 _fee,
      uint256 _refund
  ) external payable nonReentrant {
      require(_fee <= denomination, "Fee exceeds transfer value");

      // 상태 검증: 이미 사용된 Nullifier인지 확인
      require(!nullifierHashes[_nullifierHash], "The note has been already spent");

      require(isKnownRoot(_root), "Cannot find your merkle root"); 

      // 증명 검증
      require(
          verifier.verifyProof(
              _proof,
              [uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund]
          ),
          "Invalid withdraw proof"
      );

      // 상태 업데이트: 사용된 Nullifier로 기록
      nullifierHashes[_nullifierHash] = true; 

      _processWithdraw(_recipient, _relayer, _fee, _refund);
      emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee);
  }
  ```

### 2. Proof Delegation Error : 증명 위임 오류

사용자가 연산 부하를 줄이기 위해 제3자(Untrusted Prover)에게 증명 생성을 위임할 때, 시스템의 설계 결함으로 인해 제3자가 사용자의 의도를 벗어난 악의적인 행위를 할 수 있게 되는 취약점이다. 모바일 기기나 경량 클라이언트 환경에서는 무거운 ZK 증명 생성을 외부 서버에 맡기는 경우가 많다. 이때 시스템이 "사용자의 요청이 원자적(Atomic)으로, 그리고 순서대로 수행되었는지"를 강제하지 못하면, 악의적인 증명자가 실행 경로를 조작하거나 요청의 일부만 처리하여 시스템 상태를 훼손할 수 있다.

**예시**

이 취약점은 대표적으로 2가지 유형이 존재한다.

1. 실행 경로 조작(Execution Path Manipulation)

   사용자는 함수 A를 실행하여 자산을 잠금 해제한 후(Step 1), 함수 B를 실행하여 타인에게 전송하라(Step 2)는 일련의 과정을 원자적(Atomic)으로 실행하기를 원한다. 이를 위해 `[요청 A, 요청 B]`가 담긴 리스트에 서명하여 보낸다. 시스템에서는 각 요청을 개별적으로 검증하도록 설계되어 있어, `요청 A`와 `요청 B` 사이에는 강제적 연결 고리(Binding)가 부족하다. 이에 따라 악의적인 서버는 `요청 A`(자산 잠금 해제)만 실행하여 증명을 생성하고, `요청 B`(전송)는 고의로 누락시킨다.
2. 정보 유출(Information Leakage)

   위임을 위해 건네준 데이터 속에 복구되어서는 안 될 비밀 정보가 수학적으로 숨겨져 있는 시나리오이다.

   사용자는 자신의 비밀키(Signing Key)를 직접 노출하지 않으면서, 서버가 증명을 생성할 수 있도록 필요한 최소한의 정보(Witness)만을 위임 요청에 담아 보내려 한다. 그런데 요청 데이터 생성 로직에서, 서명을 만들 때 사용한 일회성 난수(Blinding Factor, r)를 다른 목적으로 재사용하여 그대로 요청에 포함시키는 실수를 범할 수 있다. 이때, 서버는 사용자가 보낸 요청에서 서명값(s)과 난수(r)를 모두 볼 수 있다. 서명 구조가 $$s = r + k \cdot x$$ (여기서 $$x$$는 비밀키) 형태라면, 서버는 간단한 연산을 통해 $$x = (s - r) \cdot k^{-1}$$와 같이 사용자의 비밀키를 역산해낼 수 있다.

**실제 사례**

* AleoVM : 실행 경로 절단

  Aleo 네트워크에서 함수 실행은 단일 호출이 아닌, 여러 개의 transition으로 분해되어 수행된다. 하나의 실행(`Execution`)은 다음과 같은 구조를 가진다.

  ```rust
  pub struct Execution<N: Network> {
      /// The transitions.
      transitions: IndexMap<N::TransitionID, Transition<N>>,
      /// The global state root.
      global_state_root: N::StateRoot,
      /// The proof.
      proof: Option<Proof<N>>,
  }
  ```

  이 구조에서 **transition**은 중첩된 함수 호출의 실행 단위를 의미한다. `main`이 여러 내부 함수를 호출하는 경우, 하나의 execution은 여러 transition으로 분해되며, 마지막 transition이 논리적인 최종 호출에 해당한다. 실행 시 각 transition은 독립적으로 회로로 합성되고, 개별 증명을 생성한다. 이 증명들은 이후 하나의 **aggregated proof**로 결합되어 검증된다.

  execution 시작 시에는 transition 단위로 서명된 요청(Request)이 검증된다. 이 요청은 사용자의 실행 의도를 증명하며, 사용자는 특정 함수 실행을 제3자 prover에게 위임할 수 있다. prover는 요청에 명시된 범위 내에서만 실행이 허용된다.

  문제는 이 요청이 execution 전체가 아닌 **개별 transition에만 귀속**된다는 점이다. 각 request는 서로 독립적이며, execution의 전체 구조와 암호학적으로 결합되어 있지 않다. 그 결과 transition의 순서나 개수가 강제되지 않는다.

  이로 인해 delegation 환경에서 제3자 prover는 일부 transition을 임의로 제거할 수 있다. 중첩 호출 구조에서 상위 transition을 생략하고, 사용자가 처음부터 하위 함수만 호출한 것처럼 execution을 구성하는 것이 가능하다. 이 경우 증명은 검증을 통과하지만, 상위 로직의 검증과 제약은 모두 우회된다.
* AleoVM : 정보 유출

  Aleo는 연산 비용이 큰 zkSNARK 증명 생성을 제3자 prover에게 위임할 수 있도록, 서명된 transition request 기반의 delegation 모델을 사용한다. 이를 통해 연산 능력이 제한된 클라이언트(예: 모바일 기기)는 신뢰되지 않은 서버에 증명 생성을 맡길 수 있다.

  이 모델에서 prover는 모든 입력 값을 관찰할 수 있으므로 프라이버시는 희생되지만, 보안적으로는 사용자의 서명 키(signing key)가 노출되지 않아야 한다. 즉, prover는 증명은 생성할 수 있어도 사용자의 자산을 임의로 사용할 수 있어서는 안 된다.

  클라이언트가 prover에게 전달하는 request는 다음과 같은 구조를 가진다.

  ```rust
  pubstructRequest<N: Network> {
      signer: Address<N>,
      network_id: U16<N>,
      program_id: ProgramID<N>,
      function_name: Identifier<N>,
      input_ids:Vec<InputID<N>>,
      inputs:Vec<Value<N>>,
      signature: Signature<N>,
      sk_tag: Field<N>,
      tvk: Field<N>,
      tsk: Scalar<N>,// transition secret key
      tcm: Field<N>,
  }
  ```

  문제의 핵심은 `tsk`(transition secret key) 필드이다.

  `tsk`는 request 서명 과정에서 Chaum–Pedersen 증명의 블라인딩 팩터 `r` 로 계산된다.

  ```rust
  // Compute r = HashToScalar(sk_sig || nonce)
  // Note: r is used as the transition secret key (tsk)
  letr = N::hash_to_scalar_psd4(&[
      N::serial_number_domain(),
      sk_sig.to_field()?,
      nonce,
  ])?;
  ```

  이후 서명은 다음과 같이 생성된다.

  ```rust
  letchallenge = N::hash_to_scalar_psd8(&message)?;
  letresponse = r - challenge * sk_sig;
  ```

  그러나 `r` 값이 그대로 `tsk`로 request에 포함되어 prover에게 전달된다.

  이로 인해 prover는 서명 값 `(challenge, response)` 와 `tsk = r` 를 이용해 **서명 비밀키 `sk_sig`를 직접 복구할 수 있다**.

### 3. Proof Composition Error : 증명 합성 오류

복잡한 로직을 여러 개의 독립적인 증명(Circuit)으로 나누어 처리할 때, 검증자(Verifier)가 이 증명들 사이의 연결 고리를 제대로 강제하지 않아 발생하는 취약점이다. 시스템이 "증명 A가 유효하다"와 "증명 B가 유효하다"는 각각 확인하지만, "증명 A의 출력값이 변조 없이 증명 B의 입력값으로 전달되었는가?"를 수학적으로 보장하지 못하면, 공격자는 중간 데이터를 바꿔치기하여 논리적으로 불가능한 동작을 수행할 수 있다.

**예시**

어떤 애플리케이션이 복잡한 로직을 두 단계로 나누어 증명한다고 가정해 보자. 첫 번째 증명 A는 "나는 어떤 값 x를 알고 있으며, 이 값은 조건 C를 만족한다"는 사실을 증명한다. 두 번째 증명 B는 "나는 어떤 값 y를 알고 있으며, 함수 f(y)의 결과가 공개값 z와 같다"는 사실을 증명한다. 설계 의도상으로는 증명 A에서 사용한 x와 증명 B에서 사용한 y가 동일한 값이어야 하며, 이를 통해 하나의 일관된 상태에 대한 논리를 완성하려는 것이다.

아래와 같은 증명을 검증하는 외부 컨트랙트(`Verifier`)가 존재한다고 가정한다.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// 일반적인 ZK Verifier 인터페이스
interface IVerifier {
    // proof: 암호학적 증명 데이터
    // publicSignals: 회로에 들어가는 공개 입력값 배열 (검증의 기준점)
    function verifyProof(
        bytes memory proof, 
        uint256[] memory publicSignals
    ) external view returns (bool);
}
```

이때, 정상적인 코드는 두 증명이 동일한 값(공유된 상태)을 사용하도록 강제한다.

```solidity
contract SecureIntegration {
    IVerifier public verifierA;
    IVerifier public verifierB;

    constructor(address _vA, address _vB) {
        verifierA = IVerifier(_vA);
        verifierB = IVerifier(_vB);
    }

    function executeLogic(
        bytes memory proofA,
        bytes memory proofB,
        uint256 sharedValue, // 핵심: x와 y를 통합한 하나의 변수
        uint256 publicZ
    ) public {
        
        // 1. 증명 A 검증
        // 여기서 sharedValue가 증명 A의 공개 출력(또는 입력)으로 사용됨
        uint256[] memory signalsA = new uint256[](1);
        signalsA[0] = sharedValue; 
        require(verifierA.verifyProof(proofA, signalsA), "Proof A Failed");

        // 2. 증명 B 검증
        // 여기서도 '동일한' sharedValue가 증명 B의 공개 입력으로 사용됨
        uint256[] memory signalsB = new uint256[](2);
        signalsB[0] = sharedValue; // <-- 강제 연결 지점
        signalsB[1] = publicZ;
        require(verifierB.verifyProof(proofB, signalsB), "Proof B Failed");

        // 설명: 
        // 사용자가 sharedValue를 조작하려 해도, 
        // 조작된 값으로는 proofA와 proofB를 동시에 생성할 수 없음.
        // (A를 만족하려면 x여야 하고, B를 만족하려면 y여야 하는데, 
        //  이 코드는 x == y == sharedValue를 수학적으로 강제함)

        _finalizeProcess();
    }

    function _finalizeProcess() internal {
        // 로직 실행...
    }
}
```

그러나 취약한 합성 구조에서는 검증자가 단순히 `verify(proof_A) == true`와 `verify(proof_B) == true`만 확인한다. 즉, 각 증명이 개별적으로 참인지 여부만 검증할 뿐, x와 y가 같은 값인지에 대해서는 어떤 제약도 두지 않는다. 이로 인해 두 증명 사이의 논리적 연결이 완전히 끊어진다.

```solidity
contract VulnerableIntegration {
    IVerifier public verifierA; // 증명 A (x 조건 검증)
    IVerifier public verifierB; // 증명 B (f(y) = z 검증)

    constructor(address _vA, address _vB) {
        verifierA = IVerifier(_vA);
        verifierB = IVerifier(_vB);
    }

    // 사용자는 증명 2개와, 각 증명에 필요한 공개값들을 따로따로 제출합니다.
    function executeLogic(
        bytes memory proofA,
        uint256 inputX,      // 공격자가 제출한 x 값
        bytes memory proofB,
        uint256 inputY,      // 공격자가 제출한 y 값 (x와 다를 수 있음!)
        uint256 publicZ      // 최종 결과값 z
    ) public {
        
        // 1. 증명 A 검증: "inputX는 조건 C를 만족한다"
        uint256[] memory signalsA = new uint256[](1);
        signalsA[0] = inputX;
        require(verifierA.verifyProof(proofA, signalsA), "Proof A Failed");

        // 2. 증명 B 검증: "inputY를 함수에 넣으면 publicZ가 나온다"
        uint256[] memory signalsB = new uint256[](2);
        signalsB[0] = inputY;
        signalsB[1] = publicZ;
        require(verifierB.verifyProof(proofB, signalsB), "Proof B Failed");

        // 3. 취약점 발생 지점
        // inputX와 inputY가 같은 값인지 확인하는 코드가 없음!
        // 공격자는 조건 C만 만족하는 쉬운 값(x)과, 
        // 결과 z를 만드는 전혀 다른 값(y)을 섞어서 제출 가능.
        
        _finalizeProcess();
    }

    function _finalizeProcess() internal {
        // 로직 실행...
    }
}
```

이 상황에서 공격자는 서로 무관한 두 증명을 조합할 수 있다. 조건 C를 만족하는 어떤 값 x₁에 대한 증명을 proof\_A로 제출하고, 공개값 z에 맞는 전혀 다른 값 y₂에 대한 증명을 proof\_B로 제출하는 것이다. 각 증명은 개별적으로는 수학적으로 완벽하게 유효하지만, 실제로는 x₁과 y₂가 서로 다르기 때문에 시스템이 의도한 "하나의 일관된 상태"는 존재하지 않는다. 그럼에도 불구하고 검증자는 두 증명을 각각 통과시켰다는 이유만으로 전체 로직을 만족한 것으로 판단하게 되며, 이 지점에서 증명 합성 오류가 발생한다.

### Reference

* <https://github.com/0xPARC/zk-bug-tracker>
* Audit of Aleo's synthesizer(<https://blog.zksecurity.xyz/2023-aleo-synthesizer.pdf>)


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://upsidezkp.gitbook.io/upside-zkp-docs/step-5/zkp/integration-layer-issues.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
