WYSINWYX: What You See Is Not What You eXecute
1. Introduction
글을 시작하기에 앞서, 이 글에서 다루는 논문 WYSINWYX: What You See Is Not What You eXecute 는 카이스트 차상길 교수님의 IS-561: Binary Code Analysis and Secure Software Systems 과목에서 읽어보면 좋은 논문으로 소개된 논문입니다. 과목 링크에 들어가보시면 이외에도 정보 보안의 근간을 이해하는데에 있어서 도움이 될 여러 논문들을 소개하고 있으니 확인해보시는 것을 추천드립니다.
WISINWYX
는 WISIWYG
이라는 표현으로부터 따온 재치있는 표현입니다. 저는 WISIWYG
를 아주 먼 옛날 워드프로세서 필기를 공부하며 들어본 것 같은데, WISIWYG
는 What You See Is What You Get
의 줄인 말로, 마치 Microsoft Word, HWP와 같은 프로그램과 같이 화면 상에 보이는대로 편집을 하면 그것이 출력 결과물인 패러다임을 의미합니다.
WYSINWYX
는 What You See Is Not What You eXecute
의 줄인 말입니다. 이 논문에서는 컴파일러를 통해 만들어진 소스 코드가 반드시 소스 코드의 로직을 완전하게 따라가는 것은 아니라는 점을 지적하고 있습니다. 그렇기 때문에 소스 코드만 확인해서는 알 수 없는 취약점이 존재할 수 있고, 그러한 관점에서 실행 파일을 기반으로 하는 분석이 소스 코드를 기반으로 하는 분석보다 더 정확하다고 주장하고 있습니다.
2. WYSINWYX
컴파일러는 소스 코드를 기반으로 실행 파일을 만드는 프로그램인데 어떻게 WYSINWYX
와 같은 일이 발생할 수 있는걸까요? 소스 코드와 실제 실행 파일이 다를 수 있는 여러 상황들을 살펴봅시다.
2-1. 컴파일러/IDE의 변조
만약 컴파일러나 IDE 자체에 결함이 있다면 소스 코드로부터 만들어낸 실행 파일이 조작될 수 있습니다. 어떻게 보면 상당히 대담한 공격이지만, 이런 공격은 국가 단위와 같이 아주 큰 규모의 집단에서 수행할 수 있습니다. 중국에서는 애플의 Xcode IDE를 직접 다운로드 받을 경우 속도가 매우 느리기 때문에 여러 개발자들이 미러 사이트에서 Xcode IDE를 다운로드 받았는데 미러 사이트에 변조된 Xcode인 XcodeGhost가 들어가있어서 Wechat, CamScanner을 비롯한 여러 어플리케이션에 백도어가 들어가는 일이 발생했습니다. XcodeGhost는 링킹 과정에서 백도어를 심었고, 백도어로 인해 일반적인 악성 앱이 그러하듯 C&C 서버와 통신하며 현재 시간과 UUID를 비롯한 여러 정보를 보내고 추후 C&C 서버의 명령을 받아 특정 주소로 접속하는 등의 작업을 수행하게끔 하는 기능이 실행될 수 있었습니다.
이와 같이 결국 소스 코드를 실행파일로 맏느는건 컴파일러이기 때문에 컴파일러가 변조된다면 실행 파일의 내용이 소스 코드에서 명시한 바와 달라질 수 있습니다.
2-2. 컴파일러의 최적화
컴파일러가 변조되지 않더라도 컴파일러는 매우 복잡하게 설계가 되어 있어 현실적으로 일반적인 사용자가 세부적인 컴파일 방식을 이해할 수 없습니다. 또한 Optimization level에 따라 컴파일러가 판단할 때 불필요한 소스 코드로 보이면 이를 컴파일 과정에서 제거해버리기도 합니다. 아래의 소스 코드를 확인해봅시다.
#include <unistd.h>
#include <cstdlib>
#include <cstring>
void do_something(){
char* password = (char*)malloc(sizeof(char)*10);
read(0, password, 8);
write(1, password, 8);
// validate a password
memset(password, '\0', 10);
free(password);
}
int main(){
do_something();
}
password
변수에 입력받은 8바이트를 대입하고 출력합니다. 간단한 예시 코드인만큼 그냥 출력만 했지만 실제로는 내부적으로 password를 검증하는 로직이 더 있다고 해봅시다. 검증 과정이 끝난 후 바로 free를 해도 되지만 혹시 이후 메모리의 leak이 발생한다고 해도 password가 유출되지 않도록 memset을 이용해 password를 zero fill한 후 free를 하는 것이 좋습니다.
그런데 컴파일러의 입장에서는 어차피 바로 free를 할 변수에 memset을 하는건 아무 의미가 없다고 판단해버릴 수 있습니다. 실제로 이 코드를 O3 레벨로 컴파일할 경우 아래와 같이 memset이 온데간데 없이 사라져버립니다(gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0 버전 기준).
gcc -o a a.cpp O3
pwndbg>
Dump of assembler code for function _Z12do_somethingv:
0x00000000000011d0 <+0>: endbr64
0x00000000000011d4 <+4>: push rbp
0x00000000000011d5 <+5>: mov edi,0xa
0x00000000000011da <+10>: call 0x10b0 <malloc@plt>
0x00000000000011df <+15>: mov edx,0x8
0x00000000000011e4 <+20>: xor edi,edi
0x00000000000011e6 <+22>: mov rbp,rax
0x00000000000011e9 <+25>: mov rsi,rax
0x00000000000011ec <+28>: call 0x10a0 <read@plt>
0x00000000000011f1 <+33>: mov rsi,rbp
0x00000000000011f4 <+36>: mov edi,0x1
0x00000000000011f9 <+41>: mov edx,0x8
0x00000000000011fe <+46>: call 0x1090 <write@plt>
0x0000000000001203 <+51>: mov rdi,rbp
0x0000000000001206 <+54>: pop rbp
0x0000000000001207 <+55>: jmp 0x1080 <free@plt>
End of assembler dump.
실제 이 예시는 논문에서 소개된 예시이고 CWE-14에서 더 자세한 설명을 확인할 수 있습니다. 비록 굉장히 옛날이지만 2002년 Microsoft사에서 해당 문제로 인한 취약점이 리포트된 적이 있습니다.
이렇듯 성능을 위해 필요한 컴파일러 단위의 최적화로 인해 보안에 필요한 소스 코드가 사라지는 일이 발생할 수 있고, 이러한 취약점은 소스 코드 분석만으로는 절대 알아낼 수 없고 실제 실행 파일을 확인해야 확인이 가능합니다.
2-3. Undefined behavior
PS를 사랑하는 사람이라면 한 번쯤은 들어봤고, 또 바람직한 코딩 습관이라고는 할 수 없지만 문제를 풀 때 필요에 따라 활용하기도 하는 undefined behavior입니다. Undefined behavior는 소스 코드를 실행한 결과가 정의되어 있지 않은 문법을 의미합니다. 자세한 내용은 evenharder님의 이 글을 참고해보세요.
가장 바람직한건 프로그래머가 undefined behavior를 발생시키지 않아 코드의 로직을 컴파일러와 무관하게 잘 컴파일 할 수 있도록 하는 것이지만 ++i + i++
와 같은 충격적인 코드는 차치하고서라도 signed int overflow
혹은 1 << 32
와 같은 코드는 프로그래머의 실수로, 혹은 컴파일러의 동작을 이해하고 있는 프로그래머가 의도적으로 작성할 수 있는 코드입니다. 이렇게 undefined behavior가 발생된다면 해당 코드에 대응되는 어셈블리 코드는 완전히 컴파일러에 의존적으로 결정되기 때문에 이 역시 소스 코드 분석으로는 파악이 불가능합니다.
3. 실행 파일 분석
논문에서는 실행 파일 분석의 이점을 소개한 후 직접 개발한 CodeSurfer/x86, WPDS++, Path Inspector
라는 이름의 실행 파일 분석 도구를 설명합니다. 이 논문은 2005년도의 논문이고 실행 파일 분석 기법은 그 이후로 꾸준히 발전되었겠지만 제 전공이 해당 분야와 다소 떨어져있어서 그런지 논문의 내용을 이해하는 것 조차 꽤 어려움을 겪었습니다. 아쉬운대로 일반적인 정적 분석 방법론에 대해 설명을 해보겠습니다.
우선 기본적으로 정적 분석은 halting problem과 닿아 있어서 이를테면 코드에 undefined behavior가 있는지, 혹은 특정 입력에 대해 런타임 에러가 발생할 수 있는지 등을 완벽하게 판단하는 프로그램을 만드는건 불가능합니다.
그렇지만 대부분의 코드에서 잘 동작하는 근사적인 접근법은 계속 연구가 이루어졌고, 이번 글에서는 Abstract Domain을 이용한 방법에 대해 간단하게 설명을 드리겠습니다.
먼저 아래의 코드를 확인해봅시다. 아래의 코드에서 우리는 zero division이 발생하는지를 확인하고 싶습니다.
Dump of assembler code for function calc:
0x0000000000001169 <+0>: endbr64
0x000000000000116d <+4>: push rbp
0x000000000000116e <+5>: mov rbp,rsp
0x0000000000001171 <+8>: sub rsp,0x20
0x0000000000001175 <+12>: mov rax,QWORD PTR fs:0x28
0x000000000000117e <+21>: mov QWORD PTR [rbp-0x8],rax
0x0000000000001182 <+25>: xor eax,eax
0x0000000000001184 <+27>: mov DWORD PTR [rbp-0x14],0x5
0x000000000000118b <+34>: mov DWORD PTR [rbp-0x10],0x2
0x0000000000001192 <+41>: lea rax,[rbp-0x18]
0x0000000000001196 <+45>: mov rsi,rax
0x0000000000001199 <+48>: lea rdi,[rip+0xe64] # 0x2004
0x00000000000011a0 <+55>: mov eax,0x0
0x00000000000011a5 <+60>: call 0x1070 <__isoc99_scanf@plt>
0x00000000000011aa <+65>: mov eax,DWORD PTR [rbp-0x18]
0x00000000000011ad <+68>: test eax,eax
0x00000000000011af <+70>: jns 0x11c6 <calc+93>
0x00000000000011b1 <+72>: mov eax,DWORD PTR [rbp-0x18]
0x00000000000011b4 <+75>: mov edx,DWORD PTR [rbp-0x10]
0x00000000000011b7 <+78>: mov ecx,edx
0x00000000000011b9 <+80>: sub ecx,eax
0x00000000000011bb <+82>: mov eax,DWORD PTR [rbp-0x14]
0x00000000000011be <+85>: cdq
0x00000000000011bf <+86>: idiv ecx
0x00000000000011c1 <+88>: mov DWORD PTR [rbp-0xc],eax
0x00000000000011c4 <+91>: jmp 0x11dc <calc+115>
0x00000000000011c6 <+93>: mov eax,DWORD PTR [rbp-0x10]
0x00000000000011c9 <+96>: lea edx,[rax+0x1]
0x00000000000011cc <+99>: mov eax,DWORD PTR [rbp-0x18]
0x00000000000011cf <+102>: mov esi,edx
0x00000000000011d1 <+104>: sub esi,eax
0x00000000000011d3 <+106>: mov eax,DWORD PTR [rbp-0x14]
0x00000000000011d6 <+109>: cdq
0x00000000000011d7 <+110>: idiv esi
0x00000000000011d9 <+112>: mov DWORD PTR [rbp-0xc],eax
0x00000000000011dc <+115>: nop
0x00000000000011dd <+116>: mov rax,QWORD PTR [rbp-0x8]
0x00000000000011e1 <+120>: xor rax,QWORD PTR fs:0x28
0x00000000000011ea <+129>: je 0x11f1 <calc+136>
0x00000000000011ec <+131>: call 0x1060 <__stack_chk_fail@plt>
0x00000000000011f1 <+136>: leave
0x00000000000011f2 <+137>: ret
프로그램을 간단하게 짠 후 O0
옵션으로 컴파일한 어셈블리 코드인데 알아보기가 상당히 난해합니다. [rbp-0x8], [rbp-0x10], [rbp-0x14], [rbp-0x18]
각각이 지역 변수에 대응되는걸 이용해서 분석을 할 수는 있으나 너무 독자를 배려하지 않는 처사로 보입니다. 비록 글 안에서 계속 실행 파일 기반의 필요성에 대해 서술했지만 현실적으로 어셈블리를 가지고 설명을 하기에는 너무 어려운 관계로 c언어 소스 코드를 통해 설명하겠씁니다.
아래의 c언어 소스 코드가 해당 어셈블리 코드에 대응되는 코드입니다.
void calc(){
int a = 5;
int b = 2;
int c, d;
scanf("%d", &c);
if(c < 0){
d = a / (b-c);
}
else{
d = a / (1+b-c);
}
}
우선 c = 3
일 떄 d = a / (1+b-c);
명령에서 1+b-c = 0
이 되어서 zero division이 발생함을 알 수 있습니다. 특정 명령에서 zero division이 발생할 수 있는지를 정적 분석으로 알아내는 여러 가지 방법 중에서 각 변수가 매 순간 가질 수 있는 값이 무엇인지를 따라가는 방법이 있습니다.
이 때 int
기준 -2147483648
부터 2147483647
의 모든 값에 대해 가능성을 확인한다면 가장 좋겠지만 실제 구현을 하기에는 다소 현실성이 떨어지기 떄문에 값이 음수인지, 0인지, 양수인지만을 가져가도록 하겠습니다. 만약 음수일수도 있고 0일수도 있다면 두 가지를 모두 가지고 있어야 합니다.
처음 int a = 5; int b = 2; int c, d; scanf("%d", &c);
까지 수행하고 나면 a = 양수, b = 양수, c = 양수/0/음수, d = 양수/0/음수
임을 알 수 있습니다.
그 다음 if으로 분기가 발생하는데, if문이 참인 상황부터 살펴보겠습니다. if문이 참이라면 a = 양수, b = 양수, c = 음수, d = 양수/0/음수
입니다. 이후 실행되는 d = a / (b-c)
에서 zero division이 발생하는지 보면 나누는 수인 b-c
는 b = 양수, c = 음수
이기 때문에 항상 양수입니다. 그렇기 때문에 zero division이 발생하지 않습니다.
if문이 거짓이라면 a = 양수, b = 양수, c = 양수/0, d = 양수/0/음수
입니다. 이후 진행되는 d = a / (1+b-c)
에서 b-c
는 양수일 수도, 음수일 수도, 0일 수도 있기 때문에 zero division이 발생할 수 있습니다. 그렇기 때문에 d = a / (1+b-c)
에서 zero division이 발생할 수 있다는 경고를 발생시킬 수 있습니다.
한편, 각 변수가 가지는 실제 값을 가지고 정적 분석을 하는게 아니라 Abstract Domain을 이용하기 때문에 실제로는 zero division이 발생하지 않지만 정적 분석에서는 zero division이 발생할 수 있다는 경고를 발생시킬 수도 있습니다.
void calc(){
int a = 5;
int b = 2;
int c, d;
scanf("%d", &c);
if(c < 2){
d = a / (b-c);
}
else{
d = a / (1+b-c);
}
}
위와 같은 코드를 생각해보면 if문이 참일 때 실행되는 d = a / (b-c)
에서는 c < 2
이기 때문에 zero division이 발생하지 않습니다. 그렇지만 양수/0/음수로 관리하는 Abstract Domain에서는 c = 양수/0/음수
로 분류되기 때문에 b - c
가 양수일 수도, 음수일 수도, 0일 수도 있다고 판단해서 zero division이 발생할 수 있다는 경고를 발생시키게 됩니다.
또한 예시로 든 함수는 반복문이 없고 각 명령이 순차적으로 최대 한 번만 실행되기 때문에 지금과 같이 아주 약식으로 도메인을 분류할 수 있었지만 반복문이 들어가서 특정한 조건이 만족할 때 까지 각 명령이 여러 번 실행될 수 있다면 상황이 약간 복잡해집니다. 이 경우에는 fixed point에 도달할 때 까지 각 명령에 대해 이전 명령에서 특정 변수가 가질 수 있는 값들과 현재 상태에서 특정 변수가 가질 수 있는 값을 합집합으로 계속 더해나가는 방식으로 구현을 해야 합니다. 이 부분에 대한 설명은 생략하겠습니다.
4. 결론
이번 글에서는 WYSINWYX: What You See Is Not What You eXecute
논문을 통해 실행 파일 분석의 이점과 필요성에 대해 알아보았습니다. 개인적으로는 논문에서 주장하는 실행파일 분석의 이점에 대체적으로 동의하나, 소스 코드가 주어진 경우에는 소스 코드를 기반으로 하는 분석이 더 효과적이라고 생각합니다.
첫 번째로 실행 파일을 분석하는 것 보다 소스 코드를 분석하는 것의 생산성이 월등히 높습니다. 당장 저도 그렇지만 굉장히 긴 시간을 투입해 어셈블리를 들여다본 사람이 아니라면 주어진 어셈블리 코드를 보고 잠재적인 취약점을 찾아내기는 커녕 코드가 의미하는 바를 파악하는 것 조차 어려움을 겪는 일이 비일비재합니다. 프로그램의 보안은 단순히 보안을 깊게 공부한 일부 사람만 신경써서 될 문제가 아니고 아예 프로그래밍 단계에서부터 취약점이 없게끔 구현을 하는게 좋은 만큼, 개발자의 입장에서 코딩을 끝낸 후 실행 파일을 직접 분석하라고 하는 것 보다는 소스 코드를 기반으로 하는 분석을 하라고 하는 것이 더 효율적으로 보입니다.
두 번째로 비록 소스 코드의 분석만으로는 알아낼 수 없는 실행 흐름이 있는 것은 맞으나 프로그램에서 undefined behavior에 의존하는 코드가 없다면 해당 실행 흐름이 취약점으로 발전할 수 없다고 생각합니다. 이 부분에 대해서는 이견이 있을 수 있지만, 예를 들어 2-2. 컴파일러의 최적화
에서 예를 든 memset의 예시도 설령 컴파일러의 최적화로 인해 password 영역이 초기화되지 않더라도 프로그램에 undefined behavior가 없다면 이미 free가 이루어진 해당 메모리 주소의 값이 leak될 수 없습니다. 비슷한 맥락으로 공격자의 관점에서도 만약 소스 코드가 주어져있다면 바로 실행 파일을 확인하는 대신 우선 소스 코드에서 취약한 부분을 찾고 이후 실행 파일을 분석해 익스플로잇을 만드는 방법이 더 효율적인 분석 방법일 것으로 보입니다.
그럼에도 불구하고 저는 논문을 통해 컴파일러를 통해 만들어진 소스 코드가 반드시 소스 코드의 로직을 완전하게 따라가는 것은 아니고 이것이 취약점으로 발생할 수 있다는 것을 배울 수 있어서 좋았습니다. 이 글에 대한 견해는 사람마다 다를 수 있겠지만 실행 파일 분석의 필요성 자체에는 충분히 공감할 수 있을 것으로 보입니다.