사전 퍼징
개념

퍼징

gabury1고친 사람 github-actions[bot]

퍼징은 프로그램에 엉뚱한 입력을 자동으로 쏟아부어 결함을 찾아냅니다. 사람이 미처 떠올리지 못한 입력을 기계가 대신 만들어 넣습니다. 프로그램이 죽거나 끝나지 않으면 그 입력을 남겨 둡니다. 남은 입력은 결함을 다시 일으키는 재료가 됩니다.

쉽고 빠른 이해

퍼징은 입력을 기계가 만들어 넣는 테스트입니다. 요청 본문을 읽는 코드에 괄호 짝이 안 맞는 글자 덩어리를 수백만 개 넣어 보고 한 번이라도 죽는지 지켜봅니다.

사람이 쓴 테스트는 사람이 떠올린 입력만 넣습니다. 바깥에서 오는 입력은 공격자가 고릅니다. 아무도 떠올리지 못한 입력에서 터지는 결함은 퍼징이 없으면 운영에 나가서야 보입니다.

어떻게 도나:

  1. 정상 입력 몇 개를 출발점으로 줍니다
  2. 퍼저(퍼징을 해 주는 프로그램)가 그 입력을 조금씩 비틀어 끝없이 넣어 봅니다
  3. 처음 가 보는 코드를 밟은 입력은 남겨 두고 다음 변형의 재료로 씁니다
  4. 프로그램을 죽이거나 끝나지 않게 만든 입력을 저장해 알려 줍니다

대가는 시간입니다. 오래 돌릴수록 더 찾지만 언제 그만둬도 되는지 알려 주는 신호가 없습니다. 틀린 값을 돌려주는 결함은 죽지 않으니 따로 조건을 심어야 잡힙니다.

상세

아이에게 새 장난감을 쥐여 주면 설명서대로 갖고 놀지 않습니다. 거꾸로 끼워 봅니다. 단추 여러 개를 한꺼번에 누르기도 합니다. 만든 사람이 생각하지 못한 약한 곳이 그렇게 드러납니다.

퍼징은 그 아이 노릇을 기계에게 맡기는 테스트입니다. 자동으로 만든 입력을 프로그램에 대량으로 넣습니다. 그리고 프로그램이 이상하게 구는 순간을 붙잡습니다.

예를 들어 파서에 닫는 괄호가 빠진 JSON(JavaScript Object Notation) 문자열을 넣어 봅니다. 길이 칸에 실제보다 훨씬 큰 값이 적힌 이미지 파일도 넣어 봅니다.

이름은 영어 fuzzing 을 소리 나는 대로 적은 말입니다. 퍼즈 테스트(fuzz testing)라고도 부릅니다.

이 절은 입력 하나를 넣고 결과를 보는 한 바퀴에서 시작합니다. 그다음 무작위 입력이 막히는 벽과 그 벽을 넘는 방법을 봅니다. 뒤쪽 소절은 쓰는 쪽으로 넘어가 퍼징을 겨눌 코드, 찾은 입력을 다루는 법, 치르는 대가를 차례로 봅니다.

퍼저가 도는 한 바퀴

퍼징을 해 주는 프로그램을 퍼저라고 부릅니다. 퍼저는 세 가지 일을 한 바퀴로 삼아 되풀이합니다.

  1. 입력을 만듭니다. 바이트를 비틀거나 새로 짓습니다
  2. 대상에 넣습니다. 검사할 함수나 프로그램을 그 입력으로 한 번 실행합니다
  3. 결과를 봅니다. 실행이 멀쩡히 끝났는지 따진 뒤 1로 돌아갑니다

이 바퀴는 1초에 수천 번 돌기도 합니다. 사람이 하루에 쓰는 테스트 입력보다 많은 입력을 몇 초 만에 넣습니다. 퍼징의 힘은 한 입력의 영리함이 아니라 이 횟수에서 나옵니다.

이상을 알아차리는 신호

