Rust로 C2 프레임워크를 설계하며 — 아키텍처 리뷰
Author
CloakCat
Time
10 min read
Read by
14
CloakCat — Rust 기반 C2 프레임워크 제작기
들어가며
RTL을 진행하면서 커스텀 TTP(Tactics, Techniques, Procedures)를 활용해 레드팀을 수행하는 챕터를 만났다. Sliver로 따라가면서 "이 C2가 내부적으로 어떻게 동작하는 거지?"라는 질문이 계속 떠올랐고, 그 순간 떠오른 답이 직접 만들어보자였다.
Cobalt Strike, Sliver, Havoc, Brute Ratel — 오픈소스부터 상용까지 C2 프레임워크는 이미 많다. 그럼에도 직접 만드는 이유는 명확했다. 구조와 원리를 몸으로 이해하고, 어떤 부분을 수정해야 방어 체계를 우회할 수 있는지 연구하기 위해서다. 결과물보다 과정에서 어느 지점을 수정하면 어떤 방어툴을 우회할 수 있는지 직접 경험해보는것이 이번 프로젝트의 가장 큰 목표였다.
이 글에서는 CloakCat이라는 Rust 기반 C2 프레임워크를 설계하고 구현하면서 겪은 기술적 선택, 아키텍처 결정, 그리고 솔직한 회고를 공유한다.
주의: CloakCat은 순수하게 보안 교육·연구 목적으로 개발되었으며, 허가되지 않은 환경에서의 사용을 금한다.
1. 벤치마킹 대상: Cobalt Strike
CloakCat의 설계 방향은 Cobalt Strike를 벤치마킹하고 있다. 레드팀 업계의 사실상 표준인 Cobalt Strike의 워크플로우를 재현하는 것이 1차 목표이며, 이를 통해 상용 C2의 내부 동작 원리를 깊이 이해하려는 것이 근본적인 의도다.
구체적으로 참고한 Cobalt Strike의 설계 요소는 다음과 같다.
Beacon 아키텍처 — 주기적 체크인 기반의 비동기 통신 모델. Sleep + Jitter로 통신 패턴을 랜덤화하고, 체크인 시 대기 중인 태스크를 일괄 수신하는 구조를 그대로 따랐다.
Malleable C2 프로파일 — HTTP 트래픽의 외형을 설정 파일로 완전히 커스터마이징하는 개념. 정상 트래픽으로 위장하는 이 아이디어는 CloakCat의 Health Profile Camouflage 시스템과 향후 TOML 기반 프로파일 엔진의 근간이 되었다.
Operator-Server-Beacon 3계층 분리 — 운영자가 서버에 접속하고, 서버가 비콘을 관리하는 구조. CloakCat도 cat-cli(운영자) → cat-server(팀서버) → cat-agent(비콘) 3계층으로 동일하게 설계했다.
BOF(Beacon Object File) — 디스크에 파일을 남기지 않고 메모리에서 .o 파일을 실행하는 Cobalt Strike의 대표 기능. CloakCat에서도 COFF 파서 + 로더를 구현하여 기존 BOF 생태계와의 호환을 목표로 하고 있다.
다만 Cobalt Strike의 모든 기능을 복제하는 것이 목표는 아니다. 상용 도구의 완성도를 1인 개발로 따라잡는 건 현실적이지 않으며, 핵심 워크플로우의 동작 원리를 이해하는 것에 집중하고 있다.
2. 왜 Rust인가
C2 프레임워크의 언어 선택지는 보통 C/C++, Go, Rust, C#으로 좁혀진다. 각각의 트레이드오프가 있지만, Rust를 선택한 이유는 기술적 이점과 개인적 동기가 겹쳤기 때문이다.
기술적 이점
메모리 안전성과 OPSEC의 교차점. C2 에이전트는 타겟 머신에서 장시간 실행된다. C/C++로 작성하면 메모리 버그 하나가 에이전트 크래시로 이어지고, 크래시는 곧 블루팀에 탐지 단서를 남긴다. Rust의 소유권 시스템은 이런 류의 버그를 컴파일 단계에서 차단한다.
네이티브 바이너리 + 작은 런타임. Go도 좋은 선택이지만(Sliver가 Go 기반이다), Go 바이너리는 런타임이 포함되어 크기가 크고, 고유한 시그니처가 있다. Rust는 no_std까지 갈 수 있어서 바이너리 크기와 탐지 표면을 최소화할 수 있다.
비동기 생태계. tokio + axum 조합은 서버 사이드에서 수천 개의 동시 비콘 연결을 처리하는 데 적합하다. Go의 goroutine만큼 자연스럽진 않지만, 성능은 동급이다.
크로스 컴파일. cross 툴을 사용하면 Linux에서 Windows/macOS 타겟 바이너리를 Docker 기반으로 빌드할 수 있다. 이건 에이전트를 다양한 플랫폼에 배포해야 하는 C2에서 중요한 요소다.
솔직한 단점
Windows API 호출이 고통스럽다. C2 에이전트의 핵심 기능(토큰 조작, 프로세스 인젝션, lateral movement)은 전부 Windows API에 의존한다. Rust에서 이걸 호출하려면 windows-sys 크레이트를 통해 unsafe 블록 안에서 FFI를 다뤄야 하고, C/C++에 비해 확실히 번거롭다.
rust// Rust에서 Windows 토큰 탈취 — unsafe가 불가피하다 unsafe { let mut token: HANDLE = std::ptr::null_mut(); OpenProcessToken(process, TOKEN_DUPLICATE, &mut token); DuplicateTokenEx(token, MAXIMUM_ALLOWED, std::ptr::null(), SecurityImpersonation, TokenPrimary, &mut dup_token); ImpersonateLoggedOnUser(dup_token); }
학습 곡선. 소유권, 라이프타임, trait 시스템은 C2 개발 자체보다 Rust 문법과 씨름하는 시간이 더 길어지게 만든다. 특히 비동기 코드에서 라이프타임 문제를 만나면 에이전트 로직보다 컴파일러 에러 해결에 더 많은 시간을 쓰게 된다.
BOF 생태계와의 간극. 기존 BOF 도구들은 C로 작성되어 있고, Cobalt Strike의 BeaconAPI를 기대한다. Rust로 이 호환 레이어를 구현하는 건 가능하지만, C로 작성하는 것보다 확실히 복잡하다.
개인적 동기
솔직히 말하면 기술적 이유만은 아니었다. Rust를 계속 학습 중이었고, 실전 프로젝트를 통해 숙련도를 올리고 싶다는 동기가 컸다. C2 프레임워크는 네트워킹, 암호화, 시스템 프로그래밍, 비동기 처리를 전부 다루기 때문에 Rust 학습 프로젝트로는 이상적이었다.
3. 아키텍처 설계
전체 구조
CloakCat은 4개의 Rust 크레이트로 구성된 Cargo Workspace다.
cloakcat-protocol/ ← 공유 타입, 암호화, 프로토콜 정의 (최하위 의존성)
cat-server/ ← 팀서버: 리스너, 비콘 관리, DB, REST API
cat-agent/ ← 비콘 에이전트: 타겟 머신에서 실행
cat-cli/ ← 운영자 CLI (catctl)
의존성 방향은 단방향이다: cloakcat-protocol ← cat-server / cat-agent / cat-cli. 프로토콜 크레이트가 최하위에 있고, 나머지 세 컴포넌트가 이를 참조한다. 순환 의존성이 없으므로 각 컴포넌트를 독립적으로 빌드·테스트할 수 있다.
[Operator CLI]
↕ REST API (X-Operator-Token)
[Team Server] ←──── PostgreSQL
↕ HTTP/S (X-Agent-Token + HMAC)
[Beacon Agent] ← 타겟 머신
통신 프로토콜
에이전트와 서버 사이의 통신은 3개 엔드포인트로 단순화했다.
POST /v1/register — 에이전트 최초 등록
GET /v1/poll/{agent_id} — 명령 수신 (long-poll)
POST /v1/result/{agent_id} — 실행 결과 전송 (HMAC 서명)
Long-poll 방식을 채택한 이유는 WebSocket보다 방화벽 통과가 쉽고, 일반 HTTPS 트래픽과 구분이 어렵기 때문이다. 비콘은 서버에 hold=45 파라미터로 최대 45초간 대기하고, 명령이 들어오면 즉시 응답받는다.
인증과 암호화
인증은 2계층으로 분리했다.
rust// 에이전트 인증: HKDF로 SHARED_TOKEN에서 파생된 키 let auth_key = derive_auth_key(shared_token.as_bytes()); // 등록·폴링용 let hmac_key = derive_hmac_key(shared_token.as_bytes()); // 결과 서명용
초기 설계에서는 SHARED_TOKEN 하나를 인증과 HMAC 서명에 동시 사용했는데, 아키텍처 리뷰에서 한 용도가 유출되면 다른 용도도 위협받는 구조적 문제를 발견했다. HKDF(HMAC-based Key Derivation Function)로 용도별 키를 파생하는 방식으로 수정했다.
결과 전송 시 HMAC-SHA256으로 무결성을 검증한다.
rust// 메시지에 null 바이트 구분자를 포함 — 없으면 필드 간 경계가 모호해진다 let msg = format!("{}\x00{}\x00{}", agent_id, cmd_id, stdout); let signature = hmac_sign(&hmac_key, &msg);
모든 비밀값 비교는 constant-time으로 수행한다. 일반 == 연산자는 타이밍 사이드채널 공격에 취약하기 때문이다.
rust// ✗ 타이밍 공격에 취약 if expected == signature { ... } // ✓ constant-time 비교 use subtle::ConstantTimeEq; if expected.as_bytes().ct_eq(signature.as_bytes()).into() { ... }
서버 내부 구조
서버는 레이어를 분리했다.
handlers.rs — HTTP 요청 추출·응답 생성 (얇은 레이어)
service.rs — 도메인 로직 (검증, 상태 관리, DB 호출)
error.rs — ServerError enum + HTTP 상태코드 매핑
middleware.rs — 에이전트/운영자 인증 미들웨어
db.rs — sqlx 쿼리 (컴파일 타임 검증)
초기에는 handlers.rs 하나에 HTTP 파싱 + 비즈니스 로직 + DB 호출이 전부 들어있었다(459행). 리팩토링 후 handler는 ~180행의 얇은 래퍼가 되었고, 도메인 로직은 service.rs로 분리되었다.
에러 처리도 anyhow::Result 일괄 사용에서 커스텀 에러 타입으로 전환했다.
rust#[derive(Debug)] pub enum ServerError { NotFound(String), Unauthorized(String), BadRequest(String), Conflict(String), Db(sqlx::Error), Internal(anyhow::Error), } impl IntoResponse for ServerError { fn into_response(self) -> Response { let (status, msg) = match &self { Self::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()), Self::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m.clone()), // ... }; (status, Json(json!({ "error": msg }))).into_response() } }
이 전환 덕분에 downcast_ref::<sqlx::Error>() 같은 부분이 사라지고, 각 에러에 맞는 HTTP 상태코드가 자연스럽게 매핑된다.
4. 설계 패턴과 구조적 결정
Trait 기반 확장성
C2 프레임워크의 생명은 확장성이다. 새 리스너 프로토콜이나 통신 채널을 추가할 때 기존 코드를 최소한으로 수정해야 한다.
Transport trait — 에이전트의 통신 채널을 추상화한다.
rust#[async_trait] pub trait Transport: Send + Sync { async fn register(&self, req: &RegisterRequest) -> Result<RegisterResponse>; async fn poll(&self, agent_id: &str, hold: u64) -> Result<Option<Command>>; async fn send_result(&self, result: &TaskResult) -> Result<()>; }
현재는 HttpTransport 하나만 구현되어 있지만, 이 설계 덕분에 DNS나 SMB 트랜스포트를 추가할 때 beacon.rs의 메인 루프를 수정할 필요가 없다. 새 Transport를 구현하고 주입하면 된다.
ListenerProfile trait — 서버의 리스너 프로파일을 추상화한다.
rustpub trait ListenerProfile: Send + Sync { fn name(&self) -> &str; fn base_path(&self) -> &str; fn validate_request(&self, path: &str, headers: &HeaderMap) -> bool; }
이전에는 새 프로파일을 추가하려면 constants.rs, paths.rs, validation.rs, routes.rs 4개 파일을 수정해야 했다. Trait 도입 후에는 구현체 하나만 작성하면 라우트와 검증이 자동으로 연동된다.
CLI 구조: clap derive + 모듈 분리
CLI는 msfconsole 스타일 REPL을 지향한다. 초기에는 554행짜리 단일 commands.rs에서 match cmd.as_str() 하나로 17개 명령을 처리했는데, 이걸 clap derive + 모듈 분리로 전환했다.
rust#[derive(Subcommand)] pub enum Commands { /// 에이전트 목록 조회 Agents, /// 에이전트 상호작용 Interact { agent_id: String }, /// 태스크 결과 조회 Results { #[arg(long)] agent: String, #[arg(long)] full: bool, // truncate 없이 전체 출력 }, // ... }
장점: 인자 파싱 반복 코드 5곳이 전부 사라졌고, --help가 자동 생성되며, 새 명령 추가가 파일 하나 추가로 끝난다.
아쉬운 점: clap은 REPL 환경에 최적화된 도구가 아니다. 파싱 에러 시 clap이 프로세스를 종료하려 하기 때문에, try_parse_from으로 감싸서 에러를 잡아야 한다. 진정한 msfconsole 스타일을 구현하려면 컨텍스트 스택(MainContext → BeaconContext) 구조가 추가로 필요하다.
폴더 구조
cat-server/src/
├── main.rs # 진입점, AppState 초기화
├── error.rs # ServerError enum
├── service.rs # 도메인 로직 레이어
├── handlers.rs # HTTP 얇은 래퍼
├── middleware.rs # 인증 미들웨어
├── routes.rs # /v1 라우트 그룹
├── validation.rs # 프로필 검증
├── state.rs # AppState + Notify + View 타입
├── db.rs # sqlx 쿼리
└── tunnel/ # SOCKS5 터널 (Phase 4)
cat-agent/src/
├── main.rs # 진입점
├── config.rs # 빌드타임 임베딩 설정
├── beacon.rs # 메인 루프 (Transport trait 의존)
├── exec.rs # 명령 실행 (타임아웃 + 크기 제한)
├── host.rs # 시스템 정보 수집
├── transport/ # Transport trait + HttpTransport
└── tasks/ # 명령 핸들러 (fs, token, lateral...)
좋은 점: 각 파일의 책임이 명확하고, 새 기능 추가 시 어디에 코드를 넣어야 하는지 바로 알 수 있다.
아쉬운 점: cat-server의 state.rs가 AppState + View 타입 + Notify 맵을 모두 들고 있어서 점점 비대해지고 있다. 서버가 복잡해지면 state/ 디렉토리로 분리가 필요할 것이다.
5. 구현 과정에서 다시 한번 알게된 요점
보안 코드는 일관성이 핵심이다
아키텍처 리뷰에서 발견된 가장 치명적인 문제는 HMAC 비교의 불일관성이었다. 서버 미들웨어에서는 ring::constant_time::verify_slices_are_equal을 올바르게 사용하면서, 정작 crypto.rs에서는 일반 == 연산자를 사용하고 있었다. 같은 코드베이스에서 같은 유형의 작업이 두 가지 방식으로 처리되고 있었던 것이다.
이런 불일관성은 "한 곳은 고쳤는데 다른 곳은 놓쳤다"는 전형적인 패턴이다. 보안 관련 유틸리티는 반드시 한 곳에서 정의하고 전체가 참조하는 구조여야 한다.
Long-poll의 DB 부하 함정
초기 poll 구현은 300ms마다 DB를 조회하는 busy-wait 루프였다. 에이전트 10대가 120초씩 폴링하면 최대 4,000회 쿼리가 발생한다. tokio::sync::Notify로 전환하여 명령이 삽입될 때만 깨어나는 구조로 바꿨다.
rust// Before: 300ms 마다 DB 조회 (비효율적) loop { if let Some(cmd) = db::get_pending_command(&pool, agent_id).await? { return Ok(Some(cmd)); } tokio::time::sleep(Duration::from_millis(300)).await; } // After: Notify 기반 (명령 삽입 시에만 깨어남) tokio::select! { _ = notify.notified() => { db::get_pending_command(&pool, agent_id).await } _ = tokio::time::sleep(hold_duration) => Ok(None) }
에이전트 안전장치는 필수다
명령 실행에 타임아웃이 없으면 sleep 999999 하나로 에이전트가 영구 블록된다. stdout 크기 제한이 없으면 yes | head -c 1G로 메모리가 고갈된다. 이런 방어적 코딩은 "나중에 하자"가 아니라 최우선으로 처리해야 한다.
rust// 300초 타임아웃 + 1MB 출력 제한 let result = tokio::time::timeout( Duration::from_secs(300), cmd.output() ).await; let stdout = truncate_output(&raw_stdout, 1_048_576); // 1MB
6. 현재 부족한 점과 향후 계획
현재 부족한 점
암호화 수준. 현재는 HMAC 기반 무결성 검증만 있고, 트래픽 자체의 E2E 암호화(ECDH + AES-256-GCM)는 아직 미구현이다. HTTPS에 의존하고 있지만, TLS를 벗기면 평문이 노출되는 구조다.
Windows 고급 기능. 토큰 조작, lateral movement, 프로세스 인젝션 등 Cobalt Strike의 핵심 포스트익스플로잇 기능이 아직 개발 중이다. 이 기능들이 없으면 실전 시나리오를 재현하기 어렵다.
탐지 회피. 현재 에이전트 바이너리는 별도의 난독화나 패킹 없이 빌드된다. Sleep 중 메모리 암호화, syscall 직접 호출, ETW 패칭 같은 EDR 우회 기법이 없어서 현대 보안 솔루션에는 즉시 탐지될 것이다.
테스트 커버리지. 보안 관련 함수(crypto.rs)에는 유닛 테스트가 있지만, 전체적인 통합 테스트가 부족하다. 에이전트-서버 간 E2E 테스트 파이프라인이 필요하다.
운영자 경험. CLI가 기능적으로는 동작하지만, Cobalt Strike의 GUI나 Sliver의 세련된 TUI에 비하면 UX가 부족하다.
향후 로드맵
| 단계 | 내용 | 상태 | |------|------|------| | Phase 0-3 | 보안 수정 + 아키텍처 리팩토링 | 완료 | | Phase 4 | 파일 전송 + SOCKS5 프록시 | 진행 중 | | Phase 5 | Windows 토큰 조작 + Lateral Movement | 예정 | | Phase 6 | BOF(Beacon Object File) 로더 | 예정 | | Phase 7 | Malleable C2 프로파일 + HTTPS 고도화 | 예정 |
Phase 5까지 완료되면 대부분의 실습 시나리오를 CloakCat으로 재현할 수 있을 것으로 기대하고, Phase 6(BOF 로더)까지 되면 기존 오펜시브 도구 생태계(mimikatz, rubeus 등)와 연동이 가능해진다.
7. 마무리
C2 프레임워크를 직접 만들어보면서 가장 크게 느낀 점은, 사용하는 것과 만드는 것은 완전히 다른 차원의 이해라는 것이다.
Sliver에서 socks5 start를 치면 프록시가 열린다. 하지만 그 뒤에서 C2 채널 위에 터널 데이터를 어떻게 멀티플렉싱하는지, SOCKS5 핸드셰이크가 어떻게 비콘까지 전달되는지는 직접 구현해봐야 체감된다.
Cobalt Strike에서 jump psexec을 실행하면 횡적이동이 된다. 하지만 SCM을 열고, 서비스를 만들고, 바이너리를 ADMIN$ 공유에 복사하고, 서비스를 시작하는 일련의 Windows API 호출 체인을 직접 짜봐야 "왜 이게 탐지되는지", "어떻게 하면 덜 탐지되는지"를 구체적으로 고민할 수 있다.
CloakCat은 아직 Cobalt Strike의 10%도 안 되는 수준이다. 하지만 그 10%를 만드는 과정에서 얻은 이해는, 도구를 100% 사용하는 것만으로는 절대 얻을 수 없는 깊이였다.
이 블로그에는 CloakCat의 개발 진행에 맞춰 앞으로의 업무내용과 각종 개인적인 연구내용들을 지속적으로 업데이트할 예정이다. Phase별 진행 상황, 새로 구현한 기능의 기술적 세부사항, 그리고 구현 과정에서 겪은 삽질기를 계속 공유하겠다.
CloakCat은 GitHub에서 개발 중이며, 교육·연구 목적의 오픈소스 프로젝트다.