AI와 암호학의 만남 2: AI가 OpenVM의 zkVM에서 발견한 것
요약
AI 감사 도구인 zkao를 사용하여 OpenVM의 zkVM 내 라이브러리에서 심각한 건전성(soundness) 버그를 발견했습니다. 이 버그는 악의적인 증명자가 페어링 등식을 위조할 수 있게 하며, AI가 생성한 후보를 전문가가 검증하는 협업 프로세스를 통해 해결되었습니다.
핵심 포인트
- zkao를 통해 OpenVM의 openvm-pairing 라이브러리에서 CVE-2026-46669 버그 발견
- AI가 후보 발견을 수행하고 인간 전문가가 검증하는 하이브리드 감사 워크플로우 활용
- 복잡한 암호학 코드베이스 감사를 위해 에이전트 기반의 병렬 처리 방식 적용
- 단순 LLM 컨텍스트 제한을 극복하기 위한 서브에이전트 구조의 필요성 확인
![]()
이 글은 시리즈의 두 번째 포스트입니다.
Cloudflare의 CIRCL에 관한 첫 번째 포스트를 읽지 않으셨을 경우를 대비해, 왜 우리가 이러한 실험을 수행하는지, 그리고 우리의 파이프라인이 어떻게 구성되어 있는지에 대한 더 많은 맥락을 제공합니다.
본 포스트에서 우리는 우리의 AI 감사 도구인 zkao를 OpenVM의 zkVM에 투입하였으며, zkao는 해당 zkVM의 게스트 라이브러리인 openvm-pairing에서 심각한 건전성(soundness) 버그를 발견했습니다.
이 버그는 악의적인 증명자(malicious prover)가 어떠한 페어링 등식(pairing equality)도 위조할 수 있게 합니다.
이것이 zkVM의 증명 시스템(proving system) 자체의 건전성 버그는 아니라는 점에 유의하십시오. 이는 오직 취약한 라이브러리를 사용하는 코드에만 영향을 미칩니다.
본 포스트의 버그는 CVE-2026-46669로 할당되었으며 OpenVM 1.6.0에서 수정되었습니다. 우리가 아는 바로는, OpenVM을 기반으로 구축하는 모든 파트너가 이후 해당 버전으로 업그레이드했습니다.
설명(Clarification), 첫 번째 포스트와 마찬가지로: AI는 최종 보고서가 아닌 후보 발견 사항(candidate finding)을 생성했습니다.
그 후 우리 팀의 인간 전문가들이 해당 이슈를 검증하고, 익스플로잇 가능성(exploitability)을 확인하며, 전체적인 영향과 영향을 받는 프로젝트를 파악하고, 공개(disclosure) 절차를 처리했습니다.
이 경우에는 zkao가 스스로 생성한 상세한 보고서와 최소한의 PoC(Proof of Concept) 덕분에, 매우 빠른 수동 분류(manual triage)만으로도 OpenVM 팀과 공유할 가치가 있다고 결정할 수 있었습니다.
발생 과정
4개월 전, 우리는 AI 실험의 일환으로 OpenVM을 스캔했습니다. 우리가 처음에 모든 것을 스캔하는 방식과 동일하게, 간단한 프롬프트를 사용하는 LLM(Large Language Model)을 먼저 실행한 다음, 우리가 전문가 수준으로 유지 관리하는 스킬을 갖춘 LLM을 실행했습니다. 우리는 Opus 4.6 및 Codex 5.3으로 실행했습니다. Opus 4.7 및 Codex 5.4가 출시되자마자 우리는 다시 실행했습니다. 후보 발견 사항들은 모두 유효한 관찰이었으며, 모델들은 그중 여러 개를 Critical 또는 High로 자신 있게 분류했지만, 실제로 익스플로잇 가능한 것은 없었습니다.
우리의 가설은 zkVM이 단순한 LLM (Large Language Model) 설정으로는 300K(30만) 토큰 또는 심지어 1M(100만) 토큰의 컨텍스트(Context)로 처리하기에는 너무 복잡하다는 것이었습니다. 모듈 간의 의존성은 일반적인 라이브러리보다 훨씬 더 밀도가 높습니다. 암호학 라이브러리의 경우, 각 서브에이전트(Subagent)에게 단일 암호학적 프리미티브 (Cryptographic Primitive)에 매핑되는 폴더를 전달하는 것만으로도 병렬적인 감사가 가능한 경우가 많습니다. 각 서브에이전트는 적은 수의 라인을 읽고, 관련 기술만을 적용하며, 발견 사항을 마크다운 (Markdown) 파일에 작성하면, 메인 에이전트 (Main Agent)가 해당 파일들을 하나로 엮습니다. 이 모든 과정은 Claude Code나 Codex와 같은 인기 있는 에이전트 기반 코딩 도구 (Agentic Coding Tools)를 통해 인간의 개입을 거의 최소화하면서 즉시 수행됩니다.
하지만 이러한 접근 방식은 OpenVM과 같이 더 복잡한 코드베이스에는 적용되지 않습니다.
그곳에서는 손쉬운 버그 (Low-hanging fruit)를 제외하면, 서브에이전트의 유용한 출력물은 버그 목록이 아닙니다.
증명 가능한 보안성을 가진 모듈 A와 증명 가능한 보안성을 가진 모듈 B가 있더라도, 이들의 조합은 여전히 보안상 안전하지 않을 수 있습니다.
따라서 이러한 "격리된" 모드에서 버그를 찾는 방식으로는 의미 있는 버그를 잡아낼 수 없습니다.
대신, 서브에이전트의 출력물은 모듈에 대한 *지식 (Knowledge)*이어야 합니다. 즉, 모듈이 무엇을 가정하는지, 호출자에게 무엇을 위임하는지, 그리고 어떤 불변량 (Invariant)에 암묵적으로 의존하고 있는지에 대한 정보입니다.
하지만 이러한 종류의 출력물을 잘 표현하는 것이 어려운 부분입니다.
내용이 너무 짧으면 버그가 실제로 존재하는 구현 세부 사항을 건너뛰게 됩니다.
내용이 너무 길면 다른 것과 결합되기도 전에 메인 에이전트의 컨텍스트 (Context)를 초과해 버립니다.
우리가 관찰한 바에 따르면, 적어도 이 글을 쓰는 시점까지는 위에서 언급한 에이전트 기반 코딩 도구들이 이 문제를 효율적으로 해결하지 못하고 있습니다.
그러한 가설을 염두에 두고, 우리는 OpenVM에서 zkao를 실행하기로 결정했습니다. 비록 이 실험들을 위한 우리의 원래 규칙은 일반 LLM들이 이미 실제 버그를 발견한 이후에만 zkao를 실행하는 것이었지만 말입니다.
우리는 zkao를 위한 컨텍스트 엔지니어링 (context engineering)에 많은 시간을 할애해 왔으며, 취약점을 찾기 위한 재사용 가능한 흐름 (flows)으로서 우리 전문가들의 작업 방식을 zkao에 인코딩해 두었기에, 바로 지금과 같은 상황에 적합한 도구처럼 보였습니다.
9시간 반 이상의 스캐닝 끝에, zkao는 많은 결과물을 반환했습니다.
이전 실험과 마찬가지로, 모든 결과물을 심도 있게 검토할 시간은 없었습니다.
빠르게 훑어본 결과, 하나가 즉시 눈에 띄었습니다. 바로 게스트 라이브러리 중 하나에 있는 페어링 체크 (pairing check) 과정에서의 심각한 건전성 (soundness) 버그였습니다.
우리의 가설이 입증되었고, 수개월간의 노력이 결실을 보았습니다!
공유할 버그는 하나뿐이지만, 첫 번째 게시물과의 일관성을 유지하기 위해 버그를 한눈에 볼 수 있게 정리했습니다.
심각도 및 수정 사항 요약
| # | 버그 | AI 심각도 | OpenVM 심각도 | 수정 커밋 | 발견자 |
|---|---|---|---|---|---|
| 1 | openvm-pairing 페어링 체크에서 스케일링 인자 (scaling factor)에 대한 적절한 부서브필드 (subfield) 체크 누락 | Critical | Critical | a720e2c | zkao |
이번에는 AI가 판단한 심각도와 메인테이너가 판단한 심각도가 일치합니다.
버그 1: openvm-pairing
페어링 체크에서 스케일링 인자 (scaling factor)에 대한 적절한 부서브필드 (subfield) 체크 누락
배경
페어링 (Pairings)은 Groth16, KZG를 사용하는 PLONK, 그리고 BLS 서명의 핵심 엔진입니다. 이 모든 프로토콜에서 검증자 (verifier)는 보통 하나의 페어링 값만을 요구하지 않습니다. 대신 페어링들의 곱이 1인지 여부를 묻습니다:
$$ \prod_i e(P_i, Q_i) = 1. $$
이 단 하나의 예/아니오 답변을 통해, 검증자는 SNARK 증명이 유효한지, KZG 오프닝 (opening)이 올바른지, 또는 서명이 검증되는지를 결론짓습니다. 따라서 만약 증명자 (prover)가 잘못된 페어링 곱이 1인 것처럼 보이게 만들 수 있다면, 그 위에 구축된 모든 것은 더 이상 건전하지 (sound) 않게 됩니다.
페어링은 쌍선형 사상 (bilinear map)입니다.
$$ e : G_1 \times G_2 \to G_T, $$ 여기서 $G_1, G_2, G_T$는 아벨 군 (abelian groups)입니다. 우리의 경우, $G_1$과 $G_2$는 타원 곡선 군 (elliptic-curve groups)이며, $G_T$는 $\mathbb{F}_{p^{12}}^{*}$의 곱셈 부분군 (multiplicative subgroup)입니다.
페어링 (pairing)의 가장 중요한 속성은 쌍선형성 (bilinearity)입니다:
$$ e([a]P, [b]Q) = e(P, Q)^{ab}. $$
이것이 페어링이 유용한 이유이지만, 버그를 이해하기 위해 이 속성이 실제로 필요하지는 않습니다. 따라서 그냥 무시하셔도 됩니다.
페어링 $e(P, Q)$를 계산하는 데는 두 가지 주요 단계가 있습니다 (바일 페어링 (Weil pairing)은 제외).
첫 번째 단계는 밀러 루프 (Miller loop)입니다.
이는 밀러 함수 (Miller function) $f_{r, Q}(P)$를 평가하며, 단순화를 위해 이를 블랙박스 (black box)로 간주할 수 있습니다.
이 단계는 $\mathbb{F}{p^{12}}^{*}$의 원소를 출력합니다.
페어링의 곱 (product of pairings)을 위해, 회로 (circuit)는 모든 밀러 루프를 실행하고 그 출력값들을 모두 곱할 수 있습니다.
이 결합된 출력을 $f$라고 합시다. 즉, $f = \prod_i f{r, Q_i}(P_i)$입니다.
문제는 $f$가 아직 페어링의 곱이 아니라는 점입니다. 이는 동치류 (equivalence class) $\mathbb{F}{p^{12}}^{*} / (\mathbb{F}{p^{12}}^{*})^r$의 하나의 대표값 (representative)일 뿐입니다. 이것은 Novakovic와 Eagen의 논문에서 나온 주요 관찰 중 하나입니다: 밀러 루프의 출력은 $r$제곱을 곱하는 것에 대해서만 유일합니다. 다시 말해, $f_1 = f_2 \cdot c^r$을 만족하는 0이 아닌 $c$가 존재한다면, $f_1$과 $f_2$는 동일한 페어링을 나타냅니다. 이 결정되지 않은 인자 (undetermined factor) $c$ 때문에 직접적인 등호 검사 (equality check)가 까다로워집니다.
이것이 바로 두 번째 단계가 존재하는 이유입니다. 최종 지수 연산 (final exponentiation)은 $f$를 다음과 같이 거듭제곱합니다.
이 과정은 모호함을 제거합니다. 왜냐하면 모든 0이 아닌 $c$에 대해 다음이 성립하기 때문입니다:
$$ (f \cdot c^r)^h = f^h \cdot c^{p^{12}-1} = f^h. $$
$\mathbb{F}_{p^{12}}$의 모든 0이 아닌 원소는 $x^{p^{12}-1} = 1$을 만족하므로 마지막 항은 사라집니다. 이 지수 연산 후에 결과는 $r$제곱근 (r-th roots of unity)의 군인 $G_T$에 도달하게 됩니다. 따라서 실제 페어링 곱 검사는 다음과 같습니다:
$$ f^h = 1. $$
문제는 지수(exponent) $h$가 회로(circuit) 내부에서 거듭제곱 연산을 수행하기에는 비용이 너무 많이 든다는 점입니다. 반면 증명자(prover)가 회로 외부에서 $c$를 계산하여 힌트(hint)로 전달하는 것은 문제가 없습니다. $f = c^r$을 확인하는 것은 $f^h$ 거듭제곱을 계산하는 것보다 훨씬 저렴합니다. 따라서 $\prod_i e(P_i, Q_i) = f^h = 1$임을 증명하기 위해, 증명자는 $f^h$를 직접 계산하는 대신 $f = c^r$을 만족하는 0이 아닌 $c$를 제공하기만 하면 되며, 이는 $f^h = 1$일 때 정확히 성립합니다.
이것이 최적화의 핵심 아이디어입니다. OpenVM은 Novakovic와 Eagen의 논문에서 제시된 잔여 증인 트릭(residue-witness trick)을 사용하여 이를 구현합니다. 즉, 증명자가 몇 개의 추가 값을 제공하면 회로는 전체 거듭제곱 연산을 수행하는 대신 저렴한 방정식을 검사합니다.
실제 최적화된 방정식은 $f = c^r$과는 약간 다릅니다:
$$ f \cdot u = c^{\lambda} \wedge u^{d^i} = 1 $$
여기서 $\lambda = m \cdot r$은 곡선 특유의 지수(curve-specific exponent)로, 그 구조 덕분에 회로는 Frobenius 맵(Frobenius map)을 통해 $c^\lambda$를 저렴하게 계산할 수 있으며, $u$는 스케일링 인자(scaling factor)라고 불립니다.
OpenVM 코드에서 이 스케일링 인자는 BN254의 경우 u로, BLS12-381의 경우 s로 불립니다.
나머지 기호는 $d = \gcd(m, h)$와 $i = v_d(h)$입니다.
$\lambda$가 원래의 $r$-잔여 검사(r-residue check)와 완벽하게 일치하지 않기 때문에 스케일링 인자가 필요합니다. 논문의 정리 3(Theorem 3)은 스케일링 인자가 작은 일차 단위근(small root-of-unity) 관계를 만족하도록 요구함으로써 그 간극을 메우는데, 이것이 바로 위의 $u^{d^i} = 1$ 조건입니다.
이 특정 곡선들에 대해 논문은 훨씬 더 저렴한 경로를 제시합니다. $u^{d^i} = 1$을 직접 확인하는 대신, 스케일링 인자를 적절한 부분체(proper subfield) $\mathbb{F}{p^6} \subset \mathbb{F}{p^{12}}$로 제한하는 것만으로도 충분합니다.

