Fuzzing Windows Application with WINNIE

Paper

Introduction

오늘날 fuzzing은 소프트웨어 취약점 탐지에 있어서 가장 유명한 방식중 하나로 자리잡게 되었다. 하지만 지금까지 Fuzzing community에서는 linux systemopen source 프로그램에 대한 연구만 진행되었다. 따라서 Windows 환경에 대한 fuzzing방법이 필요하다. 하지만 Windowsclosed source환경인 경우가 지배적이고 프로그램에 GUI를 통한 user interaction이 필수적인 것이 fuzzing에 많은 제약으로 존재한다. 또한 linux에서 사용되는 대표적인 fuzzer인 AFL의 경우 fork syscall을 이용하여 fuzzer의 처리량을 증가시키고 그 안정성을 보장해 주는데 Windows에서는 그러한 기능이 존재하지 않는다. 이러한 문제를 해결하기 위해 기존에는 harness라고 하는 fuzzing 전용 프로그램을 직접 작성해 주어야 했다. WINNIE는 이러한 문제를 해결하기 위해 반 자동화된 harness생성기와 Fork를 이용한 Full-speed Fuzzing을 제시한다.

About Harness

Windows application에서는 GUI가 일반적인 프로그램 형태이다. GUI application은 input을 받기 위해서 유저와의 상호작용이 필요하다. GUI를 우회하는 작업은 애매한 작업이다. 만약 매크로와 같은 스크립트 작성을 통해 GUI를 우회한다면 심각한 속도 저하를 불러 올 것이다. 또한 프로그래머가 input에 대한 처리 로직에 비동기 GUI code를 삽입해 두었다면 이를 우회하기 위해 프로그램에 대한 높은 이해가 필요해진다. Fuzzing에서 GUI application은 상호작용 문제 이외에도 속도 문제를 생기게 한다. GUI 초기 실행에 위한 시간이 많이 필요하기 때문이다. 이러한 문제 때문에 CLI 기반의 타겟을 만들어 내는 것이 필수적이다. 일반적으로 많이 알려진 WINAFL의 경우 이 문제를 해결하기 위해 harness 라고 하는 것을 만드는 것을 추천한다. Harness는 실제 프로그램에서 동작하는 특정 로직을 똑같이 수행할 수 있게 만들어 놓은 작은 프로그램이다. 일반적으로 LoadLibrary()와 같은 함수를 통해 dll을 로드하고 dll 내의 원하는 함수의 calling convention과 함수의 인자들을 조사하여 함수로 만든 후 main 로직에서 호출하게 하는 형태로 작성한다.

Challenge of Harness

WINNIE의 목표는 Source Code가 없는 경우 Harness의 작성을 자동화 하는것이다. Harness의 작성은 앞서도 언급했듯 비동기 GUI와 같은 여러 문제 때문에 복잡하고 작성을 했다고 하더라도 오류가 있을 가능성이 존재한다. 따라서 Harness를 자동으로 생성하기 전에 Harness 작성시 발생하는 문제점들에 대해서 분석이 선행되어야 한다.

Harness Generation

Harness는 실제 프로그램과 유사하게 동작하며 작성자가 원하는 code까지 도달 할 수 있어야한다. 이러한 요구조건을 만족하려면 복잡하고 실제로 harness가 유효한지 확인하는데는 여러 어려움이 또 존재한다. 따라서 WINNIE에서는 좋은 harness 작성에 대한 4단계를 제시한다.

  1. target discovery
  2. call sequence recovery
  3. argument recovery
  4. control flow and data flow dependence reconstruction

이러한 방식을 통해 WINNIE의 논문에서는 아래와 같은 harness를 생성해낸다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1) Declare structures and callbacks
int callback1(void* a1, int a2) { ... }
int callback2(void* a1) { ... }
// 2) Prepare file handle
FILE *fp = fopen("filename", "rb");
// 3) Initialize objects, internally invoking ReadFile()
int *f0_a0 = (int*) calloc(4096, sizeof(int));
 int f0_ret = JPM_Document_Start(f0_a0, &callback1, &fp);
 if (f0_ret){ exit(0); }

 // 4) Get property of the image
 int f1_a2 = 0, int f4_a2 = 0;
 JPM_Document_Get_Page_Property((void *)f0_a0[0], 0xA, &f1_a2);
 ...
 JPM_Document_Get_Page_Property((void *)f0_a0[0], 0xD, &f4_a2);

 // 5) Decode the image
 JPM_Document_Decompress_Page((void *)f0_a0[0], &callback2);

 // 6) Finish the harness
 JPM_Document_End((void *)f0_a0[0])

