본문 바로가기

프론티어

프론티어_AFL 코드 분석

AFL 코드 분석을 위해서 해당 사이트에서 실제로 AFL 코드를 clone받아 하나씩 뜯어본다.

https://github.com/google/AFL/

 

GitHub - google/AFL: american fuzzy lop - a security-oriented fuzzer

american fuzzy lop - a security-oriented fuzzer. Contribute to google/AFL development by creating an account on GitHub.

github.com

 

 

 

일단 make를 해준 뒤 실제로 작동하는지 확인을 해준다.

 

그 후 AFL을 실행하면서 하나씩 어떻게 돌아가는지 확인을 하고 싶어서 gpt에게 간단한 코드를 짜달라고 한다.

 

 

 

AFL instrumentation을 넣기 위해서는 ./afl-gcc를 활용해야한다. instrumentation을 넣는 이유는 커버리지 퍼징을 지향하는 afl에서 커버리지 측정을 하기 위해서 넣는 것 이다.

 

 

그 후 코드 흐름상 더 깊은 코드로 들어가기 위해서는 AFL이라는 문자열을 한 글자씩 다 맞아야 더 깊은 곳으로 들어갈 수 있다.

그러면 실제로 seed를 A, AF, AFL로 설정을 해놓으면 점점 더 깊은 Captured가 뜨는 것을 확인할 수 있다.

./afl-showmap

여기서 사용한 명령어는 afl-showmap인데 AFL 도구 중 하나이다.

./afl-showmap
 
해당 도구의 역할은 target 프로그램을 한 번 실행하고 그때 찍힌 coverage map을 보여주거나 파일로 저장한다. 이걸 사용한 이유은지금은 퍼징을 오래 돌리는 것이 아니라 어떤 seed값에 따라서 어떻게 coverage가 증가하는지를 확인하고 싶어서 사용했다. 단 하나의 seed를 가지고 target coverage를 확인할 수 있는 명령어이다.
 
 

 

실제로 나온 MAP을 보면 a, af, afl 이렇게 증가할 수록 한 줄씩 커버리지가 추가되는 것을 확인할 수 있다. 그리고 값을 자세하게 보면 점점 값이 커지는 것도 확인할 수 있다.

 

실제로 coverage가 증가하는 건지, 어떤 seed를 넣었을 때 실제로 coverage가 증가하는 건지를 확인할 수 있었다.

 

그러면 실제로 어떤 코드가 afl에 있을까?. 일단 큰 코드 구조들부터 확인해본다.

 

코드 구조

  ./afl-fuzz -i in -o out -- ./target

 

위에 있는 코드를 활용하여서 명령어를 입력하면 main함수로 들어간다.

항상 코드의 첫 시작점은 main함수이다. 

 

 

 

처음에는 argc를 확인하며 제대로 된 인자값들이 들어오는지 확인한 뒤에 

  argv[0] = ./afl-fuzz
  argv[1] = -i
  argv[2] = in
  argv[3] = -o
  argv[4] = out
  argv[5] = --
  argv[6] = ./target

이런 느낌으로 파싱이 된다.

그 후 난수 설정을 하여 입력 변이, 스케줄링을 하고 각자 argv를 이용하여 AFL 옵션을 파싱한다.

 

실제로 i, o가 없으면 코드 실행이 안 되도록 막아놓은 모습이다.

 

  setup_signal_handlers(); 			
  check_asan_opts();        		
  fix_up_sync();					
  save_cmdline(argc, argv);
  fix_up_banner(argv[optind]);
  check_if_tty();
  get_core_count();
  bind_to_free_cpu();
  check_crash_handling();
  check_cpu_governor();
  setup_post();
  setup_shm();
  init_count_class16();
  setup_dirs_fds();
  read_testcases();
  load_auto();
  pivot_inputs();
  load_extras(extras_dir);
  find_timeout();
  detect_file_args(argv + optind + 1);
  setup_stdio_file();
  check_binary(argv[optind]);
  perform_dry_run(use_argv);

 

그 후, 이런 함수들을 초기화 & call하면서 fuzzing을 하는 기초를 준비한다.

 

