본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 30. 15:50

두 개의 DeFi 프로토콜에서 1,950만 달러를 유출시킨 latestRoundData()의 치명적 실수

요약

Chainlink 가격 피드 활용 시 발생한 통합 실수로 인해 DeFi 프로토콜에서 약 1,950만 달러가 유출된 사례를 분석합니다. 데이터 업데이트 트리거인 편차 임계값과 하트비트 메커니즘을 이해하지 못해 발생한 위험성을 경고합니다.

핵심 포인트

  • Chainlink 피드는 편차 임계값과 하트비트 중 먼저 도달하는 조건으로 업데이트됨
  • TerraUSD 폭락 당시 피드가 일시 중단되었으나, 프로토콜이 최신성 확인 없이 데이터를 사용해 손실 발생
  • 자산별, 체인별로 상이한 하트비트 및 편차 임계값을 반드시 사전에 확인해야 함

가설이 아닌 실제 사건

2022년 5월, TerraUSD가 달러 페깅(peg)을 잃고 LUNA가 1센트 미만으로 폭락했을 때, Chainlink의 LUNA/USD 가격 피드(price feed)는 극한 상황에서 설계된 대로 정확히 작동했습니다. 바로 일시 중단(pause)된 것입니다. 해당 피드를 읽고 있던 두 대출 프로토콜인 BSC의 Venus Finance와 Avalanche의 Blizz Finance는 가격이 여전히 업데이트되고 있는 것처럼 계속 작동했습니다. Venus는 약 1,120만 달러를 잃었고, Blizz는 약 830만 달러를 잃었습니다. 합계는 1,950만 달러에 약간 못 미치는 금액으로, 이는 Chainlink의 집계(aggregation) 버그 때문이 아니라, 읽어들이는 데이터가 실제로 최신 상태인지 확인하지 않은 컨트랙트들 때문에 발생했습니다.

이 글은 Chainlink의 아키텍처(architecture)에 관한 28일 시리즈 중 5일 차입니다. 1일부터 4일까지는 오라클 문제(oracle problem), 기존의 요청 모델(request model), DON(Decentralized Oracle Network)이 멀티시그(multisig)가 아닌 이유, 그리고 모든 피드 하단에 있는 OCR(Off-Chain Reporting) 합의 프로토콜을 다루었습니다. 오늘은 이 모든 내용이 구체화되는 단계입니다. Chainlink 가격 피드(Price Feed)가 실제로 언제 업데이트할지 어떻게 결정하는지, data.chain.link에서 복사한 주소 뒤에 실제로 무엇이 있는지, 그리고 작동 중이던 안전 메커니즘을 1,950만 달러짜리 구멍으로 만들어버린 구체적인 통합(integration) 실수는 무엇인지 알아봅니다.

Pull이 아닌 Push: 피드를 실제로 업데이트하는 두 가지 트리거

Chainlink 데이터 피드(Data Feeds)는 연속적으로 스트리밍되지 않습니다. 피드는 정확히 두 가지 조건에 따라 업데이트되며, 먼저 발생하는 조건이 우선권을 갖습니다.

편차 임계값 (Deviation threshold). 개별 노드(node)들은 데이터 소스를 지속적으로 모니터링합니다. 오프체인(off-chain) 가격이 마지막 온체인(on-chain) 값으로부터 설정된 백분율 이상으로 움직이는 순간, 새로운 집계 라운드(aggregation round)가 시작됩니다. Ethereum 메인넷의 ETH/USD의 경우, 그 임계값은 0.5%입니다. 즉, 마지막 업데이트 이후 시간이 얼마나 흘렀는지와 관계없이 0.5%의 변동이 발생하면 즉시 업데이트가 트리거됩니다.

