본문으로 건너뛰기

퍼징

이 챕터에서 다루는 것

테이블 주도 테스트는 사람이 생각한 입력만 검사한다. 퍼징은 그 반대다. 입력을 기계가 만들고, 계속 변형하고, 무너지는 지점을 찾는다.

이 챕터는 testing 패키지에 내장된 퍼저를 쓴다. 별도 도구 설치가 없다. 그리고 이 챕터의 크래시는 실제로 퍼저가 찾은 것이다.

문제 — 사람은 자기가 짠 코드의 빈틈을 못 본다

인코더와 디코더 한 쌍을 쓴다고 하자. 테스트로 이런 케이스들을 쓸 것이다. "a|b", "빈 값", "이스케이프된 구분자". 다 통과한다.

그런데 진짜 버그는 대개 이런 데 있다. 이스케이프 문자를 값에 넣었을 때. 자기가 그 규칙을 설계했기 때문에 그 조합을 생각해 내지 못한다.

퍼저는 그런 편견이 없다. 커버리지를 보면서 입력을 변형하고, 새로운 코드 경로를 여는 입력을 코퍼스에 모으고, 그 위에서 다시 변형한다.

대상 코드

구분자로 나뉜 필드를 한 줄로 합치고 되돌리는 코드다.

examples/08-testing/06-fuzzing/fieldline/fieldline.go
// Package fieldline은 문자열 여러 개를 한 줄로 합치고 되돌린다.
//
// 구분자는 '|'이고, 값에 들어 있는 '|'와 '\'는 '\'로 이스케이프한다.
// 인코딩과 디코딩이 짝을 이루는 코드는 퍼징에 딱 맞는 대상이다 —
// "합쳤다 되돌리면 원래대로"라는 성질을 기계가 검증할 수 있다.
package fieldline

import (
"errors"
"fmt"
"strings"
)

const (
sep = '|'
escape = '\\'
)

var (
// ErrTrailingEscape는 줄이 이스케이프 문자로 끝날 때다.
ErrTrailingEscape = errors.New("줄이 이스케이프 문자로 끝난다")
// ErrBadEscape는 이스케이프할 수 없는 문자를 이스케이프했을 때다.
ErrBadEscape = errors.New("잘못된 이스케이프")
)

// Join은 fields를 한 줄로 합친다.
func Join(fields []string) string {
var b strings.Builder
for i, f := range fields {
if i > 0 {
b.WriteByte(sep)
}
for j := range len(f) {
// 이스케이프 문자 자체도 이스케이프해야 한다.
// 이 한 줄을 빼먹는 것이 이 챕터에서 퍼저가 찾아낼 버그다.
if f[j] == sep || f[j] == escape {
b.WriteByte(escape)
}
b.WriteByte(f[j])
}
}
return b.String()
}

// Split은 Join이 만든 줄을 원래 필드로 되돌린다.
//
// 바이트 단위로 훑어도 안전하다. UTF-8 멀티바이트 문자의 모든 바이트는
// 0x80 이상이라 '|'나 '\'와 겹치지 않는다.
func Split(line string) ([]string, error) {
var fields []string
var cur strings.Builder

for i := 0; i < len(line); i++ {
switch line[i] {
case escape:
i++
if i >= len(line) {
return nil, ErrTrailingEscape
}
if line[i] != escape && line[i] != sep {
return nil, fmt.Errorf("%w: %q", ErrBadEscape, line[i])
}
cur.WriteByte(line[i])
case sep:
fields = append(fields, cur.String())
cur.Reset()
default:
cur.WriteByte(line[i])
}
}
fields = append(fields, cur.String())

return fields, nil
}

퍼즈 타깃의 형태

examples/08-testing/06-fuzzing/fieldline/fieldline_test.go
// FuzzRoundTrip은 "합쳤다 되돌리면 원래대로"라는 성질을 검사한다.
// f.Add로 넣은 값이 시드 코퍼스다 — 퍼저는 여기서 출발해 변형한다.
func FuzzRoundTrip(f *testing.F) {
f.Add("a", "b", "c")
f.Add("", "", "")
f.Add("a|b", "c", "d")
f.Add("한글", "🙂", "")

f.Fuzz(func(t *testing.T, a, b, c string) {
in := []string{a, b, c}

line := Join(in)
out, err := Split(line)
if err != nil {
t.Fatalf("Split(Join(%q)) = %q: 예상치 못한 에러 %v", in, line, err)
}
if !slices.Equal(out, in) {
t.Fatalf("왕복 실패\n입력: %q\n중간: %q\n출력: %q", in, line, out)
}
})
}

