어떤 대상에게 명령을 내리려면, 그 대상과의 소통에 사용할 언어가 필요합니다. 컴퓨터 과학자들은 컴퓨터에 명령을 내리기 위해 기계어(Machine Language) 라는 컴퓨터의 언어를 정의했습니다. 그리고 필요한 연산을 컴퓨터가 대신하도록 기계어로 명령을 내렸습니다.
그런데 기계어는 0과 1로 이루어져 있어서 사람이 이해하기 어려웠으며, 이를 이용해서 컴퓨터에 명령을 내리는 것은 비효율적이었습니다. 그래서 컴퓨터 과학자들은 사람이 이해하기 쉬운 새로운 언어로 어셈블리어(Assembly Language) 를 고안했고, 이를 기계어로 번역해주는 어셈블러(Assembler) 를 개발했습니다.
어셈블리어는 기계어에 비하면 효율적이었으나, 여전히 규모가 큰 프로그램을 개발하기에는 부족했습니다. 그래서 C, C++, Go, Rust 등을 비롯하여 어셈블리어보다 더욱 사람이 이해하기 쉬운 언어들을 만들었고, 이들을 기계어로 번역해주는 컴파일러(Compiler) 를 개발했습니다. 이 언어들은 프로그램 개발의 효율을 극대화해주었으며 현재까지도 널리 사용되고 있습니다.
프로그래밍에 사용하는 언어 중, 사람이 이해하기 쉬운 언어를 고급 언어(High-Level Language) 라고 부르며, 그 반대의 언어를 저급 언어(Low-Level Language) 라고 부릅니다. 위에서 살펴본 C, C++, Go, Rust가 전자에, 기계어나 어셈블리어가 후자에 속합니다. 초기에는 컴파일 이론 및 관련된 기술들이 미흡해서 고급 언어로 개발하는 것이 만족스럽지 않을 때가 있었으나, 현대에는 이런 단점이 거의 보완되었습니다. 생산성의 측면에서, 고급 언어가 저급 언어보다 압도적으로 효율적이기 때문에 이제는 특별한 경우를 제외하고는 저급 언어로 프로그램을 개발하지 않습니다.
이번에는 프로그램(Program)이 무엇인지 알아보고, 고급 언어로 작성된 소스 코드가 어떤 과정을 거쳐 프로그램으로 번역되는지 살펴보겠습니다.
일상에서 여러 명에게 공유되어야 할 내용이나, 누군가에게 반복적으로 지시해야 할 사항이 있다면, 매번 말로 전달하는 것보다 문서로 정리하여 대상이 읽도록 하는 것이 효율적입니다. 프로그램(Program)도 비슷한 맥락에서 이해할 수 있습니다.
프로그램(Program) 은 연산 장치가 수행해야 하는 동작을 정의한 일종의 문서입니다. 프로그램을 연산 장치에 전달하면, CPU는 적혀있는 명령들을 처리하여 프로그래머가 의도한 동작을 수행합니다. 사용자가 정의한 프로그램을 해석하여 명령어를 처리할 수 있는 연산 장치를 programmable하다고 하는데, 현대의 컴퓨터가 대표적인 programmable 연산 장치이고, 일반 계산기는 대표적인 non-programmable 연산 장치입니다.
과거에는 프로그램을 내부 저장 장치에 저장할 수 없어서 사람이 전선을 연결하여 컴퓨터에 전달하거나, 천공 카드(Punched card)에 프로그램을 기록하여 재사용하는 방식을 사용했습니다. 전자의 방식을 사용한 컴퓨터가 에니악(ENIAC)인데, 프로그램이 바뀔 때마다 배선을 재배치해야 했으므로 매우 비효율적이었고, 크기가 큰 프로그램을 사용하기도 어려웠습니다.
그러던 중, 이런 단점을 해결한 Stored-Program Computer가 1950년경에 최초로 상용화되었습니다. 이 컴퓨터는 프로그램을 메모리에 전자적으로, 또는 광학적으로 저장할 수 있었는데, 기존의 컴퓨터들보다 월등히 많은 프로그램을 저장할 수 있었으며, 저장된 프로그램을 사용하는 것도 간편했습니다. 그래서 이제는 컴퓨터의 대부분이 Stored-Program Computer의 형태로 개발됩니다.
소프트웨어 개발자, 해커 등 많은 정보 분야의 엔지니어들이 프로그램을 바이너리(Binary) 라고 부르곤 하는데, 이는 Stored-Program Computer에서 프로그램이 저장 장치에 이진(Binary) 형태로 저장되기 때문입니다. 텍스트가 아닌 다른 데이터들도 바이너리라고 불리긴 하지만, 많은 경우에 바이너리라고 하면 프로그램을 의미합니다.
프로그래밍 언어(Programming Language) 는 프로그램을 개발하기 위해 사용하는 언어를 말합니다. 서론에서 언급한 C, C++, Go, Rust와 같은 고급 언어들과 어셈블리어, 기계어 등의 저급 언어들이 있습니다.
CPU가 수행해야 할 명령들을 프로그래밍 언어로 작성한 것을 소스 코드(Source Code) 라고 하는데, 이를 컴퓨터가 이해할 수 있는 기계어의 형식으로 번역하는 것을 컴파일(Compile) 이라고 합니다. 컴파일을 해주는 소프트웨어는 컴파일러(Compiler) 라고 불리는데, 대표적으로 GCC, Clang, MSVC 등이 있습니다. 한번 컴파일되면 결과물이 프로그램으로 남기 때문에 언제든지 이를 실행하여 같은 명령을 처리하게 할 수 있습니다.
그러나 모든 언어가 컴파일을 필요로 하는 것은 아닙니다. 대표적으로 Python, Javascript 등의 언어는 컴파일을 필요로 하지 않습니다. 이 언어들은 사용자의 입력, 또는 사용자가 작성한 스크립트를 그때 그때 번역하여 CPU에 전달합니다. 이 동작이 통역과 비슷하기 때문에 인터프리팅(Interpreting)이라고 불리며, 마찬가지로 이를 처리해주는 프로그램을 인터프리터(Interpreter) 라고 합니다.
컴파일은 아무 배경지식이 없는 사람이 책을 읽을 수 있도록 배경지식을 엮고, 번역하여 하나의 번역본을 만드는 과정으로, 그리고 인터프리팅은 동시 통역사를 거쳐 대화하는 것으로 비유하여 이해할 수 있습니다. 전자는 결과물이 남아서 언제든 다시 읽어볼 수 있지만 한 번 번역하는데 시간이 많이 필요하고, 후자는 상대방과 빠르게 의사소통할 수 있지만, 같은 이야기를 하더라도 매번 통역사를 거쳐야 한다는 단점이 있습니다.
이후, 윈도우 PE 바이너리의 리버스 엔지니어링에 대해 다룰 것입니다. 이에 관련된 배경 지식으로 C 소스 코드가 어떻게 바이너리로 컴파일되는지, 개략적인 과정을 살펴보겠습니다. 윈도우 실행파일을 생성하려면 MSVC를 사용해야 하지만, 여기서는 조금 더 자세한 설명을 위해 리눅스 환경에서 GCC를 사용하겠습니다.
C언어로 작성된 코드는 일반적으로 전처리(Preprocess), 컴파일(Compile), 어셈블(Assemble), 링크(Link) 의 과정을 거쳐 바이너리로 번역됩니다.
아래의 예제 코드를 이용하여 각 단계에 대해 자세히 알아보겠습니다.
// Name: add.c
#include "add.h"
#define HI 3
int add(int a, int b) { return a + b + HI; } // return a+b
// Name: add.h
int add(int a, int b);
전처리(Preprocessing) 는 컴파일러가 소스 코드를 어셈블리어로 컴파일하기 전에, 필요한 형식으로 가공하는 과정입니다.
언어마다 조금씩 다르지만, 컴파일 언어의 대부분은 다음의 전처리 과정을 거칩니다.
#define으로 정의한 매크로는 자주 쓰이는 코드나 상숫값을 단어로 정의한 것입니다. 전처리 과정에서 매크로의 이름은 값으로 치환됩니다.$ gcc -E add.c > add.i
$ cat add.i
# 1 "add.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "add.c"
# 1 "add.h" 1
int add(int a, int b);
# 2 "add.c" 2
int add(int a, int b) { return a + b + 3; }
위의 add.i는 add.c를 전처리한 결과입니다. gcc에서는 -E 옵션을 사용하여 소스 코드의 전처리 결과를 확인할 수 있습니다. 결과를 살펴보면 먼저 소스 코드의 주석이었던 // return a+b가 사라졌고, HI가 3으로 치환되었습니다. 그리고 add.h의 내용이 #include에 의해 병합되었습니다.
컴파일(Compile) 은 소스 코드를 어셈블리어로 번역하는 것입니다. 이 과정에서 컴파일러는 소스 코드의 문법을 검사하는데, 오류가 있다면 컴파일을 멈추고 에러를 출력합니다.
또한, 컴파일러는 코드를 번역할 때, 몇몇 조건을 만족하면 최적화릂 적용하여 효율적인 어셈블리 코드를 생성해줍니다. gcc에서는 -O -O0 -O1 -O2 -O3 -Os -Ofast -Og 등의 옵션을 사용하여 최적화를 적용할 수 있습니다.
예를 들어, 아래의 opt.c를 최적화하여 컴파일하면, 컴파일러는 반복문을 어셈블리어로 옮기는 것이 아니라, 반복문의 결과로 x가 가질 값을 직접 계산하여, 이를 대입하는 코드를 생성합니다. 이를 통해 사용자가 작성한 소스 코드와 연산 결과는 같으면서도, 최적화를 적용하지 않았을 때보다 더 짧고, 실행 시간도 단축되는 어셈블리 코드가 만들어지게 됩니다.
// Name: opt.c
// Compile: gcc -o opt opt.c -O2
#include <stdio.h>
int main() {
int x = 0;
for (int i = 0; i < 100; i++) x += i; // x에 0부터 99까지의 값 더하기
printf("%d", x);
}
0x0000000000000560 <+0>: lea rsi,[rip+0x1bd] ; 0x724
0x0000000000000567 <+7>: sub rsp,0x8
0x000000000000056b <+11>: mov edx,0x1356 ; hex((0+99)*50) = '0x1356' = sum(0,1,...,99)
0x0000000000000570 <+16>: mov edi,0x1
0x0000000000000575 <+21>: xor eax,eax
0x0000000000000577 <+23>: call 0x540 <__printf_chk@plt>
0x000000000000057c <+28>: xor eax,eax
0x000000000000057e <+30>: add rsp,0x8
0x0000000000000582 <+34>: ret
아래와 같이 -S 옵션을 이용하면 소스 코드를 어셈블리 코드로 컴파일할 수 있습니다.
$ gcc -S add.i -o add.S
$ cat add.S
.file "add.c"
.intel_syntax noprefix
.text
.globl add
.type add, @function
add:
.LFB0:
.cfi_startproc
push rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp
.cfi_def_cfa_register 6
mov DWORD PTR -4[rbp], edi
mov DWORD PTR -8[rbp], esi
mov edx, DWORD PTR -4[rbp]
mov eax, DWORD PTR -8[rbp]
add eax, edx
add eax, 3
pop rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size add, .-add
.ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
.section .note.GNU-stack,"",@progbits
어셈블(Assemble) 은 컴파일로 생성된 어셈블리어 코드를 ELF형식의 목적 파일(Object file), 즉 리눅스의 실행 파일로 변환하는 과정입니다. 윈도우에서 어셈블한다면 목적 파일은 PE형식을 갖게 됩니다.
목적 파일로 변환되고 나면 어셈블리 코드가 기계어로 번역되므로 더 이상 사람이 해석하기 어려워집니다.
다음은 gcc의 -c 옵션을 통해 add.S를 목적 파일로 변환하고, 결과로 나온 파일을 16진수로 출력한 것입니다. 읽을 수 있는 문자열 중 코드로 보이는 것은 없습니다.
$ gcc -c add.S -o add.o
$ file add.o
add.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ hexdump -C add.o
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 01 00 3e 00 01 00 00 00 00 00 00 00 00 00 00 00 |..>.............|
00000020 00 00 00 00 00 00 00 00 10 02 00 00 00 00 00 00 |................|
00000030 00 00 00 00 40 00 00 00 00 00 40 00 0b 00 0a 00 |....@.....@.....|
00000040 55 48 89 e5 89 7d fc 89 75 f8 8b 55 fc 8b 45 f8 |UH...}..u..U..E.|
00000050 01 d0 5d c3 00 47 43 43 3a 20 28 55 62 75 6e 74 |..]..GCC: (Ubunt|
00000060 75 20 37 2e 35 2e 30 2d 33 75 62 75 6e 74 75 31 |u 7.5.0-3ubuntu1|
00000070 7e 31 38 2e 30 34 29 20 37 2e 35 2e 30 00 00 00 |~18.04) 7.5.0...|
00000080 14 00 00 00 00 00 00 00 01 7a 52 00 01 78 10 01 |.........zR..x..|
00000090 1b 0c 07 08 90 01 00 00 1c 00 00 00 1c 00 00 00 |................|
000000a0 00 00 00 00 14 00 00 00 00 41 0e 10 86 02 43 0d |.........A....C.|
000000b0 06 4f 0c 07 08 00 00 00 00 00 00 00 00 00 00 00 |.O..............|
...
링크(Link) 는 여러 목적 파일들을 연결하여 실행 가능한 바이너리로 만드는 과정입니다. 링크가 필요한 이유를 다음의 코드를 통해 설명하겠습니다.
// Name: hello-world.c
// Compile: gcc -o hello-world hello-world.c
#include <stdio.h>
int main() { printf("Hello, world!"); }
위 코드에서 printf 함수를 호출하지만, printf 함수의 정의는 hello-world.c 에 없으며, libc라는 공유 라이브러리에 존재합니다. libc는 gcc의 기본 라이브러리 경로에 있는데, 링커는 바이너리가 printf를 호출하면 libc의 함수가 실행될 수 있도록 연결해줍니다. 링크를 거치고 나면 실행할 수 있는 프로그램이 완성됩니다.
다음은 add.o를 링크하는 명령어입니다. 링크 과정에서 링커는 main 함수를 찾는데, add의 소스 코드에는 main 함수의 정의가 없으므로 에러가 발생할 수 있습니다. 이를 방지하기 위해 --unresolved-symbols를 컴파일 옵션에 추가했습니다.
$ gcc add.o -o add -Xlinker --unresolved-symbols=ignore-in-object-files
$ file add
add: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, ...
바이너리를 분석하려면 바이너리를 읽을 수 있어야 합니다. 그런데 컴파일된 프로그램의 코드는 기계어로 작성되어 있으므로 이를 그 자체로 이해하기는 매우 어렵습니다. 그래서 분석가들은 이를 어셈블리어로 재번역하고자 하였습니다. 이 과정은 앞서 살펴본 어셈블의 역과정이므로, 디스어셈블(Disassemble)이라고 부릅니다.
다음 명령어로 쉽게 디스어셈블된 결과를 확인할 수 있습니다.
$ objdump -d ./add -M intel
...
000000000000061a <add>:
61a: 55 push rbp
61b: 48 89 e5 mov rbp,rsp
61e: 89 7d fc mov DWORD PTR [rbp-0x4],edi
621: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
624: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
627: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
62a: 01 d0 add eax,edx
62c: 5d pop rbp
62d: c3 ret
62e: 66 90 xchg ax,ax
...
디스어셈블 기술의 등장으로 예전보다는 바이너리를 분석하기 쉬워졌지만, 여전히 규모가 큰 바이너리의 동작을 어셈블리 코드만으로 이해하기는 어려웠습니다. 그래서 리버스 엔지니어들은 어셈블리어보다 고급 언어로 바이너리를 번역하는 디컴파일러(Decompiler) 를 개발하였습니다.
그런데 어셈블리어와 기계어는 거의 일대일로 대응되어서 오차없는 디스어셈블러를 개발할 수 있었지만, 고급 언어와 어셈블리어 사이에는 이런 대응 관계가 없습니다. 또한, 코드를 작성할 때 사용했던 변수나 함수의 이름 등은 컴파일 과정에서 전부 사라지고, 코드의 일부분은 최적화와 같은 이유로 컴파일러에 의해 완전히 변형되기도 합니다.
이런 어려움으로 인해 디컴파일러는 일반적으로 바이너리의 소스 코드와 동일한 코드를 생성하지는 못합니다. 그러나, 이 오차가 바이너리의 동작을 왜곡하지는 않으며, 디스어셈블러를 사용하는 것 보다 압도적으로 분석 효율을 높여주기 때문에, 디컴파일러를 사용할 수 있다면 반드시 디컴파일러를 사용하는 것이 유리합니다. 특히 최근에는 Hex Rays, Ghidra를 비롯한 뛰어난 디컴파일러들이 개발되어서 분석의 효율을 더욱 높여주고 있습니다.