퍼징에는 「이 입력이면 이 값이 나와야 한다」는 기대값이 없습니다. 입력을 기계가 만들었으니 답을 미리 적어 둘 수 없습니다. 그래서 퍼저는 누가 봐도 잘못된 반응만 결함으로 칩니다.

이상 신호 무엇이 일어났나
프로세스가 죽는다 잘못된 메모리 접근 같은 이유로 운영체제가 프로그램을 강제로 끝냈다
예상 못 한 예외 아무도 처리하지 않은 예외가 끝까지 올라왔다
시간 초과 입력 하나를 처리하다가 끝나지 않는다
메모리 한도 초과 작은 입력 하나가 엄청난 메모리를 요구했다
새니타이저(메모리 접근 검사 장치) 보고 죽지는 않았지만 메모리를 잘못 건드렸다
단언(코드에 적어 둔 검사) 실패 개발자가 심어 둔 조건이 깨졌다

표의 다섯째 줄은 죽지 않는 결함을 잡습니다. C 언어처럼 메모리를 직접 다루는 언어에서는 배열 끝을 한 칸 넘어 써도 프로그램이 안 죽을 수 있습니다. 옆 데이터가 조용히 망가진 채 실행이 이어집니다.

그래서 퍼징은 흔히 새니타이저를 켜고 돌립니다. 새니타이저는 컴파일러가 메모리 접근마다 검사 코드를 덧붙여 주는 장치입니다. 잘못 건드리는 바로 그 순간 프로그램을 끝내고 어느 줄인지 알려 줍니다.

마지막 줄의 단언은 「여기서는 이 조건이 늘 참이어야 한다」를 코드에 적어 둔 검사입니다. 단언을 많이 심어 둘수록 퍼저가 알아차리는 결함도 늘어납니다.

입력을 만드는 두 방법

입력을 어디서 얻느냐에 따라 퍼징이 둘로 갈립니다. 있는 입력을 비트는 방법과 입력의 문법을 알고 새로 짓는 방법입니다.

변형 기반 퍼징 생성 기반 퍼징
출발점 정상 입력 몇 개 입력이 따르는 문법
만드는 법 바이트를 뒤집고 자르고 이어 붙인다 문법 규칙을 따라 새 입력을 짓는다
사람이 준비할 것 정상 입력을 모으면 된다 문법을 퍼저에게 알려 줘야 한다
잘 닿는 곳 형식이 느슨한 입력 문법 검사를 여러 겹 거쳐야 하는 입력

변형 기반에서 출발점으로 주는 정상 입력을 시드(seed)라고 부릅니다. 이미지 파서를 퍼징한다면 멀쩡한 이미지 파일 몇 장이 시드가 됩니다.

퍼저가 비틀 재료로 모아 두는 입력 묶음은 코퍼스(corpus)입니다. 처음에는 시드만 들어 있습니다. 퍼저는 다음에 비틀 입력을 이 코퍼스에서 꺼냅니다.

생성 기반은 문법을 지키는 입력을 만듭니다. 그래서 「문법이 틀렸다」고 바로 튕겨 나가는 입력이 적습니다. SQL(Structured Query Language) 문장처럼 문법이 까다로운 입력을 받는 코드에서 이 차이가 커집니다.

무작위로는 못 넘는 벽

바이트를 아무렇게나 바꾸는 퍼저는 깊은 코드에 좀처럼 닿지 못합니다. 입력의 앞부분이 정해진 값이어야만 다음으로 넘어가는 코드가 흔하기 때문입니다. 아래는 세 바이트가 차례로 맞아야 결함에 닿는 코드입니다.

Java
if (d[0] == 'B')         // 256번에 한 번
  if (d[1] == 'U')       // 256번에 한 번
    if (d[2] == 'G')     // 256번에 한 번
      throw new Error(); // 여기가 결함

한 바이트가 가질 수 있는 값은 256가지입니다. 무작위 입력이 세 바이트를 한꺼번에 맞추려면 256을 세 번 곱한 약 1,677만 번에 한 번꼴로 운이 따라야 합니다. 실제 파일 형식은 이런 검사를 수십 겹 거칩니다. 무작위 입력은 대부분 첫 검사에서 튕겨 나갑니다.

