커널 퍼징
Linux에는 약 450개의 시스템 호출이 있는데 언뜻 보기에 퍼징할 공격 표면이 많지 않아 보일 수 있다. 하지만 실제 공격 표면은 매우 넓은데 이러한 시스템 콜이 수많은 커널 하위 시스템과의 통신 채널을 제공하기 때문이다. 가장 간단한 시스템 콜 open() 조차도 파일 시스템 드라이버(ext4, btrfs) 마다 /dev 하위의 노드 (/dev/null, /dev/sda...) 마다 각각 다른 구현을 가지고 있다.
또 네트워크 패킷 처리, 파일시스템 마운트 (USB 등 외부 미디어), 하드웨어 장치와 직접 통신까지 생각하면 커널은 훨씬 많은 공격 표면을 가지고 있다.
따라서 syzlang 정의가 아무리 많아도 모든 케이스를 커버하기 어려워 여전히 모든 공격 표면을 완전히 커버하지는 못한다.
Syzkaller 핵심 동작 원리
syzkaller는 리눅스 커널용 최고의 퍼저중 하나이다. KCOV를 통해 커버리지를 지원하고 퍼징하려는 시스템을 선언적으로 기술할 수 있는 방법을 제공한다. 또한 KASAN, UBSAN 등 퍼징 중에 더 많은 버그를 찾을 수 있게 해주는 유용한 기능도 함께 사용할 수 있다.
지원 os도 다양하다.
- Linux (원래 타겟)
- FreeBSD
- NetBSD, OpenBSD
- Windows
- macOS/iOS
- Fuchsia
- 기타 약 12개 OS
퍼징 target은 주로 다음과 같다.
- 주로 시스템 콜 (그래서 "system call fuzzer"라고도 불림)
- USB 스택 (Linux에서 수십 개 버그 발견)
- 네트워크 패킷 주입
- 기타 커널 인터페이스
syzkaller는 다음과 같은 컴포넌트들로 구성되어있다.

