Voltar para o Blog
Research

Grammar 기반 퍼징: 랜덤 뮤테이션이 놓치는 것

무작위 뮤테이션 퍼저가 구조화된 프로토콜에서 커버리지를 잃는 이유와, Grammar로 PDU를 모델링해 필드 조합을 체계적으로 순회하는 방법. And/Or 조합 폭발 제어, 경계값 자동 생성, Size 필드 자동 계산, Lua 기반 체크섬 재계산까지.

··8 min de leitura
fuzzinggrammar-fuzzingprotocolICSOTvulnerability-researchAFLboofuzz

들어가며

예전에 PLC 프로토콜 퍼저를 설계하면서 블랙박스 뮤테이션 퍼징의 한계를 절감했다. AFL로 파일 파서를 두들기는 것과, 상태를 가진 이진 프로토콜을 두들기는 것은 난이도가 다르다. 프로토콜에는 길이 필드가 있고, 체크섬이 있고, "이 필드가 이 값일 때만 저 필드가 존재한다"는 문맥 의존성이 있다. 바이트를 무작위로 뒤집는 퍼저는 이 구조를 모른 채 유효성 검사의 첫 관문에서 대부분의 입력이 걸러진다.

Grammar 기반 퍼징은 이 문제를 정면으로 다룬다. "무엇을 뒤집을지"를 랜덤에 맡기는 대신, 프로토콜 PDU 구조 자체를 언어로 기술하고, 각 필드를 체계적으로 순회한다. 이 글은 산업용 프로토콜 견고성 시험에서 쓰이는 Grammar 언어의 설계를 뜯어보면서, AFL·boofuzz 같은 도구를 써 본 독자가 바로 대응시켜 볼 수 있도록 정리한다.

랜덤 퍼저가 구조에서 무너지는 지점

무작위 뮤테이션 퍼저의 동작은 단순하다. 시드 입력을 받아 바이트를 뒤집고, 자르고, 붙이고, 다시 대상에 던진다. 파일 포맷처럼 "대충 맞아도 파서가 일단 읽어 들이는" 입력에서는 이 전략이 놀랄 만큼 잘 통한다. 커버리지 피드백(AFL 계열)이 붙으면 더 그렇다.

문제는 세 가지 구조 앞에서 드러난다.

길이 필드. 패킷 앞머리에 "이후 페이로드가 N바이트"라고 선언하는 필드가 있으면, 페이로드를 한 바이트 늘리는 순간 그 값도 같이 바뀌어야 한다. 랜덤 퍼저는 둘의 관계를 모르니, 길이 필드를 건드리면 페이로드가 안 맞고 페이로드를 건드리면 길이가 안 맞는다. 대부분 "malformed" 판정으로 즉시 버려진다.

체크섬. CRC나 단순 합산 체크섬이 걸린 프로토콜은 더 가혹하다. 페이로드를 한 비트라도 바꾸면 체크섬이 틀어지고, 대상은 체크섬 검증 단계에서 패킷을 폐기한다. 취약점이 체크섬 검증 이후 로직에 있다면, 랜덤 퍼저는 그 코드에 영원히 도달하지 못한다.

문맥 의존 필드. "메시지 타입이 0x03일 때만 뒤에 옵션 헤더가 붙는다" 같은 규칙. 랜덤 뮤테이션은 이 조합을 우연에 맡긴다. 유효 조합의 공간이 넓을수록 우연히 맞을 확률은 급격히 낮아진다.

결국 랜덤 퍼저의 커버리지는 측정할 수 없다. 몇 개의 필드 조합을 시도했는지, 어떤 경로가 아직 안 밟혔는지 알 방법이 없다. "충분히 오래 돌렸다"는 말이 "충분히 테스트했다"를 보장하지 못한다.

Grammar: PDU를 언어로 기술한다

Grammar 기반 접근은 발상을 뒤집는다. 입력을 뒤집는 게 아니라, 유효한 입력의 구조를 먼저 정의하고 그 안에서 변형을 생성한다.

