저자: 니클라스 괴게
출처: https://brink.dev/blog/2026/01/07/fuzzamoto-introduction/
약 1년 전, 저는 비트코인 노드 전체를 구현한 퍼즈 테스트 도구인 " Fuzzamoto " 개발을 시작했습니다. 이 블로그 시리즈에서는 개발 과정에서 겪었던 경험, 예상치 못한 어려움, 그리고 해결되지 않은 질문들을 공유할 예정입니다. 첫 번째 글에서는 Fuzzamoto 프로젝트를 시작하게 된 동기와 설계 및 아키텍처에 대한 개요를 소개합니다. 이후 글에서는 결정론, 테스트 케이스 생성 및 뮤테이션 테스트, 발견된 버그, 테스트 범위 이상의 결과를 보여주는 퍼즈 테스트에 대한 피드백 등을 자세히 살펴보겠습니다.
동기 부여
현재까지 비트코인 코어는 네트워크에서 가장 널리 사용되는 비트코인 프로토콜 구현체이므로, 버그가 발생하면 심각한 결과를 초래할 수 있습니다. 이러한 이유로 비트코인 코어는 매우 보수적인 개발 문화를 가지고 있는데, 이는 어느 정도 필요한 부분이기도 하고 장점이기도 합니다(심각한 버그가 많지 않은 주된 이유이기도 합니다). 하지만 이러한 문화는 개발자들의 좌절감, 소진, 그리고 주요 프로젝트의 진행 속도 저하로 이어질 수도 있습니다.
단순히 테스트를 개선하거나, 기존 코드를 리팩토링하여 테스트하기 쉽게 만들거나, 테스트 항목을 추가하는 것만으로도 상당한 저항 대면 수 있습니다. 이 프로젝트의 리소스는 극히 제한적이며, 복잡한 PR은 리뷰어들에게 매력적이지 않습니다. 초기화 동기화 시간 20% 단축, P2P 프로토콜 기능 추가, 기타 사소한 변경 사항들이 리뷰하는 데 드는 시간과 노력에 훨씬 더 많은 부담을 주기 때문입니다. 설상가상으로, 테스트를 추가하기 위해 코드를 리팩토링해야 하는 경우, 리팩토링 과정 자체가 버그를 발생시킬 수도 있습니다. 이는 닭과 달걀의 문제와 같습니다. 테스트는 리팩토링의 리스크 줄여주지만, 테스트를 추가하려면 리팩토링이 필수적이기 때문입니다.
이러한 리팩토링은 일반적으로 모듈 결합도를 낮추고, 코드를 별도의 모듈 로 분리하거나 모킹을 가능하게 하는 인터페이스를 추가합니다. 이는 인프로세스/지속적인 퍼징에 종종 필요합니다. 예를 들어, 테스트 대상 구성 요소가 디스크 I/O에 의존하는 경우 퍼징은 느리고 확장성이 떨어지므로 속도를 높이기 위해 디스크 I/O를 모킹해야 합니다.
현재로서는 비트코인 코어 테스트의 상당한 개선(심각한 기존 버그 발견 또는 버그 확산 방지)이 이루어질 가능성이 여전히 매우 높습니다. 예를 들어, 블록, 트랜잭션 및 고밀도 블록 검증을 위한 P2P 코드는 퍼즈 테스트를 거치지 않았으며, 체인 재조립 및 기타 유사 프로세스와 관련된 더욱 긴밀한 코드 경로도 마찬가지입니다. 기존의 프로세스 내 제어 방식을 사용하여 이러한 코드를 효율적으로 테스트하려면 합의 메커니즘, P2P 코드 등 필요한 수준의 테스트를 거치지 않은 핵심 코드에 대한 변경이 필요합니다.
제 개인적인 경험으로 말씀드리자면, 이러한 리팩토링에 대한 지지를 얻는 것은 고통스럽고, 몇 달 또는 몇 년 동안 PR 관리에 흥미를 잃은 적도 있습니다. 리팩토링의 이점은 종종 높은 기능 변경률과 사소한 변경 사항에 가려지는 것 같습니다.
이러한 맥락 대면, 비트코인 코어는 보수적인 검토 과정을 불필요하게 부담시키거나 비트코인 코어 자체를 변경하지 않아도 되는 테스트를 통해 분명히 이점을 얻을 수 있습니다. 이러한 테스트는 남아있는 테스트 격차를 해소하고 미래의 리스크 줄일 수 있습니다. 궁극적인 목표는 실제 배포용 바이너리를 입력으로 받아 버그를 출력하는 외부 테스트 도구를 개발하는 것입니다.
비트코인 코어의 기능 테스트는 완전한 노드를 생성하고 노드의 외부 인터페이스(RPC, P2P, IPC 등)를 통해 테스트했기 때문에 이러한 개념에 가장 근접한 형태였습니다. 기능 테스트를 작성하는 데에는 리팩토링이 필요하지 않았고, 많아야 새로운 RPC 메서드를 추가하여 소프트웨어 상태의 일부를 검사하는 것만으로 충분했습니다. 이러한 테스트는 높은 코드 커버리지를 달성하고 대량 버그를 발견했지만, 속성 기반 테스트가 아니었기 때문에 예외적인 상황을 자동으로 드러내지는 못했습니다. 예를 들어, 기능 테스트를 위한 한 줄짜리 패치로는 CVE-2024-35202를 발견할 수 없었습니다. 실제로 이 취약점은 리팩토링과 새로운 퍼즈 테스트 작성을 통해 발견되었습니다(덧붙여 말하자면, 이러한 테스트와 리팩토링은 비트코인 코어에 병합되지 않았습니다). 만약 이러한 기능 테스트가 속성 기반 테스트였다면, 문제를 발견할 수 있었을지도 모릅니다.
이 점을 깨닫고 나서 저는 스스로에게 "기능적 퍼즈 테스트"가 가능할지 질문했습니다. 기능 테스트와 동일한 테스트 개념을 공유하지만, 하드코딩된 테스트 시나리오와 미리 정해진 결과를 사용하는 대신 시스템 수준에서 속성을 테스트하기 위해 퍼즈 테스트를 활용합니다. 이것이 바로 Fuzzamoto의 철학입니다. 즉, 완전한 시스템을 퍼즈 기반으로 시뮬레이션하는 것입니다.
설계
추상적으로 말하자면, Fuzzamoto를 사용한 퍼징은 테스트 대상으로서 전체 노드 백그라운드 프로세스(예: bitcoind, btcd 등), 모호한 입력값을 기반으로 테스트 실행을 제어하는 테스트 도구, 그리고 도구 실행에 필요한 입력값을 생성하는 데 사용되는 퍼징 엔진으로 구성됩니다.