Target discovery

먼저 input을 핸들링 해주는 target을 찾아야 한다. input은 다양한 형태로 존재할 수 있는데 예를 들면 파일 이름, 파일 descriptor, 파일 자체의 데이터 등이 있다. 위 예제에서는 JPM_Document_Start와 같은 직관적인 API가 ldf_jpm.dll에 들어있어 file descriptor를 통해 사용자가 input을 주입 할 수 있게 준비해준다.

Call-sequenece recovery

Harness는 여러 함수들의 call에 있어서 정확한 순서로 재현해 주어야 한다. 이번 예제에서는 총 10개의 API가 최종 harness작성에 필요하다. 정적 분석만으로 모든 call들을 확인 하기는 힘들다. 왜냐하면 jump table과 간접 호출이 실제 프로그램에서 빈번하게 사용되기 때문이다. 따라서 이 과정에서 동적 분석이 반드시 함께 수행 되어야 한다.

Argument recovery

Harness는 정확한 인자를 각각의 함수들에 넣어주어야 한다. 이러한 인자를 다시 복원하는 것은 어려운 일이다. 왜냐하면 각각의 인자는 위 harenss에서 사용한 &callback1처럼 함수 포인터 일 수 도있고 &f1_a2와 같이 정수형 값에 대한 포인터일 수 도 있기 때문이다. 따라서 각각의 함수들에 대한 정확한 분석이 반드시 필요해진다.

Control-flow and data-flow dependence

앞서 call-sequence recovery를 이용하여 각각의 함수들에 대한 순서를 파악 했더라도 충분하지 않은 경우가 있다. 또한 라이브러리들이implicit semantic 을 정의하고 있다. 이러한 관계는 control-flow 의존성과 data-flow 의존성에 나타나 있다. 예를 들면 API 호출 간에 조건 분기가 harness에서 필요 할 수 있다. 위 harness의 if (f0_ret){ exit(0); }가 그 예시이다. 하나의 API가 이후 로직에서 call되는 API에 사용되는 포인터의 값들을 바꾸거나 리턴 한다면 문제가 될 수 있다. 만약 이러한 관계를 고려하지 않고 harness를 작성한다면 false positive나 false negative문제가 발생할 수 있다. 위 harness를 보면f0_a0 배열을 계속해서 update해주는 것을 확인 할 수 있는데 이러한 관계를 파악하는 것은 Closed Source환경에서 매우 어려운 일이다.

Limitation of Existing Solution

Windows에서는 linux의 fork()와 같은 빠른 프로세스 복제 기능을 제공하지 않는다. 따라서 Windows에서 사용 가능한 fuzzer들은 각각의 실행에서 프로그램의 초기부터 시작할 수 밖에 없다. 이는 Windows환경의 GUI가 각각의 실행에 있어서 모두 새롭게 실행되는 것이기 때문에 매우 많은 시간 낭비로 이어진다. 현재 존재하는 해결 방안은 WinAFL이 제공하는 persistent mode이다. 이 방식은 같은 프로세스에서 EIP조작을 통해 타겟 함수만 지속적으로 실행하는 것이다. 즉 프로그램을 재시작 하지 않는다. 이는 global program state를 오염 시킬 수 밖에 없으며 이는 fuzzer의 false positivefalse negative에 큰 영향을 줄 가능성도 존재하며 추가적으로 정상적인 code coverage가 회수 된 것인지도 확신 할 수 없게 만든다. 이는 crash가 발생했을 때도 root cause 분석이 힘들게 하거나 crash에 대한 reproduce가 불가능 할 수 도 있다는 것을 의미한다. 또한 harness 내부에 파일을 닫아주는 로직이 존재하지 않으면 그 다음 과정에서 새로 파일을 열 수 없게 되면서 testcase를 새롭게 mutating 할 수 없게 된다. 이처럼 기존의 WinAFL은 많은 제약 사항을 가지고 있다.