지나간 길을 보고 입력을 고르는 퍼징

벽을 넘는 방법은 「조금이라도 앞으로 나간 입력」을 알아보는 것입니다. 첫 if 를 통과한 입력은 아직 결함에 닿지 못했어도 쓸모가 있습니다. 그 입력을 버리지 않고 다음 변형의 출발점으로 삼습니다.

앞으로 나갔는지 알려면 입력마다 코드의 어느 갈림길을 지났는지 세야 합니다. 실행이 코드의 어디를 지나갔는지 재는 잣대가 코드 커버리지입니다. 새 갈림길을 지난 입력은 커버리지를 넓힌 입력입니다.

지나간 길을 세려면 대상 코드가 스스로 기록을 남겨야 합니다. 갈림길마다 기록용 코드를 끼워 넣는 일이 계측입니다. 퍼저는 흔히 대상을 컴파일할 때 계측을 해 둡니다. 그러면 실행이 끝날 때마다 어느 갈림길을 지났는지 알 수 있습니다.

이렇게 도는 퍼징을 커버리지 유도 퍼징(coverage-guided fuzzing)이라고 부릅니다. 한 바퀴는 아래 그림처럼 돕니다.

flowchart TD
    A["코퍼스에서 입력 하나를 고른다"] --> B["조금 비튼다"]
    B --> C["대상을 실행한다"]
    C --> D{"이상 신호가 났나"}
    D -->|"났다"| E["그 입력을 저장해 보고한다"]
    E --> A
    D -->|"안 났다"| F{"처음 지나는 길이 있었나"}
    F -->|"있었다"| G["코퍼스에 추가한다"]
    F -->|"없었다"| A
    G --> A

앞의 BUG 코드에 이 바퀴를 돌려 보겠습니다. B 로 시작하는 입력이 우연히 나오면 처음 지나는 길이 생깁니다. 그 입력이 코퍼스에 들어갑니다. 이제 퍼저는 둘째 바이트만 맞추면 됩니다.

세 바이트를 한꺼번에 맞출 필요가 없어졌습니다. 어림으로 256번 남짓의 시도를 세 번 되풀이하면 결함에 닿습니다. 1,677만 번에 한 번이던 일이 수천 번 안에 끝납니다.

대상 안을 얼마나 들여다보나

퍼징은 대상 코드를 얼마나 아느냐로도 나눕니다. 안을 전혀 안 보는 쪽부터 조건을 하나하나 읽는 쪽까지 셋으로 부릅니다.

이름 대상 안을 얼마나 보나 다음 입력을 고르는 법
블랙박스 퍼징 안 본다 응답만 보고 무작위로 만든다
그레이박스 퍼징 지나간 길만 센다 새 길을 연 입력을 살려 둔다
화이트박스 퍼징 코드의 조건식을 읽는다 조건을 풀어 통과할 입력을 계산한다

앞 절의 커버리지 유도 퍼징이 가운데 줄인 그레이박스입니다. 화이트박스는 d[0] == 'B' 같은 조건을 식으로 바꿔 답을 풉니다. 이렇게 코드를 식으로 바꿔 입력을 구하는 방법이 기호 실행입니다.

블랙박스는 소스 코드가 없어도 됩니다. 돌고 있는 서버에 이상한 요청을 보내 응답만 보는 퍼징이 여기 듭니다. 대신 안을 모르니 깊은 코드에 닿기가 가장 어렵습니다.

퍼징을 겨누는 코드

퍼징이 가장 자주 겨누는 곳은 바깥에서 온 바이트를 해석하는 코드입니다. 그 바이트는 공격자가 마음대로 고를 수 있습니다. 해석하는 코드는 갈림길이 많아 사람이 모든 경우를 떠올리기 어렵습니다.

