본문으로 건너뛰기

Trident를 사용하여 프로그램에 퍼즈 테스트를 수행하는 방법

업데이트됨:
2026년 8월 7일

읽는 데 39분 소요

개요

정보

Trident는 Ackee Blockchain Security에서 개발하고 관리합니다. 이 가이드는 Quicknode Ackee Blockchain Security가 공동으로 제작했습니다.

DeFiLlama에 따르면, 2026년 3월 Solana 총 예치 가치(TVL)는 65억 9천만 달러를 넘어선 것으로 Solana . TVL이 증가하고 실물 자산이 온체인으로 이동함에 따라, 공격 표면도 함께 확대되고 있습니다.

단위 테스트는 몇 가지 극한 사례를 검증하지만, 개발자가 테스트해야겠다고 생각한 입력값만 다룰 뿐입니다. 보안 감사는 알려진 취약점 패턴을 포착하지만, 비용이 많이 들고 대개 프로젝트 수명 주기의 초기 단계에서만 수행됩니다. 두 방법 모두 프로그램이 실제 운영 환경에서 직면하게 될 수백만 건의 무작위 트랜잭션을 통해 프로그램을 극한까지 테스트하지는 못합니다.

퍼징은 이러한 공백을 메워줍니다. 퍼징은 프로그램에 무작위적이고, 유효하거나 무효한, 예상치 못한 입력값을 쏟아부어 수동 테스트에서는 놓치기 쉬운 버그—계정 제약 조건 누락, 잘못된 토큰 처리, 예상치 못한 상태 전환 등—를 찾아냅니다.

Trident는 Solana 위해 개발된 퍼징 프레임워크입니다. 명령어를 무작위 순서로 실행하는 블랙박스 퍼저와 달리, Trident는 개발자가 현실적인 명령어 순서(먼저 설정, 중간에 순열, 마지막에 정리)를 직접 지정하는 수동 유도형 퍼징 방식을 사용하여, 퍼저가 의미 있는 상태 경로를 탐색하도록 합니다.

이 가이드에서는 Trident 설정 방법, 토큰 에스크로 프로그램에 대한 퍼즈 테스트 작성 방법, 실제 제약 조건 취약점을 발견하는 방법, 그리고 내장된 메트릭 대시보드를 통해 결과를 모니터링하는 방법을 단계별로 안내합니다.


요약
  • Trident는 Solana (Solana Anchor) 프로그램을 위한 수동 방식의 퍼저입니다.
  • 단위 테스트에서는 놓치기 쉬운 누락된 계정 제약 조건, 잘못된 토큰 처리, 그리고 승인되지 않은 상태 변경을 포착합니다.
  • 이 가이드는 의도적으로 삽입된 토큰 대체 취약점을 이용하여 토큰 에스크로 프로그램을 퍼징합니다.
  • 설정에는 약 15분이 소요되며, 퍼저가 첫 실행에서 바로 버그를 찾아냅니다.

주요 업무


  • Trident CLI를 설치하고 Anchor 프로젝트에 퍼즈 테스트 스캐폴딩을 생성하기
  • 의도적으로 취약점을 포함시킨 토큰 에스크로 프로그램에 대한 퍼즈 테스트를 설정한다
  • 다음과 같이 명령어 흐름을 정의합니다. #[초기화], #[flow], 그리고 #[끝] 주석
  • Trident의 토큰 헬퍼를 사용하여 SPL 토큰 발행 및 계정을 기반으로 한 다중 행위자 퍼징 구성하기
  • Trident 메트릭스 대시보드를 사용하여 결과를 모니터링하세요
  • 각 반복 후 토큰의 정확성을 확인하기 위해 불변 조건 검사를 추가합니다.

준비물

이 가이드에서는 독자가 Anchor 프로그램 작성 및 테스트에 어느 정도 익숙하다고 가정합니다. 또한 다음 사항도 갖추고 있어야 합니다:


또한 다음 항목들도 설치해야 합니다:

의존성버전
Solana3.0.4
앵커0.32.1
Rust1.93.1
Node.js24
trident-cli0.12.0

수동 유도 퍼징이란 무엇인가?

수동 유도 퍼징은 개발자가 명령어 시퀀스의 구조(먼저 설정, 중간에 순열, 마지막에 정리)를 정의하고, 퍼저가 해당 구조 내에서 입력, 금액 및 계정을 무작위로 생성하는 보안 테스트 기법입니다.

기존의 “블랙박스” 퍼저는 완전히 무작위적인 명령어 시퀀스와 데이터를 생성합니다. 이는 단순한 프로그램의 경우 효과적이지만, Solana 특정 명령어 순서를 요구합니다. A 제안하기 명령은 다음보다 먼저 실행되어야 합니다 제안 수락. 블랙박스 퍼저(black-box fuzzer)는 런타임이 즉시 거부할 무효한 순서에 대부분의 시간을 낭비한다.

블랙박스 퍼징수동 유도 퍼징
명령어 순서완전히 무작위개발자가 지정한 구조
잘못된 시퀀스 처리거절된 주문에 대한 사이클 낭비설계상 유효하지 않은 정렬 순서는 건너뜁니다
입력 무작위화무작위개발자가 정의한 범위 내에서 무작위로
의미 불변량없음개발자가 직접 작성한 어설션
다음에 가장 적합합니다간단한, 상태 비저장형 프로그램명령어 순서가 지정된 상태 유지 프로그램

퍼저(fuzzer)는 각 단계 내에서 실행할 명령어의 순열, 실행 순서, 입력값을 무작위로 선택하되 전체 구조는 유지합니다. 즉, 퍼징의 각 반복 과정마다 현실적인 트랜잭션 순서를 실행함으로써 실제 버그를 발견할 가능성을 높입니다.

Trident는 Anchor 프로젝트와 직접 연동됩니다. 이 도구는 프로그램의 IDL(인터페이스 정의 언어)을 분석하여 명령어 시그니처와 계정 구조를 파악한 후, 퍼즈 테스트 템플릿을 생성합니다. 개발자는 명령어 흐름, 입력 범위 및 계정 저장소 구성을 입력하면 됩니다. Trident는 실행, 메트릭 수집 및 충돌 재현을 처리합니다.

Trident는 어떤 버그를 자동으로 탐지하나요?

Trident는 완전히 수동으로 제어됩니다. 사용자는 퍼즈 반복 과정의 모든 단계를 직접 제어할 수 있습니다. 즉, 어떤 플로우가 실행될지, 어떤 순서로 실행될지, 그리고 각 플로우에 어떤 값이 할당될지를 결정합니다. 각 명령어 인자에 대해 고정된 값을 직접 지정하거나, Trident가 무작위 값을 생성하도록 지시할 수 있습니다(예: trident.random_from_range(0..u64::MAX)). 계정에도 마찬가지입니다. 다음 항목에 어떤 내용이 포함될지 구성합니다. 주소 저장소 그리고 Trident는 실행 시점에 이 중에서 무작위로 선택합니다.

사용자로부터 어떠한 불변 조건도 주어지지 않은 경우, Trident는 다음과 같이 동작합니다:


  • 정의한 흐름에서 무작위로 추출한 명령어 시퀀스를 실행하여, 예상치 못한 순서로 인해 발생하는 크래시 및 Rust 패닉을 파악해 보세요.
  • 저장해 둔 주소(예: 잘못된 민트, 잘못된 서명자, 잘못된 PDA 등)를 사용하여 임의의 계정 대체를 시도해 보고, 그로 인해 발생하는 패닉이나 예기치 않은 충돌이 있으면 보고해 주세요.
  • 범위 또는 무작위성 헬퍼를 사용하여 구성한 모든 인자에 대해 무작위 입력 값을 시도해 보고, 극단적인 경우의 숫자나 문자열로 인해 발생하는 패닉을 포착하십시오.