분류를 조금 해보자면

 

1. 종료/환경 안전 장치

  setup_signal_handlers();          -> Ctrl+C 등 시그널 처리 등록
  check_asan_opts();                -> ASAN/MSAN 환경 확인
  check_crash_handling();           -> crash 감지 방해 요소 확인
  check_cpu_governor();             -> CPU 성능 모드 확인

 

 

2. 병렬 fuzzing / 실행 정보 정리

  fix_up_sync();                    -> 병렬 fuzzing sync 설정 정리
  save_cmdline(argc, argv);         -> 실행 명령어 저장
  fix_up_banner(argv[optind]);      -> 화면에 보여줄 이름 설정
  check_if_tty();                   -> UI 출력 방식 결정

 

3. CPU/ 성능 관련 초기화

  get_core_count();                 -> CPU 코어 수와 사용량 확인
  bind_to_free_cpu();               -> 비어 있는 CPU에 AFL 고정

 

4. Coverage 수집 준비

  setup_shm();                      -> shared memory coverage bitmap 생성
  init_count_class16();             -> coverage count bucket 변환 테이블 초기화

5. 입력/출력 

  setup_dirs_fds();                 -> out 디렉터리 구조 생성
  read_testcases();                 -> in 디렉터리 seed 읽기
  load_auto();                      -> 자동 dictionary 로드
  pivot_inputs();                   -> seed를 AFL queue 형식으로 정리
  load_extras(extras_dir);          -> 사용자 dictionary 로드

6. 타겟 실행 방식 준비

  find_timeout();                   -> 실행 timeout 결정
  detect_file_args(argv + optind + 1); -> @@ 방식인지 확인
  setup_stdio_file();               -> stdin 방식 입력 파일 준비
  check_binary(argv[optind]);       -> 타겟 바이너리 검사

 

이렇게 준비가 다 되면 실제로 fuzzing을 하는 단계로 접어든다.

 

 

fuzzing은 이런 식으로 흘러간다. 

 

queue에 있는 입력 하나를 고른다
    -> 그 입력을 변이한다
    -> 타겟을 실행한다
    -> 새로운 coverage가 나오면 queue에 추가한다
    -> 다음 queue 입력으로 넘어간다
    -> 무한 반복

 

call_queue를 사용하여서 AFL이 퍼징을 할 때 발견하는 입력들을 queue에 쌓아놓는다. 그 중에서도 현재까지 찾은 coverage를 기준으로 그나마 쓸만한 입력들을 골라서 사용한다.

 

그 후에 skipped_fuzz를 이용해서 실제 fuzzing을 시도하는 것 이다.

 

사실 fuzzing을 하는 과정이 제일 중요하니 실제로 fuzzing을 하는 코드로 들어가보자.

fuzz_one은 내부에서 변이 입력을 만들고 해당 입력으로 ./target을 여러 번 실행하게 된다.

 

초기에 변수 설정을 끝내고 나서

 

현재 queue entry를 fuzzing을 할지 말지를 결정하는 부분이다. 사실 queue를 사용하여 발견한 모든 변수에 대해서 fuzzing을 하게 된면 시간이 낭비된다. 그래서 AFL은 3개의 특징을 가진 표시를 가진다.

1. favored

2. was_fuzzed

3. pending_favored.

favored는 coverage 관점에서 가장 우선순위가 높은 입력이고 was_fuzzed는 이미 fuzzing을 한번 해본 입력이다. pending_favored는 아직 fuzzing을 하지 않고, favored 입력만 남아놓은 상태이다.

 

예시를 들어보자.

#0 "x"     already fuzzed 

#1 "A"     favored, not fuzzed

#2 "AF"   favored, not fuzzed  

#3 "AAAA"  non-favored.

 

이렇게 되면 우선순위는 favored가 붙어있고, fuzzing을 하지 않은 1, 2번을 먼저 fuzzing을 시도한다.

 

그 후 로그 출력을 시도하고

 

 