규칙이 넷이다.

  1. 함수 이름이 Fuzz로 시작하고 서명이 func FuzzXxx(f *testing.F)다.
  2. f.Add(...)로 시드 코퍼스를 넣는다. 퍼저의 출발점이자, 퍼징 없이 go test를 돌릴 때 실행되는 케이스다.
  3. f.Fuzz(func(t *testing.T, ...) {...})를 정확히 한 번 부른다. 첫 인자는 *testing.T이고, 그다음 인자들이 퍼징 대상이다.
  4. f.Add의 인자 개수와 타입이 f.Fuzz 함수의 인자와 정확히 일치해야 한다. 다르면 실행 시점에 실패한다.

허용되는 타입은 정해져 있다. string, []byte, int/int8int64, uint/uint8uint64, float32/float64, bool, rune, byte. 구조체나 슬라이스는 못 넘긴다 — 필요하면 바이트열을 받아 테스트 안에서 구조체로 만든다.

성질을 무엇으로 잡을 것인가

퍼저에게 "정답"을 알려 줄 수는 없다. 대신 입력이 무엇이든 항상 참인 성질을 준다. 자주 쓰이는 것이 넷이다.

성질형태
왕복(round-trip)Decode(Encode(x)) == x직렬화, 압축, 인코딩
정규형Encode(Decode(y)) == y표현이 유일한가
불변식출력이 항상 조건을 만족정렬 결과는 오름차순
차등(differential)두 구현이 같은 답최적화 전후, 다른 라이브러리와
패닉 없음그냥 안 죽으면 통과파서, 언마셜러

이 예제는 앞의 둘을 쓴다. FuzzRoundTrip이 왕복이고, 반대 방향이 정규형이다.