Heartbeat 임계값 (Heartbeat threshold). 가격 변동과는 독립적으로 작동하는 백업 타이머입니다. 만약 설정된 하트비트(heartbeat) 기간 내에 편차 임계값(deviation threshold)이 트리거되지 않더라도, 시장이 조용할 때조차 데이터가 너무 오랫동안 중단되지 않도록 보장하기 위해 피드(feed)는 어쨌든 업데이트됩니다. ETH/USD의 하트비트는 3600초, 즉 1시간입니다. 많은 피드, 특히 변동성이 낮은 자산의 경우 하트비트가 1시간을 훨씬 상회하기도 합니다.

두 조건 중 먼저 충족되는 쪽이 해당 라운드에서 우선권을 갖습니다. 변동성이 큰 자산은 대부분의 시간을 편차(deviation)에 의해 업데이트되며, 거래량이 적고 가격이 정체된 자산은 대부분의 시간을 하트비트(heartbeat)에 의해 업데이트하며 보냅니다. 두 수치 모두 공개되어 있으며, data.chain.link에서 피드별로 조회할 수 있습니다. 또한 이 수치들은 모든 피드에서 동일하지 않으며, 심지어 동일한 자산이라도 서로 다른 체인에 있다면 다를 수 있습니다. 이전에 사용했던 다른 피드와 일치할 것이라고 가정하지 말고, 통합하려는 정확한 피드의 특정 하트비트 및 편차 임계값을 확인하는 것은 단 5분이면 끝나는 작업이며, 이를 통해 특정 유형의 버그들을 방지할 수 있습니다.

복사한 주소의 실제 정체

data.chain.link에서 피드 주소를 가져와 컨트랙트에서 호출할 때, 여러분은 실제로 집계(aggregation)를 수행하는 컨트랙트를 호출하는 것이 아닙니다. 여러분은 프록시(Proxy)를 호출하고 있는 것입니다.

프록시는 실제 구현체(implementation)로 호출을 전달하는 얇은 통로 역할을 합니다. 실제 구현체는 AccessControlledOffchainAggregator 컨트랙트로, 이곳이 OCR 서명된 보고서가 실제로 도착하여 검증되고, latestAnswer가 실제로 업데이트되는 곳입니다. 프록시를 통해 라우팅된다는 것은 소비자 컨트랙트가 단 하나의 주소도 업데이트할 필요 없이, 업그레이드, 버그 수정 또는 설정 변경을 위해 하단의 집계기(aggregator)를 교체할 수 있음을 의미합니다. 집계기가 교체될 때마다 phaseId가 증가하며, 이는 여러분이 현재의 집계기뿐만 아니라 과거의 집계기들도 조회할 수 있게 해주는 역할도 합니다.

그러한 업그레이드 가능성(upgradeability)이 신뢰 가정(trust assumptions)으로부터 자유롭지 않다는 점을 인지해야 하며, "Chainlink Data Feeds"를 하나의 거대한 단일 신뢰 경계(monolithic trust boundary)로 취급하는 대신 그 가정들이 정확히 무엇인지 명확히 규정할 가치가 있습니다. Chainlink 자체 피드의 프록시 컨트랙트(Proxy contract)는 9명의 소유자와 4명의 서명 임계값(signing threshold)을 가진 Safe 멀티시그(multisig)가 소유하고 있습니다. 4개의 서명만 있으면 피드의 기본 집계기(underlying aggregator)를 임의로 업데이트할 수 있습니다. 이는 라운드마다 발생하는 OCR 합의(consensus)와는 의미상으로 다르며 더 중앙집중화된 신뢰 가정이며, 데이터 계층(data-layer)의 위험이 아닌 거버넌스 계층(governance-layer)의 위험입니다. 이는 본 시리즈의 3일 차에서 다루었던 DON 합의와 멀티시그로 제어되는 파라미터(parameters) 사이의 구분과 정확히 일치합니다.

모두가 호출하지만, 거의 모두가 무시하는 필드

사용자 컨트랙트(Consumer contracts)는 AggregatorV3Interface를 통해 피드를 읽으며, 가장 흔하게는 latestRoundData()를 호출합니다. 이 함수는 roundId, answer, startedAt, updatedAt, answeredInRound라는 다섯 가지 값을 반환합니다.

