풀 18
채굴자 없음
워커 없음

< 모든 글

Ravencoin KAWPOW 버그: 작업 증명 검사를 스스로 고른 블록 헤더

높이 4,487,776에서 채굴된 Ravencoin 블록이 자신의 블록 헤더에 더 낮은 높이를 적었습니다. 그 숫자가 2,383,550의 마지막 체크포인트보다 낮았기 때문에, 검증은 전체 메모리 집약적 검사를 실행하는 대신 헤더에 이미 들어 있던 mix_hash를 그대로 신뢰했습니다.

8월 둘째 주, Ravencoin은 자신의 체인을 약 3일 정도 되돌렸습니다. 원인은 채굴 카르텔도, 지갑 취약점도, 깨진 해시 함수도 아니었습니다. 노드가 두 가지 작업 증명 검사 중 어느 것을 실행할지 정할 때 믿었던, 블록 헤더 안의 필드 하나였습니다. 아래에 인용한 코드 조각은 모두 공지가 아니라 Ravencoin 자체의 합의 소스에서 읽은 것이며, 공지에는 나오지만 소스에서 확인되지 않는 내용은 추측하지 않고 그렇다고 밝힙니다.

KAWPOW 헤더에 담긴 것

KAWPOW는 Ravencoin의 작업 증명으로, 코인을 일반 GPU에서 계속 채굴할 수 있도록 체인이 2020년 5월에 전환한 ProgPoW 변형입니다. 이 전환으로 블록 헤더가 바뀌었습니다. 활성화 시점 이전에는 헤더가 Bitcoin에서 파생된 모든 체인이 쓰는 일반 32비트 nonce로 끝납니다. 그 이후에는 직렬화 코드가 그 자리에 세 개의 필드를 이 순서로 씁니다: nHeight, 다음으로 nNonce64, 그다음 mix_hash.

그중 두 개는 특별할 것이 없습니다. nNonce64는 그저 더 넓은 nonce입니다. mix_hash는 알고리즘의 메모리 집약적 부분의 중간 결과로, 검증자가 처음부터 계산을 다시 하는 대신 대조할 값을 갖도록 헤더에 담깁니다. 흥미로운 것은 세 번째입니다. nHeight는 블록이 자신의 헤더 안에서 스스로 생각하는 높이를 밝히는 값입니다.

체인에서 블록의 실제 위치는 주장으로 정해지는 것이 아닙니다. hashPrevBlock이 그것을 고정합니다. 어떤 블록 위에 쌓느냐가 어디에 놓일지를 결정하며, 이를 속이면서 체인 끝에 연결될 수는 없습니다. nHeight는 같은 사실의 두 번째, 쓰기 가능한 사본이며, 사실의 사본은 그것을 원본과 비교하는 검사만큼의 가치만 가집니다.

해시 함수 두 개, 그중 하나는 아무것도 검증하지 않습니다

Ravencoin의 hash.cpp는 KAWPOW 진입점 두 개를 정의하며, 함수 본문을 한 줄도 읽기 전에 그 시그니처가 이야기의 대부분을 말해 줍니다. 첫 번째는 uint256 KAWPOWHash(const CBlockHeader& blockHeader, uint256& mix_hash)입니다. 두 번째는 uint256 KAWPOWHash_OnlyMix(const CBlockHeader& blockHeader)입니다.

첫 번째 함수는 mix_hash를 출력 매개변수로 받습니다. mix를 만들어 내기 때문입니다. ethash::get_epoch_number(blockHeader.nHeight)로 데이터셋을 고르고, 해당 에포크 컨텍스트가 없으면 먼저 만든 다음, 그 위에서 progpow::hash를 실행합니다. 반환되는 mix는 작업을 실제로 수행한 부산물입니다. 이것이 메모리 집약적 경로이며, 메모리 집약성은 KAWPOW가 일반 해시가 아닌 형태로 존재하는 이유의 전부입니다.