Solution

이에 따라 해당 논문에서는 앞서 언급한 여러 문제점을 해결하여 Windows환경에서 closed source application의 효율적인 fuzzing을 위해 WINNIE를 제안한다. WINNE는 크게 크게 두 가지 컴포넌트로 구성된다. 반 자동화된 Windows closed source harness generator와 fork가 구현된 fast cloning fuzzer이다. 아래 사진은 WINNE의 전체적인 구성도이다.

Untitled

먼저 타겟 바이너리와 input을 Harness Generator에 넣어주면 해당 타겟의 동작을 추적하며 타겟의 런타임 정보나 메모리 , 인자들을 수집한다. 이 정보들을 바탕으로 target 바이너리가 input을 핸들링 하는 부분을 찾아낼 수 있다. 이렇게 발견된 fuzzing 타겟에 대해서 앞서 설명한 4단계의 harness작성 법을 통해 최종 harness를 만들어낸다. 이후 hareness에 대한 안정성이나 정확성을 체킹하고 fuzzer를 작동 시키면 된다. 이제 정확히 WINNIEharness를 만들어내는 방식에 대하서 알아보자.

Fuzzing Target Identification

WINNIE는 이 단계에서 타겟 프로그램이 fuzzing이 가능한지 식별하고 만약 가능하다면 타겟 함수를 식별한다. 이때 여러 input을 타겟 프로그램에 주입해보면서 동적 분석을 수행하는데 이때 수집하는 정보와 동작과정은 아래와 같다.

  1. 로드된 모든 모듈에 대해서 이름과 base 주소를 기록한다.
  2. 모듈간의 control flow를 바꾸는 call과 jump에 대해서 thread id, caller와 callee의 주소, 인자 그리고 심볼이 있는 경우라면 심볼도 함께 기록한다. 만약 심볼이 없어 기록하려고 하는 함수의 원형을 알 수 없다면 RCX, RDX, R8과 같은 레지스터나 stack의 상위 값을 잠재적인 인자로 취급한다.
  3. retrun 인스트럭션을 실행할 때 return 값들을 모두 기록한다.
  4. 만약 특정값이 실제로 접근 가능한 메모리 주소라면 해당 값은 pointer라고 취급되며 해당 주소 역시 덤프하여 메모리에 적힌 값을 기록한다. 만약 다중 포인터인 경우라면 해당 과정으로 재귀적으로 수행한다. 또한 포인터에 일반적인 문자열 인코딩 값이 존재한다면 이 값 역시 함께 기록해준다.

위의 과정을 통해 수집 된 정보들을 바탕으로 fuzzing 타겟이 사용하는 함수들을 확인해야 한다. 라이브러리가 사용자의 input을 file path을 통해 받고 파일을 열고 내부 내용을 파싱하는 것이 일반적으로 fuzzing에 적합하다고 판단되는 요소이다. 즉 이러한 특성을 fuzzing 라이브러리 선정에 사용한다. 예를 들어 특정 함수 호출에서 인자로 file path가 사용된다면 이는 harness제작의 기초가 될 수 있다. 다음으로 파일과 관련된 API(ReadFile,OpenFile)들을 식별한다. 즉 라이브러리가 파일 경로가 포함된 인자를 사용하는 함수나 파일과 관련된 API를 호출하는 라이브러리는 input 파싱과 fuzzing target의 후보군으로 선정한다. 추가적으로 file descriptor를 사용하거나 메모리상에 있는 input파일에 대한 정보가 특정 함수내에서 사용될 경우 해당 함수 역시 잠재적인 후보 군으로 판단한다.

WINNIE는 주로 라이브러리가 노출하는 외부 인터페이스에 우선적으로 집중한다. 즉 harness generator는 라이브러리 내부 로직을 나타내는 것 같은 control flow는 기록하지 않는다. 왜냐하면 이러한 인터페이스를 통해 내부 API를 호출하면 기존의 프로그램과 동일하게 동작하므로 harness가 작성되었을 때 별다른 영향을 미치지 않기 때문이다. 대신 프로그램의 main 실행을 특별한 케이스로 취급하고 control-flow에 대한 모든 것을 기록한다. 이는 main 프로그램에서 모든 외부 라이브러리에 대한 호출을 수행하기 때문이다. 그러므로 적합한 fuzzing target을 찾기 위해서 main프로그램 실행 시점의 외부 라이브러리들에 대한 call-graph를 만들어낸다.