핵심 용어부터 정리하자.

용어정의
Operator무언가를 생성하는 방법. 인수를 받는다
ExpressionPDU를 생성하는 연산자의 집합
Rule이름이 붙은 Expression
Grammar여러 Expression과 Rule의 집합 → 여러 PDU 생성

가장 단순한 예:

TestCase{ And(Or("hi", "bye"), " ", Or("Jim", "Mary")) }
-- 생성: "hi Jim", "hi Mary", "bye Jim", "bye Mary"

Or는 각 인수당 하나의 값을 내고, And는 하위 값들을 완전 조합한다. 위 식은 2 × 1 × 2 = 4개의 PDU를 낳는다. 규칙으로 이름을 붙이면 구조가 읽기 쉬워진다.

TestCase{
  And(R"say", " ", R"to"),
  say = Or("hi", "bye"),
  to  = Or("Jim", "Mary"),
}

R"say"say 규칙을 참조한다. 규칙은 정의 순서와 무관하고, 참조되지 않은 규칙은 무시되며, 미정의 규칙을 참조하면 실행 에러가 난다. 여기까지는 문자열 놀이처럼 보이지만, 핵심은 이 구조가 프로토콜 필드에 그대로 대응된다는 점이다.

조합 폭발을 제어하는 세 가지 And

And의 완전 조합은 필드가 늘수록 폭발한다. 필드 10개가 각각 값 5개를 가지면 5¹⁰ ≈ 976만 개의 PDU다. 실기에서는 감당이 안 된다. 그래서 조합의 밀도를 조절하는 변종이 있다.

And("a","b"), Or("x","y"), Or("1","2"))
  →  ax1, ax2, ay1, ay2, bx1, bx2, by1, by2   (8개, 완전 조합)
 
And1("a","b","c"), Or("x","y","z"), Or("1","2","3"))
  →  ax1, bx1, cx1, ay1, az1, ax2, ax3   (7개, 단일 값 조합)
 
And2(Or("a","b","c"), Or("x","y","z"), Or("1","2","3"))
  →  ax1, bx1, cx1, ay1   (4개, 쌍 조합)
  • And: 모든 경우의 수. 필드 간 상호작용까지 다 보지만 비싸다.
  • And1: 각 필드의 모든 값을 한 번씩은 쓰되, 필드 간 조합은 만들지 않는다. "한 필드를 흔들고 나머지는 기본값 고정."
  • And2: 필드 쌍 단위 조합(pairwise). 소프트웨어 테스트의 pairwise 기법과 같은 발상 — 대부분의 버그는 두 파라미터의 상호작용에서 나온다는 경험칙에 기댄다.

이게 랜덤 퍼저 대비 Grammar의 결정적 이점이다. 커버리지 전략을 명시적으로 고른다. 조합이 과하면 And1로 내리고, 필드 상호작용이 의심되면 And로 올린다. 몇 개의 PDU를 생성하는지 실행 전에 계산할 수 있다.

값 생성: 경계값과 인코딩

필드에 무슨 값을 넣을지가 다음 문제다. 유효값 나열은 Or로 되지만, 버그는 대개 경계에 산다.

BoundaryFuzz(default, bit_width, [...])

BoundaryFuzz는 필드의 비트 폭을 받아 최솟값·최댓값·중간값 근처, 그리고 기본값을 자동 생성한다. 8비트 필드라면 0, 1, 127, 128, 254, 255 언저리를 알아서 만든다. 오프-바이-원 실수와 부호 처리 버그를 겨냥한 고전적인 값들을, 손으로 나열하지 않고 얻는다.

인코딩 연산자는 텍스트/이진, 엔디안, 주소를 다룬다.

연산자용도
Nb16/24/32/64(...)빅엔디안(네트워크 바이트 순서) 인코딩
Le16/24/32/64(...)리틀엔디안 인코딩
Byte(...)8비트 바이트 인코딩
Addr(...)IP/MAC 주소를 이진으로 변환
Range(begin, end[, step])범위 값 생성
Hex(...)16진 문자열 → 바이트 스트림