두 번째 함수에는 그런 매개변수가 없습니다. 아무것도 만들어 내지 않기 때문입니다. progpow::hash_no_verify(blockHeader.nHeight, header_hash, to_hash256(blockHeader.mix_hash.GetHex()), blockHeader.nNonce64)를 통해 헤더에 이미 들어 있는 mix를 그대로 사용합니다. 업스트림 라이브러리는 이 함수에 hash_no_verify라는 이름을 붙였고, 그 이름은 정확합니다. 건네받은 값에 대해 마지막 혼합 단계를 수행하고 결과를 돌려줍니다. 데이터셋을 만들지 않으므로, 건네받은 값이 작업을 수행해야만 얻을 수 있는 값이라는 사실도 결코 확인하지 않습니다.

두 함수 모두 실수가 아닙니다. 저렴한 쪽에는 타당한 존재 이유가 있습니다. 하드코딩된 체크포인트가 이미 확정한 수백만 개의 오래된 블록을 동기화하는 노드가, 아무도 다투지 않는 기록을 다시 확인하려고 에포크마다 수 GB짜리 데이터셋을 다시 만들 필요는 없습니다. 이런 설계가 답해야 할 질문은 하나뿐입니다. 어떤 블록이 이 길을 갈 수 있는가입니다.

관문, 그리고 그것이 신뢰한 것

그 질문에 답하는 것은 validation.cpp의 CheckBlockHeader이며, 답 바로 위의 주석은 의도를 분명히 밝힙니다: "If we are checking a KAWPOW block below a know checkpoint height. We can validate the proof of work using the mix_hash". 의도는 옳습니다. 하지만 구현은 block.nHeight <= (uint32_t)pcheckpoint->nHeight를 조건으로 삼았고, block.nHeight는 블록이 자신에 대해 적은 숫자입니다.

그 아래 줄은 범용 CheckProofOfWork(uint256 hash, unsigned int nBits, const Consensus::Params&)를 호출합니다. 이 시그니처에 무엇이 없는지 보세요. mix도, nonce도, 헤더도 없습니다. 완성된 해시와 타깃을 받아 둘을 비교할 뿐이며, 받은 숫자를 두 함수 중 어느 쪽이 만들었는지 알 방법이 없습니다. KAWPOW 블록에서 CBlockHeader::GetHash()는 KAWPOWHash_OnlyMix(*this)를 반환하므로, 이 함수가 받는 숫자는 저렴한 쪽의 숫자입니다.

이를 이어 보면, 충분히 낮은 nHeight를 선언한 블록은 자신의 mix hash에서 나온 값이 타깃보다 낮은지만 확인받고 검증을 통과했습니다. 이 경로 어디에서도 mix가 어디서 왔는지 묻지 않았고, 선언된 높이를 블록이 실제로 차지한 위치와 비교하지도 않았습니다.

얼마나 낮아야 충분히 낮은지는 전혀 미묘하지 않습니다. 출시된 mainnet 체인 파라미터에서 가장 높은 체크포인트는 2,383,550입니다. 첫 번째 위조 블록은 4,487,776에 있었습니다. 그 헤더는 실제 위치보다 약 210만 블록 뒤의 위치를 주장해야 했고, 그렇게 선택된 분기는 그 차이를 한 번도 들여다보지 않았습니다.

지름길이 준 것과 주지 않은 것

여기서는 정확히 말할 필요가 있습니다. 대부분의 보도가 이 부분을 느슨하게 다뤘고, 느슨한 설명은 이 버그를 실제보다 더 단순하면서도 더 기이하게 들리게 만들기 때문입니다.

지름길은 난이도를 낮추지 않았습니다. nBits는 실제 이전 블록이 요구하는 값과 대조해 검증되며, 실제 이전 블록은 hashPrevBlock으로 정해집니다. 공격자는 이를 위조하면서 체인 끝에 놓일 수 없습니다. 위조 블록도 같은 분에 정직하게 채굴된 블록과 정확히 같은 타깃을 넘어야 했습니다.

바뀐 것은 한 번의 추측에 드는 비용입니다. 정직한 채굴자는 시도할 때마다 GB 단위의 데이터셋 전체를 한 번씩 훑는 비용을 치르며, 이것이 메모리 집약적 알고리즘의 핵심이자 KAWPOW가 6년 동안 Ravencoin에서 ASIC을 막아 온 이유입니다. 지름길의 비용은 헤더 해시 한 번과, 공격자가 마음대로 고른 값에 대한 마지막 혼합 단계 한 번뿐입니다. 같은 복권, 장마다 같은 당첨 확률이지만 복권 값은 몇 자릿수나 싸졌습니다. 2Miners는 그 결과를 거의 아무 작업 없이 만든 블록이라고 표현했고, KAWPOW가 원래 치러야 할 비용과 비교하면 공정한 표현입니다.