이후 WINNIE의 harness generator는 위에서 만든 call-graph를 바탕으로 메인 바이너리에 대한 검색을 진행한다. 특히 WINNIE는 I/O함수들에 대한 lowest comman ancestor(LCA)와 앞서 식별했던 parsing라이브러리의 API로 부터 분석을 시작한다. LCA는directed acyclic graph 두개의 노드에 대해서 모두 도달이 가능한 가장 깊은 곳이다. WINNIE는 LCA를 찾기 위해 바이너리의 call-graph에서 아래의 두 가지 기준을 만족하는 곳을 찾는다.

  1. LCA는 파일을 읽는 작업을 수행하는 부분 이전에 존재해야한다.
  2. LCA는 input에 대한 parsing을 진행하는 API에 도달할 수 있는 위치에 존재해야 한다.

아래의 그래프는 ACDSee 프로그램에 대한 call graph를 보여준다.

Untitled

위 예제에서 0x5cce80에 위치한 함수가 앞서 언급한 LCA이다. 해당 함수는 OpenFile과 ReadFile에 도달 가능하며 ide_acdstd.apl에 들어있는 parsing 함수를 호출한다. 그리고 만약 0x5cce80 함수가 harness에서 정상적으로 작동하지 않는다면 0x5cce80의 조상인 main()을 LCA로 대체 가능하다. 이러한 LCA를 찾는 것은 높은 품질의 harness작성에 큰 도움이 된다.

추가적으로 WINNIE에서 target을 선정할 때 미분 분석을 이용하여 fuzzing target을 재 선정 할 수 있다. 만약 harness 생성 단계에서 두 개의 input이 제공된다면 WINNIE는 두 가지의 input을 동적 분석하면서 fuzzing에 더 적합한 함수를 찾게 된다. 이때 두 input에 대해 동일하게 존재하는 기능은 향후 target 식별에 있어서 무시한다. 이러한 방식은 다중 스레드 application에서 하나의 스레드가 input을 처리하는 경우에 효과적이다.

Call-sequence Recovery


앞선 과정을 바탕으로 fuzzing target들을 선정해 낼 수 있었다. 이 단계에서는 API들의 call-sequence 복원하여 우리가 fuzzing하고자 하는 함수에 도달 할 수 있게 만들어주어야 한다. 우리가 도달하고자 하는 함수가 존재하는 라이브러리의 traceing 기록들을 바탕으로 harness-skeleton을 만들어낸다. 그리고 각각의 함수에 대한 원형을 동적 분석과 정적 분석을 함께 사용하여 추론한다. 즉 앞서 tracer로 부터 기록된 동적 정보들과 IDA ProGhidra와 같은 정적 분석을 결합하여 사용한다. 앞서 tracer가 메모리에 유효한 메모리 주소가 존재하면 일단은 재귀적으로 값을 기록하면서 다중 포인터에 대한 정확한 깊이를 알 수 없는데 이 과정을 통해 정확한 추론이 가능해진다. 이 과정을 마무리 하면 최종적으로 harenss에 대한 기초 코드가 만들어진다. 아래는 WINNIE가 자동으로 생성해주는 코드의 일부이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
...
typedef int (__stdcall *IDP_Init_func_t)(int);
typedef int (__stdcall *IDP_GetPlugInInfo_func_t)(int);
...