직접 작성해야 할 불변 조건은 무엇인가요?

Trident는 무작위 계정 대체 방식을 통해 가짜 민트를 자동으로 통과시키려 시도하겠지만, assert!(result.is_error(), ...), 시스템은 트랜잭션이 성공한 것으로 인식하고 다음 단계로 넘어갑니다. 시스템은 성공한 트랜잭션 자체가 버그라는 사실을 알 방법이 없습니다. 올바른 동작이 어떤 모습인지 명시적으로 정의해야 합니다.

이는 모든 의미론적 보안 속성에 대해서도 마찬가지입니다:

비즈니스 로직: “촬영이 성공적으로 끝난 후, 제작자는 정확히 token_b_wanted_amount"는 의도된 의미를 파악해야 합니다"

프로그램의 수학 표현을 불변식에 그대로 복사하지 마십시오. 프로그램에서 값을 계산하는 방식에 버그가 있다면, 그 버그가 어설션의 양쪽에 모두 나타나게 되어, 프로그램이 제대로 작동하지 않더라도 불변식은 항상 통과하게 됩니다.

더 안전한 두 가지 방법은 다음과 같습니다:


  • 퍼즈 테스트에서 자신만의 논리를 사용하여 수학적 계산을 독립적으로 재구현하여, 버그가 발생했을 때 양쪽 결과가 서로 다르게 나오도록 하세요.
  • 정확한 값 검사 대신 관계 불변량을 사용하십시오. 이러한 검사는 프로그램 내 수학적 계산의 정확성에 의존하지 않으며, 실수로 버그가 반영될 가능성이 더 적습니다.

승인 불변 조건: “제안자는 오직 본인만이 자신의 제안을 취소할 수 있다”는 조건에 따라, 승인되지 않은 성공이 어떤 형태인지 정의해야 합니다.

균형 불변 조건: “모든 계정에서 총 토큰 공급량이 보존되어야 한다”는 조건은 상태를 읽고 관계를 검증해야 함을 의미한다

Trident는 사용자의 어떠한 안내도 없이 두 가지 유형의 문제를 자동으로 찾아냅니다:


  • 프로그램 패닉: 충돌, 예기치 않은 롤백, 산술 오버플로우( mode 포착됨). 패닉이 항상 치명적인 것은 아니지만, 치명적일 수도 있습니다. 특정 입력 시퀀스에 의해 유발되는 패닉은 서비스 거부(DoS) 취약점으로 간주됩니다. 프로그램을 확실하게 충돌시킬 수 있는 공격자는 사용자의 자금을 동결하거나 프로토콜 운영을 차단할 수 있기 때문입니다.
  • 불변량 위반: 아무거나 assert! 퍼즈 테스트에서 실패하는 경우입니다. 이는 토큰 잔액이 허용 범위를 벗어난 경우, 승인되지 않은 상태 변경, 또는 프로그램에서 절대로 허용해서는 안 되는 기타 사항 등, 여러분이 코딩한 의미적 속성을 나타냅니다.

“이 작업은 절대로 성공해서는 안 된다”거나 “만들기만 한 사람만이 취소할 수 있다”와 같은 의미론적 보안 속성의 경우, 해당 속성이 무엇인지 파악하고 이를 불변 조건으로 정의해야 합니다. 이러한 지식은 퍼저 자체에서 나오는 것이 아니라, 보안 감사 체크리스트, 위협 모델링 또는 해당 분야의 전문 지식을 통해 얻어집니다.

에스크로 프로그램

이 가이드에서는 Solana Program Examples 저장소에 있는 토큰 에스크로 프로그램을 사용합니다. 이 프로그램은 신뢰가 필요 없는 P2P 토큰 스왑을 가능하게 합니다. 즉, 매도자는 PDA가 관리하는 금고에 토큰을 예치하여 매도 제안을 생성하고, 매수자는 요청된 토큰을 매도자에게 전송하고 그 대가로 금고에 예치된 토큰을 수령함으로써 해당 제안을 수락합니다.

이 프로그램에는 두 가지 명령어가 있습니다:


  • 제안하기 — 발행자는 두 가지 토큰(A와 B)을 지정하고, 토큰 A를 PDA로 제어되는 금고에 예치한 뒤, 그 대가로 원하는 토큰 B의 양을 기록합니다.
  • 제안 수락 — 테이커는 요청받은 토큰 B를 메이커에게 보내고, 볼트로부터 토큰 A를 받습니다. 스왑이 완료된 후 오퍼 계정과 볼트는 폐쇄됩니다.

퍼저가 포착할 수 있도록 의도적으로 취약점을 추가하게 됩니다:


  • 토큰 발행 제약 조건 누락: 그 TakeOffer 계정 확인 과정에서 누락된 항목이 있을 것입니다 has_one = token_mint_b 오퍼 계정에 대한 제약 조건입니다. 즉, 테이커는 가치가 없는 토큰을 발행하여 메이커에게 가짜 토큰을 전송하더라도, 여전히 볼트에서 실제 토큰 A를 받을 수 있습니다.

Anchor 프로젝트 설정하기

Solana 예제 저장소를 클론한 다음, 에스크로 프로그램으로 이동하세요:

git clone https://github.com/solana-developers/program-examples.git
cd program-examples/tokens/escrow/anchor

Trident는 IDL을 읽어 퍼즈 테스트의 기본 구조를 코드 생성하므로, 먼저 프로젝트를 빌드하여 IDL을 생성해야 합니다:

앵커 빌드

Anchor 프로그램에서 명령어 시그니처나 계정 구조체를 변경할 때마다 다음을 다시 실행해야 합니다. 앵커 빌드 퍼징을 시작하기 전에 이 작업을 수행해야 합니다. 그렇지 않으면 퍼징 테스트 구조체가 프로그램이 실제로 기대하는 내용과 일치하지 않게 됩니다.

컴파일된 바이너리 파일이 있는지 확인하십시오:

ls target/deploy/escrow.so

기존 Anchor 테스트를 실행하여 샘플 에스크로 프로젝트가 별다른 설정 없이도 정상적으로 작동하는지 확인하십시오. 이 테스트들은 예제 저장소의 일부이며 수정되지 않습니다:

npm install
anchor test

예상 출력:

[DEBUG LOGS ...]
escrow
✔ Puts the tokens Alice offers into the vault when Alice makes an offer
✔ Puts the tokens from the vault into Bob's account, and gives Alice Bob's tokens, when Bob takes an offer

버그 신고하기

Trident가 어떤 취약점을 탐지할 수 있는지 보여주기 위해, 퍼즈 테스트를 작성하기 전에 의도적으로 취약점을 삽입해 보겠습니다. 다음 코드를 주석 처리하고 has_one = token_mint_b ~에서의 제약 조건 take_offer.rs 58행에서.

이 검증이 없다면, 프로그램은 수취인이 전송한 토큰이 발행자가 원래 요청한 발행량과 일치하는지 더 이상 확인하지 않게 됩니다. 이로 인해 공격자가 가치가 없는 가짜 토큰을 대치시켜 금고를 털 수 있게 됩니다.

