block.timestamp가 NFT 민팅 취약점이 될 수 있는 이유 (그리고 VRF가 실제로 수행하는 역할)
요약
block.timestamp와 같은 온체인 데이터 기반의 무작위성이 가진 보안 취약점과 실제 공격 사례를 분석합니다. 이를 해결하기 위해 Chainlink VRF가 암호학적 증명을 통해 어떻게 예측 불가능한 무작위성을 보장하는지 설명합니다.
핵심 포인트
- block.timestamp와 blockhash는 검증자에 의해 조작될 수 있어 보안에 취약함
- Meebits NFT 사례를 통해 예측 가능한 무작위성이 초래한 실제 피해 확인
- Chainlink VRF는 암호학적 증명을 통해 온체인 무작위성의 신뢰성을 보장
- 진정한 무작위성은 블록 데이터와 사전 커밋된 개인키의 결합으로 구현됨
block.timestamp를 사용하는 누구도 생각하지 못한 765,000달러 규모의 NFT 취약점
2021년 5월, 한 공격자가 Larva Labs의 프로젝트 중 하나인 Meebits NFT 민팅(mint) 과정에서 예측 가능한 무작위성(randomness) 메커니즘을 악용하여 공격을 감행했습니다. Meebits는 새로 민팅되는 각 NFT의 토큰 ID를 생성하기 위해 block timestamp, nonce, difficulty를 포함한 온체인(on-chain) 입력값을 사용했습니다. 토큰 ID에 따라 희귀도가 달랐으며, 희귀한 ID일수록 2차 시장(secondary market)에서 훨씬 더 높은 가치를 지녔습니다.
공격자는 생성 공식을 알아낸 뒤, 트랜잭션을 확정하기 전에 결과를 시뮬레이션했으며, 희귀한 NFT를 얻을 때까지 동일한 트랜잭션 내에서 민팅을 반복적으로 다시 시도(reroll)했습니다. 그 결과 그들은 나중에 약 200 ETH(당시 가치로 약 765,000달러)에 판매된 Meebit을 챙길 수 있었습니다. 컨트랙트는 프로그래밍된 대로 정확히 작동했습니다. 문제는
blockhash: 만약 검증자 (validator)가 해시값이 자신에게 불리한 결과를 생성하는 블록을 채굴하려 한다면, 단순히 해당 블록을 발행하지 않을 수 있습니다. 이들은 블록 보상 (block reward)을 포기하게 되지만, 만약 복권 잭팟 금액이 블록 보상보다 크다면 이는 합리적인 거래가 됩니다. 이로 인해 스테이크된 가치가 한 블록의 보상 가치를 초과하는 순간, blockhash에서 파생된 모든 무작위성 (randomness)은 보안상 취약해집니다.
block.difficulty / block.prevrandao: 머지 (merge) 이후, block.difficulty는 RANDAO 비콘 출력을 전달하는 block.prevrandao로 대체되었습니다. 이는 타임스탬프 (timestamp)나 blockhash보다 훨씬 낫지만, RANDAO는 여전히 검증자들에 의해 편향 (biasable)될 수 있습니다. 검증자는 현재 누적값 (accumulator)을 관찰하고, 자신의 공개 보상을 포기하는 대가로 공개를 보류함으로써 유리한 결과를 얻을 수 있는 추가적인 기회를 살 수 있기 때문입니다.
이 중 그 어느 것도 진정한 무작위성이 아닙니다. 이것들은 단지 시간이 지남에 따라 우연히 변하는 블록체인 상태 값 (blockchain state values)일 뿐이며, 무작위성과는 다른 개념입니다.
VRF가 정확히 수행하는 역할
Chainlink VRF (Verifiable Random Function, 검증 가능한 무작위 함수)는 무작위 값과 해당 값이 어떻게 생성되었는지에 대한 암호학적 증명 (cryptographic proof)을 동시에 생성합니다. 이 증명은 온체인 (on-chain)에 게시되며, 소비 컨트랙트 (consuming contract)가 숫자를 확인하기 전에 VRF 코디네이터 (VRF Coordinator) 컨트랙트에 의해 검증됩니다.
생성 메커니즘: VRF는 요청이 이루어지는 시점에는 아직 알 수 없는 블록 데이터 (block data)와 오라클 노드 (oracle node)의 사전 커밋된 개인키 (pre-committed private key)라는 두 가지 입력을 결합하여 출력을 생성합니다. 이 구체적인 결합 방식이 중요합니다. 블록 데이터는 요청이 온체인에 커밋된 이후에나 오라클이 사용할 수 있으므로, 오라클은 요청이 제출되기 전에 출력을 미리 계산할 수 없습니다. 또한 개인키는 사전에 커밋되므로, 오라클이 사후적으로 유리한 출력을 생성하는 키를 선택할 수 없습니다.
그 결과 다음과 같은 특성을 가진 함수가 만들어집니다:
- 공개되기 전까지 예측 불가능 (Unpredictable before it's revealed): 소비자(consumer), 오라클(oracle), 또는 그 어떤 제3자도 블록 데이터 입력값이 확정되기 전에는 출력을 계산할 수 없습니다.
- 생성 후 변경 불가능 (Unalterable after it's generated): 증명(proof)은 출력을 해당 입력값에 고유하게 결합합니다. 출력을 변경하면 증명이 무효화됩니다.
- 공개적으로 검증 가능 (Publicly verifiable): 누구나 온체인(on-chain) 상에서 출력이 커밋된 키(committed key) 및 요청 시드(request's seed)와 일치하는지 검증할 수 있습니다.
대부분의 설명에서 생략되는 세부 사항: 노드가 해킹당해도 왜 여전히 속일 수 없는가
이 지점이 바로 VRF의 보안 모델이 단순히 "오라클을 사용하면 된다"는 식의 순진한 접근 방식과 진정으로 차별화되는 부분입니다.
만약 Chainlink 노드 운영자가 완전히 해킹당한다면, 해당 운영자의 개인 키(private key)를 제어하는 공격자는 두 가지 선택지를 갖게 됩니다.
옵션 1: 편향된 출력 생성 (generate a biased output). 특정 결과에 유리한 무작위 값을 생성하려고 시도합니다. 하지만 출력을 편향시키려면, 그 편향된 값에 대한 유효한 암호학적 증명(cryptographic proof)을 생성해야 합니다. 해당 증명을 위조하려면 기반이 되는 타원 곡선 암호학 (elliptic-curve cryptography)을 깨뜨려야 하는데, 이는 현재 기술로는 계산적으로 불가능합니다.
옵션 2: 응답을 완전히 보류 (withhold the response entirely). 단순히 VRF 요청에 응답하지 않는 것입니다. 이는 서비스 거부 (denial of service)이지, 조작이 아닙니다. 소비자 컨트랙트(consumer contract)는 무작위 숫자를 받지 못하게 되지만, 그렇다고 편향된 무작위 숫자를 받는 것도 아닙니다. 하위 로직(downstream logic)은 (타임아웃이나 폴백 경로(fallback paths)를 통해) 미전달 상황을 유연하게 처리해야 하지만, 어떤 공격자도 이 방식을 통해 가치를 추출할 수는 없습니다.
해킹된 VRF 오라클이 할 수 있는 최악의 행동은 답변을 거부하는 것입니다. 거짓말을 할 수는 없습니다. 이는 단순히 API 엔드포인트가 진정으로 무작위인 숫자를 반환할 것이라고 믿는 그 어떤 방식보다 의미 있게 강력한 보장입니다.
요청 및 수신 사이클 (The request-and-receive cycle)
VRF는 무작위성의 두 트랜잭션 특성에 맞춰 조정된, 초기부터 사용된 기본 요청 모델 (Basic Request Model)과 동일한 요청 및 수신 패턴을 사용합니다.
사용자의 컨슈머 컨트랙트(consumer contract)는 VRF 코디네이터(VRF Coordinator)의 requestRandomWords()를 호출하며, 이때 원하는 랜덤 워드(random words)의 개수, 콜백(callback)을 위한 가스 한도(gas limit), 그리고 오라클(oracle)이 응답하기 전까지 기다릴 블록 확정(block confirmations) 횟수를 지정합니다. 확정 횟수가 많을수록 체인 재구성(chain reorganizations)으로부터 더 강력한 보호를 받을 수 있습니다. 희귀 NFT 민팅(mint)이나 거액의 복권 당첨금과 같이 가치가 높은 결과물(high-value outcomes)의 경우, 추가적인 지연 시간(latency)을 감수하더라도 더 높은 확정 횟수를 설정할 가치가 있습니다.
오라클은 온체인(on-chain) 상의 요청을 관찰하고, 지정된 확정 횟수를 기다린 다음, 확정된 블록 데이터(finalized block data)를 시드(seed)로 사용하여 랜덤 출력값과 증명(proof)을 계산합니다. 그 후 이 두 가지를 코디네이터 컨트랙트에 제출하며, 코디네이터는 온체인에서 해당 증명을 검증합니다. 증명이 유효하면 코디네이터는 검증된 랜덤 숫자를 사용하여 사용자의 컨트랙트에 있는 fulfillRandomWords() 콜백을 호출합니다. 만약 증명 검증에 실패하면 사용자의 컨트랙트는 호출되지 않습니다.
VRF v2 vs v2.5: 실제로 무엇이 바뀌었나
VRF v2는 구독 기반 결제(subscription-based billing) 방식을 도입하여, 개발자가 단일 계정에 자금을 미리 충전해 두고 여러 컨슈머 컨트랙트가 해당 계정에서 자금을 인출할 수 있도록 권한을 부여할 수 있게 했습니다. 이는 이전의 직접 충전(direct-funding) 모델과 비교했을 때 요청당 가스 오버헤드(gas overhead)가 낮고 예측 가능한 비용 관리가 가능합니다.
VRF v2.5에는 알아둘 만한 두 가지 사항이 추가되었습니다:
네이티브 토큰 결제 (Native token payment). 이전에는 VRF 요청을 LINK로만 충전할 수 있었습니다. V2.5는 네트워크의 네이티브 가스 토큰(메인넷의 ETH 등)으로 결제할 수 있는 옵션을 추가했습니다. 다만, 네이티브 토큰 결제는 LINK 결제보다 약간 더 높은 요율로 부과됩니다.
가스 비율 기반 프리미엄 (Gas-percentage-based premium). 가스 상황과 관계없이 요청당 고정된 LINK 프리미엄을 부과하는 대신, v2.5는 콜백의 실제 가스 비용에 대한 일정 비율로 프리미엄을 책정합니다. 이를 통해 가스 급등(gas spikes) 시에도 비용을 더 예측 가능하게 만들고, 오라클의 인센티브를 실제 이행 비용(fulfillment cost)과 일치시킵니다.
VRF를 사용하는 모든 컨트랙트를 위한 감사 체크리스트 (The audit checklist for any contract using VRF)
1. requestConfirmations가 스테이크(value at stake) 규모에 맞게 적절히 설정되었는가?
기본값은 일반적으로 3번의 컨퍼메이션 (confirmations)입니다. 상금이 매우 크거나 희귀 ID가 일반 ID보다 훨씬 더 높은 가치를 지닌 NFT 민팅 (mint)의 경우, 이 값을 10~20 블록으로 높이면 리오그 (reorg, 재구성) 위험을 유의미하게 줄일 수 있습니다. Meebits 취약점 사례는 리오그 조작을 필요로 하지 않았지만, 이해관계가 큰 무작위성 (randomness)은 종종 추가적인 컨퍼메이션 깊이를 필요로 합니다.
2. fulfillRandomWords가 콜백 (callback) 내부에서 최소한의 작업만 수행하는가?
콜백 가스 리미트 (gas limit)는 요청 시점에 설정됩니다. 만약 가스 부족으로 인해 콜백 로직이 리버트 (revert)되면, 전체 풀필먼트 (fulfillment)가 실패하고 무작위 숫자는 유실됩니다. 콜백은 가볍게 유지하십시오. 무작위 숫자를 저장하고, 이벤트를 방출(emit)한 뒤, 무거운 후속 로직은 해당 이벤트에 의해 트리거되는 별도의 트랜잭션에서 처리하십시오.
3. 요청 주소 (requesting address)를 조작할 수 있는가?
만약 VRF 컨슈머 (consumer)가 임의의 호출자에게 requestRandomWords를 트리거할 수 있도록 허용한다면, 악의적인 공격자가 요청을 스팸처럼 보내 구독 잔액 (subscription balance)을 고갈시킬 수 있습니다. 요청 함수를 호출할 수 있는 대상을 제한하십시오.
4. 구독 잔액이 모니터링되고 있는가?
구독 잔액이 비어 있으면 VRF 요청에 응답이 오지 않습니다. 모든 프로덕션 애플리케이션 (production application)의 경우, 구독 충전(top-ups)을 자동화하거나 최소한 잔액이 0에 도달하기 전에 경고를 보내도록 설정하십시오.
5. 무작위 단어 (random words)가 올바르게 사용되고 있는가?
fulfillRandomWords에서 반환되는 각 uint256은 균등 분포된 256비트 숫자입니다. 만약 이를 길이가 N인 배열의 인덱스를 선택하는 데 사용한다면, 올바른 패턴은 randomWords[0] % N입니다. 잘못된 패턴은 모듈로 (modulo) 연산 없이 더 작은 타입으로 캐스팅 (casting)하는 것이며, 이는 소리 없는 인덱스 범위 초과 (index-out-of-bounds) 또는 예상치 못한 절단 (truncation)을 발생시킬 수 있습니다.
앞으로 이어가야 할 패턴
이 시리즈의 5일 차에서는 데이터 피드 (Data Feeds)에서의 데이터 신선도(staleness) 관련 실수(footgun)를 다루었습니다. 즉, 데이터가 실제로 최신인지 확인하지 않고 오라클 (Oracle)의 출력값을 신뢰하는 문제입니다. VRF (Verifiable Random Function) 또한 다른 입력값에 적용된 동일한 범주의 문제입니다. 즉, 값이 실제로 예측 불가능하고 편향되지 않았는지 검증하지 않은 채 "무작위 (random)"라고 표시된 값을 신뢰하는 것입니다.
두 경우 모두 해결책은 동일한 근본 원칙을 따릅니다: 신뢰하지 말고 검증하십시오 (verify, don't trust). 데이터 피드의 경우, 이는 updatedAt을 확인하는 것을 의미합니다. 무작위성의 경우, 값과 함께 암호학적 증명 (cryptographic proof)을 함께 제공하는 소스를 사용하고, 증명이 일치하지 않으면 값을 전달하기를 거부하는 코디네이터 컨트랙트 (coordinator contract)를 사용하는 것을 의미합니다.
저는 Chainlink의 전체 아키텍처를 28일 동안 파헤치며 글을 쓰고 있는 스마트 컨트랙트 보안 연구원입니다. ramprasadgoud.dev 또는 X @0xramprasad에서 함께해 주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기