대상 들어오는 입력
파서 JSON · XML(Extensible Markup Language) · 설정 파일
역직렬화 네트워크로 받은 바이트를 객체로 되살리는 코드
파일 형식 처리 이미지 · 문서 · 압축 파일
네트워크 프로토콜 처리 서버가 받는 요청의 헤더와 본문

백엔드 서버에서는 요청 본문을 읽는 파서와 역직렬화 코드가 첫 후보입니다. 로그인 전에 도는 코드일수록 누구나 입력을 넣을 수 있습니다.

언어에 따라 달라지는 수확

C·C++ 처럼 메모리를 직접 다루는 언어에서는 퍼징이 메모리 결함을 주로 찾습니다. 메모리 결함은 공격자가 프로그램을 조종하는 통로가 되기도 합니다.

대표는 둘입니다. 하나는 배열 끝을 넘어 쓰는 버퍼 오버플로입니다. 다른 하나는 이미 돌려준 메모리를 다시 쓰는 해제 후 사용입니다.

자바처럼 메모리 안전을 언어가 지켜 주는 쪽에서는 메모리가 망가지지 않습니다. 대신 다른 결함이 나옵니다. 아무도 잡지 않은 예외, 끝나지 않는 반복, 작은 입력에 메모리를 수 기가바이트 잡는 코드입니다. 셋 다 서버 하나를 쓰러뜨리는 서비스 거부 공격의 재료가 됩니다.

개발자가 쓰는 퍼즈 타깃

퍼저에게 무엇을 검사할지 알려 주는 작은 함수를 퍼즈 타깃이라고 부릅니다. 퍼저가 만든 바이트 배열 하나를 받아 검사할 코드를 부르는 것이 전부입니다. 생김새는 단위 테스트와 닮았습니다.

Java
void fuzz(byte[] data) {
  try {
    Json.parse(data);   // 대개 조용히 끝
  } catch (ParseException e) {
    // 문법 오류는 정상 반응
  }
}

Json.parse 는 검사할 파서를 대신 적은 이름입니다. 문법이 틀린 입력에 파서가 ParseException 을 던지는 것은 약속된 반응입니다. 그래서 그 예외는 잡아서 삼킵니다. 그 밖의 예외가 튀어나오거나 실행이 끝나지 않으면 퍼저가 결함으로 기록합니다.

이 구분을 빠뜨리면 틀린 입력마다 결함 보고가 쌓입니다. 퍼즈 타깃을 쓰는 일의 절반은 「무엇이 정상 반응인가」를 정하는 일입니다.

찾은 입력을 다루는 법

퍼저가 남긴 입력은 대개 쓸데없는 바이트를 잔뜩 달고 있습니다. 퍼저는 결함이 계속 재현되는 한에서 바이트를 하나씩 걷어 봅니다. 이렇게 재현 입력을 가장 짧게 줄이는 일을 입력 최소화라고 합니다.

같은 결함이 서로 다른 입력으로 수백 번 보고되기도 합니다. 그래서 프로그램이 죽은 곳의 호출 순서가 같은 것끼리 묶어 하나로 셉니다.

묶은 결함에는 종류를 붙입니다. 흔히 CWE(Common Weakness Enumeration, 공통 약점 목록)의 유형을 씁니다. CWE 는 소프트웨어 결함을 종류별로 번호를 매겨 모은 목록입니다. 결함 보고서와 보안 검사 도구가 같은 번호를 쓰면 어느 도구가 찾은 결함이든 같은 종류끼리 모아 볼 수 있습니다.

결함을 고친 뒤에는 그 입력을 테스트에 넣어 둡니다. 나중에 누가 코드를 바꿔 같은 결함이 되살아나면 그 테스트가 먼저 실패합니다. 이렇게 한 번 고친 결함이 돌아오지 않게 막는 테스트가 회귀 테스트입니다.

다른 테스트와 다른 점

단위 테스트는 사람이 고른 입력과 기대값을 짝지어 적습니다. 퍼징은 기계가 입력을 만들고 기대값 없이 「죽지 않나」만 봅니다. 둘 사이에 속성 기반 테스트가 있습니다.