programs/escrow/src/instructions/take_offer.rs
...
#[account(
mut,
close = maker,
has_one = maker,
has_one = token_mint_a,
// has_one = token_mint_b,
seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()],
bump = offer.bump
)]
offer: Account<'info, Offer>,
...

Trident CLI 설치

Trident는 Cargo 패키지로 배포되므로, 설치 과정은 단 한 번의 cargo install 명령어. 이 명령어는 crates.io에서 CLI의 최신 공개 버전을 가져와 삼지창 셸에서 바이너리를 사용할 수 있습니다.

cargo 설치 trident-cli

설치 상태 확인:

trident --version

예상 출력:

Trident 0.12.0

Trident 퍼즈 테스트 초기화

trident 초기화 컴파일된 Anchor IDL을 읽어들이고 퍼즈 테스트를 위한 타입이 지정된 Rust 스캐폴딩을 생성합니다. Trident는 IDL에서 직접 명령어 인자 타입, 계정 구조체 및 판별자를 도출하므로, 퍼즈 테스트가 항상 프로그램의 실제 인터페이스와 일치하도록 유지됩니다.

프로젝트 루트 디렉터리에서 퍼즈 테스트 파일을 생성합니다:

trident 초기화

이렇게 하면 trident-tests/ 다음과 같은 구조를 가진 디렉터리:

trident-tests/
├── Cargo.toml # Fuzz test dependencies (separate workspace)
├── Trident.toml # Fuzzer configuration
└── fuzz_0/
├── test_fuzz.rs # Entry point: FuzzTest struct, flows, and main
├── fuzz_accounts.rs # AccountAddresses struct (account address storage)
└── types.rs # Auto-generated instruction types and Offer struct

Trident.toml 여기에는 컴파일된 프로그램 바이너리의 경로, 메트릭 및 대시보드 설정, 커버리지 구성이 포함되어 있습니다. 대시보드를 활성화하려면 trident-tests/Trident.toml ~을 추가하여 dashboard = true:

trident-tests/Trident.toml
[fuzz.metrics]
enabled = true
dashboard = true

[[fuzz.programs]]
address = "qbuMdeYxYJXBjU6C6qFKjZKjXmrU83eDQomHdrch826"
program = "../target/deploy/escrow.so"

Cargo.toml 선언한다 trident-fuzz 의존성으로 지정하고 다음을 정의합니다. fuzz_0 바이너리 대상. Trident는 Anchor 프로젝트와의 종속성 충돌을 방지하기 위해 별도의 작업 공간을 사용합니다.

에스크로가 SPL 토큰 계정을 사용하므로, 다음을 활성화하십시오. 토큰 setup 및 flow 에서 사용되는 SPL 토큰 헬퍼를 불러오는 기능:

trident-tests/Cargo.toml
...
[dependencies.trident-fuzz]
version = "0.12.0"
features = ["token"]
...

fuzz_0/test_fuzz.rs 는 퍼즈 테스트의 진입점입니다. 이 함수는 FuzzTest 구조체, 보조 함수, 실행 흐름, 그리고 메인. 두 개의 관련 파일, fuzz_accounts.rs 그리고 types.rs, 는 다음을 통해 Rust 모듈로 포함됩니다. mod.

생성된 유형 검토

실행하면 trident 초기화, Trident는 Anchor IDL을 읽어들이고 자동으로 생성합니다 trident-tests/fuzz_0/types.rs. 이 파일을 수동으로 편집하지 마십시오 (Anchor 0.29 이하 버전을 사용 중인 경우는 예외입니다 — 아래 참고 사항 참조). IDL 프로그램이 변경된 경우, 다음 명령을 사용하여 다시 생성하십시오:

trident fuzz refresh fuzz_0

types.rs 프로그램 크레이트를 직접 임포트하지 않고도 프로그램 명령어를 빌드하고 제출하는 데 필요한 모든 것을 정의합니다:


  • escrow::program_id(): 프로그램의 배포 주소
  • 제안 제출 지침 계정 / 제안 수락 지침 계정: 공개 키 각 필수 계정에 대한 구조체
  • 제안 지침 데이터 생성 / 제안 수락 지침 데이터: 보르시(Borsh) 시리얼라이즈 가능한 명령어 매개변수 구조체
  • 제안 제출 안내 / 제안 수락 안내: 시리얼화된 객체를 조립하는 빌더 지침 올바른 8바이트 식별자와 계정 메타데이터(서명자/쓰기 가능 플래그가 미리 설정된 상태)를 사용하여
  • 제안: 온체인 데이터의 보르시(Borsh)를 통해 역직렬화 가능한 미러 제안 반복 처리 도중 계정 상태를 읽기 위한 구조체

Associated Token Program 및 System Program 주소는 각 명령어 내부에 하드코딩되어 있으며, accounts() 빌더를 사용하면 이러한 값을 수동으로 전달할 필요가 전혀 없습니다.


앵커 0.29

Anchor 0.29 및 그 이전 버전의 IDL에는 프로그램 ID나 명령어 식별자가 포함되어 있지 않습니다. 구버전 프로그램을 사용하는 경우, types.rs 이 파일은 자리 표시자 값이 포함된 상태로 생성되며, 퍼저를 실행하기 전에 프로그램 ID와 식별자를 수동으로 입력해야 합니다.

계정 주소 저장 방식 구성

fuzz_accounts.rs 정의한다 계정 주소, 다음과 같은 구조체인 주소 저장소 필드, 프로그램 내의 명명된 계정마다 하나씩. trident 초기화 IDL에서 이를 기반으로 생성하고, 사용자 정의 필드(예: fake_mint)를 직접 확인하고 사용하지 않는 것은 제거하세요.

trident-tests/fuzz_0/fuzz_accounts.rs
use trident_fuzz::fuzzing::*;

/// Storage for all account addresses used in fuzz testing.
#[derive(Default)]
pub struct AccountAddresses {
pub maker: AddressStorage,
pub token_mint_a: AddressStorage,
pub token_mint_b: AddressStorage,
pub offer: AddressStorage,
pub vault: AddressStorage,
pub associated_token_program: AddressStorage,
pub token_program: AddressStorage,
pub system_program: AddressStorage,
pub taker: AddressStorage,
pub fake_mint: AddressStorage,
}

모든 주소 저장 공간을 하나의 구조체에 묶어두면, 각 메서드마다 개별 변수를 전달할 필요 없이 모든 flow 상태를 공유할 수 있습니다.

FuzzTest 구조체 정의하기

test_fuzz.rs 모듈 임포트로 시작하며 다음을 정의합니다. FuzzTest 구조체. 이 구조체는 두 개의 필드를 포함합니다:


  • 삼지창: 트랜잭션을 실행하고, 난수 기능을 제공하며, 토큰 헬퍼를 노출하는 트라이던트 클라이언트
  • fuzz_accounts: 그 계정 주소 ~에서 가져온 예시 fuzz_accounts.rs 모든 참가자 및 토큰 주소를 한곳에 모아두는

#[derive(FuzzTestMethods)] 매크로는 테스트 실행 루프(반복 제어, flow , 타이밍)를 생성합니다. 이 #[flow] 의 속성 impl 블록은 이를 [출처]로 표시합니다. #[초기화], #[flow], 그리고 #[끝] 방법.

다음 섹션에서는 다음 내용을 작성하게 됩니다:


  • 반복되는 코드를 줄여주는 헬퍼 함수
  • A 설정 민트를 초기화하고 계좌에 자금을 입금하는 데 도움을 주는 도구
  • A 제안하기 제작자 역할을 flow
  • A 제안 수락 선택적으로 가짜 발행 대체 기능을 갖춘 테이커 역할을 flow

