Go에서의 퍼징
원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기
퍼징은 무작위 입력을 이용한 테스트 기법으로, 사용자 입력을 받는 코드에서 문제가 될 수 있는 엣지 케이스나 보안 문제를 찾는 데 사용된다. Go 패키지 개발자는 Dmitry Vyukov가 만든 인기 있는 go-fuzz 도구를 이용해 코드를 퍼즈 테스트할 수 있다. 이 도구는 Go 표준 라이브러리는 물론 서드파티 패키지에서도 수백 개의 찾기 어려운 버그를 발견했다. 하지만 이 도구는 기본으로 내장되어 있지 않고, 본래 가능할 만큼 사용하기 쉽지도 않다. 이를 해결하기 위해 Go 팀원인 Katie Hockman은 최근 표준 go test 명령어의 일급 기능으로 퍼즈 테스트를 추가하자는 초안 설계를 공개했다.
무작위 테스트 입력을 이용해 버그를 찾는 역사는 펀치 카드 시절까지 거슬러 올라간다. 작가이자 오랜 프로그래머인 Gerald Weinberg는 이렇게 회고한다:
1950년대에는 그것을 퍼징이라고 부르지 않았지만, 쓰레기통에서 가져온 펀치 카드 덱을 입력해 프로그램을 테스트하는 것이 우리의 표준 관행이었다. 우리는 난수 펀치 카드 덱도 사용했다. 당시에는 네트워크로 연결되어 있지 않았기 때문에 보안에 대해 크게 걱정하지 않았지만, 우리의 무작위/쓰레기 덱은 종종 원치 않는 동작을 드러냈다.
보다 최근에는 american fuzzy lop(AFL)이나 Vyukov가 Go로 만든 syzkaller 도구 같은 도구를 이용해, Bash와 libjpeg에서 Linux 커널에 이르는 소프트웨어에서 수많은 버그와 주목할 만한 보안 문제들이 퍼즈 테스트로 발견되어 왔다.
퍼즈 테스트의 기본 아이디어는 함수에 무작위 입력을 생성해 넣어 함수가 크래시되거나 함수 API에 속하지 않는 예외를 발생시키는지 확인하는 것이다. 하지만 단순한 방식으로 무작위 입력을 생성하는 것은 극도로 시간이 오래 걸리고 엣지 케이스를 효율적으로 찾지 못한다. 그래서 대부분의 현대 퍼징 도구들은 테스트를 주도하고 새로 생성된 입력이 새로운 코드 경로를 실행하는지 판단하기 위해 “커버리지 기반 퍼징”을 사용한다. Vyukov가 공동 작성한 제안서에는 이 기법이 어떻게 동작하는지에 대한 간결한 설명이 담겨 있다:
start with some (potentially empty) corpus of inputs
for {
choose a random input from the corpus
mutate the input
execute the mutated input and collect code coverage
if the input gives new coverage, add it to the corpus
}코드 커버리지 데이터를 수집하고 입력이 “새로운 커버리지를 제공한다”는 것을 감지하는 일은 간단하지 않다. 이를 위해서는 코드에 커버리지 레코더를 호출하는 특수 코드를 삽입하는 도구가 필요하다. 계측된 코드가 실행되면 퍼징 프레임워크는 이전 테스트 입력들의 코드 커버리지와 새로운 입력의 커버리지를 비교하고, 다른 코드 블록이 실행되었다면 그 새로운 입력을 코퍼스에 추가한다. 물론 이는 입력이 어떻게 변형되는지, 커버리지 계측이 정확히 어떻게 동작하는지 등 많은 세부 사항을 생략한 설명이다. 하지만 기본 기법은 효과적이다. AFL은 이를 수많은 C 및 C++ 프로그램에 활용해 왔으며, 웹 페이지에는 발견되어 수정된 수많은 버그를 나열한 섹션이 있다.
go-fuzz 도구
AFL은 훌륭한 도구이지만 GCC나 Clang으로 컴파일해야 하는 C, C++ 또는 Objective C로 작성된 프로그램에서만 동작한다. Vyukov의 go-fuzz 도구는 AFL과 유사한 방식으로 동작하지만 Go 전용으로 작성되었다. Go 프로그램에 커버리지 기록을 추가하기 위해 개발자는 먼저 (go build 대신) go-fuzz-build 명령을 실행하는데, 이는 내장된 ast 패키지를 사용해 소스 코드의 각 블록에 계측을 추가하고 그 결과를 일반 Go 컴파일러로 전달한다. 계측된 바이너리가 빌드되면 go-fuzz 명령이 여러 CPU 코어에서 무작위로 변형되는 입력으로 바이너리를 반복 실행하면서 크래시(스택 트레이스와 이를 유발한 입력 포함)를 기록한다.
Damian Gryski는 go-fuzz 도구를 좀 더 자세히 사용하는 방법을 보여주는 튜토리얼을 작성했다. 앞서 언급했듯이 go-fuzz README에는 이 도구가 발견한 수많은 버그가 나열되어 있지만, 그곳에 나열되지 않은 서드파티 패키지에도 거의 확실히 더 많은 버그가 있을 것이다. 필자 역시 GoAWK에 go-fuzz를 사용해 보았는데, 몇 개의 “crasher”를 찾아냈다.
일급 기능으로의 여정
Go에는 프로젝트의 테스트(그리고 선택적으로는 벤치마크)를 자동으로 찾아 실행하는 내장 명령어인 go test가 있다. 퍼징도 테스트의 한 유형이지만, 내장 도구 지원이 없으면 설정이 다소 번거롭다. 2017년 2월, Vyukov와 Konstantin Serebryany를 대신해 Go GitHub 저장소에 이슈가 등록되었는데, go 도구가 “오늘날 테스트와 벤치마크, 레이스 탐지를 지원하는 것처럼 퍼징을 네이티브로 지원
”하도록 제안하는 내용이었다. 이 이슈는 “go-fuzz는 존재하지만 테스트와 벤치마크를 작성하고 go test -race를 실행하는 것만큼 쉽지 않다
”고 지적한다. 이 이슈는 엄청난 지지와 많은 댓글을 모았다.
이후 Vyukov 등은 이러한 통합이 어떤 모습일지에 대한 동기 문서와 API 및 툴링 제안서를 추가했다. Go 기술 리드인 Russ Cox는 “새로운 go test 퍼즈 모드가 되길 원하는 모습이 정확히 무엇인지
”에 대한 프로토타입 버전을 요구했다. 2019년 1월 “thepudds”가 바로 그것을 공유했다 — 원래 제안서의 대부분을 별도 도구로 구현한 fzgo라는 도구였다. 이는 당시에는 호평을 받았지만 공식적인 것으로 이어지지는 않은 것으로 보인다.
하지만 최근 Go 팀이 이 아이디어를 다시 꺼내 들었고, Hockman이 일급 퍼징을 위한 최근 초안 설계를 작성했다. 목표는 비슷하게 표준 go test 도구로 퍼즈 테스트를 쉽게 실행할 수 있게 하는 것이지만, 제안된 API는 초기 코퍼스를 프로그래밍 방식으로 시딩할 수 있게 하고 바이트 문자열(“byte 슬라이스” 또는 Go의 []byte) 외의 입력 타입을 지원하기 위해 조금 더 복잡하다.
현재 개발자는 *_test.go 소스 파일에 TestFoo(t *testing.T) 시그니처를 가진 테스트 함수를 작성할 수 있으며, go test는 이러한 함수를 단위 테스트로 자동 실행한다. 기존의 testing.T 타입은 테스트를 제어하고 실패를 기록하기 위해 테스트 함수에 전달된다. 새로운 초안 설계는 유사한 방식으로 FuzzFoo(f *testing.F) 퍼즈 테스트를 작성한 뒤 go test -fuzz와 같은 간단한 명령으로 실행할 수 있는 기능을 추가한다. 제안된 testing.F 타입은 시드 코퍼스에 입력을 추가하고 퍼즈 테스트 자체를 구현하는 데 사용된다(중첩된 익명 함수를 이용한다). 다음은 계산기 라이브러리를 위한 calc_test.go의 일부가 될 수 있는 예제이다:
func FuzzEval(f *testing.F) {
// Seed the initial corpus
f.Add("1+2")
f.Add("1+2*3")
f.Add("(1+2)*3")
// Run the fuzz test
f.Fuzz(func(t *testing.T, expr string) {
t.Parallel() // allow parallel execution
_, _ = Eval(expr) // function under test (discard result and error)
})
}이 몇 줄의 코드만으로도 계산기 라이브러리의 Eval() 함수를 무작위 입력으로 실행하고 크래시(Go 용어로는 “panic”)를 기록하는 기본적인 퍼즈 테스트가 된다. panic의 예로는 범위를 벗어난 배열 접근, nil 포인터 역참조, 0으로 나누기 등이 있다. 좀 더 정교한 퍼즈 테스트는 결과를 다른 라이브러리(이 예제에서는 calclib이라 부른다)와 비교할 수도 있다:
...
// Run the fuzz test
f.Fuzz(func(t *testing.T, expr string) {
t.Parallel()
r1, err := Eval(expr)
if err != nil {
t.Skip() // got parse error, skip rest of test
}
// Compare result against calclib
r2, err := calclib.Eval(expr)
if err != nil {
t.Errorf("Eval succeeded but calclib had error: %v", err)
}
if r1 != r2 {
t.Errorf("Eval got %d, calclib got %d", r1, r2)
}
})
}퍼징 함수와 새로운 testing.F 타입을 설명하는 것에 더해, Hockman의 초안 설계는 “컴파일러 계측을 이용해 커버리지 정보를 파악하고, 뮤테이터로 테스트 인자를 생성하며, 코퍼스를 유지하는 역할을 할
” 새로운 커버리지 기반 퍼징 엔진을 구축할 것을 제안한다. Hockman은 이것이 새로운 구현이 될 것이지만 기존 작업(go-fuzz와 fzgo)에서 많은 부분을 차용할 것임을 분명히 한다. 뮤테이터는 기존 입력으로부터 새로운 무작위 입력(“생성된 코퍼스”)을 생성하며, 내장 타입이나 내장 타입으로 구성된 구조체에 대해서는 자동으로 동작한다. 기존의 BinaryUnmarshaler나 TextUnmarshaler 인터페이스를 구현한 경우 다른 타입도 지원된다.
기본적으로 엔진은 퍼즈 테스트를 무기한으로 실행하며, 첫 번째 크래시가 발견되면 해당 테스트 실행을 중단한다. 사용자는 (지속적 통합 스크립트에서 사용하기 위해) -fuzztime 명령줄 플래그로 일정 시간 동안 실행하도록 지시할 수 있으며, -keepfuzzing 플래그로 크래시 이후에도 계속 실행하도록 지시할 수 있다. 크래시 보고서는 testdata 디렉터리의 파일에 작성되며, 크래시를 유발한 입력과 오류 메시지 또는 스택 트레이스를 포함한다.
논의와 향후 전망
최근의 파일시스템 및 파일 임베딩에 대한 초안 설계와 마찬가지로, 이 설계에 대한 공식 논의는 Reddit 스레드를 통해 이루어졌다. 전반적으로 피드백은 긍정적이었다.
testing.F 인터페이스에 대한 논의도 있었다. David Crawshaw는 testing.T 및 벤치마킹에 사용되는 testing.B와의 일관성을 위해 기존의 testing.TB 인터페이스를 구현해야 한다고 제안했다. Hockman은 이에 동의해 설계를 업데이트하여 이를 반영했다. “etherealflaim”의 제안에 따라 Hockman은 최상위와 퍼즈 함수 모두에서 testing.F를 재사용하지 않도록 설계도 업데이트했다. 명령어를 go test -fuzz로 할지 go fuzz로 할지에 대한 바이크셰딩도 있었다. etherealflaim은 go test를 재사용하는 것은 “역사가 있고 많은 사람들이 타임아웃 등을 설정해 두었기 때문에
” 좋지 않은 생각이라고 제안했다.
Jeremy Bowers는 뮤테이션 엔진이 플러그 가능해야 한다고 권장했다:
퍼즈 엔진은 플러그 가능해야 한다고 생각합니다. 물론 기본 엔진은 함께 제공할 수 있고, 플러그 가능성은 “버전 2”로 미뤄도 되지만, 계획에는 포함되어야 한다고 봅니다. 퍼징은 대부분 하나의 크기로 대부분을 커버할 수 있지만, 항상 더 전문화된 것이 필요한 경우가 생기기 마련입니다.
Hockman은 그러나 플러그 가능성은 이 기능을 추가하는 데 필수적인 것은 아니지만 “설계 단계 후반에 고려될 수 있다
”고 답변했다.
초안 설계는 서두에서 “이 초안 설계를 배포하는 목표는 피드백을 모아 향후 정식 제안서를 구체화하는 것이다
”라고 밝히고 있으므로, 다음 단계가 정확히 무엇이 될지, 언제 이루어질지는 말하기 어렵다. 하지만 Go 팀으로부터 이 문제에 공식적인 에너지가 투입되고 있는 것을 보는 것은 고무적이다. Vyukov의 원래 제안서에 대한 Cox의 피드백을 고려하면, 업데이트된 제안서의 프로토타입이 브랜치에서 개발되거나 fzgo와 유사하게 개발자가 실행할 수 있는 별도 도구 형태로 등장할 것으로 예상한다.
Reddit 스레드에서의 논의는 아직 진행 중이므로, 이처럼 큰 기능에 대한 공식 제안서와 구현이 2020년 11월에 도래하는 Go 1.16 릴리스 동결 시점에 맞춰 준비될 가능성은 낮아 보인다. 2021년 8월에 출시 예정인 Go 1.17에 포함될 가능성이 더 높다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기