boofuzz를 써 봤다면 s_word, s_dword, s_size 블록 프리미티브가 떠오를 것이다. 발상은 같다 — 필드를 타입 있는 블록으로 선언한다. 차이는 조합 전략을 연산자 수준에서 명시적으로 고르는 부분이다.

길이 필드: Size가 자동으로 계산한다

앞서 랜덤 퍼저가 무너지던 첫 지점이 길이 필드였다. Grammar는 이걸 연산자로 해결한다.

TestCase{
  And(Size(nb16, 2), R"flag", R"data"),
  flag = Byte(0, 1),
  data = Zeros(Range(0, 4, 2))
}

Size(nb16, 2)는 "인덱스 2 이후 필드들(flag + data)의 총 크기를 nb16으로 인코딩해 이 자리에 넣어라"는 뜻이다. data가 0바이트든 4바이트든, 길이 필드는 항상 실제 크기를 반영한다. 페이로드를 흔들어도 길이가 자동으로 따라온다 — 랜덤 퍼저가 못 하던 바로 그 일이다.

그리고 길이 필드 자체를 공격 대상으로 삼고 싶으면:

SizeFuzz(width, ...)

SizeFuzz는 올바른 크기에 더해 크기−1, 크기+1, 그리고 비트 폭 기준 경계값까지 생성한다. "선언된 길이와 실제 길이가 어긋난 패킷"을 체계적으로 만들어낸다. 버퍼 오버플로와 언더리드가 숨어 있기 딱 좋은 곳이다.

체크섬: Lua로 재계산한다

두 번째 무너지던 지점, 체크섬. Grammar 엔진은 페이로드 가공을 Lua 함수 두 개로 처리한다.

함수역할
disassemble(payload)손상시킬 부분을 추출. 나머지(헤더·체크섬 자리)는 state로 반환
assemble(damaged, state)손상된 부분 + state로 완성 패킷 재조립. 이때 체크섬 재계산
function disassemble(payload)
  local hdr = payload:sub(1, 4)              -- 앞 4바이트 헤더는 손상 제외
  local part = payload:sub(5, -5)            -- 5번째 ~ 끝에서 5번째가 손상 대상
  return part, hdr                            -- (손상 대상, 재조립용 state)
end
 
crc = bcrc.crc32()
 
function assemble(damaged, state)
  local hdr = state
  local payload = hdr .. damaged
  local chksum = fmt.le32(crc(payload))      -- 손상된 페이로드에 맞는 CRC32 재계산
  return payload .. chksum
end

이 구조의 값어치는 분명하다. 손상은 페이로드 본문에만 가하고, 체크섬은 손상된 결과에 맞춰 다시 계산한다. 결과 패킷은 체크섬 검증을 통과하고, 그제서야 검증 이후의 파싱 로직에 도달한다. 랜덤 퍼저가 영원히 못 넘던 관문을 이 두 함수가 넘긴다.

체크섬 검증이 있는 프로토콜을 뮤테이션 퍼징하려다 히트율이 바닥을 치는 경험을 했다면, 이게 그 해법이다. 대상이 잘못된 체크섬을 즉시 버리는 한, 손상 후 체크섬 재계산은 선택이 아니라 필수다.

실전: RPC Port Mapper Grammar

조각들을 모으면 실제 프로토콜 하나가 나온다. 아래는 포트 111의 RPC Port Mapper를 겨냥한 Grammar다.

TestCase{
  And(R"head", R"xid", R"messagetype", R"rpcver",
      R"progid", R"progver", R"procedure",
      R"credflavor", R"credlength",
      R"veriflavor", R"verilength", R"data"),
  head        = Hex("80000028", "8FFFFFFF", "EFFFFFFF", "80000000"),
  xid         = Nb32(0),
  messagetype = Nb32(0),                     -- CALL = 0
  rpcver      = Nb32(2),
  progid      = Nb32(100000, 536871731, 536872005, 536870914, 0, 0xFFFFFFFF),
  progver     = Nb32(2, 1, 0xFFFFFFFF),
  procedure   = Nb32(4, 5, 0, 0xFFFFFFFF),
  credflavor  = R"flavor",
  credlength  = R"length",
  veriflavor  = R"flavor",
  verilength  = R"length",
  flavor      = Nb32(0, 1, 0xFFFFFFFF),
  length      = Nb32(0, 4, 0xFFFF, 0xFFFFFFFF),
  data        = Or("", "\0" * 40, "\255" * 1000)
}