헬퍼 함수 작성하기

파일 상단에 세 개의 보조 함수를 추가하세요. test_fuzz.rs setup 메서드 내의 중복을 줄이기 위해.

trident-tests/fuzz_0/test_fuzz.rs
// use/mod statements

// initializes a new token mint with the given authority and 6 decimal places
fn setup_mint(trident: &mut Trident, payer: &Pubkey, mint: &Pubkey, authority: &Pubkey) {
let ixs = trident.initialize_mint(payer, mint, 6, authority, None);
trident.process_transaction(&ixs, None);
}

// creates an associated token account for a given owner and mint without funding it
fn setup_ata(trident: &mut Trident, payer: &Pubkey, mint: &Pubkey, owner: &Pubkey) {
let ix = trident.initialize_associated_token_account(payer, mint, owner);
trident.process_transaction(&[ix], None);
}

// creates the ATA and mints tokens into it in two transactions
fn setup_funded_ata(
trident: &mut Trident,
payer: &Pubkey,
mint: &Pubkey,
owner: &Pubkey,
authority: &Pubkey,
amount: u64,
token_program: &Pubkey,
) {
let ix = trident.initialize_associated_token_account(payer, mint, owner);
trident.process_transaction(&[ix], None);
let ata = trident.get_associated_token_address(mint, owner, token_program);
let ix = trident.mint_to(&ata, mint, authority, amount);
trident.process_transaction(&[ix], None);
}

#[derive(FuzzTestMethods)]
...

#[init] 메서드 구성

퍼즈 테스트 대상은 단일 #[초기화] Trident가 퍼징 반복 과정의 시작마다 실행하는 메서드입니다. trident 초기화#[초기화] 핸들러. 각 반복 처리 시마다 새로운 키 쌍, 민트, 토큰 계정 및 저장된 프로그램 ID를 할당받을 수 있도록 해당 스텁을 다음 코드로 대체하십시오.