네트워크에서 일어난 일

알려진 첫 번째 무효 블록은 높이 4,487,776이며, 타임스탬프는 2026-08-07 15:44:01 UTC입니다. 악용은 8월 10일 긴급 릴리스가 나올 때까지 계속되었습니다. 2Miners는 조사한 2,089 블록 구간에서 영향을 받은 블록 96개를 셌습니다. 즉 위조 블록은 한 구간에 모여 있지 않고 분기 전체에 흩어져 있었으며, 그래서 해결책은 정밀한 제거가 아니라 체인 재구성이어야 했습니다.

노드 운영자에게 보인 증상은 무효 블록이 조용히 받아들여지는 것보다 더 나빴습니다. 노드가 이 블록들에서 비정상 종료되었고, 재시작한 뒤에는 동기화를 멈췄습니다. Tron Black이 이후 그 메커니즘을 설명했는데, 깔끔한 부수 피해 사례입니다. 블록 인덱스는 헤더가 선언한 높이가 아니라 블록이 실제로 놓인 높이를 저장합니다. 재시작할 때 노드는 인덱스에서 각 헤더를 다시 만들고 해시를 다시 계산하므로, 위조 블록의 경우 헤더에 거짓 높이가 있던 자리에 실제 높이를 넣어 해시합니다. 해시는 더 이상 일치하지 않고, 작업 증명 검사가 실패하며, LoadBlockIndexGuts가 중단됩니다. 블록은 한 번 받아들여진 뒤 불러올 수 없게 되었고, 그래서 두 번째 증상이 첫 번째보다 오래 남았습니다.

분기가 해결되지 않은 동안 여러 거래소가 RVN 입출금을 중단했습니다. 최근 기록이 곧 다시 쓰일 체인에 대한 평범하고 올바른 대응이며, 코인에 대한 판결로 읽기보다는 예상해 두는 것이 좋습니다.

패치, 그리고 그 출처

첫 번째 수정은 8월 10일 버전 4.6.1.1-hf1로 나왔고, Ravencoin 프로젝트가 아니라 채굴 풀인 2Miners가 만들었습니다. 이를 설치한 사람에게는 피해를 멈춰 주었습니다. 하루 뒤 다른 버전으로 대체되었습니다.

8월 11일 Ravencoin 저장소를 통해 공개된 버전 4.8.0이 실행해야 할 릴리스입니다. 그보다 이전 버전에 만족하지 마세요. 이 버전은 4,487,776부터 헤더에 선언된 높이가 실제 위치와 맞지 않는 블록을 거부하고, 4,487,775를 마지막 정상 블록으로 체크포인트에 넣고, 손상된 체인 상태를 중단하지 않고 자동으로 다시 만들며, 긴급 핫픽스에는 없던 자산 전송 오버플로 수정과 기타 업스트림 작업을 함께 담고 있습니다.

그중 진짜 수정은 첫 번째뿐입니다. 선언된 높이가 실제 위치와 맞지 않는 블록을 거부하면 관문은 닫힙니다. 체크포인트와 자동 재구축은 복구 조치이며, 특히 체크포인트는 투박한 도구입니다. 특정 블록 하나가 올바른 블록이라는 하드코딩된 단언이고, 체인이 가능한 한 적게 쓰고 싶어 하는 종류의 것입니다. 여기에 쓰인 것은 정직한 분기를 해시레이트만으로 될 때보다 더 빨리 굳혀야 했기 때문입니다.

복구 지점 자체도 뻔한 선택이 아니라 판단의 문제였습니다. Tron Black은 재구성을 더 얕게 하고 위조 블록은 예외로 처리하도록 더 최근의 지점을 고려해 달라고 풀들에 요청했다고 썼습니다. 풀들은 거절하고 가장 깨끗한 선을 택했습니다. 작업 증명 시스템에서 이런 논쟁은 스스로 결론이 납니다. 정직한 해시레이트의 다수가 어떤 기록을 거부하면 그 기록은 지고, 가장 큰 두 풀이 합쳐서 그 다수를 갖고 있었습니다.

