서론
간단한 예제인 HelloWorld.exe를 분석해보는 실습이다.
예제는 1초를 대기하고 Hello, world!를 출력하는 프로그램이다.
/*
File: hello-world.cpp
Build opts:
- /MT -> Library Static Linking
- /DYNAMICBASE:NO -> Disable ASLR
- /od -> Disable Optimization
*/
#include <Windows.h>
#include <stdio.h>
char* str;
int main() {
int delay = 1000;
Sleep(delay); // 1000ms(1초)를 대기합니다.
str = (char*)"Hello, world!\n";
printf(str);
return 0;
}
정적 분석
IDA로 파일 열기
신뢰할 수 없는 프로그램을 분석할 때는 악성 프로그램일 가능성을 대비하여 정적 분석을 먼저 시도해보는 것이 바람직하다.
IDA를 실행하고, HelloWorld.exe를 끌어넣는다.

파일이 정상적으로 열리면 위와 같은 창이 나타나는데, 아무것도 변경하지 말고, OK를 클릭해야 한다.
main 함수 찾기
정적 분석은 주로 main 함수를 찾고, 이를 분석하며 시작된다.
IDA가 이를 자동으로 찾아주지만, 이번에는 IDA의 도움을 받지 않고 main함수를 찾아보았다.
바이너리에서 어떤 함수를 찾는 방법은 크게 두 가지가 있다.
하나는 프로그램의 시작 지점인 진입점(Entry Point, EP)부터 분석을 시작하여 원하는 함수를 찾을 때까지 탐색하는 것이고, 다른 하나는 대상 함수의 특성이나 프로그램의 여러 외적인 정보를 이용하여 탐색하는 방법이다.
전자는 바이너리의 규모가 조금만 커져도 분석에 소요되는 시간이 급증하므로 일반적으로는 적용하기 어렵다.
그래서 후자의 방식으로 main 함수를 탐색했다.
문자열 검색
프로그램을 정적 분석할 때, 많이 사용되는 정보 중 하나가 프로그램에 포함된 문자열이다.
프로그래머는 디버깅 메세지를 출력하거나, 로그 파일을 생성하는 등의 목적으로 여러 문자열을 프로그램에 포함시키는데, 이 문자열들은 특성상 유용한 정보를 제공할 때가 많다.
예를 들어 프로그램의 로그와 관련된 문자열은 로그를 생성하는 함수의 이름과 인자가 적혀 있기도 하다.
이 문자열이 어느 함수에서 사용되는지 찾는다면, 원하는 함수를 쉽게 찾을 수도 있고, 함수의 이름과 인자를 통해 기능을 유추할 수도 있을 것이다.
IDA는 바이너리에 포함된 문자열을 쉽게 찾을 수 있도록 “문자열 탐색” 기능을 제공하고 있다.

1. Shift + F12를 누르면 다음과 같이 바이너리에 포함된 문자열이 열거된 Strings 창이 나타난다.

2. 많은 문자열 중에 'Hello, world!'라는 문자열이 보인다. 컴파일 과정에서 다양한 문자열이 바이너리에 추가되는데, 이 문자열은 그런 종류가 아니라 프로그래머가 넣었을 것으로 추측할 수 있다.

3. 해당 문자열을 더블 클릭하여 따라간다.
상호 참조

“aHelloWorld”를 클릭하고 상호 참조의 단축키 X를 누르면 위와 같이 xrefs(cross reference) 창이 나타난다.
이 창에는 해당 변수를 참조하는 모든 주소가 출력된다.
첫 번째 항목을 더블 클릭하여 이를 따라가면 main함수를 찾을 수 있다.

일반적으로 main함수는 C계열 언어에서 프로그래머가 작성한 코드 중 가장 먼저 실행되는 함수이다.
그래서 리버싱을 처음 공부할 때는 프로그램에서 가장 먼저 실행되는 코드가 main함수의 코드라고 생각하기 쉽다.
그러나 사실 운영체제는 바이너리를 실행할 때, 바이너리에 명시된 진입점부터 프로그램을 실행한다.
진입점이 main함수인게 불가능한 것은 아니지만, 일반적으로는 그렇지 않다.
진입점과 main함수의 사이를 채우는 것은 컴파일러의 몫이다.
대부분의 컴파일러는 둘 사이에 여러 함수를 삽입하여 바이너리가 실행될 환경을 먼저 구성하고, 그 뒤에 main함수를 호출한다.
잠시 편의상 진입점에 위치한 함수를 start함수라고 해보았다.
main함수를 쉽게 찾고 싶다면, 컴파일러가 작성하는 start함수에 익숙해져야 한다.
start함수를 여러 번 분석하다 보면 나중에는 start함수를 보고, main함수가 어디서 호출될지를 쉽게 찾아낼 수 있다.
main 함수 분석
이제 main 함수를 찾았으므로, F5를 눌러 이를 디컴파일한다.
디컴파일된 코드를 통해 함수의 주요 정보를 살펴보았다.