trident-tests/fuzz_0/test_fuzz.rs
#[flow]
impl FuzzTest {
fn new() -> Self {
Self {
trident: Trident::default(),
fuzz_accounts: AccountAddresses::default(),
}
}

#[초기화]
fn start(&mut self) {
self.fuzz_accounts.token_program.insert_with_address(pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"));
self.fuzz_accounts.associated_token_program.insert_with_address(pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"));
self.fuzz_accounts.system_program.insert_with_address(pubkey!("11111111111111111111111111111111"));

let maker = self.fuzz_accounts.maker.insert(&mut self.trident, None);
let taker = self.fuzz_accounts.taker.insert(&mut self.trident, None);
self.trident.airdrop(&maker, 10_000_000_000);
self.trident.airdrop(&taker, 10_000_000_000);

let mint_a = self.fuzz_accounts.token_mint_a.insert(&mut self.trident, None);
let mint_b = self.fuzz_accounts.token_mint_b.insert(&mut self.trident, None);
let fake_mint = self.fuzz_accounts.fake_mint.insert(&mut self.trident, None);

setup_mint(&mut self.trident, &maker, &mint_a, &maker);
setup_mint(&mut self.trident, &taker, &mint_b, &maker);
setup_mint(&mut self.trident, &taker, &fake_mint, &taker);

let token_program = self.fuzz_accounts.token_program.get(&mut self.trident).unwrap();
setup_funded_ata(&mut self.trident, &maker, &mint_a, &maker, &maker, 1_000_000_000, &token_program);
setup_funded_ata(&mut self.trident, &taker, &mint_b, &taker, &maker, 1_000_000_000, &token_program);
setup_funded_ata(&mut self.trident, &taker, &fake_mint, &taker, &taker, 1_000_000_000, &token_program);

// Pre-create the ATAs that take_offer's init_if_needed would otherwise
// fund at the taker's expense, which would cause the taker's SOL balance
// to decrease even after the vault-close rent is returned.
setup_ata(&mut self.trident, &taker, &mint_a, &taker); // taker receives token A
setup_ata(&mut self.trident, &maker, &mint_b, &maker); // maker receives token B

// Reset offer/vault so take_offer cannot run against a stale PDA from a
// previous iteration (which would have a different token_mint_b).
self.fuzz_accounts.offer = AddressStorage::default();
self.fuzz_accounts.vault = AddressStorage::default();
}

// ADD TEST FLOWS HERE
}

Flow 구성

다음 기호로 표시된 방법 #[flow] 각 반복 과정에서 퍼저가 무작위로 선택하여 실행하는 명령어를 정의합니다.

제안하기

~ 이후 시작 메서드에서, flow 첫 번째 flow 추가합니다. 제안하기:

trident-tests/fuzz_0/test_fuzz.rs
// Flow: make_offer
//
// Simulates the maker creating an escrow offer. Each call generates a fresh
// random offer ID, offered amount, and wanted amount, so the fuzzer exercises
// a wide range of numeric inputs and PDA addresses across iterations.
//
// After a successful transaction the flow verifies:
// - The exact number of tokens were moved from the maker into the vault (no
// extra tokens created or lost in transit).
// - Every field written to the on-chain Offer account matches the values
// passed into the instruction (guards against silent data corruption in
// save_offer or Borsh serialisation).
// - The vault token account is owned by the offer PDA, not by the maker,
// ensuring the maker cannot withdraw the tokens unilaterally.
// - If wanted_amount is zero the program still accepts the offer, which is
// a latent policy issue — a warning is printed so it is visible in fuzz
// output without hard-failing (the program has no explicit guard for this).
#[flow]
fn make_offer(&mut self) {
let Some(maker) = self.fuzz_accounts.maker.get(&mut self.trident) else { return; };
let Some(mint_a) = self.fuzz_accounts.token_mint_a.get(&mut self.trident) else { return; };
let Some(mint_b) = self.fuzz_accounts.token_mint_b.get(&mut self.trident) else { return; };
let Some(token_program) = self.fuzz_accounts.token_program.get(&mut self.trident) else { return; };

// Randomise all offer parameters so the fuzzer explores the full input space.
// wanted_amount starts at 0 (not 1) to exercise the zero-amount edge case.
let id: u64 = self.trident.random_from_range(0..u64::MAX);
let offered_amount: u64 = self.trident.random_from_range(1u64..100_000u64);
let wanted_amount: u64 = self.trident.random_from_range(0u64..100_000u64);

// Derive the offer PDA and vault address the same way the program does,
// so the instruction accounts are always consistent with the on-chain seeds.
let id_bytes = id.to_le_bytes();
let (offer_pda, _) = self.trident.find_program_address(
&[b"offer", maker.as_ref(), &id_bytes],
&escrow::program_id(),
);

let maker_ata_a =
self.trident.get_associated_token_address(&mint_a, &maker, &token_program);
let vault =
self.trident.get_associated_token_address(&mint_a, &offer_pda, &token_program);

let accounts = escrow::MakeOfferInstructionAccounts::new(
maker,
mint_a,
mint_b,
maker_ata_a,
offer_pda,
vault,
token_program,
);
let data = escrow::MakeOfferInstructionData::new(id, offered_amount, wanted_amount);
let ix = escrow::MakeOfferInstruction::data(data)
.accounts(accounts)
.instruction();

// Snapshot the maker's token A balance before the transaction so we can
// verify the exact amount that leaves their account.
let maker_ata_a_before = self.trident
.get_token_account(maker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);

let result = self.trident.process_transaction(&[ix], Some("make_offer"));

if result.is_success() {
let vault_balance = self.trident
.get_token_account(vault)
.map(|a| a.account.amount)
.unwrap_or(0);
let maker_ata_a_after = self.trident
.get_token_account(maker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);

// Check that the vault received exactly offered_amount tokens and that
// the same number left the maker's account — no tokens created or lost.
assert_eq!(vault_balance, offered_amount,
"make_offer: vault balance {vault_balance} != offered_amount {offered_amount}");
assert_eq!(maker_ata_a_after, maker_ata_a_before - offered_amount,
"make_offer: maker_ata_a drained incorrectly");

// Every field written to the offer PDA must round-trip correctly.
// A bug in save_offer (e.g. wrong field order, off-by-one in Borsh layout)
// would silently store incorrect data that only shows up at take time.
let offer_state = self.trident
.get_account_with_type::<crate::types::Offer>(&offer_pda, 8)
.expect("offer PDA not readable after make_offer");
assert_eq!(offer_state.maker, maker, "offer.maker mismatch");
assert_eq!(offer_state.token_mint_a, mint_a, "offer.token_mint_a mismatch");
assert_eq!(offer_state.token_mint_b, mint_b, "offer.token_mint_b mismatch");
assert_eq!(offer_state.token_b_wanted_amount, wanted_amount, "offer.wanted_amount mismatch");
assert_eq!(offer_state.id, id, "offer.id mismatch");

// The vault token account must be owned by the offer PDA, not
// the maker. If the maker were the authority they could drain the vault at
// any time, bypassing the escrow entirely.
let vault_acct = self.trident.get_token_account(vault).unwrap();
assert_eq!(vault_acct.account.owner, offer_pda,
"make_offer: vault authority is not the offer PDA");
assert_eq!(vault_acct.account.mint, mint_a,
"make_offer: vault has wrong mint");

self.fuzz_accounts.offer.insert_with_address(offer_pda);
self.fuzz_accounts.vault.insert_with_address(vault);
}

// The program has no explicit wanted_amount > 0 guard. If the
// fuzzer generates wanted_amount=0 and the transaction succeeds, the taker
// could drain the vault without transferring anything in return.
if wanted_amount == 0 && result.is_success() {
eprintln!("WARNING: make_offer succeeded with wanted_amount=0 — verify this is intentional");
}

self.trident.record_histogram("offered_amount", offered_amount as f64);
}

제안 수락

다음으로, 제안 수락에 flow 추가합니다:

trident-tests/fuzz_0/test_fuzz.rs
// Flow: take_offer
//
// Simulates a taker accepting an existing escrow offer. The flow is only
// attempted when a live offer PDA exists in the pool (created by make_offer).
//
// Token substitution attack: on each call the fuzzer randomly chooses whether
// to pass the correct token_mint_b or a fake mint. Passing a wrong mint must
// always be rejected — if it succeeded, the taker could drain the vault while
// paying with worthless tokens.
//
// After a successful swap the flow verifies:
// - The taker received exactly the vault balance in token A (all escrowed
// tokens transferred, nothing left behind or double-spent).
// - The taker paid exactly wanted_amount in token B (no under-payment).
// - The maker received exactly wanted_amount in token B (no over-payment or
// tokens redirected to a third party).
// - The vault token account was closed (Anchor `close` directive executed).
// - The offer PDA account was closed (account data and lamports wiped).
// - Both the maker and taker received their rent-exempt SOL back from the
// closed accounts (maker gets offer PDA rent, taker gets vault rent).
#[flow]
fn take_offer(&mut self) {
let Some(taker) = self.fuzz_accounts.taker.get(&mut self.trident) else { return; };
let Some(maker) = self.fuzz_accounts.maker.get(&mut self.trident) else { return; };
let Some(mint_a) = self.fuzz_accounts.token_mint_a.get(&mut self.trident) else { return; };
let Some(offer_pda) = self.fuzz_accounts.offer.get(&mut self.trident) else { return; };
let Some(token_program) = self.fuzz_accounts.token_program.get(&mut self.trident) else { return; };

// Derive the vault address from the offer PDA rather than reading it from
// storage — this avoids any pool-sync issues and is how the program does it.
let vault =
self.trident.get_associated_token_address(&mint_a, &offer_pda, &token_program);

// Read wanted_amount from the live on-chain state, not from a local variable,
// so the assertion reflects what the program actually stored.
let Some(offer_state) = self.trident
.get_account_with_type::<crate::types::Offer>(&offer_pda, 8) else { return; };
let wanted_amount = offer_state.token_b_wanted_amount;

// Token substitution attack: randomly supply the correct mint or an unrelated
// fake mint. The program must reject any mint that does not match the one
// recorded in the offer PDA.
let use_fake_mint = self.trident.random_bool();
let mint_b = if use_fake_mint {
let Some(m) = self.fuzz_accounts.fake_mint.get(&mut self.trident) else { return; };
m
} else {
let Some(m) = self.fuzz_accounts.token_mint_b.get(&mut self.trident) else { return; };
m
};

let taker_ata_a =
self.trident.get_associated_token_address(&mint_a, &taker, &token_program);
let taker_ata_b =
self.trident.get_associated_token_address(&mint_b, &taker, &token_program);
let maker_ata_b =
self.trident.get_associated_token_address(&mint_b, &maker, &token_program);

// Snapshot SOL balances before the transaction so we can verify that both
// parties receive their rent-exempt lamports back when the accounts close.
let maker_sol_before = self.trident.get_account(&maker).lamports();
let taker_sol_before = self.trident.get_account(&taker).lamports();

// Snapshot all token balances before the transaction so post-transaction
// deltas can be checked exactly.
let vault_before = self.trident
.get_token_account(vault)
.map(|a| a.account.amount)
.unwrap_or(0);
let taker_ata_a_before = self.trident
.get_token_account(taker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);
let taker_ata_b_before = self.trident
.get_token_account(taker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);
let maker_ata_b_before = self.trident
.get_token_account(maker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);

let accounts = escrow::TakeOfferInstructionAccounts::new(
taker,
maker,
mint_a,
mint_b,
taker_ata_a,
taker_ata_b,
maker_ata_b,
offer_pda,
vault,
token_program,
);
let data = escrow::TakeOfferInstructionData::new();
let ix = escrow::TakeOfferInstruction::data(data)
.accounts(accounts)
.instruction();

let result = self.trident.process_transaction(&[ix], Some("take_offer"));

// Token substitution check: any attempt to swap with a mint that does not
// match the one stored in the offer PDA must be rejected by the program.
if use_fake_mint {
if result.is_success() {
eprintln!("VULNERABILITY: take_offer accepted a fake token mint (token substitution attack succeeded)");
self.trident.record_histogram("fake_mint_accepted", 1.0);
} else {
self.trident.record_histogram("fake_mint_accepted", 0.0);
}
}

// All post-success invariants are only meaningful when the correct mint was
// used. On success the offer PDA is closed on-chain, so we also reset the
// pool to prevent future iterations from hitting the dead account.
if !use_fake_mint && result.is_success() {
let taker_ata_a_after = self.trident
.get_token_account(taker_ata_a)
.map(|a| a.account.amount)
.unwrap_or(0);
let taker_ata_b_after = self.trident
.get_token_account(taker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);
let maker_ata_b_after = self.trident
.get_token_account(maker_ata_b)
.map(|a| a.account.amount)
.unwrap_or(0);

// Verify the complete token swap: taker gets all of the vault (token A),
// taker pays exactly wanted_amount (token B), maker receives that same
// amount. Any deviation indicates tokens were created, destroyed, or
// redirected.
assert_eq!(taker_ata_a_after, taker_ata_a_before + vault_before,
"take_offer: taker did not receive correct token A amount");
assert_eq!(taker_ata_b_after, taker_ata_b_before - wanted_amount,
"take_offer: taker did not pay correct token B amount");
assert_eq!(maker_ata_b_after, maker_ata_b_before + wanted_amount,
"take_offer: maker did not receive correct token B amount");

// The vault token account must be fully closed. If it is
// still open, tokens could be stranded or the account reused unexpectedly.
assert!(self.trident.get_token_account(vault).is_err(),
"take_offer: vault was not closed after successful swap");

// The offer PDA itself must be closed and its data wiped.
// An open PDA could be replayed or its storage reinterpreted by another
// instruction.
assert!(self.trident
.get_account_with_type::<crate::types::Offer>(&offer_pda, 8)
.is_none(),
"take_offer: offer PDA still exists after successful swap");

// Closing the offer PDA returns rent to the maker, and
// closing the vault returns rent to the taker. Neither party should end
// up with less SOL than before (rent recovered > tx fees in a local env).
let maker_sol_after = self.trident.get_account(&maker).lamports();
let taker_sol_after = self.trident.get_account(&taker).lamports();
assert!(maker_sol_after > maker_sol_before,
"take_offer: maker did not receive offer PDA rent");
assert!(taker_sol_after > taker_sol_before,
"take_offer: taker did not receive vault rent");

self.fuzz_accounts.offer = AddressStorage::default();
}

self.trident.record_histogram(
"take_offer_result",
if result.is_success() { 1.0 } else { 0.0 },
);
}

End 메서드와 Main 추가하기

#[끝] 이 메서드는 각 반복이 완료된 후 실행됩니다. 이 퍼즈 테스트에서는 불변 조건 검사가 티어다운 단계가 flow 각 flow 내부에서 인라인으로 수행됩니다. 이 #[끝] block은 반복문 수준의 정리 작업이나 전역 불변 조건에 사용할 수 있습니다:

trident-tests/fuzz_0/test_fuzz.rs
#[끝]
fn end(&mut self) {
// Perform any cleanup here, this method will be executed
// at the end of each iteration
}
}

fn main() {
FuzzTest::fuzz(1000, 100);
}

FuzzTest::fuzz(1000, 100) 반복 1,000회를 수행하며, 각 반복당 최대 100개의 흐름을 처리하므로 총 거래 건수는 약 100,000건에 달합니다.

이 위조 민트 수표는 다음에서 인라인으로 처리됩니다. 제안 수락 ~를 사용하여 eprintln! ~보다는 assert!, 따라서 가짜 민트 스왑이 성공하면 취약점 콘솔에 명령줄을 출력하고 퍼저를 중지하지 않은 채 메트릭을 기록합니다. 이를 통해 실행이 완료되고 결과 표에 전체적인 상황이 반영됩니다.

사용 시 흔히 나타나는 패턴 :