quere_car->frame으로 현재 fuzzing을 할 입력 파일 경로를 확인하여서 queue파일을 읽으며 favored가 붙은 변이 대상을 가져온다.

 

그 후 ck_alloc_nozero를 사용하면서 

in_buff에는 원본 queue 입력을

out_buf에는 변이해서 타겟에 넣을 입력을 집어넣는다.

 

예를 들어보자면 in_buf가 "x"이면 "A","AF" 이런걸 만들 수 있다.

근데 여기서 왜 x시작인데 "A"가 나오지?.

 

"x"를 하나의 문자로 모는 것이 아니라 ASCII로 보는 것 이다.

x = 0x78 = 0111 1000

A = 0x41 = 0100 0001

이렇듯, 시작 기준 값에서 byte, bit를 바꿔가면서 mutation을 시도하는 것 이다.

 

 

 

 

그 후 subseq_tmouts =0;

cur_depth = quere_cur -> depth;

를 하여서 연속 timeout 횟수를 관리, 그리고 현재 depth를 확인한다.

 

seed "x" 일 때 depth 1

"x" -> "A" depth 2

"A" -> "AF" depth 3.

 

이렇게 얼마나 depth가 깊어지는지를 확인한다.

 

 

이 후 trimming이라는 작업을 수행해준다.

이 과정은 예시로 드는 것이 이해하기 좋을 것 같다.

 

queue 입력이   "AAAAAAAFLBBBBB" 이런 형식이라고 할 때, 우리가 필요한 입력은 AFL 딱 3글자이다.

그러면 AFL은 일부 byte를 지워보면서도 coverage가 유지되는지를 확인하다.

 

 "AAAAAAAFLBBBBB"

 "AAAAAAAFLBBBB"

 "AAAAAAAFLBBB"

 

그러면 coverage가 유지되면서 더 짧은 입력으로 바꿀 수 있게된다. -> 더 짧은 입력으로 줄이면 fuzzing이 더 효율적으로 돌아갈 수 있다. 

 

 

orig_perf = perf_score를 활용하여서 quere 입력을 "얼마나 많이" fuzzing할지 점수를 매긴다.

 

EX)

실행 속도가 빠른가?

새로운 coverage를 잘 만드냐?

입력 크기가 작냐?

아직 많이 fuzzing을 하지 않았냐?

 

이런 식으로 점수를 많이 매기면서 더 높은 점수를 가진 변수에게 더 많이 돌 기회를 준다.

 

이 후 AFL에 2가지 mutation 방법이 나오는데

 

  1. deterministic stage
    -> bitflip, byteflip, arithmetic처럼 체계적으로 하나씩 바꿔보는 단계

  2. havoc stage
    -> 랜덤하게 여러 변이를 섞어서 때려보는 단계

 

이다. 만약 사용자가 -d 옵션을 주면 1번은 생략한다. 또한 이미 체계적인 입력을 통해서 한 번 시도한 입력은 하지 않는다.

그 후 doing_det=1까지 오면 deterministic fuzzing을 하겠다는 의미이다. 보통은 2번을 선택하며 1번을 하기 위해서는 여러 가지 조건이 필요한 것 같다.

 

 

 

deterministice fuzzing을 시작하고 첫 단계는 1비트씩 하나씩 뒤집어보는 것이다.

 

예를 들어 입력이 "X"이고 길이가 1바이트일 때

x = 0111_1000

-> 1111_1000

-> 0011_1000

 

이렇게 한 비트씩 하나하나 뒤집어보는 것 이다.

 

 

해당 bit를 하나씩 바꿔보면서 발견한 새로운 hit들을 저장한다. 만약 new_hit_cnt가 더 높으면 stage_finds에 기록된다.

  bitflip 1/1 -> bit 1개씩 바꿈
  bitflip 2/1 -> bit 2개씩 바꿈
  bitflip 4/1 -> bit 4개씩 바꿈

 

그 다음으로는 이렇게 bit를 바꾸는 갯수를 조금씩 달리하여서도 fuzzing을 시도한다. 이렇게 fuzzing을 준비하면서도 고려할 점이 있다.

 