- 인자 분석
IDA는 argc, argv, envp로 3개의 인자를 받는다고 해석했다.
- 동작
- Sleep함수를 호출하여 1초 대기한다.
- qword_14001DBE0에 “Hello, world!\n” 문자열의 주소를 넣는다.
- sub_140001060에 “Hello, world!\n” 를 인자로 전달하여 호출한다.
- 0을 반환합니다.
- 반환 값
0을 반환한다.
-퀴즈

sub_140001060 함수 분석

sub_140001060 함수의 디컴파일 결과를 살펴보았다.
먼저 va_start 함수를 통해 가변 인자를 처리하는 함수임을 알 수 있다.
__acrt_iob_func 함수는 스트림을 가져올 때 사용되는 함수인데, 인자로 들어가는 1은 stdout 을 의미한다.
따라서 문자열 인자를 받고 stdout 스트림을 내부적으로 사용하는 가변 함수임을 알 수 있다.
이러한 모든 정황을 통해 sub_140001060 함수는 printf 함수로 추정할 수 있다.
스트림 (Stream) : 데이터가 조금씩 흘러들어온다는 의미이다.
데이터는 스트림의 형식으로 한 프로세스에서 다른 프로세스로, 또는 한 프로세스에서 다른 파일 등으로 이동한다.
운영체제는 stdin (standard input), stdout (standard output), stderr (standard error) 와 같은 기본 스트림들을 프로세스마다 생성한다.
이들은 일반적으로 사용자와 프로세스를 연결해주기 위해 사용된다.
printf 함수는 stdout 을 통해 출력 데이터를 볼 수 있게 하고, scanf 함수는 키보드 입력을 stdin으로 받아서 프로세스에 전달한다.
이런 기본 스트림외에, 프로그래머는 필요에 따라 스트림을 생성할 수도 있다.
예를 들어, dream.txt 라는 텍스트파일에 데이터를 저장하고 싶다면, 이 파일과 스트림을 형성하여 내용을 작성할 수 있다.
동적 분석
main 함수 진입
앞에서 분석한 내용을 동적 분석으로 살펴보았다.
정적 분석이 정확했는지 살펴보고, 앞에서 배운 함수의 호출 규약이 어떻게 구현되는지 자세히 분석했다.
중단점 설정(Break Point, F2) 및 실행(Run, F9)
동적 분석은 프로그램을 실행하면서 분석하는 방법이다.
그런데 우리는 대개 전체 프로그램 중 아주 일부분의 동작(예제에서는 main 함수)에만 관심이 있다.
진입점부터 main함수까지 코드를 한 줄씩 실행시켜서 main함수에 도달한다면, 디버깅은 그렇게 효율적인 분석방법이 아니다.
그래서 대부분의 디버거에는 우리가 원하는 지점까지 프로그램을 실행시킬 수 있는 중단점과 실행이라는 기능이 있다.
중단점을 특정 주소에 설정하고, 실행 명령을 내리면 프로그램은 중단점까지 멈추지 않고 실행된다.
우리는 main함수의 동작이 궁금하므로, 이 기능을 이용하여 main함수로 진입했다.

1. main 함수에 단축키 F2로 중단점을 설정한다.

2. 단축키 F9로 디버깅을 시작하여 main함수까지 실행한다. 디버거를 고르라는 창이 나타나면 Local Windows debugger를 선택한다.

3. 동적 분석을 위한 준비가 끝났다.
한 단계 실행(Step Over, F8)
앞서 살펴본 중단점과 실행이 필요한 실행 과정을 생략하는 기능이라면, Step Over(F8)은 관심이 있는 부분의 코드를 정밀하게 분석하기 위해 사용하는 기능이다. F8을 누르며 프로그램의 동작을 분석했다.