  • 여러 계정을 조회해야 하는 전역 불변 조건(예: 총 공급량이 모든 잔액의 합과 일치함)을 검증합니다.
  • 어떤 플로우가 실행되었는지와 관계없이 적용되는 조건을 명시합니다(예: 해당 오퍼가 없는 경우 메이커의 토큰 A 잔액은 절대 증가하지 않음).
  • 다음 명령어를 사용하여 회귀 스냅샷에 계정을 추가합니다. self.trident.add_to_regression(&pubkey, "label"). Trident는 반복이 끝날 때 해당 계정의 온체인 내용을 기록합니다. 이는 프로그램 버전 간 동작을 비교하는 데 유용합니다. 동일한 시드를 사용하여 버전 A와 버전 B에 대해 퍼저를 실행한 다음, 트라이던트 비교 스냅샷 간의 차이를 비교하여 리팩토링으로 인해 관찰 가능한 계정 상태가 변경되지 않았는지 확인합니다.

퍼즈 테스트 실행

다음으로 이동하세요: trident-tests/ 디렉터리로 이동한 후 방금 생성한 퍼즈 테스트를 실행합니다:

cd trident-tests
trident fuzz run fuzz_0

Trident는 이 별도의 작업 공간에서 퍼즈 테스트 바이너리를 컴파일한 다음, 테스트 흐름을 순차적으로 실행합니다. 일반적인 컴퓨터에서는 이 작업이 몇 초 만에 완료됩니다.

예상 출력:

...
VULNERABILITY: take_offer accepted a fake token mint (token substitution attack succeeded)
Overall: [00:00:02] [####] 100000/100000 (100%) [00:00:00] Parallel fuzzing completed!
+-------------+---------------+------------+-----------+----------------------+
| Instruction | Invoked Total | Ix Success | Ix Failed | Instruction Panicked |
+-------------+---------------+------------+-----------+----------------------+
| make_offer | 49460 | 49460 | 0 | 0 |
+-------------+---------------+------------+-----------+----------------------+
| take_offer | 24404 | 24404 | 0 | 0 |
+-------------+---------------+------------+-----------+----------------------+
MASTER SEED used: "96c563af7b3ddf3dac6cfd30f6ec8273ebee7f849c3b2129248e3a576150a873"

결과 표에는 다음과 같이 나와 있습니다. 제안하기 약 50,000회 호출되었으며 제안 수락 총 100,000회의 flow 중 약 25,000회 발생합니다. 이러한 2:1 비율은 예상되는 현상입니다.

퍼저는 매 호출마다 두 흐름 중 하나를 무작위로 선택하므로, 각 흐름은 대략 50,000번씩 선택되지만, 제안 수락 풀에 오퍼가 없을 때 실행을 건너뛰는 조기 반환 가드를 가지고 있습니다. 왜냐하면 start() 각 반복의 시작 시점에 풀을 초기화하고, 스왑이 성공할 때마다 풀이 비워지기 때문에, 다음과 같은 경우가 많이 발생합니다. 제안 수락 선택되어 있지만 적용할 대상이 없습니다.

이러한 초기 반환 결과는 호출 횟수로 집계되지 않기 때문에, 그 수치가 대략 절반에 불과한 것입니다. 제안하기.

취약점 콘솔에 출력된 줄이 핵심적인 발견 사항입니다: with has_one = token_mint_b 프로그램에서 주석 처리된 부분에서, take_offer는 시도할 때마다 가짜 토큰 발행 요청을 수락했습니다.

지표 대시보드를 통해 결과 모니터링하기

이미 다음 위치에서 메트릭 대시보드를 활성화하셨습니다. trident-tests/Trident.toml 이 가이드의 앞부분에서 ([fuzz.metrics] ~와 함께 dashboard = true).

별도의 터미널 창에서 Trident 대시보드 서버를 실행하여 퍼징 결과를 시각화합니다:

트라이던트 서버

열기 http://localhost:8000 브라우저에서. 대시보드에는 다음과 같이 표시됩니다:

"총 트랜잭션 수, 성공률 및 명령어별 트랜잭션 통계를 보여주는 Trident 퍼징 대시보드"

상단 대시보드에는 전체 세션에 대한 요약 카운터가 표시됩니다. ‘트랜잭션 통계’ 섹션에서는 이러한 수치를 명령어별로 세분화하여 보여줍니다.

Solana 모든 트랜잭션이 성공적으로 처리되었습니다. 프로그램 오류도, 패닉(panic)도 발생하지 않았습니다. 이것이 바로 토큰 대체 취약점이 결과에서 나타나는 전형적인 모습입니다. 이 취약점을 악용해도 프로그램이 중단되지 않고, 무효한 입력을 아무런 오류 메시지 없이 묵인합니다. 이 발견 사항은 콘솔 출력( VULNERABILITY 줄)과 아래의 사용자 정의 메트릭 히스토그램에서 확인할 수 있습니다.

'사용자 정의 메트릭 ' 섹션에는 퍼즈 테스트 실행 결과로 생성된 세 개의 히스토그램이 표시됩니다:

"Fake Mint의 수락 및 제안 금액, 그리고 제안 수락 결과를 나타내는 히스토그램을 보여주는 사용자 지정 지표 대시보드"

미터법개수범위평균중위수섀넌 엔트로피
fake_mint_accepted12,4131.00 – 1.001.001.000.0000
제안된 금액49,8212.00 – 99,999.0049,763.2149,696.0015.1491
take_offer_result24,5891.00 – 1.001.001.000.0000

fake_mint_accepted 그리고 take_offer_result 둘 다 다양한 1.00 – 1.00 그리고 다음의 섀넌 엔트로피는 0.0000. 모든 값은 1 (성공) — 위조 지폐가 받아들여졌고 제안 수락 모든 시도에서 성공했습니다. 엔트로피가 0이라는 것은 변동성이 전혀 없다는 뜻입니다. 즉, 이 익스플로잇은 완전히 신뢰할 수 있으며, 불안정한 예외 사례가 아닙니다.

제안된 금액 반대되는 양상을 보인다. 그 범위는 2 ~에 99,999 엔트로피가 높은 (15.15), 이는 퍼저가 모든 항목에 걸쳐 광범위하고 고르게 분포된 토큰 금액을 탐색했음을 확인시켜 주었다. 제안하기 통화.

오류 재현 및 디버깅

불변 오류나 패닉이 발생하면 Trident는 이를 유발한 크래시 시드를 출력합니다. 조사하기 위해 전체 프로그램 로그와 함께 해당 정확한 시퀀스를 재현할 수 있습니다:

trident fuzz debug fuzz_0 <crash_seed>

이 기능은 실패한 반복 처리를 로깅을 활성화한 상태로 다시 실행하므로, 정확히 어떤 계정이 통과되었는지, 온체인 상태가 어땠는지, 그리고 불변 조건이 어디서 발동되었는지 확인할 수 있습니다.

퍼징 세션이 종료되면 Trident는 해당 세션 전체에 대한 마스터 시드 ( Master Seed )도 출력합니다. 이를 통해 전체 세션을 다시 실행하여 동일한 무작위 반복 순서를 재현할 수 있는 반면, 크래시 시드(crash seed)는 오류가 발생한 단일 입력만 재현합니다. 실행 결과를 공유하거나 보관하려면 마스터 시드를 저장하십시오.

회귀 스냅샷 비교

버그를 수정하고 퍼저를 다시 실행한 후, 각 실행 결과 간의 계정 상태를 비교합니다:

trident compare snapshot_before.json snapshot_after.json

여기에는 계정 잔액, 데이터 필드의 차이점 및 수정 과정에서 발생한 새로운 오류들이 표시됩니다.

취약점 수정 및 검증

퍼저가 누락된 제약 조건을 찾아냈습니다. 열기 programs/escrow/src/instructions/take_offer.rs 그리고 누락된 부분을 추가하고 has_one = token_mint_b, 제약 조건:

programs/escrow/src/instructions/take_offer.rs
#[account(
mut,
close = maker,
has_one = maker,
has_one = token_mint_a,
has_one = token_mint_b,
seeds = [b"offer", maker.key().as_ref(), offer.id.to_le_bytes().as_ref()],
bump = offer.bump,
)]
pub offer: Account<'info, Offer>,