AFL에서는 effecrtor map이라는 것을 만드는데,  왜 AFL은 effector map이라는 걸 만들까?
입력의 어떤 byte는 바꿔도 coverage에 영향이 없을 때가 있다. 

  예를 들어 입력이 이런 식이라고 해보자.
AFLxxxxxxxxxxxxxxxx
타겟 코드가 앞 3바이트만 본다면:
  if (buf[0] == 'A')
  if (buf[1] == 'F')
  if (buf[2] == 'L')
뒤의 xxxxxxxx들은 아무리 바꿔도 실행 경로가 안 바뀔 수 있다.. 그러면 AFL 입장에서는 뒤쪽 byte들을 열심히 바꾸는 게 낭비가 되는 것 이다..

 

그래서 effector map은 byte가 바뀔 때 coverage가 달라지냐 안 달라지냐를 명시해주는 것 이다.

 

이제 아래와 같이 계속 fuzzing을 한 뒤에 arithmetic mutation이 시작된다.


  bitflip 1/1   -> 1비트씩
  bitflip 2/1   -> 2비트씩
  bitflip 4/1   -> 4비트씩
  bitflip 8/8   -> 1바이트씩
  bitflip 16/8  -> 2바이트씩
  bitflip 32/8  -> 4바이트씩

 

arithemitc mutation은 'A'가 65일 때, 64, 66, 63 등등 이런 값들을 넣는 것 이다.

arithemtic mutation 다음으로는 

0, 1, 1, 127, 128 등등 다양한 경계값이 있는 수들을 더하거나 빼는 mutation을 수행한다.

요약하면

  INTERESTING VALUES
    -> AFL이 미리 알고 있는 경계값들을 넣어본다

  interest 8/8
    -> 1바이트 경계값 삽입

  interest 16/8
    -> 2바이트 경계값 삽입
    -> little/big endian 둘 다 시도

  interest 32/8
    -> 4바이트 경계값 삽입
    -> little/big endian 둘 다 시도

 

이렇다. 남은 fuzzing들도 요약해보면

 1. dictionary overwrite
  2. dictionary insert
  3. havoc
  4. splice
으로 나눌 수 있으며

  1. dictionary overwrite
    -> 입력의 기존 byte 구간을 dictionary token으로 덮어쓴다
예:
    입력:  "xxxx"
    토큰:  "AFL"

    결과:  "AFLx"
          "xAFL" 
  2. dictionary insert
    -> 입력의 기존 내용을 밀어내고 dictionary token을 삽입한다.
  예:
    입력:  "xxxx"
    토큰:  "AFL"

    결과:  "AFLxxxx"
          "xAFLxxx"
          "xxAFLxx"

  3. havoc
    -> 여러 mutation을 랜덤하게 섞어서 수행한다
예:
    bit flip
    byte set
    arithmetic
    interesting value 삽입
    dictionary token 삽입/덮어쓰기
    byte 삭제
    byte 복제
    byte overwrite

  이런 것들을 랜덤으로 여러 개 쌓아서 한 번에 적용한다.

  4. splice
    -> 서로 다른 queue 입력 두 개를 섞은 뒤 havoc을 수행한다

  예:
    입력 1: "AAAAxxxx"
    입력 2: "BBBByyyy"

    splice 결과:
      "AAAByyyy"
      "BBBBxxxx"
이렇게 두 입력을 섞고,     그 결과물에 다시 havoc mutation을 수행한다.

 

전체적으로 확인을 해보면 fuzzing은.

  fuzz_one()
    -> bitflip 계열
    -> arithmetic 계열
    -> interesting values 계열
    -> dictionary 계열
    -> havoc
    -> splice + havoc

 

fuzz_one이 끝나면 다시 while문으로 돌아가서 다시 다음 loop을 돌게 된다. 진짜 간략하게 요약하면

 

 queue 전체를 한 바퀴 돌고
  -> 다시 처음부터 돌고
  -> 새로 발견된 입력도 queue에 추가하면서
  -> 계속 반복

이 행동을 반복하는 것 이다.

 