$\mathbb{F}_{p^6}$ 부분체에 속한다는 것은 홀수 인덱스의 계수들이 0임을 의미합니다:
$$ c_1 = c_3 = c_5 = 0. $$
따라서 보안에 중요한 전체 부분체 검사는 단 세 번의 등식 확인만으로 이루어집니다.
버그 (The bug)
검사(check)의 형태를 떠올려 보십시오. 회로(circuit)는 결합된 Miller-loop 출력 $f$를 가지며, 최종 지수 연산(final exponentiation) 후에 페어링 곱(pairing product)이 1이 될 때만 수락해야 합니다. OpenVM은 다음과 같은 최적화된 관계식을 확인함으로써 그 비용이 많이 드는 지수 연산을 피했습니다:
$$ f \cdot u = c^\lambda. $$
하지만 해당 관계식은 $u$가 올바른 부분체(subfield)로 제한될 때만 유효합니다. OpenVM은 힌트(hint) $c$가 0이 아니라는 점만 확인하고 거기서 멈췄습니다. 스케일링 인자(scaling factor)가 $\mathbb{F}_{p^6}$에 있는지 확인하지 않았습니다. 수정 전의 BLS12-381 코드 경로는 다음과 같습니다:
// guest-libs/pairing/src/bls12_381/pairing.rs
let (c, s) = Self::pairing_check_hint(P, Q);
// ... s가 Fp6에 있는지 확인하는 절차 없음 ...
...
BN254도 동일한 패턴을 가지고 있었으며, 단지 if c == Fp12::ZERO { return None; } 구문이 있었을 뿐입니다.
따라서 회로는 $c$가 0인 경우는 거부했지만, 어떤 스케일링 인자든 수락했습니다.
그 시점에서 최적화된 방정식은 더 이상 실제 페어링 검사를 증명하지 못합니다. 잘못된 페어링 방정식에서 나온 것이라 할지라도, 어떤 Miller 출력 $f$에 대해서든 증명자(prover)는 다음과 같이 설정할 수 있습니다:
$$ c = 1, \qquad u = f^{-1}. $$
그러면 $c^\lambda = 1$이 되고, 확인된 관계식은 다음과 같이 됩니다:
$$ f \cdot f^{-1} = 1 = c^\lambda. $$
검사를 통과하게 됩니다. 동일한 대입 방식이 두 곡선 모두에서 작동합니다: BN254의 경우 $c = 1, u = f^{-1}$이며, BLS12-381의 경우 $c = 1, s = f^{-1}$입니다.
정상적인 경우 $f^{-1}$은 $\mathbb{F}{p^6}$ 부분체의 원소가 아니라 전체 $\mathbb{F}{p^{12}}$ 원소입니다. 바로 이 점 때문에 부분체 검사(subfield check)가 있었다면 위조(forgery)를 거부했을 것입니다.
또한 미묘한 제어 흐름(control-flow)의 세부 사항이 있습니다. 최적화된 루틴이 성공을 반환했기 때문에, 전체 최종 지수 연산을 수행했을 더 느린 폴백(fallback) 루틴에 도달하지 못했습니다.
영향 (Impact)
임의의 페어링 검사를 위조하는 것은 수많은 것들이 기반하고 있는 암호학적 토대(cryptographic floor)를 무너뜨립니다:
- BLS12-381 곡에서는 증명자(prover)가 KZG opening 증명을 위조할 수 있으며, 이는 데이터 가용성(data availability), blob 검증, 그리고 PLONK 또는 KZG 검증기(verifier)의 기반이 되는 다항식 약속 스킴(polynomial commitment schemes)을 무너뜨립니다.
- BN254 곡에서는 동일한 위조가 Groth16 SNARK 검증기, BLS 서명 확인, 그리고 페어링 방정식(pairing equation)에 의존하는 모든 브리지(bridge)나 프로토콜을 무너뜨립니다.
- OpenVM의 페어링 검사(pairing check)를 통해 주소
0x08에 있는 EthereumecPairing프리컴파일(precompile)을 에뮬레이션하는 모든 zkVM 게스트(guest)는 위조된 결과를 생성하여 잘못된 EVM 실행으로 이어지게 됩니다.
따라서 OpenVM 게스트 프로그램 내부에서 페어링을 검증하는 모든 L2 롤업(rollup), 브리지(bridge), 또는 프라이버시 프로토콜은 이 문제를 그대로 상속받습니다. zkao는 이를 Critical(심각) 등급으로 평가했으며, OpenVM 유지 관리자들도 이를 Critical로 확인했습니다.
해결 방법 (The fix)
해결 방법은 누락된 부분체 멤버십 테스트(subfield membership test)를 추가하는 것이었습니다. 이는 스케일링 인자(scaling factor)의 홀수 인덱스 계수들이 0임을 단언(assert)합니다:
// 스케일링 인자가 부분체 Fp6에 속할 때만 정직한 힌트(honest hint)가 됩니다
for i in [1, 3, 5] {
if s.c[i] != Fp2::ZERO {
...
$f^{-1}$와 같은 스케일링 인자는 보통 0이 아닌 홀수 계수를 가지므로, 이는 거부되어 위조가 사라지게 됩니다.
해결책은 커밋 a720e2c에 반영되었으며, OpenVM 1.6.0에 포함되었습니다.
우리가 배운 몇 가지 사항
단순한 LLM은 여전히 복잡한 코드베이스에서 한계에 부딪힙니다.
이것이 우리가 서두에서 제기했던 관찰이었으며, 실험을 통해 확인되었습니다. 두 세대의 모델을 거치며 일반적인 LLM 패스(pass)가 생성한 결과물들은 모두 OpenVM에서 Informative(정보 제공) 등급으로 분류되었습니다.
그 이유는 위에서 설명한 것과 같습니다.
zkVM은 라이브러리처럼 독립적인 프리미티브(primitives)로 분해되지 않기 때문에, 에이전트가 상위로 전달해야 하는 작업 단위는 *모듈에 대한 지식(knowledge about a module)*이며, 이는 진정으로 표현하기 어려운 영역입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 HN AI Posts의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기