대부분의 통합 튜토리얼은 이 다섯 가지를 모두 구조 분해(destructures)한 뒤 오직 answer만을 사용하는 코드를 보여줍니다. 이것이 바로 한 문장으로 요약할 수 있는 모든 실수(footgun)의 핵심입니다. updatedAt은 타임스탬프(timestamp)로, 이 라운드가 실제로 온체인(on-chain)에 기록된 마지막 시점을 나타냅니다. 이는 반환값에 그대로 포함되어 있어 추가 호출 없이도 무료로 사용할 수 있음에도 불구하고, 엄청나게 많은 운영 환경(production)의 컨트랙트들이 이를 전혀 읽지 않습니다.

이 필드가 존재하는 이유는 다음과 같습니다. 피드는 해킹 때문이 아니라, 정확한 가격 책정이 가장 중요해지는 바로 그 극심한 변동성 이벤트 때문에 업데이트를 중단할 수 있습니다. 서킷 브레이커(Circuit breakers), 의도적인 일시 중지 조건, L2에서의 시퀀서 다운타임(sequencer downtime), 그리고 실제 네트워크 문제 등은 피드의 updatedAt이 뒤처질 수 있는 실제적이고 문서화된 이유들입니다. 이런 상황이 발생했을 때 latestRoundData()는 리버트(revert)되지 않습니다. 호출하는 컨트랙트가 해당 답변이 실제로 얼마나 오래되었는지 구체적으로 확인하지 않는 한, 내장된 오류 신호 없이 그저 자신이 가진 가장 최근의 성공적으로 보고된 답변을 계속 반환할 뿐입니다.

그것이 바로 Venus와 Blizz에서 정확히 일어난 일입니다. 극단적인 상황에서 LUNA/USD 피드(feed)가 일시 중단되었습니다. 두 프로토콜 모두 latestRoundData()를 계속 호출했고, 계속해서 answer를 반환받았으며, 이를 최신 데이터로 계속 취급했습니다. 왜냐하면 그들의 통합 코드(integration code) 어디에도 해당 라운드가 실제로 여전히 신선한지 확인하기 위해 updatedAt을 피드 자체의 게시된 하트비트(heartbeat)와 비교하는 로직이 전혀 없었기 때문입니다.

모든 통합 과정에 존재해야 하는 체크 사항

해결책은 복잡하지 않으며, 바로 그 점 때문에 실제 공격을 당한 두 프로토콜에서 이 로직이 누락되었다는 사실이 매우 주목할 만합니다.