여기까지 한 코드 분석은 AFL 전체 흐름과 Mutation이 어떻게 이루어지는지에 대한 설명이다. 세세하게

1. Instrumentation 코드는 어떻게 이루어지는지

2. Coverage bitmap 동작 원리는 무엇인지

3. Fork Server에 대해서

를 이어서 설명하겠다.

 

Instrumentation.

코드는 아래와 같이 시작한다.

 

내용이 길어져서, 간단하게만 설명을 하자면

 

 AFL instrumentation은 ./afl-gcc를 통해서 들어간다. 흐름은 afl-gcc -> gcc -> afl-as 이런 식이다. afl-as.c의 add_instrumentation() 함수가 assembly 코드를 한 줄씩 읽는다.

 

그 중 .text section, 함수 label, branch label, 조건 분기 같은 위치를 찾는다. 그리고 해당 위치에 afl-as.h에 정의된 trampoline 코드를 삽입한다.

 

이 trampoline은 실행될 때 __afl_maybe_log를 호출한다. __afl_maybe_log는 현재 위치인 cur_loc와 이전 위치인 prev_loc를 XOR한다. 그 결과값을 coverage bitmap의 index로 사용한다. 그리고 trace_bits[cur_loc ^ prev_loc]++ 이런 식으로 해당 경로가 실행됐다는 것을 기록한다. 결국 AFL은 이 bitmap을 보고 새로운 coverage가 나왔는지 판단하는 것이다. 

 