속성 기반 테스트도 입력은 기계가 만듭니다. 대신 「정렬한 결과는 길이가 그대로다」처럼 사람이 적어 둔 성질이 지켜지는지를 봅니다. 아래 표는 이 셋을 나란히 놓습니다.

단위 테스트 속성 기반 테스트 퍼징
입력을 누가 만드나 사람 기계 기계
무엇을 확인하나 기대값과 같은가 사람이 적은 성질이 지켜지나 죽거나 끝나지 않는 입력이 없나
한 번 돌리는 시간 짧다 짧다 몇 시간에서 며칠

속성 기반 테스트는 퍼징과 가장 가깝습니다. 퍼징보다 입력이 덜 거칠고 짧게 끝납니다. 단위 테스트는 아는 경우를 맡고, 나머지 둘은 사람이 떠올리지 못한 경우를 맡습니다.

퍼징이 치르는 대가

첫째 대가는 시간입니다. 퍼징은 오래 돌릴수록 더 찾지만 「이제 다 찾았다」는 신호가 없습니다. 그래서 개발자가 테스트를 돌릴 때가 아니라 지속적 통합 서버에서 밤새 돌리는 경우가 많습니다.

둘째는 넘기 어려운 검사입니다. 체크섬은 데이터가 깨졌는지 보려고 입력에 붙여 두는 계산값입니다. 입력 안에 체크섬이 있으면 비튼 입력은 거의 다 그 검사에서 버려집니다. 이런 검사는 퍼즈 타깃에서 꺼 두거나 퍼저가 다시 계산해 넣게 해야 합니다.

셋째는 틀린 답을 못 잡는다는 점입니다. 파서가 죽지 않고 엉뚱한 값을 돌려주면 퍼저는 모릅니다.

단언을 심으면 그 빈 곳을 메울 수 있습니다. 같은 일을 하는 구현 둘에 같은 입력을 넣고 답이 갈리는지 보는 방법도 있습니다. 이 방법이 차분 퍼징입니다.

관련 항목

퍼징이 속하는 상위 분류

동적 분석 · 소프트웨어 테스트 · 보안 테스트 · 침투 테스트 · 취약점 분석

퍼저를 이루는 구성 요소

퍼저 · 퍼즈 타깃 · 시드 · 코퍼스 · 변이 연산자 · 입력 최소화 · 크래시 분류 · 테스트 오라클

퍼징의 하위 종류

변형 기반 퍼징 · 생성 기반 퍼징 · 문법 기반 퍼징 · 커버리지 유도 퍼징 · 블랙박스 퍼징 · 그레이박스 퍼징 · 화이트박스 퍼징 · 차분 퍼징

퍼징과 함께 켜는 검사 장치

새니타이저 · AddressSanitizer · UndefinedBehaviorSanitizer · 계측 · 코드 커버리지 · 단언

퍼징이 찾아내는 결함

버퍼 오버플로 · 해제 후 사용 · 정수 오버플로 · 널 포인터 역참조 · 메모리 누수 · 무한 루프 · 정의되지 않은 동작 · 스택 오버플로 · 서비스 거부 공격

퍼징이 주로 겨누는 입력 처리 코드

파서 · 역직렬화 · 파일 형식 · 네트워크 프로토콜 · JSON · XML · 압축

퍼징과 겨루거나 함께 쓰는 검사 기법

속성 기반 테스트 · 단위 테스트 · 회귀 테스트 · 정적 분석 · 기호 실행 · 콘콜릭 실행 · DAST

퍼징이 찾은 결함을 이어받는 분류 체계

CWE · CVE · 취약점 · 메모리 안전 · 버그 바운티

퍼징을 구현한 도구

AFL · libFuzzer · OSS-Fuzz · honggfuzz · Jazzer

다른 이름: fuzzing · fuzz testing · 퍼즈 테스트 · 퍼즈 테스팅 · 퍼징 테스트