- syz-manager (핵심 관리자)
- VM 인스턴스 관리
- crash 시 자동 재시작
- 주기적 재시작 (corpus rotation)
- console 모니터링 (각 VM의 시리얼 콘솔 출력, panic 메시지 감시 )
- crash 데이터베이스
- panic 메시지로 버그 식별/분류
- reproducer 저장
- corpus 관리
- fuzzer들에게 corpus 배포
- VM 인스턴스 관리
- syz-fuzzer (퍼저 엔진)
- syz-manager와 RPC 연결 수립
- corpus 수신
- 워커 스레드(goroutine) 생성
- syz-executor (실행기)
- 실제로 시스템 콜을 실행하는 프로그램
- syz-fuzzer가 syz-executor 프로세스 생성 후 syz-executor 에서 다음 절차로 퍼징 실행
- 공유 메모리로 프로그램 수신
- /dev/kcov 열기 (커버리지 수집 준비)
- 샌드박스 진입 (선택적)
- 스레드 풀로 시스템 콜 실행
- 커버리지 정보 반환
- syz-prog2c (C 코드 변환기)
- syzkaller 프로그램을 독립 실행 가능한 C 코드로 변환
- 용도
- 개발자가 버그를 재현할 때 사용
- syzkaller 설치 없이도 버그 확인 가능
- 버그 리포트에 첨부
- syz-ci (지속적 통합 - 선택사항)
- 완전 자동화된 퍼징 인프라
- Google syzbot
- oogle이 운영하는 공개 syzkaller 인스턴스
- Linux, FreeBSD 등 지속적으로 퍼징
- 버그 발견 시 자동으로 메일링 리스트에 리포트
- https://syzkaller.appspot.com 에서 확인 가능
- netdumpd (크래시 덤프 수집)
- VM이 panic할 때 커널 코어 덤프 수집
기본 퍼징 루프는 다음과 같다.
- 시스템 콜 프로그램 생성
- 예: open → read → close 시퀀스
- VM에서 실행
- 커널이 에러 진단했는지 확인 (panic 등)
- Yes → 크래시 정보 수집, 최소 재현자 찾기
- No → 커버리지 정보 수집
- 새 커버리지 발견했는지 확인
- Yes → 프로그램 변형해서 계속
- No → 새 프로그램으로 시작
통신 방식 정리
| 구간 | 프로토콜 | 목적 |
| manager ↔ VM | SSH | VM 시작, 파일 복사, fuzzer 실행 |
| manager ↔ fuzzer | RPC | corpus 동기화, 새 입력 전송 |
| fuzzer ↔ executor | 공유 메모리 | 고속 프로그램 전달 |
| executor ↔ kcov | ioctl | coverage 수집 on/off |
kcov(4)- 커버리지 수집 설정
kcov는 /dev/kcov라는 장치 파일을 통해 접근하는 커널 드라이버로 kcov(4)는 LLVM의 SanitizerCoverage를 커널에서 사용할 수 있도록 래핑한 커널 서브시스템이다.
대략적으로 다음과 같은 작업을 한다.
- syzkaller (syz-executor)에서 사용자 공간에서 /dev/kcov 접근
- kcov(4) 드라이버
- /dev/kcov 장치 제공
- ioctl 인터페이스
- LLVM SanitizerCoverage
- 컴파일 시 코드에 트레이싱 함수 삽입
- __sanitizer_cov_trace_pc() 등
Sanitizer란 컴파일된 코드에 특정 유형의 introspection(내부 검사)을 가능하게 하는 코드 조각을 삽입하는 컴파일러 기능이다.
대표적인 Sanitizer들
| 이름 | 전체 명칭 | target | 역할 |
| ASAN | AddressSanitizer | 유저 공간 | 메모리 오류 탐지 |
| KASAN | Kernel AddressSanitizer | 커널 | 메모리 오류 탐지 |
| UBSAN | UndefinedBehaviorSanitizer | 유저 공간 | 정의되지 않은 동작 탐지 |
| KUBSAN | Kernel UBSAN | 커널 | 정의되지 않은 동작 탐지 |
| MSAN | MemorySanitizer | 유저 공간 | 초기화 안 된 메모리 읽기 탐지 |
| KMSAN | Kernel MSAN | 커널 | 초기화 안 된 메모리 읽기 탐지 |
| TSAN | ThreadSanitizer | 유저 공간 | 데이터 레이스 탐지 |
| KCSAN | Kernel Concurrency Sanitizer | 커널 | 데이터 레이스 탐지 |
| SanitizerCoverage | SanitizerCoverage |
범용(kernel space, user space) | 코드 커버리지 수집 |
AddressSanitizer 예시
// 원본 코드
int arr[10];
arr[15] = 42; // 버퍼 오버플로우!
// ASAN이 삽입한 코드 (개념적)
int arr[10];
__asan_check_write(&arr[15], sizeof(int)); // ← 삽입됨
arr[15] = 42; // 여기 도달 전에 ASAN이 에러 발생시킴
SanitizerCoverage와 기본 블록(Basic Block)
기본 블록이란 제어 흐름 명령어 없이 순차적으로 실행되는 명령어 시퀀스을 의미한다.
대부분의 CPU 명령어는 다음과 같이 순차 실행한다.
명령어 1 → 명령어 2 → 명령어 3 → 명령어 4
제어 흐름 명령어 (jmp, jne, call, ret 등) 의 경우 다음과 같이 분기하여 동작한다.
명령어 1 → 명령어 2 → JNE ─┬─▶ 주소 A로 점프
└─▶ 다음 명령어
예를 들어 다음과 같은 코드의 경우 제어 흐름 그래프는 다음과 같다. (LLVM 관련)
void process(int x) {
if (x > 0) { // 블록 A
handle_positive(); // 블록 B
} else {
handle_negative(); // 블록 C
}
cleanup(); // 블록 D
}