예시를 들어보자면

  if (buf[0] == 'A') {
    if (buf[1] == 'F') {
      if (buf[2] == 'L') {
        printf("Captured\n");
      }
    }

 

또 이런 코드를 가져와서, 이 코드를 ./afl-gcc target.c -o target으로 컴파일한다고 하면 컴파일 중간에 asembly가 만들어지고 해당 assembly를 다시 afl-as라는 코드가 읽는다. 그리고 if 문으로 인해 생기는 branch 위치마다 AFL trampoline 코드가 삽입된다.

 

그래서 target이 실행되면서 buf[0] =='A' 분기를지나가면 bitmap의 위치가 증가하게 되고, 또 F, L을 지나면 계속 위치가 증가하는 것 이다. 이렇게 증가한 bits들을 보면서 새롱누 경로로 들어갔구나 하는 것을 알게 된다.

 

 그러면 여기서 나오는 bitmap은 무엇일까?.

 

Bitmap

Coverage bitmap 동작 원리는 trace_bits라는 64KB짜리 배열을 기준으로 보면 된다.  AFL은 target을 실행하기 전에 shared memory를 하나 만든다.
-> 이 shared memory가 바로 coverage bitmap이고, AFL 코드에서는 trace_bits라는 이름으로 사용된다.

 

크기는 config.h:328에 정의되어 있다.

  #define MAP_SIZE_POW2 16
  #define MAP_SIZE      (1 << MAP_SIZE_POW2)


-> 즉, 2^16 = 65536 byte 크기의 bitmap이다.  AFL은 afl-fuzz.c의 setup_shm()에서 이 shared memory를 만든다.
그 후 __AFL_SHM_ID 환경변수로 target에게 shared memory id를 넘긴다. target 안에 삽입된 instrumentation 코드는 실행될 때 이 shared memory에 붙는다.
그리고 특정 분기나 함수 위치를 지나갈 때마다 다음과 같은 방식으로 값을 증가시킨다. (예시를 생각하면 편할 것 같다) 

trace_bits[cur_loc ^ prev_loc]++;

해당 코드에서 cur_loc는 현재 실행 위치를 의미하고, prev_loc는 바로 이전에 실행된 위치를 의미한다. 둘을 XOR하면 “이전 위치에서 현재 위치로 이동한 edge”를 표현할 수 있다.
즉 AFL은 단순히 어떤 줄이 실행됐는지가 아니라, 어떤 경로로 이동했는지를 기록한다. target 실행이 끝나면 AFL은 이 trace_bits를 확인한다.
기존에 본 적 없는 bitmap 위치가 켜졌다면 새로운 coverage라고 판단한다. 그러면 해당 입력은 의미 있는 입력으로 보고 queue에 저장한다.

 

Fork Server.

퍼징을 돌리기 위해서는 입력을 넣는 곳, 입력이 넣어지는 곳이 필요하다. 해당 부분들을 컴퓨터를 두 개로 돌리기 보다는 컴퓨터 하나로 fork해서 돌리는 편이 좋기에, Fork를 다루는 곳도 추가로 알아보겠다.

 

Fork Server는 AFL이 target을 매번 처음부터 실행하지 않기 위해 사용하는 구조이다.  일반적으로 target을 실행할 때마다 execve()를 하면 프로그램 로딩, 동적 링킹, 초기화 과정이 계속 반복된다. 이렇게 계속 반복을 하게 되면, 퍼징은 target을 수천 번, 수만 번 실행해야 하기 때문에 이 비용이 꽤 커진다.

 

그래서 AFL은 target을 한 번만 실행해서 어느 정도 초기화된 상태로 멈춰놓는다.멈춰놓은 것을 활용하여 새로운 입력을 테스트해야 할 때마다 그 멈춰 있는 process를 fork()해서 child process를 만든다.

 

이렇게 하면 매번 execve()로 바이너리를 새로 로딩하지 않아도 된다. 흐름을 보면 먼저 afl-fuzz가 pipe를 만들고 target을 한 번 실행한다. target 안에는 instrumentation과 함께 fork server 코드도 삽입되어 있다.

실제로 forkserver를 초기화하는 코드의 화면이다.  init_forkserver() 함수는 AFL이 target을 매번 execve()로 새로 실행하지 않기 위해 fork server를 준비하는 부분이다. 실제로 코드 흐름상은 아래와 같이 흘러간다.

 

1. 먼저 pipe()를 이용해서 AFL과 target이 서로 통신할 수 있는 통로를 만든다.
2. 그 후 fork()를 호출해서 fork server 역할을 할 process를 하나 만든다.
3. child process 쪽에서는 target 실행 환경을 설정한다.
4. stdout, stderr를 /dev/null로 돌리고, stdin은 AFL이 만든 입력 파일을 바라보게 설정한다.
5. 그리고 control pipe와 status pipe를 target 안에서 사용할 고정 fd에 연결한다.
6. 여기서 사용하는 fd가 FORKSRV_FD와 FORKSRV_FD + 1이다.
7. 그 다음 execv()를 통해 실제 target binary를 실행한다.
8. target은 실행되면서 AFL이 삽입해둔 instrumentation 코드에 도달한다.
9. 이때 target 내부의 fork server 코드가 실행되고, AFL에게 4바이트 handshake를 보낸다.
10. 부모 process인 afl-fuzz는 이 handshake를 기다린다.
11. 정상적으로 4바이트를 받으면 fork server 초기화가 성공한 것이다.
12. 이후부터 AFL은 target을 새로 execve()하지 않는다.
13. 대신 pipe를 통해 fork server에게 이번 입력으로 한 번 실행해줘라고 보낸다.
14. fork server는 요청을 받을 때마다 fork()로 child를 만들고, 그 child가 실제 입력을 처리한다.
15. child 실행이 끝나면 fork server는 종료 상태를 AFL에게 다시 전달한다.
16. AFL은 그 결과와 coverage bitmap을 보고 crash인지, timeout인지, 새로운 coverage인지 판단한다.

 

짧게 요약하면 pipe를 만들고, 해당 pipe에서 target에 고정할 fd를 딱  연결 시킨 뒤에 해당 타겟을 실행시킨다. 이제 성공을 하면 부모 process는 target을 다시 실행시키지 않고 값만 바꿔서 계속 퍼징을 시도하는 것 이다.

'프론티어' 카테고리의 다른 글

프론티어_자료구조  (0) 2026.05.26
프론티어_fuzzing_101 환경 설정  (0) 2026.05.18
프론티어_CodeQL실습  (0) 2026.05.13
프론티어_CodeQL.  (0) 2026.05.13
프론티어_IDS/IPS에 대해서  (0) 2026.05.13