examples/08-testing/06-fuzzing/fieldline/fieldline_test.go
// FuzzSplitCanonical은 반대 방향을 본다.
// Split이 에러 없이 성공했다면 그 결과를 다시 Join했을 때
// 원래 줄과 같아야 한다 — 표현이 유일하다는 뜻이다.
func FuzzSplitCanonical(f *testing.F) {
f.Add("")
f.Add("a|b")
f.Add(`a\`)
f.Add(`a\x`)
f.Add(`\\|\|`)

f.Fuzz(func(t *testing.T, line string) {
fields, err := Split(line)
if err != nil {
return // 거부된 입력은 검사 대상이 아니다
}
if got := Join(fields); got != line {
t.Fatalf("Join(Split(%q)) = %q, want %q (필드: %q)", line, got, line, fields)
}
})
}

err != nil일 때 그냥 return하는 것이 중요하다. 임의의 바이트열이 유효한 인코딩일 리 없으니, 거부는 정상이다. 여기서 t.Fatal을 부르면 퍼저가 첫 쓰레기 입력에서 멈춰 버리고 아무것도 못 찾는다.

:::tip 퍼징은 예제 테스트를 대체하지 않는다 같은 파일에 평범한 테이블 주도 테스트가 먼저 있다.

examples/08-testing/06-fuzzing/fieldline/fieldline_test.go
// 퍼징은 예제 기반 테스트를 대체하지 않는다. 사람이 고른 케이스가 먼저다.
func TestJoinSplit(t *testing.T) {
tests := []struct {
name string
fields []string
line string
}{
{"평범한 값", []string{"a", "b", "c"}, "a|b|c"},
{"빈 값 포함", []string{"", "b", ""}, "|b|"},
{"구분자 포함", []string{"a|b", "c"}, `a\|b|c`},
{"이스케이프 문자 포함", []string{`a\b`}, `a\\b`},
{"둘 다 포함", []string{`a\|b`}, `a\\\|b`},
{"한 개", []string{"solo"}, "solo"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Join(tt.fields); got != tt.line {
t.Errorf("Join(%q) = %q, want %q", tt.fields, got, tt.line)
}
got, err := Split(tt.line)
if err != nil {
t.Fatalf("Split(%q): %v", tt.line, err)
}
if !slices.Equal(got, tt.fields) {
t.Errorf("Split(%q) = %q, want %q", tt.line, got, tt.fields)
}
})
}
}

퍼저는 성질만 검사한다. Join([]string{"a|b", "c"})가 정확히 a\|b|c여야 한다는 것 — 즉 명세 자체 — 는 사람이 적어야 한다. 왕복 성질은 인코더와 디코더가 똑같이 틀려도 통과한다. :::

퍼저 없이 돌리기

go test를 평범하게 돌리면 퍼즈 타깃은 시드 코퍼스만 실행하는 테스트가 된다.

cd examples/08-testing
go test -run 'FuzzRoundTrip' -v ./06-fuzzing/fieldline
=== RUN FuzzRoundTrip
=== RUN FuzzRoundTrip/seed#0
=== RUN FuzzRoundTrip/seed#1
=== RUN FuzzRoundTrip/seed#2
=== RUN FuzzRoundTrip/seed#3
=== RUN FuzzRoundTrip/ca0591b8f2a2f629
--- PASS: FuzzRoundTrip (0.00s)
--- PASS: FuzzRoundTrip/seed#0 (0.00s)
--- PASS: FuzzRoundTrip/seed#1 (0.00s)
--- PASS: FuzzRoundTrip/seed#2 (0.00s)
--- PASS: FuzzRoundTrip/seed#3 (0.00s)
--- PASS: FuzzRoundTrip/ca0591b8f2a2f629 (0.00s)
PASS

seed#0~seed#3f.Add로 넣은 넷이다. ca0591b8f2a2f629는 다르다testdata/fuzz/FuzzRoundTrip/에 있는 파일이고, 예전에 퍼저가 찾아낸 크래시다. 곧 그 이야기를 한다.

이것이 퍼징의 가장 실용적인 측면이다. CI는 -fuzz 없이 평범하게 돌리면 되고, 그러면 시드 코퍼스와 과거 크래시가 회귀 테스트로 매번 실행된다.

실제로 퍼징하기

-fuzz에 타깃 이름 패턴을 준다. 퍼저는 찾을 때까지 무한히 돈다.

go test -run '^$' -fuzz FuzzRoundTrip -fuzztime 10s ./06-fuzzing/fieldline
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 10 workers
fuzz: elapsed: 3s, execs: 1365277 (455034/sec), new interesting: 25 (total: 29)
fuzz: elapsed: 6s, execs: 3093175 (575651/sec), new interesting: 27 (total: 31)
fuzz: elapsed: 9s, execs: 5128963 (678594/sec), new interesting: 27 (total: 31)
fuzz: elapsed: 10s, execs: 5712672 (539161/sec), new interesting: 27 (total: 31)
PASS

읽는 법이다.

  • gathering baseline coverage — 시드 코퍼스를 먼저 돌려 커버리지 기준선을 잡는다.
  • now fuzzing with 10 workersGOMAXPROCS만큼 프로세스를 띄운다.
  • execs — 실행 횟수와 초당 속도. 초당 50만 회를 돌렸다.
  • new interesting: 27 — 새 코드 경로를 연 입력의 수. 이것이 커버리지 기반 퍼징의 핵심이다. 무작정 무작위가 아니라, 새로운 곳에 닿은 입력을 코퍼스에 넣고 그것을 다시 변형한다.

:::warning -fuzz는 한 번에 하나만 -fuzz는 패턴에 매치되는 타깃이 정확히 하나여야 한다. 여러 개가 매치되면 이렇게 거부된다.

testing: will not fuzz, -fuzz matches more than one fuzz test: [FuzzRoundTrip FuzzSplitCanonical]

그리고 -fuzz를 주면 매치된 그 타깃만 퍼징 모드로 돌고, 나머지 테스트는 -run 패턴에 따라 평범하게 돈다.

-fuzztime을 안 주면 영원히 돈다. CI에서는 반드시 시간을 제한하거나, 아예 -fuzz 없이 회귀 실행만 한다. :::

실제 버그 찾기

이제 버그를 심어 본다. Join에서 이스케이프 문자 자체의 이스케이프를 빼는 한 줄 변경이다.

// 위 Join의 이 부분을
if f[j] == sep || f[j] == escape {
b.WriteByte(escape)
}

// 이렇게 바꾼다 — '\'를 이스케이프하지 않는다
if f[j] == sep {
b.WriteByte(escape)
}

TestJoinSplit"이스케이프 문자 포함" 케이스가 이것을 잡긴 한다. 그 케이스를 사람이 생각해 냈기 때문이다. 퍼저는 생각해 낼 필요가 없다.

go test -run '^$' -fuzz FuzzRoundTrip -fuzztime 60s .
fuzz: elapsed: 0s, gathering baseline coverage: 0/31 completed
fuzz: minimizing 53-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 10/31 completed
--- FAIL: FuzzRoundTrip (0.03s)
--- FAIL: FuzzRoundTrip (0.00s)
fieldline_test.go:73: 왕복 실패
입력: ["0" "\\" "0"]
중간: "0|\\|0"
출력: ["0" "|0"]

Failing input written to testdata/fuzz/FuzzRoundTrip/ca0591b8f2a2f629
To re-run:
go test -run=FuzzRoundTrip/ca0591b8f2a2f629
FAIL
exit status 1

여기서 볼 것이 셋이다.

1. 자동 최소화

fuzz: minimizing 53-byte failing input file. 퍼저가 처음 찾은 입력은 53바이트짜리 쓰레기였다. 그것을 계속 줄여 가며 여전히 실패하는 가장 작은 입력을 찾았다. 결과가 ["0", "\\", "0"] — 필드 셋, 가운데가 역슬래시 하나다.

디버깅 관점에서 이것이 결정적이다. 53바이트 쓰레기로는 원인을 알 수 없지만, ["0", "\\", "0"]을 보면 즉시 안다.

2. 실패 메시지가 중간 단계를 보여 준다

입력: ["0" "\\" "0"]
중간: "0|\\|0"
출력: ["0" "|0"]

가운데 줄이 Join의 결과다. 역슬래시가 이스케이프되지 않아 \|가 되었고, Split은 그것을 "이스케이프된 구분자"로 읽어 | 문자 하나로 되돌렸다. 필드 3개가 2개가 됐다.

t.Fatalf에 중간 단계를 넣은 것이 값을 했다. 입력과 출력만 찍었다면 어느 쪽이 잘못했는지 알 수 없었다.

3. 재현 파일이 testdata에 저장된다

Failing input written to testdata/fuzz/FuzzRoundTrip/ca0591b8f2a2f629

파일 내용은 텍스트다.

examples/08-testing/06-fuzzing/fieldline/testdata/fuzz/FuzzRoundTrip/ca0591b8f2a2f629
go test fuzz v1
string("0")
string("\\")
string("0")

첫 줄이 형식 버전, 그다음 줄들이 f.Fuzz 함수의 인자다.

이 파일을 커밋한다. 그 순간부터 이 케이스는 -fuzz 없는 평범한 go test에서도 매번 실행되는 회귀 테스트가 된다. 위의 -v 출력에서 FuzzRoundTrip/ca0591b8f2a2f629가 서브테스트로 돌던 것이 바로 이것이다.

:::info 코퍼스는 두 군데에 산다

  • testdata/fuzz/<타깃>/ — 시드 코퍼스. 커밋한다. 실패 입력은 여기 저장된다.
  • $GOCACHE/fuzz/ — 생성 코퍼스. 퍼저가 찾은 "흥미로운" 입력들이 쌓인다. 커밋하지 않으며, go clean -fuzzcache로 지운다.

생성 코퍼스가 쌓여 있으면 다음 퍼징이 훨씬 빨리 버그를 찾는다. 위 실행에서 baseline coverage: 0/31이 31개인 것은 앞선 10초 퍼징이 남긴 것들이다. CI에서 매번 처음부터 시작하면 그 이점이 없으므로, 긴 퍼징은 코퍼스를 보존하는 전용 잡으로 돌린다. :::

버그를 고치면(원래 Join으로 되돌리면) 회귀 테스트가 통과한다.

어떤 코드가 퍼징에 적합한가

적합

  • 파서와 디코더. 임의의 바이트열을 받는 코드가 1순위다. 패닉만 없어도 성질이 성립한다.
  • 인코더/디코더 쌍. 왕복 성질이 공짜로 나온다.
  • 문자열·바이트 조작. 이스케이프, 정규화, 분할, 치환.
  • 경계 계산이 있는 코드. 슬라이스 인덱싱, 오프셋 계산.
  • 보안 경계. 경로 정규화, 입력 검증, 권한 판정.

부적합

  • 입력 공간이 좁은 코드. 열거형 세 개를 받는 함수는 테이블로 다 덮인다.
  • 외부 I/O를 하는 코드. 초당 수십만 번 실행되는데 DB를 건드리면 안 된다. 순수 부분을 떼어 내서 퍼징한다.
  • 성질을 표현할 수 없는 코드. "이 UI가 예쁜가"는 성질이 아니다.
  • 느린 코드. 실행이 밀리초 단위면 퍼저가 탐색할 수 있는 공간이 극히 좁아진다.

흔히 하는 실수

1. f.Addf.Fuzz의 인자가 안 맞는다

f.Add("a") // 인자 1개
f.Fuzz(func(t *testing.T, a, b string) {}) // 인자 2개 → 실패

컴파일 에러가 아니라 실행 시점 실패다.

2. 거부된 입력에서 실패시킨다

f.Fuzz(func(t *testing.T, line string) {
fields, err := Split(line)
if err != nil {
t.Fatal(err) // 임의의 바이트열은 대부분 유효하지 않다
}
// ...
})

퍼저가 첫 쓰레기에서 멈춘다. 거부는 정상이니 return한다.

3. 성질이 사실은 명세다

왕복 성질만으로는 "인코딩 형식이 스펙과 맞는가"를 검증하지 못한다. 인코더와 디코더가 같은 방식으로 틀려도 왕복은 성립한다. 예제 테스트가 명세를 잡고, 퍼저가 빈틈을 잡는다.

4. 퍼즈 타깃이 결정적이지 않다

시간, 난수, 맵 순회, 고루틴 스케줄링에 의존하면 재현 파일을 다시 돌려도 같은 결과가 안 나온다. testdata에 저장된 크래시가 재현되지 않는 것만큼 답답한 일이 없다. 8-3의 주입으로 먼저 결정적으로 만든다.

5. 코퍼스 파일을 커밋하지 않는다

testdata/fuzz/ 아래 파일은 회귀 테스트다. .gitignore에 넣으면 같은 버그를 두 번 만난다.

6. CI에서 -fuzztime 없이 돌린다

영원히 돈다. CI는 -fuzz 없이 회귀 실행만 하고, 퍼징은 별도의 시간 제한 있는 잡으로 돌린다.

정리

  • func FuzzXxx(f *testing.F) + f.Add(시드...) + f.Fuzz(func(t, 인자...)). f.Fuzz는 정확히 한 번, 인자 타입은 f.Add와 일치해야 한다.
  • 넘길 수 있는 타입은 기본 타입뿐이다. 구조체는 바이트열에서 만든다.
  • 퍼저에게는 정답이 아니라 성질을 준다. 왕복, 정규형, 불변식, 차등, 패닉 없음.
  • 거부된 입력에서는 return한다. t.Fatal이 아니다.
  • -fuzz 이름 + -fuzztime으로 돌린다. -fuzz 없이 돌리면 시드 코퍼스와 과거 크래시만 실행되는 평범한 테스트다 — CI가 쓰는 형태가 이쪽이다.
  • 퍼저는 실패 입력을 자동으로 최소화한다. 53바이트가 ["0","\\","0"]이 됐다.
  • 크래시는 testdata/fuzz/<타깃>/에 저장되고 커밋 대상이다. 그 순간부터 회귀 테스트가 된다. 생성 코퍼스는 $GOCACHE/fuzz/에 있고 커밋하지 않는다.
  • 실패 메시지에 중간 단계를 넣으면 어느 쪽이 잘못했는지가 바로 보인다.
  • 퍼징은 파서·인코더·문자열 조작·경계 계산에 잘 맞고, 예제 테스트를 대체하지 않는다.

연습문제

  1. Split에서 ErrTrailingEscape 검사(if i >= len(line))를 지우고 FuzzSplitCanonical을 돌려 보자. 무엇이 일어나는가 — 패닉인가 왕복 실패인가? 최소화된 입력은 무엇인가? 그 입력을 보고 원인을 즉시 알 수 있었는가?

  2. 구분자를 런타임에 지정할 수 있게 Join/Split을 바꾸고, 구분자까지 퍼징 대상에 넣어 보자(f.Fuzz(func(t *testing.T, sepByte byte, a, b string))). 구분자가 \이거나 UTF-8 연속 바이트일 때 무슨 일이 생기는가? 유효하지 않은 구분자를 어떻게 걸러야 퍼저가 멈추지 않는가?

  3. 필드 개수를 3개로 고정한 것이 이 퍼즈 타깃의 한계다. []byte 하나를 받아 테스트 안에서 임의 개수의 필드로 쪼개는 방식으로 바꿔 보자. 커버리지가 늘어나는가(new interesting 수를 비교)? 대신 최소화된 입력을 읽기가 어려워지지는 않는가?