핵심 아이디어는 각 기본 블록의 시작 부분에 트레이싱 함수를 삽입하면, 어떤 코드 경로가 실행됐는지 알 수 있다.
전체 코드와 함께 확인해보자.
#include <stdio.h>
void handle_positive(void) {
printf("Positive!\n");
}
void handle_negative(void) {
printf("Negative!\n");
}
void cleanup(void) {
printf("Cleanup!\n");
}
void process(int x) {
if (x > 0) {
handle_positive();
} else {
handle_negative();
}
cleanup();
}
int main() {
process(5);
process(-3);
return 0;
}
다음 코드를 최적화없이 빌드 후 컴파일한 어셈블리 부분을 확인해보면
clang -S -O0 -fsanitize-coverage=trace-pc,trace-cmp -o test_no_opt.s test_coverage.c 2>&1
$ grep -A 80 "^process:" test_no_opt.s | head -85
process:
========== 블록 1 시작 ==========
# %bb.0:
pushq %rbp
...
callq __sanitizer_cov_trace_pc@PLT ← 커버리지 트레이싱: "블록 1 실행됨" 기록
...
callq __sanitizer_cov_trace_const_cmp4@PLT ← 비교 연산 트레이싱: 비교값 기록
cmpl $0, %eax
jle .LBB3_2 ← 조건 분기
========== 블록 2 (조건이 false일 때, x > 0, positive) ==========
# %bb.1:
callq __sanitizer_cov_trace_pc@PLT ← 커버리지 트레이싱: "블록 2 실행됨" 기록
callq handle_positive
jmp .LBB3_3
========== 블록 3 (조건이 true일 때, x <= 0, negative) ==========
.LBB3_2:
callq __sanitizer_cov_trace_pc@PLT ← 커버리지 트레이싱: "블록 3 실행됨" 기록
callq handle_negative
========== 블록 4 (공통 종료) ==========
.LBB3_3:
callq cleanup
...
retq
삽입되는 트레이싱 함수들
| 함수 | 역할 |
| __sanitizer_cov_trace_pc() | 현재 PC(프로그램 카운터) 기록 → 기본 블록 실행 추적 |
| __sanitizer_cov_trace_const_cmp1() | 1바이트 상수와의 비교 기록 |
| __sanitizer_cov_trace_const_cmp2() | 2바이트 상수와의 비교 기록 |
| __sanitizer_cov_trace_const_cmp4() | 4바이트 상수와의 비교 기록 |
| __sanitizer_cov_trace_const_cmp8() | 8바이트 상수와의 비교 기록 |
| __sanitizer_cov_trace_cmp1() | 1바이트 변수간 비교 기록 |
| __sanitizer_cov_trace_switch() | switch 문 비교 기록 |
비교 트레이싱이 중요한 이유는 다음과 같다.
if (magic == 0xDEADBEEF) { // 매직 넘버 체크
// 중요한 코드 경로
}
- 퍼저가 0xDEADBEEF라는 값을 "학습"할 수 있음
- 비교 대상 값을 알면 해당 분기로 들어가는 입력 생성 가능
kcov(4) 사용 방법
syz-executor 에서 동작 흐름은 다음 순서로 진행된다.
// 버퍼 할당
void *cover = malloc(COVER_SIZE);
// /dev/kcov 열기
int fd = open("/dev/kcov", O_RDWR);
// 버퍼 매핑
ioctl(fd, KIOSETBUFSIZE, COVER_SIZE);
cover = mmap(..., fd, ...);
// 트레이싱 활성화
ioctl(fd, KIOENABLE, KCOV_MODE_TRACE_PC);
// 시스템 콜 실행
read(some_fd, buf, size); // 이 동안 커버리지 수집
// 트레이싱 비활성화
ioctl(fd, KIODISABLE, 0);
// 커버리지 데이터 읽기
n = cover[0]; // 수집된 PC 개수
for (i = 0; i < n; i++)
printf("PC: %lx\n", cover[i+1]);
실제 코드 예시
#include <sys/kcov.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#define COVER_SIZE (64 << 10) // 64KB
int main(void) {
int fd;
unsigned long *cover;
unsigned long n, i;
// 1. /dev/kcov 열기
fd = open("/dev/kcov", O_RDWR);
if (fd < 0) {
perror("open /dev/kcov");
return 1;
}
// 2. 버퍼 크기 설정
if (ioctl(fd, KIOSETBUFSIZE, COVER_SIZE) < 0) {
perror("ioctl KIOSETBUFSIZE");
return 1;
}
// 3. 버퍼 매핑
cover = mmap(NULL, COVER_SIZE * sizeof(unsigned long),
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (cover == MAP_FAILED) {
perror("mmap");
return 1;
}
// 4. 트레이싱 활성화 (현재 스레드에 대해)
if (ioctl(fd, KIOENABLE, KCOV_MODE_TRACE_PC) < 0) {
perror("ioctl KIOENABLE");
return 1;
}
// 5. 시스템 콜 실행 - 이 동안 커버리지가 수집됨
read(-1, NULL, 0); // 의도적으로 실패하는 호출
// 6. 트레이싱 비활성화
if (ioctl(fd, KIODISABLE, 0) < 0) {
perror("ioctl KIODISABLE");
return 1;
}
// 7. 결과 확인
n = cover[0]; // 첫 번째 요소: 수집된 PC 개수
printf("Collected %lu PCs:\n", n);
for (i = 0; i < n; i++) {
printf(" 0x%lx\n", cover[i + 1]);
}
// 정리
munmap(cover, COVER_SIZE * sizeof(unsigned long));
close(fd);
return 0;
}
출력 예시
Collected 15 PCs:
0xffffffff81234560
0xffffffff81234580
0xffffffff81234620
0xffffffff812347a0
syzlang - 시스템 콜 정의
Syzkaller의 핵심은 syscall 정의 언어이다.
왜 syzlang이 필요할까
퍼저가 시스템 콜을 효과적으로 퍼징하려면 인터페이스에 대한 지식이 필요하다.
하지만 C 프로토타입은 정보가 부족하다.
| parameter | C가 알려주는것 | 실제로 필요한 정보 |
| fd | 그냥 int | 유효한 파일 디스크립터여야 함 |
| buf | void 포인터 | 커널이 쓰기할 버퍼 (out) |
| nbytes | size_t | buf의 크기와 연관됨 |
open() syscall을 예로 들었을 때 man page에서 open의 정의는 다음과 같지만
$ man open
int open(const char *pathname, int flags, ...
/* mode_t mode */ );
syzkaller에서의 정의는 다음과 같다.
open(file ptr[in, filename], flags flags[open_flags], mode flags[open_mode]) fd
문법 구조
함수명(파라미터명 타입, 파라미터명 타입, ...)
─────────────
이름이 먼저, 타입이 뒤에 (Go 스타일)
각 파라미터 의미
- file ptr[in, filename] → 첫 번째 인자는 파일명 문자열을 담은 입력 포인터
- flags flags[open_flags] → open_flags 배열에 정의된 플래그들 중 하나
- mode flags[open_mode] → open_mode 배열에 정의된 모드 중 하나
- fd → 반환값 (파일 디스크립터), 다른 syscall에서 재사용 가능
위 정의를 바탕으로 syzkaller가 자동 생성하는 프로그램은 다음과 같다.
r0 = open(&(0x7f0000000000)="./file0", 0x3, 0x9)
read(r0, &(0x7f0000000000), 42)
close(r0)
- `r0`에 `open()`의 반환값(fd)을 저장
- 그 fd를 `read()`와 `close()`에서 사용
- 이런 프로그램을 대량으로 생성하면서 커버리지를 높이는 방향으로 진화
ioctl 같은 복잡한 syscall 처리도 확인해보자.
ioctl의 경우 `request` 값에 따라 세 번째 인자의 타입이 완전히 달라져 서브시스템별로 세분화되어있다. C 언어로는 이 관계를 표현할 수 없다.
# man ioctl
int ioctl(int fd, unsigned long op, ...);
# 일반적인 ioctl
ioctl(fd fd, cmd intptr, arg buffer[in])
# DRM 전용
ioctl$DRM_IOCTL_VERSION(fd fd_dri, cmd const[DRM_IOCTL_VERSION], arg ptr[in, drm_version])
# Video4Linux 전용
ioctl$VIDIOC_QUERYCAP(fd fd_video, cmd const[VIDIOC_QUERYCAP], arg ptr[out, v4l2_capability])
$ 연산자로 특정 용도에 맞는 변형을 정의할 수 있다.
References
'Security > Fuzzing' 카테고리의 다른 글
| syzkaller를 이용한 커널 퍼징 #1 Fuzzing 이란 (1) | 2025.12.10 |
|---|