void fuzz_me(char* filename){

    IDP_Init_func_t IDP_Init_func;
    IDP_GetPlugInInfo_func_t IDP_GetPlugInInfo_func;
...
/* Harness function #0 */
    int* c0_a0 = (int*) calloc (4096, sizeof(int));    
    LOAD_FUNC(dlllib, IDP_Init);
    int IDP_Init_ret = IDP_Init_func(&c0_a0);
    dbg_printf("IDP_Init, ret = %d\n", IDP_Init_ret);

int main(int argc, char ** argv)
{
...
    dlllib = LoadLibraryA("%s");
    if (dlllib == NULL){
        dbg_printf("failed to load library, gle = %d\n", GetLastError());
        exit(1);
    }

    char * filename = argv[1];    
    fuzz_me(filename);    
    return 0;
}

다만 실제 Windows Application의 특성 상 다중 스레드를 사용할 수 있는데 WINNIE에서는 이러한 경우가 발생할 경우 파일과 관련된 API가 포함된 스레드만 고려한다. 이는 harness의 정확도를 높이기 위해 반드시 고려되어야 하는 부분이다. 앞서 tracer가 함수의 호출에 대해서 Thread ID를 기록하는 것은 이러한 작업을 하기 위한 것이다. 이러한 작업을 통해 harness가 간결하게 작성되고 이는 처리량과 안정성에 대한 상승으로 이어지게 된다.

Argument Recovery

이 단계에서는 harness skeleton에 포함된 API들에 대한 인자 복원이 진행된다. 먼저 WINNIE는 각각의 raw한 인자들을 ConstantVariable로 심볼링 해준다. 첫번째로 포인터 값들에 대한 인자 식별이 진행된다. 여기서 좀 특이한 방식을 사용한다. 하나의 input에 대한 tracing을 두번 진행하면 ASLR로 인해 포인터 값들은 고정되지 않고 바뀌게 된다. 즉 ASLR로 인해 값이 매 tracing마다 다르고 값이 접근가능한 메모리의 주소라면 포인터라고 간주 된다. 그리고 Static한 인자와 Varible한 인자를 구별한다. 이 때도 단순히 여러번의 tracing을 이용하여 값의 변화에 따라 각각 staticvariable로 구별한다.

Control-Flow and Data-Flow Reconstruction

WINNIE는 타겟 프로그램에 대한 control-flow와 data-flow를 harness에 반영하기 위해 프로그램을 분석해준다. Control-flow dependency는 여러 API 호출들이 논리적으로 어떤 관계를 가지는지에 대한 것이다. Control-flow dependency를 찾기 위해서는 정적분석이 필요하다. WINNIE는 호출된 함수의 리턴값에서 return이나 exit와 같은 종료조건까지의 두개의 API호출 경로를 분석한다. 이러한 경로가 발견되었다면 디컴파일된 control-flow 코드를 복제한다. 현재 버전의 WINNIE에서는 많은 할당을 사용하는 복잡한 흐름이나 여러 조건 분기가 존재하는 복잡한 흐름에 대한 분석은 최대한 피한다. 이러한 부분은 아직까지 사람의 노력이 필요하다. 이러한 특성을 통해 잘못된 Control-flow dependency가 harness에 최대한 영향을 미치지 않게 해준다. 예를 들어 실제 프로그램에서는 종료되었어야 하는 exit나 error handling이 harness에서 종료되지 않는다면 실제 프로그램에서는 존재하지 않는 상태로 진입하기 때문에 false positive가 발생하거나 harness에 대한 심각한 안정성 저하 문제가 발생할 수 있다.

Data-flow dependency는 함수의 인자와 리턴 값에 대한 관계를 나타낸다. 이러한 Data-flow dependency를 찾기 위해서 WINNIE는 여러 함수 호출에서 동일하게 자주 사용되는 변수들에 대해서 새롭게 선언하지 않고 동일한 변수로 연결시켜준다. 이러한 경우는 아래와 같은 큰 종류로 분류 할 수 있다.

  • Simple flows from retun value

    일반적으로 앞선 함수 콜에서 리턴되는 값들은 이후의 함수 콜에서 사용될 확률이 높다. WINNIE에서는 이러한 경우를 함수의 인자가 항상 이전의 리턴 값과 같은 경우를 체킹해서 찾아낸다. 이러한 방식을 항상 사용하는 것은 아니며 값이 일정한 기준을 만족하는 경우에만 이러한 방식을 사용한다.

  • Points to relationships

    특정 인자들은 앞서 함수에서 리턴된 값이고 실제 접근 가능한 메모리에 대한 값일 수도 있다. 위에서 언급한 JPM parser에 대한 예제를 살펴보자. 마지막 라인의 f0_a0변수는 메모리로부터 로드되는 값이고 JPM_Document_Start에서 값을 초기화 해준다. 이러한 관계가 추적 과정에서 발견된 경우 WINNIE는 Pointer Dereferencing(*pointer)을 통해 Harness에 반영해준다. 이는 이중 포인터나 삼중 포인터인 경우에도 적용이 가능하다.

  • Aliasing

    WINNIE는 두번 이상 사용되는 값에 대해서 변수로 선언해준다. 즉 상수값이 아니더라도 인자로 여러번 사용되는 경우 하나의 변수로 Aliasing해준다.

Harness Validation and Finalization

위 과정들을 바탕으로 harness작성이 완료되었더라도 이는 완전하지 못할 수 있다. 따라서 harness에 대한 검증이 필요하다. 먼저 harness의 안정성에 대해 확인해 보아야 한다. 일반적인 input을 harenss에 주입했을 때 crash가 발생한다면 이는 harness에 중대한 문제가 존재하는 것이므로 다시 작성 해야한다. 다음으로 code coverage가 정상적으로 수집되고 code coverage기반의 mutation 을 통해 새로운 code path가 발견되는지 확인해야 한다. 이는 fuzzer를 실제로 짧게 돌려보면 확인 할 수 있다. 만약 정상적으로 code path가 증가하지 않는다면 harness를 다시 작성해야한다.

Fast Process Cloning on Windows

Fork기능은 Windows에 존재하기는 하지만 안정적으로 구현되어있지는 않다. 따라서 Windows internal API와 service에 대한 리버싱을 통해서 안정적으로 구현하기 위한 바탕을 마련했다. 이러한 과정을 바탕으로 Windows Fuzzing을 위한 안정적인 Fork Server를 구현했다. 이러한 fork 메커니즘을 이용한 Fuzzing은 기존의 CSRSS(Client/Server Runtime Subsystem)와 관련된 문제들을 해결해준다. 이는 Windows 환경에 user 프로세스를 관리하는 기본적인 layer에 대한 문제이다. 만약 프로세스가 CSRSS에 연결되어있지 않다면 CSRSS에 접근하여 Windows API를 사용하는 순간 crash가 발생한다. Fork Server는 CSRSS에 새로운 자식 프로세스가 생성되었다고 알려주는 과정이 필요하다. 아래 그림은 WINNIE에 구현된 Fork Server가 작동하는 과정이다.

Untitled

이러한 구현은 기존에 존재하던 Windows에서 Fork를 사용할 수 있는 CygWin이나 WSL과 같은 방식의 여러 문제점을 해결해 주었다. Cygwin의 경우 애초에 Fork의 구현 자체가 Windows의 COTS application에 대한 지원을 하지 않고 WSL의 경우에도 ELF 바이너리에서만 fork를 사용할 수 있다.

결과적으로 windows fuzzing에서 사용할 수 있는 fork server를 구현하였지만 아직까지 몇 가지 문제점이 존재한다. 먼저 fork를 사용할 때 fork를 호출한 쓰레드만 복제가 가능하다. 이러한 문제는 멀티 쓰레드 환경에서 deadlock이나 hang을 유발 할 수 있다. 일반적으로 GUI에서 이러한 문제가 발생하라 확률이 높기 때문에 Harness작성에서는 반드시 GUI에 대한 우회가 진행되어야 한다.
두번째로 객체에 대한 handling을 수행 할 때 기본적으로 자식 프로세스에서는 상속이 정상적으로 수행되지 않는다. 따라서 이러한 문제를 해결하기 위해 모든 handle에 대해 마킹을 수행한다. 마지막으로 fork구현에 사용된 API들이 사용하는 구조체들에 대한 차이가 존재한다. 이는 Windows 버전에 영향을 받으며 현재 WINNIEWindows10환경에서 작동하도록 구현되었다.

Fork Internal

WIndows 환경에서 Fork를 구현하기 위해서 ntdll.dll의 NtCreateUserProcess와 CSRSS (Client/Server Runtime Subsystem)를 리버싱 하였다. CSRSS는 Console 화면 할당과 프로세스 종료에 필수적으로 사용되기 때문에 새로운 프로세스가 만들어진다면 반드시 CSRSS와 연결되어 있어야 한다. 이러한 과정을 위해 Windows Kernel과 직접 통신 할 수 있는 Windwos Native API들을 사용한다. 아래는 실제 구현 코드의 핵심 부분이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
NTSTATUS result = NtCreateUserProcess(
&hProcess, &hThread, MAXIMUM_ALLOWED, MAXIMUM_ALLOWED,
NULL, NULL, PROCESS_CREATE_FLAGS_INHERIT_FROM_PARENT
| PROCESS_CREATE_FLAGS_INHERIT_HANDLES,
THREAD_CREATE_FLAGS_CREATE_SUSPENDED,
NULL, &procInfo, NULL
);
if (!result) { // Parent process
 // Inform the CSRSS that a new process was created
 // via CsrClientCallServer(CreateProcessRequest)
 NotifyCsrssParent(hProcess, hThread);
 // Allow the child to connect to CSR and resume.
 ResumeThread(hThread);
 return GetProcessId(hProcess);
 } else { // Child process
 // De-initialize ntdll variables before re-initialization
 memset(pCsrData, 0, csrDataSize);
 // Connect to the CSRSS via CsrClientConnectToServer
 ConnectCsrChild();
 return 0;
 }

아래는 실제 동작 순서이다.

  1. 먼저 부모 프로세스가 NtCreateUserProcess를 적절한 flag값과 함께 호출하여 부모 프로세스의 address space를 CoW(copy on write)로 복사하고 자식 프로세스를 대기 된 상태로 만들어준다.
  2. 부모 프로세스는 NotifiyCsrssParent를 통해 CSRSS에게 새로운 프로세스가 생성되었다고 알린다.
  3. 부모 프로세스가 대기 중이던 자식 프로세스를 다시 시작 시킨다. 그리고 부모프로세스는 fork를 리턴해준다.
  4. 복사된 address space들에는 부모 프로세스만 사용하게 정해져있는 특정 전역 변수(CsrServerApiRoutine in ntdll.dll)가 존재하는데 이를 모두 0으로 바꾸어 주는 과정을 진행한다.
  5. 자식 프로세스는 CsrClientConnectToServer를 호출하여 CSRSS에 연결을 시도한다.
  6. 최종적으로 fork와 관련된 모든 로직이 마무리된다.

IMPLEMENTATION

WINNIE는 32bit와 64비트를 모두 지원하며 WinAFL 위에서 작동하도록 설계되었다. Harness생성을 위한 TracerIntel-PT기술을 사용한다. 아래는 WINNIE의 Fuzzer부분의 전체적인 작동 구조이다.

Untitled

  1. Fork Server를 포함하는 Fuzzing Agent는 타겟 프로그램에 injection된다.
  2. Injection된 Agent는 Entry Point와 타겟 함수에 대한 후킹을 수행한다.
  3. 그리고 code coverage 회수를 위해 모든 basic block에 대해서 instrument code를 삽입한다.
  4. Fuzzer와 타겟 프로그램 사이의 PIPE를 사용하여 Forked Process를 생성한다.
  5. agent가 지속적으로 상태를 체킹한다.
  6. 또한 새로운 커버리지에 대한 도달과 Crash를 기록한다.

WINNIE의 fuzzer는 특이하게도 일반적으로 WinAFL이 사용하는 dynamic instrumentation을 사용해서 code coverage를 수집하지 않는다. 일반적으로 dynamic instrumentation에서는 DynamoRIO나 Intel-PT기술을 이용하는데 해당 기술을 이용한 방법은 WINNIE에서 알 수 없는 오류가 자주 발생한다고 한다.

이러한 문제을 해결하기 위해 WINNIE에서는 dynamic instrumentation을 사용하지 않는다. 먼저 모든 basic block들을 int 3 instruction으로 패치 해준다. 이 상태로 fuzzer가 타겟 프로그램에 대해서 testcase들을 주입해준다면 새로운 basic block에 도달할 때 int 3으로 인하여 execption이 발생하는데 이때 다시 int 3을 원래대로 복구 시켜 준다.

Conclustion

여기까지 WINNIE가 어떤 방식을 사용해서 Harness를 생성해내고 빠른 속도를 위한 Fast cloning을 구현하였는지 살펴 보았다. 이러한 내용을 바탕으로 실제 Real World 타겟에 대한 적용을 통해 일반적으로 WinAFL의 Harness와 WINNIE에서의 Harness에 대한 성능 비교와 제로데이 헌팅을 목표로한다.