(
    uint80 roundId,
    int256 answer,
...

MAX_DELAY는 임의의 추측이 아니라, 특정 피드의 게시된 하트비트(heartbeat)를 기준으로 설정되어야 합니다. 만약 피드의 하트비트가 1시간이라면, 그보다 더 타이트한 MAX_DELAY는 완벽하게 정상적인 데이터에 대해서도 컨트랙트가 불필요하게 리버트(revert)되게 만들어, 스스로 서비스 거부(denial of service)를 초래하게 됩니다. 반대로 하트비트보다 느슨한 MAX_DELAY는 애초에 체크를 하는 목적 자체를 무색하게 만듭니다. 올바른 수치는 다른 피드의 문서나 우연히 특정 숫자를 사용한 튜토리얼에서 복사해 오는 것이 아니라, data.chain.link에 해당 피드에 대해 게시된 동일한 하트비트 값에서 직접 가져와야 합니다.

데이터의 신선도 체크(staleness check)가 실패했을 때는, 체크 자체만큼이나 그에 따른 대응 방식도 중요합니다. 전체 트랜잭션을 리버트(revert)하는 것이 가장 간단한 옵션이지만, 청산이 진행 중인 대출 프로토콜(lending protocol)의 경우 통제되지 않은 리버트 자체가 서비스 거부(denial-of-service) 벡터가 될 수 있습니다. 더 성숙한 통합 방식은 더 이상 보증할 수 없는 데이터에 따라 행동하는 대신, 특정 작업을 일시 중단하거나, 보조 오라클(secondary oracle)로 전환하거나, 혹은 포지션을 유지합니다. 어떤 전략이 옳은지는 컨트랙트가 가격을 가지고 실제로 무엇을 하느냐에 따라 전적으로 달라지지만, Venus와 Blizz가 사실상 취했던 전략인 '아무것도 하지 않는 것'은 결코 정답이 될 수 없는 유일한 옵션입니다.

두 번째의, 더 조용한 함정: minAnswer와 maxAnswer

Staleness(데이터 신선도)가 가장 많은 관심을 받는 이유는 실제 금액이 수반된 실제적이고 공개적인 사고와 연결되어 있기 때문입니다. 하지만 현재의 동작 방식보다는 시간이 흐름에 따라 어떻게 변화해 왔는지 때문에 알아둘 가치가 있는 두 번째 필드가 있습니다.

Aggregator(애그리게이터) 컨트랙트에는 원래 회로 차단기(circuit-breaker) 경계값, 즉 피드(feed)가 터무니없고 명백히 잘못된 숫자를 보고하는 것을 방지하기 위한 건전성 검사(sanity check) 용도로 설계된 minAnswermaxAnswer 값이 포함되어 있습니다. 현재 대부분의 Chainlink 피드에서 이러한 경계값은 더 이상 능동적으로 강제되지 않으며, 컨트랙트가 가장 최근의 답변을 읽는 것을 막지 않습니다. 이 점은 과거에 적어도 하나의 주요 프로토콜을 물었던 방식 때문에 특히 알아둘 가치가 있습니다. 즉, 경계값 자체를 직접 확인하는 대신, 피드가 범위를 벗어난 답변을 항상 revert(되돌리기)하거나 거부할 것이라는 가정을 하드코딩(hard-coded)한 컨트랙트는, 실제로 존재가 보장되지 않는 강제 기능에 의존하고 있었던 것입니다. 이 교훈은 이 하나의 필드를 넘어 일반화될 수 있습니다. 상위 피드(upstream feed)가 당신을 대신해 무엇을 하고 있다고 가정하든 상관없이, 당신의 컨트랙트에서 명시적으로 확인하지 않는 모든 안전 속성(safety property)은 실제로 당신이 보유하지 않은 안전 속성입니다.

이것이 내일로 이어지는 내용

오늘은 가장 널리 사용되는 Chainlink 제품이자 거의 모든 DeFi 프로토콜이 가장 먼저 통합하는 Data Feeds(데이터 피드)를 다루었습니다. 편차(deviation) 및 하트비트(heartbeat) 메커니즘, Proxy-to-aggregator 아키텍처, 그리고 staleness(데이터 신선도) 체크는 1일 차부터 4일 차까지 이론으로 쌓아 올린 모든 것의 밑바탕이 되는 구체적이고 감사 가능한(auditable) 세부 사항들입니다. 내일은 VRF로 넘어갑니다. 암호학적 증명(cryptographic proof)이 어떻게 무작위성(randomness)에 대한 신뢰를 대체하는지, 그리고 왜 무작위성의 소스로 block.timestamp를 사용하는 것이 이번 글에서 설명한 실수(입력이 실제로 당신이 생각하는 것이 맞는지 확인하지 않고 신뢰하는 것)와 도박 및 NFT 민팅 분야에서 정확히 일치하는 오류인지 알아볼 것입니다.

저는 노드 레이어(node layer)부터 Chainlink 런타임 환경(Chainlink Runtime Environment)까지, 28일 동안 Chainlink의 전체 아키텍처를 통해 글을 쓰는 스마트 컨트랙트 보안 연구원(smart contract security researcher)입니다. ramprasadgoud.dev 또는 X @0xramprasad에서 저를 팔로우해 주세요.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0