루트 폴더에서 Anchor 프로그램을 다시 빌드하십시오:

cd ..
앵커 빌드

출처: trident-tests 폴더에서 퍼즈 테스트를 다시 실행하십시오:

cd trident-tests
trident fuzz run fuzz_0

다음과 비슷한 출력이 표시되어야 합니다:

+-------------+---------------+------------+-----------+----------------------+
| Instruction | Invoked Total | Ix Success | Ix Failed | Instruction Panicked |
+-------------+---------------+------------+-----------+----------------------+
| make_offer | 49648 | 49648 | 0 | 0 |
+-------------+---------------+------------+-----------+----------------------+
| take_offer | 32867 | 16341 | 16526 | 0 |
+-------------+---------------+------------+-----------+----------------------+

~50%에 달하는 실패율은 제안 수락 이는 예상되는 현상이며, 실제로는 수정 작업이 효과를 보고 있다는 신호입니다. 퍼저가 3개의 조폐국(토큰 A, 토큰 B, 가짜 조폐국) 중에서 무작위로 선택하기 때문에, 상당 부분이 제안 수락 통화 시 잘못된 주조국의 동전이 지급됩니다. 다음과 같이 하면 has_one = token_mint_b 이제 해당 조치가 적용됨에 따라, 이러한 호출은 더 이상 아무런 오류 메시지 없이 성공하는 대신 Anchor의 제약 조건 유효성 검사를 통해 올바르게 거부됩니다. 악용 경로가 더 이상 존재하지 않기 때문에 불변 조건 위반 건수는 0으로 줄었습니다.

지금까지 Trident 워크플로우의 전체 과정을 처음부터 끝까지 살펴보았습니다. 퍼즈 테스트의 기본 구조를 구축하고, 흐름을 정의하며, 불변 조건을 도입하고, 실제 취약점을 발견하고, 수정 사항을 검증하는 과정이었습니다. 퍼즈 테스트는 수동으로 작성한 단위 테스트에서는 놓치기 쉬운 버그, 특히 예상치 못한 입력 조합에서만 나타나는 권한 및 제약 조건 관련 문제를 찾아냅니다.

고급 기법

이 가이드에 소개된 퍼즈 테스트는 Trident의 핵심 워크플로를 다루고 있지만, Trident는 에스크로 예제에서는 필요하지 않았던 추가 기능들도 제공합니다. 아래 섹션에서는 프로그램의 복잡성이 높아짐에 따라 활용할 수 있는 몇 가지 강력한 기능을 소개합니다.

시간 조작

클럭을 왜곡하여 시간 의존적 논리를 테스트합니다:

// Forward time by one hour
self.trident.forward_in_time(3600);

// Jump to a specific Unix timestamp
self.trident.warp_to_timestamp(1_700_000_000);

// Advance to a specific slot or epoch
self.trident.warp_to_slot(500);
self.trident.warp_to_epoch(10);

토큰 확장 기능 지원

에스크로 예시에서는 다음을 사용합니다. TokenInterface 다음 두 가지 모두에서 작동합니다 SPL 토큰 그리고 토큰 확장 기능 (Token-2022). Trident는 또한 다음을 지원합니다. 토큰 확장 기능 직접:

// Token-2022 helpers
self.trident.initialize_mint_2022(mint_pubkey, authority, decimals, &[]);
self.trident.mint_to_2022(mint_pubkey, token_account, authority, amount);
self.trident.initialize_associated_token_account_2022(wallet, mint);