3일짜리 체인 재구성이 실제로 뜻하는 것

취약한 노드들이 위조 블록을 받아들였기 때문에, 그 블록들을 포함한 분기 위에 진짜 체인이 쌓였습니다. 그것을 버린다는 것은 체인 재구성을 뜻하며, Ravencoin의 1분 블록 목표로 보면 4,487,776부터 패치까지의 구간은 약 3일 분량의 블록입니다.

RVN을 보유하고 있다면, 실제 의미는 버려진 분기에서 컨펌된 거래가 더 이상 컨펌 상태가 아니게 된다는 것입니다. 대부분은 mempool로 돌아가 살아남은 체인에서 다시 채굴되지만, 보장되지는 않으며 아예 돌아올 수 없는 거래도 있습니다. 버려진 블록의 coinbase를 쓴 거래는 살아남은 체인에 더 이상 존재하지 않는 코인을 쓰는 것이며, 그 위에 쌓인 모든 거래도 마찬가지입니다. 이런 거래는 대기 중이 아니라 그냥 무효입니다. 그래서 거래소의 입출금 중단은 과잉 반응이 아니라 올바른 결정이었고, 하나의 체인 끝이 확정될 때까지 4,487,775 이후의 활동은 조심할 필요가 있었습니다.

그 주에 노드가 꿈쩍하지 않는 것을 보았다면 알아 둘 만한 세부 사항이 있습니다. Ravencoin 노드에는 긴 재구성 금지 규칙이 있으며, 예기치 않은 다수 공격이 깊은 기록을 다시 쓰는 것을 막기 위해 존재합니다. 계속 실행되던 오래된 노드 대부분은 결국 이 규칙이 의존하는 피어 수 아래로 떨어졌고, 설계대로 더 긴 체인을 받아들였습니다. 일부는 그러지 않았습니다. 피어를 충분히 잃지 않았고, 기록된 규칙상으로는 아무것도 위반되지 않았기 때문입니다. 보호 장치가 원래 대상이 아니었던 문제에 대해 명세 그대로 작동한 것입니다. 이번 분리는 다수에 대한 공격이 아니라 검증 결함에서 비롯되었고, 이 규칙은 둘을 구분할 수 없습니다.

RVN을 채굴한다면 해야 할 일

자신의 Ravencoin 노드를 운영한다면 4.8.0을 실행하세요. 이 문제의 일부만 해결한 이전 릴리스는 쓰지 마세요. 노드를 멈추고, 바이너리를 교체하고, 다시 시작하세요. 실행할 reindex도, 수동 단계도 없습니다. 소프트웨어가 필요한 것을 스스로 다시 만듭니다. 긴급 핫픽스에서 올라오는 노드는 빠르게 재시작합니다. 패치되지 않은 소프트웨어에서 올라오는 노드는 재생과 재구축을 해야 하며 일반 하드웨어에서 몇 시간이 걸립니다. 그동안 나오는 bad-blk-height 오류는 패치의 결함이 아니라 패치가 작동하고 있다는 뜻입니다.

풀을 통해 채굴한다면 바꿀 것이 없습니다. 새 주소도, 새 포트도, 채굴 프로그램 업데이트도 필요 없습니다. 여기서 KAWPOW 자체는 문제가 아니었습니다. 알고리즘은 설계대로 정확히 작동했고, 결함은 언제 그것을 실행할지 정하는 코드에 있었습니다. 이전 분기에 보낸 해시레이트는 진짜 작업이었습니다. 다만 그 사이에 흩어져 있던 위조 블록과 함께 버려진 블록에 들어갔을 뿐입니다.

올바른 체인에 있는지 한 줄로 확인하는 방법이 있으며, 그렇다고 가정하기보다 실제로 확인할 가치가 있습니다. 블록 4,494,143의 해시를 확인하세요. 정식 체인에서는 4c67d6a4로 끝나며, 프로젝트가 네트워크 복구를 선언했을 때 독립적인 Ravencoin 익스플로러들은 모두 같은 체인을 보여 주고 있었습니다.

