엔지니어링이란 완성품과 이를 구성하는 부품들의 기능과 설계를 고안하고, 제작하는 과정을 말합니다. 이와 대비되는 리버스 엔지니어링(Reverse Engineering)은 용어의 ‘리버스’가 의미하듯, 위 과정을 거꾸로 하는 행위를 말합니다.
C언어로 작성된 코드는 일반적으로 전처리(Preprocess), 컴파일(Compile), 어셈블(Assemble), 링크(Link)의 과정을 거쳐 바이너리로 번역됩니다
전처리(Preprocessing)는 컴파일러가 소스 코드를 어셈블리어로 컴파일하기 전에, 필요한 형식으로 가공하는 과정입니다.
언어마다 조금씩 다르지만, 컴파일 언어의 대부분은 다음의 전처리 과정을 거칩니다.
1. 주석 제거
주석은 개발자가 자신과 개발자들의 코드 이해를 돕기위해 작성하는 메모입니다. 주석은 프로그램의 동작과 상관이 없으므로 전처리 단계에서 모두 제거됩니다.
2. 매크로 치환
#define으로 정의한 매크로는 자주 쓰이는 코드나 상숫값을 단어로 정의한 것입니다. 전처리 과정에서 매크로의 이름은 값으로 치환됩니다.
3. 파일 병합
일반적인 프로그램은 여러 개의 소스와 헤더 파일로 이루어져 있습니다. 컴파일러는 이를 따로 컴파일해 합치기도 하지만, 어떠한 경우는 전처리 단계에서 파일을 합치고 컴파일하기도 합니다.
컴파일(Compile)은 C로 작성된 소스 코드를 어셈블리어로 번역하는 것입니다. 이 과정에서 컴파일러는 소스 코드의 문법을 검사하는데, 코드에 문법적 오류가 있다면 컴파일을 멈추고 에러를 출력합니다.
또한, 컴파일러는 코드를 번역할 때, 몇몇 조건을 만족하면 최적화 기술을 적용하여 효율적인 어셈블리 코드를 생성해줍니다. gcc에서는 -O -O0 -O1 -O2 -O3 -Os -Ofast -Og 등의 옵션을 사용하여 최적화를 적용할 수 있습니다.
옵션 최적화 수준 특징 언제 사용?
-O0 | 없음 | 디버깅에 유리, 빠른 컴파일 | 디버깅할 때 |
-O1 | 기본 | 무난한 최적화, 빠른 컴파일 | 기본적인 성능 향상 |
-O2 | 고급 | 실행 속도 향상, 코드 크기 증가 가능 | 성능이 중요한 경우 |
-O3 | 최대 | 공격적 최적화, 코드 크기 커짐 | 최대 성능 필요할 때 |
-Os | 크기 최적화 | 코드 크기 최소화 | 메모리 제한이 있는 환경 |
-Ofast | 초고속 | IEEE 표준 무시, 성능 최우선 | 부동소수점 정확도 무관한 경우 |
-Og | 디버깅 최적화 | 디버깅에 유리하면서도 최적화 | 디버깅 + 실행 속도 개선 |
소스 코드를 어셈블리 코드로 컴파일
$ gcc -S add.i -o add.S
$ cat add.S
어셈블(Assemble)은 컴파일로 생성된 어셈블리어 코드를 ELF형식의 목적 파일(Object file)로 변환하는 과정입니다. 여기서 ELF는 리눅스의 실행파일 형식입니다. 윈도우에서 어셈블한다면 목적 파일은 PE형식을 갖게 됩니다.
링크(Link)는 여러 목적 파일들을 연결하여 실행 가능한 바이너리로 만드는 과정입니다.
ex) printf라는 함수는 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, ...
기계어(바이너리 코드)를 **어셈블리어(Assembly Language)**로 변환하는 과정. 즉, 컴파일된 실행 파일(예: ELF, PE 등)을 사람이 읽을 수 있는 어셈블리 코드로 변환하는 것
다음 명령어로 쉽게 디스어셈블된 결과를 확인할 수 있습니다.
$ objdump -d ./add -M intel |
어셈블리어보다 고급 언어로 바이너리를 번역하는 디컴파일러(Decompiler)
디컴파일러는 일반적으로 바이너리의 소스 코드와 동일한 코드를 생성하지는 못합니다. 그러나, 이 오차가 바이너리의 동작을 왜곡하지는 않으며, 디스어셈블러를 사용하는 것 보다 압도적으로 분석 효율을 높여주기 때문에, 디컴파일러를 사용할 수 있다면 반드시 디컴파일러를 사용하는 것이 유리합니다. 특히 최근에는 Hex Rays, Ghidra를 비롯한 뛰어난 디컴파일러들이 개발되어서 분석의 효율을 더욱 높여주고 있습니다.
이 커리큘럼에서는 무료이면서도 성능이 뛰어난 IDA Freeware를 사용하겠습니다.