이를 통해 프로그램이 두 가지 토큰 표준을 모두 올바르게 처리하는지 테스트할 수 있습니다. 프로그램이 모든 토큰이 원래의 SPL 토큰 프로그램을 사용한다고 가정할 경우, 이는 흔한 버그 원인이 될 수 있습니다.

사용자 지정 계정 상태

특정 시나리오를 시뮬레이션하기 위해 사용자 정의 계정 데이터를 주입합니다:

self.삼지창.set_account_custom(공개 키, account_data);

이는 개발망(devnet)이나 mainnet )의 계정을 로컬 퍼징 mainnet 가져와 실제 운영 환경과 유사한 상태에서 테스트하는 데 유용합니다.

여러 배우

허용 범위를 더 철저히 테스트하기 위해 여러 매커와 테이커를 저장하여 퍼즈 테스트를 확장합니다:

#[초기화]
fn setup(&mut self) {
// Store multiple makers and takers
for _ in 0..5 {
self.fuzz_accounts.maker.insert(&mut self.trident, None);
self.fuzz_accounts.taker.insert(&mut self.trident, None);
}
// Fund all of them, create mints, etc.
// ...
}

#[flow]
fn take_offer(&mut self) {
// Fuzzer picks a random taker — tests that any taker can accept any open offer
let Some(taker) = self.fuzz_accounts.taker.get(&mut self.trident) else { return; };
let Some(offer_pda) = self.fuzz_accounts.offer.get(&mut self.trident) else { return; };
// ...
}

여러 주체가 존재하는 상황에서, 퍼저(fuzzer)는 한 발행자의 오퍼를 서로 다른 수취인이 수락하거나, 동일한 수취인이 여러 오퍼를 수락하려는 시도를 하는 등의 시나리오를 탐색합니다. 이를 통해 권한 관련 버그를 발견할 가능성이 높아집니다.

코드 커버리지 활성화 (선택 사항)

Ackee Blockchain은 Trident 코드 커버리지를 인라인으로 시각화해 주는 Solana Code 확장 프로그램을 출시했습니다.

정보

이 VS Code 확장 프로그램은 VS Code 1.96 이상과 Rust 나이트리 툴체인이 필요합니다. 또한 unsafe math, 서명자 확인 누락, 부적절한 sysvar 액세스 등 Solana 흔히 Solana 대한 실시간 탐지 기능을 갖춘 정적 보안 분석도 제공합니다.

생중계 설정:


  1. VS Code 마켓플레이스에서 확장 프로그램을 설치하세요
  2. 업데이트 Trident.toml JSON 커버리지 출력을 활성화하려면:
trident-tests/Trident.toml
[fuzz.coverage]
format = "json"
loopcount = 100
  1. 퍼저를 실행하세요 — 다음 조건이 충족되면 확장 프로그램이 자동으로 활성화됩니다. trident-tests/ 실시간으로 정보를 제공하고 업데이트합니다
  2. 아니면 실행하세요 Solana: 코드 커버리지 표시 퍼징을 수행한 후 VS Code 명령 팔레트에서 저장된 보고서를 불러오기

커버리지 데이터는 프로그램의 어떤 코드가 몇 번 실행되었는지 보여줌으로써, 추가적인 실행 흐름이나 입력 범위가 필요한 테스트되지 않은 코드 경로를 파악할 수 있게 해줍니다.

자주 묻는 질문

Trident는 Anchor가 아닌 프로그램에서도 작동하나요?

아닙니다. Trident는 Anchor를 필요로 하며, Anchor IDL에서 명령어 유형을 파생합니다. 프로그램은 Anchor 0.29.0 이상 버전으로 빌드해야 합니다. 프로그램에서 다른 프레임워크(예: Pinocchio 또는 기본 Solana SDK)를 사용하는 경우, 현재로서는 Trident와 호환되지 않습니다.

수동 유도 퍼징은 커버리지 유도 퍼징과 어떻게 다른가요?

수동 안내 퍼징에서는 명령어 흐름, 계정 저장소 및 입력 범위를 직접 정의합니다. Trident는 매 반복마다 사용자가 구성한 범위에서 값을 추출하고 흐름을 무작위로 선택합니다. 사용자는 모든 과정을 완전히 제어할 수 있습니다. u64 인수를 변동시키고 싶다면 Trident에 random_from_range(0..u64::MAX)를 사용하도록 지시하고, 고정된 값을 원한다면 해당 값을 하드코딩하면 됩니다. 커버리지 기반 퍼저(AFL이나 HonggFuzz 등)는 작동 방식이 다릅니다. 이들은 코드 경로 커버리지를 극대화하기 위해 입력을 자동으로 변형하지만, 명령어 순서 제약 조건을 인식하지 못하며 실행마다 서로 다른 입력을 생성하는 경우도 드뭅니다. Trident는 AFL++를 포기하고 순수한 수동 안내 방식을 채택했는데, 이는 Solana 엄격한 순서 요구 사항을 가지고 있어 커버리지 기반 퍼저가 이를 제대로 처리하지 못하기 때문입니다.

실제 온체인 상태를 사용하여 퍼즈 테스트를 수행할 수 있나요?

네. Trident는 set_account_custom()을 사용하여 devnet, testnet 또는 mainnet 계정을 로컬 TridentSVM mainnet 불러오는 기능을 지원합니다. 이를 통해 실제 클러스터에 연결하지 않고도 실제 운영 환경과 유사한 조건에서 테스트를 수행할 수 있습니다. 포크 테스트(퍼징 중 실시간 온체인 상태 반영) 기능은 현재 개발 중입니다.

Trident를 CI/CD에 통합할 수 있나요?

Yes. The Trident repository includes GitHub Actions examples. Add trident fuzz run <target> as a CI step with a fixed iteration count. The fuzzer exits with a non-zero code on panics or invariant failures, so it integrates naturally with CI pipelines.

Trident는 주로 어떤 종류의 버그를 탐지하나요?

일반적으로 발견되는 문제로는 계정 제약 조건 누락(이 가이드에 소개된 토큰 대체 버그 등), 산술 오버플로우 및 언더플로우, 서명자 또는 권한 확인 누락, 잘못된 계정 상태 전환, 잘못된 PDA 도출, 그리고 패닉 상태에 빠지는 코드 경로로 인한 DoS 벡터 등이 있습니다. 불변 조건 검사 시스템은 또한 잘못된 토큰 처리나 무단 상태 변경과 같은 논리적 오류도 포착합니다.

마치며

Trident를 설치하고, 토큰 에스크로 프로그램에 대한 퍼지 테스트를 구성했으며, 무작위화된 입력과 토큰 발행이 포함된 다중 행위자 명령 흐름을 정의했고, 누락된 부분으로 인해 발생한 실제 토큰 대체 취약점을 발견했습니다. has_one 제약 조건을 적용하고, 메트릭 대시보드를 통해 결과를 모니터링한 뒤, 퍼저를 다시 실행하여 수정 사항이 제대로 적용되었는지 확인했습니다.

These are the same techniques professional auditors use, and Trident was built by the security team at Ackee Blockchain.

퍼징은 반복적인 과정입니다. 실행할 때마다 새로운 경계 사례가 드러나며, 프로그램이 발전해 나가는 동안 불변 조건 검증을 통해 지속적으로 신뢰성을 확보할 수 있습니다. 이를 개발 워크플로우의 표준 절차로 삼으면 프로그램의 내구성이 크게 향상될 것입니다.

자료