이 버그의 형태

세부 사항을 걷어 내면 남는 것은 체인 안팎에서 끊임없이 되풀이되는 형태입니다. 바깥에서 들어온 값이, 바로 그 값을 얼마나 꼼꼼히 검사할지 정하는 데 쓰였습니다. 헤더는 자신의 높이를 밝혔고, 노드는 그 주장을 바탕으로 헤더가 통과해야 할 작업 증명 검사를 골랐습니다.

올바른 방식은 복잡하지 않으며, 패치는 사실상 그중 한 줄입니다. 선언된 높이가 무엇이든 결정하게 하기 전에, 먼저 블록이 실제로 차지한 위치와 비교하는 것입니다. 그것이 왜 빠져 있었는지가 더 흥미로운 절반입니다. 지름길은 이미 신뢰된 블록을 위해 작성되었고, 그 가정 안에서 관문은 누군가 부정직하게 답할 수 있는 질문이 아니라 사실을 말하는 문장처럼 읽힙니다. 신뢰할 수 없는 블록이 그 필드를 채울 수 있게 되는 순간 그렇게 읽히지 않지만, 코드 어디에도 그것이 바뀐 순간이 표시되어 있지 않았습니다.

어떻게 발견되었는지에 대해 기록해 둘 점이 두 가지 있습니다. Ravencoin 코드는 전문 보안 감사를 받았지만, 감사는 이 문제도, 함께 수정된 자산 오버플로도 잡아내지 못했습니다. Tron Black은 둘 다 프런티어 모델 AI 보안 검토로 드러났을 것으로 강하게 의심한다고 썼습니다. 어떤 도구였든 이 형태와 맞아떨어집니다. 지름길을 그 자체의 논리로 읽는 동안에는 결함이 보이지 않고, 지름길이 신뢰하는 필드를 누가 채우는지 물을 때 비로소 드러납니다.

마지막으로 눈여겨볼 것은 누가 어떤 순서로 움직였는가입니다. 한 채굴 풀이 검증 경로를 읽고 이해한 뒤 3일 안에 긴급 바이너리를 내놓았습니다. 이어서 가장 큰 두 풀이 편리한 지점 대신 공격 기간 전체를 버리는 체인을 채굴하기로 합의했고, 더 얕은 지점을 택해 달라는 요청을 받았지만 거절했습니다. 프로젝트는 하루 뒤 핫픽스를 대체하고 더 많은 것을 고친 릴리스로 뒤따랐습니다. 누가 먼저였는지보다 둘 다 일어났다는 것이 중요하며, 실제로 결론을 낸 것은 어느 공지도 아닌, 분리된 한쪽에 쌓인 진짜 작업의 무게였습니다. 탈중앙화가 백서 속에 머물지 않고 무게를 감당해야 하는 날에는 이런 모습입니다.

이 숫자들의 출처

저희 말을 그대로 믿지 않고 누구나 확인할 수 있도록 적습니다. 헤더 구조, 두 해시 함수와 그 시그니처, 에포크 선택, 그리고 주석이 달린 검증 분기는 Ravencoin 자체의 소스인 src/primitives/block.h, src/primitives/block.cpp, src/hash.cpp, src/hash.h, src/pow.h, src/validation.cpp에서 읽었습니다. mainnet 최고값인 2,383,550을 포함한 체크포인트 목록은 src/chainparams.cpp에서 읽었습니다. 첫 번째 무효 높이와 그 타임스탬프는 Ravencoin 프로젝트 자체의 네트워크 공지에서 가져왔습니다. 블록 인덱스 충돌 경로, 돌아올 수 없는 coinbase 의존 거래, 긴 재구성 금지 동작, 거절된 더 얕은 복구 지점, 감사와 AI 검토에 관한 언급, 4.8.0에 포함된 내용, 그리고 4,494,143 확인 방법은 Tron Black이 Ravencoin 프로젝트를 대신해 남긴 설명에서 가져왔습니다. 긴급 핫픽스 버전과 2,089 블록 중 96 블록이 영향을 받았다는 집계는 2Miners의 릴리스와 관련 보도에서 가져왔습니다. 출처에 나와 있지 않은 내용은 채워 넣지 않고 이 글에서 뺐습니다.