1. sub rsp, 38을 통해 main 함수가 사용할 스택 영역을 확보한다.
2. rsp+0x20에 4 바이트 값인 0x000003e8을 저장한다.
3. rp+0x20에 저장된 값을 ecx에 옮겨서 함수의 첫 번째 인자를 설정한다.
4. Sleep함수를 호출한다. ecx가 0x3e8이므로, Sleep(1000)이 실행되어 1초간 실행이 멈춘다.
5. "Hello, world!\n” 문자열의 주소를 rax에 옮긴다.
6. 아래의 메모리 덤프 창을 이용하여 0x14001a140의 데이터를 보면 실제로 해당 문자열이 저장되어 있음을 확인할 수 있다.
7. rax의 값을 data세그먼트의 주소인 0x14001dbe0에 저장한다.
8. 0x14001dbe0에 저장된 값을 rcx에 옮겨서 다음 호출할 함수의 첫번째 인자로 사용한다.
9. 0x140001060함수를 호출한다. 정적 분석을 통해 이 함수를 printf함수라고 추측했다.
10. 프로그램을 확인하면, Hello, world!가 출력되어 있다. 정적 분석을 할 때는 함수의 기능을 추측하기 어려웠지만, 동적 분석으로는 문자열을 출력하는 함수라는 사실을 쉽게 알 수 있다.
11. 시작할 때 확장한 스택 영역을 add rsp, 38을 통해 다시 축소하고, ret으로 원래 실행 흐름으로 돌아간다.
함수 내부로 진입하기(Step Into, F7)
앞의 Step Over를 자세히 관찰해 Step Over가 함수의 내부로 진입하지 않는다는 것을 발견했다.
그런데 어떤 함수를 분석하다 보면 그 함수가 호출하는 다른 함수까지 정밀하게 분석해야 할 때가 있다.
많은 디버거는 그런 상황을 대비하여 Step Into(F7)라는 기능을 지원하고 있다.
이번엔 printf()에 중단점을 설정하고, 해당 함수의 내부로 진입해 보았다.

1. 디버깅을 중단(Ctrl-F2)하고, printf를 호출하는 0x14000110b에 중단점을 설정한다.

2. 디버깅을 다시 시작하고, Continue(F9)를 클릭하여, printf 함수에 도달한다.

3. F7 단축키를 통해 함수 내부로 들어간다. 함수 내부로 RIP가 이동한 것을 확인할 수 있다.
Appendix, 실행 중인 프로세스 조작하기
IDA를 이용하면 실행중인 프로세스의 메모리를 조작할 수 있다.
Sleep Forever
기존 코드에서는 Sleep(delay=1000)을 호출하여 1초 동안 프로세스를 정지시켰다.
이번에는 delay의 값을 1000000으로 조작하여 1000초 동안 프로세스를 정지시켜 보았다.

1. delay를 Sleep함수의 인자로 전달하는 부분에 중단점을 설정하고, 프로세스를 재시작한다.

2. 스택을 보면 rsp+0x20에 delay의 값인 0x3e8이 저장되어 있다.

3. 해당 값을 클릭하고, F2를 누른 뒤 0xf4240(=1000000)을 입력하고 다시 F2를 눌러서 값을 저장한다. delay의 값이 변경된 것을 확인할 수 있다.

4. 이제 F9를 눌러서 Sleep함수를 호출한다. 아까와 달리 한참을 기다려도 프로세스가 재개되지 않다. 1000초는 대략 20분이므로, 20분 정도를 대기해야 프로세스가 재개된다.
여기서는 결과를 체감하기 쉽게 하기 위해 Sleep함수의 인자를 조작했지만, 실제로 어떤 함수의 동작을 모를 때는 인자를 적절히 조작해 봄으로써 함수의 동작과 인자의 역할들을 유추해볼 수 있다.
마치며
- BreakPoint(F2): 중단점을 설정합니다. 프로그램이 해당 지점에 도달하는 순간 정지한다.
- Restart(Ctrl + F2): 디버깅을 중단한다.
- Run(F9): 프로그램을 계속 실행, 또는 디버깅을 시작한다.
- Step Into(F7): 어셈블리 코드를 한 줄 실행한다. 함수의 호출이라면, 함수 내부로 들어간다.
- Step Over(F8): 어셈블리 코드를 한 줄 실행한다. 함수 내부로는 들어가지 않는다.