읽어 보면 RPC 호출의 필드 구조가 그대로 보인다. messagetype은 CALL로 고정, progid·procedure는 유효값과 극단값(0xFFFFFFFF)을 섞고, length 필드는 정상값과 오버플로 후보(0xFFFF, 0xFFFFFFFF)를 나열한다. data는 빈 값, 40바이트 널, 1000바이트 초과분으로 길이 처리를 흔든다. 완전 조합이라 flavor·length가 credential/verifier 양쪽에서 교차하며 상당한 수의 PDU를 만들어낸다.

이 Grammar는 "RPC로서 유효한 골격"을 유지한 채 각 필드의 위험값을 순회한다. 랜덤 퍼저가 우연에 기대던 조합을, 선언적으로 남김없이 훑는다.

Grammar vs Fuzzer, 그리고 Grammar vs AFL

산업용 견고성 시험 도구들은 테스트를 여러 유형으로 나눈다. 그중 대비가 선명한 둘:

  • Fuzzer: 무작위 헤더값으로 잘못된 패킷 생성. 랜덤 넘버 제너레이터 기반이라 필드 선택도 값도 무작위. 비체계적이라 커버리지를 측정할 수 없다.
  • Grammar: 모든 필드와 조합을 순회 + 공통 구현 오류를 겨냥한 지능적 퍼즈값. 정량적 커버리지를 달성한다.

같은 프로토콜에 두 방식을 다 제공하면서도 "Grammar가 Fuzzer보다 커버리지가 낫다"고 명시하는 건 우연이 아니다. 무작위성은 값싸지만 보증이 없고, Grammar는 작성 비용이 들지만 무엇을 테스트했는지 말할 수 있다.

여기서 AFL과의 관계를 오해하면 안 된다. AFL의 강점은 커버리지 피드백이다 — 실행 경로를 관측해 새 경로를 여는 입력을 살려 나간다. Grammar의 강점은 구조 지식이다 — 유효 골격을 알고 그 안에서 변형한다. 둘은 대립이 아니라 보완이다. 실제로 가장 강력한 조합은 구조를 아는 생성기(Grammar/boofuzz류)로 유효 골격을 만들고, 그 위에 커버리지 피드백을 얹는 것이다. 소스가 있으면 AFL의 계측을, 없으면 대상의 응답·크래시를 신호로 쓴다.

정리하면 선택 기준은 이렇다.

  • 입력이 느슨하고 소스가 있다 → AFL 계열 뮤테이션 + 커버리지 피드백부터.
  • 입력이 엄격한 구조·체크섬·상태를 가진다 → Grammar/생성 기반으로 유효 골격을 세우고 필드를 순회.
  • 둘 다 → 생성기로 씨앗을 만들고 커버리지로 키운다.

마치며

랜덤 뮤테이션 퍼저가 구조화된 프로토콜에서 무너지는 지점은 세 곳이었다 — 길이 필드, 체크섬, 문맥 의존 필드. Grammar 기반 접근은 이 셋을 각각 Size/SizeFuzz, Lua disassemble/assemble, 그리고 명시적 And/And1/And2 조합 전략으로 정면 돌파한다. 대가는 프로토콜 구조를 손으로 기술하는 작업이지만, 그 대가로 무엇을 테스트했는지 말할 수 있는 퍼징을 얻는다.

체크섬 검증에서 히트율이 죽는 프로토콜을 만나거든, 바이트를 더 세게 뒤집으려 하기 전에 구조를 기술하는 쪽을 먼저 고려해 볼 만하다.

참고