스냅샷 퍼즈 테스트
이 설계의 단순한 구현 방식은 퍼즈 테스트 실행 후 대상 노드에 상태가 누적되어 후속 테스트에 영향을 미치고 비결정성을 초래할 수 있다는 명백한 문제점을 안고 있습니다. 결정성과 그 문제점에 대해서는 이 시리즈의 후속 블로그 게시물에서 더 자세히 살펴보겠지만, 간단히 말해 퍼즈 테스트를 효율적으로 만들기 위해서는 테스트 케이스 실행이 결정적이어야 합니다. 즉, 동일한 입력이 주어지면 테스트 동작도 동일해야 합니다.
Fuzzamato는 체계적인 스냅샷 퍼징 방식을 사용하여 이러한 상태 문제를 해결합니다. 핵심 원리는 대상 노드와 테스트 도구를 특수 가상 머신 내에서 실행하는 것입니다. 이 가상 머신은 모든 상태(메모리, CPU 상태, 장치 등)의 스냅샷을 생성하고 해당 상태로 빠르게 재설정할 수 있습니다. 현재 Fuzzamato는 Nyx를 가상 머신 백엔드로 사용하지만, 이론적으로 유사한 기능을 가진 모든 백엔드를 사용할 수 있습니다.
이를 통해 매번 상태를 생성하고 해제하는 번거로운 작업(비용이 많이 드는 작업)을 피할 수 있습니다. 필요한 상태를 처음에 직접 설정한 다음 스냅샷을 찍고 퍼징을 시작한 후 실행이 끝나면 가상 머신을 초기 상태로 빠르게 재설정할 수 있습니다. 특히 비트코인 풀 노드 퍼징의 경우, 이 방법을 사용하면 (예를 들어) 블록체인을 미리 채굴하고 완성도 높은 (즉시 테스트 가능한) 코인베이스 출력값을 제공할 수 있습니다.
이 블로그 시리즈의 다음 게시물에서는 Nyx의 기술적 세부 사항, 즉 작동 방식, Fuzzamoto에서 Nyx를 사용하는 방법, 그리고 이 모드에서 커버리지 가이던스가 작동하는 방식에 대해 설명하겠습니다.
장면
Fuzzamoto에서 퍼징 도구는 "씬"이라고 불리며, 스냅샷 상태를 설정하고, 퍼지 입력의 실행을 제어하고, 테스트 엔진에 결과를 보고하는 역할을 합니다. 각 씬은 다음 두 가지 기능을 구현해야 합니다.
- 시나리오 생성 및 스냅샷 상태 설정에는 대상 전체 노드 프로세스를 생성하고 노드를 퍼즈 테스트 실행에 필요한 상태로 만드는 과정이 포함됩니다.
- 테스트 케이스 실행이란 이전에 생성된 상태에서 테스트 케이스를 실행하는 것을 의미합니다.
HTTP 서버, 지갑 마이그레이션, RPC 인터페이스 및 특정 P2P 프로토콜 스트림(예: 컴팩트 블록 포워딩) 테스트는 각각 고유한 시나리오를 가지고 있습니다. 이러한 테스트는 모두 원시 바이트를 입력으로 받으며, Arbitrary를 사용하여 이러한 바이트를 구조화된 테스트 입력으로 파싱한 다음 대상에서 실행합니다. 입력이 일반적인 바이트 배열이므로 Nyx를 사용한 스냅샷 퍼징을 지원하는 AFL++를 사용하여 이러한 시나리오를 퍼징할 수 있습니다.
다양한 P2P 프로토콜 스트림(트랜잭션 포워딩, 덴스 블록 포워딩, 체인 재조립 등)을 테스트하기 위한 개별 시나리오를 개발하던 중, 이러한 스트림들을 중첩해서 테스트하는 것이 유리하다는 생각이 문득 들었습니다. 예를 들어, 덴스 블록 포워딩이나 체인 재조립을 테스트할 때, 다양한 형태와 유형의 트랜잭션을 테스트 노드에 동시에 제출하는 것(트랜잭션 포워딩 테스트 시나리오처럼)이 버그를 유발할 가능성이 높다고 가정하는 것은 무리가 없습니다. 따라서, 노드의 전체 P2P 인터페이스를 포괄하는 매우 광범위한 시나리오를 개발하는 방향으로 아이디어가 전환되었습니다. Fuzzamoto에는 이러한 퍼징 시나리오를 위해 특별히 설계된 LibAFL 기반 퍼징 엔진이 포함되어 있으며, 이에 대해서는 이 시리즈의 후반부에서 자세히 설명하겠습니다.
초기 성공
제가 처음 작성한 시나리오 중 하나는 비트코인 코어의 RPC를 대상으로 했으며, 특히 다양한 관련 및 비관련 RPC 결과를 흥미로운 방식으로 조합하는 데 초점을 맞췄습니다. 이 시나리오는 퍼징 엔진이 선택한 순서대로 RPC를 호출한 다음, 퍼지 입력의 일부를 RPC 입력으로 파싱하거나 이전 RPC에서 반환된 값 풀에서 값을 선택합니다. 예를 들어, 테스트에서 generatetoaddress 호출하는 경우, 나중에 이 RPC에서 반환된 블록 해시를 다른 RPC의 입력으로 전달하거나 퍼지 입력에서 해시 값을 제거할 수 있습니다.
이 시나리오를 통해 블록 인덱스 데이터 구조의 버그를 성공적으로 발견했습니다. 이 버그는 invalidateblock 과 reconsiderblock RPC를 동시에 사용할 때만 발생합니다(두 함수 모두 테스트 모드에서만 사용 가능).
bitcoind: validation.cpp:5392: void ChainstateManager::CheckBlockIndex(): Assertion '(pindex->nStatus & BLOCK_FAILED_MASK) == 0' failed.이 버그는 심각한 보안 문제는 아니지만(테스트 모드로 제한된 RPC를 통해서만 발생 가능), 체계적인 접근 방식의 중요성을 잘 보여줍니다. 즉, 버그인지 오탐지인지(예: 백그라운드 프로세스가 충돌하는 현상, 이는 절대 발생해서는 안 됨) 즉시 밝혀낼 수 있으며, 스냅샷 퍼즈 테스트를 통해 효율적인 상태 재설정이 가능합니다(스냅샷이 없는 경우 리팩토링을 통해서만 가능했을 것입니다).
동일한 버그는 코드 리팩토링 및 블록 인덱싱 코드에 대한 퍼즈 테스트 작성 작업을 진행 한 다른 기여자들 에 의해서도 발견되었습니다. 이 작업을 코드베이스에 추가하는 PR은 병합되기까지 무려 1년 동안 열려 있었습니다!
Fuzzamoto 테스트는 기능 테스트와 동일한 수준에서 작동하므로, 모든 Fuzzamoto 테스트 케이스를 Bitcoin Core 기능 테스트로 변환할 수 있습니다(단, 버그가 확정적으로 재현 가능한 경우에 한함). 예를 들어, 다음 기능 테스트는 블록 인덱스 버그를 재현합니다.
from test_framework.test_framework import BitcoinTestFrameworkclass CheckBlockIndexBug(BitcoinTestFramework): def set_test_params(self): self.setup_clean_chain = True self.num_nodes = 1 def run_test(self): self.generatetoaddress(self.nodes[0], 1, "2N9hLwkSqr1cPQAPxbrGVUjxyjD11G2e1he"); hashes = self.generatetoaddress(self.nodes[0], 1, "2N9hLwkSqr1cPQAPxbrGVUjxyjD11G2e1he"); self.generatetoaddress(self.nodes[0], 1, "2N2CmnxjBbPTHrawgG2FkTuBLcJtEzA86sF"); res = self.nodes[0].gettxoutsetinfo() self.generatetoaddress(self.nodes[0], 3, "2N9hLwkSqr1cPQAPxbrGVUjxyjD11G2e1he"); self.log.info(self.nodes[0].invalidateblock(res["bestblock"])) self.generatetoaddress(self.nodes[0], 3, "2N9hLwkSqr1cPQAPxbrGVUjxyjD11G2e1he"); self.nodes[0].reconsiderblock(hashes[0]) self.nodes[0].invalidateblock(hashes[0]) self.log.info(self.nodes[0].reconsiderblock(res["bestblock"]))if __name__ == '__main__': CheckBlockIndexBug(__file__).main()이러한 방식으로, 이 버그를 수정해야 하는 개발자는 Fuzzamoto를 직접 빌드할 필요 없이 익숙한 도구를 사용하여 문제를 디버깅할 수 있습니다.
지금까지 확인되어 공개된 문제점 목록은 이 프로젝트의 README 파일 에 있는 자랑거리 섹션을 참조하십시오.
이 시리즈의 다음 글에서는 비결정론을 둘러싼 고려 사항들을 살펴보겠습니다.




