바이너리에 대한 공부는 전 포스팅에서 정리를 했으므로 넘어가도록 하겠습니다.
순수하게 공부 목적으로 작성한 포스팅입니다.
프로그래밍 언어(Programming Language)는 프로그램을 개발하기 위해 사용하는 언어를 말합니다.
CPU가 수행해야 할 명령들을 프로그래밍 언어로 작성한 것을 소스 코드(Source Code)라고 하는데, 이를 컴퓨터가 이해할 수 있는 기계어의 형식으로 번역하는 것을 컴파일(Compile)이라고 합니다. 컴파일을 해주는 소프트웨어는 컴파일러(Compiler)라고 부릅니다.
* 컴파일(Compile) : 어떤 언어로 작성된 소스 코드(Source Code)를 다른 언어의 목적 코드(Object Code)로 번역하는 것입니다.
1. 컴파일 과정
C언어로 작성된 코드는 일반적으로 전처리(Preprocess) , 컴파일(Compile), 어셈블(Assemble), 링크(Link)의 과정을 거쳐서 바이너리로 번역이 됩니다.
// name : add.c
#include "add.h"
#define HI 3
int add(int a, int b) { return a + b + HI; }
// Name : add.h
int add(int a, int b);
전처리(Preprocessing)
컴파일러가 소스 코드를 어셈블리어로 컴파일하기 전에, 필요한 형식으로 가공하는 과정입니다.
언어마다 차이가 있지만, 컴파일 언어의 대부분은 다음의 전처리 과정을 거칩니다.
1. 주석 제거
주석은 프로그램의 동작과는 상관이 없으므로, 전처리 단계에서 모두 제거가 됩니다.
2. 매크로 치환
#define 으로 정의한 매크로는 자주 쓰이는 코드나 상숫값을 단어로 정의한 것입니다. 전처리 과정에서 매크로의 이름은 값으로 치환됩니다.
3. 파일 병합
프로그램은 여러 개의 소스와 헤더 파일로 이루어져 있습니다. 컴파일러는 이를 따로 컴파일해 합치기도하지만, 어떠한 경우는 전처리 단계에서 파일을 합치고 컴파일하기도 합니다.
$ gcc -E add.c > add.i
add.c를 전처리한 결과입니다. gcc에서는 -E 옵션을 사용해서 소스 코드의 전처리 결과를 확인할 수 있습니다.
add.c 에서 주석이었던 // return a + b 가 사라졌다는 것을 알 수 가있습니다. HI가 3으로 치환이 되었고, add.h 의 내용이 #include에 병합되었습니다.
컴파일(Compile)
C로 작성된 소스 코드를 어셈블리어로 번역하는 것입니다. 이 과정에서 컴파일러는 소스 코드의 문법을 검사하고, 문법적 오류가 있을 경우 컴파일을 멈추고 에러를 출력합니다.
컴파일러는 코드를 번역할 때, 몇몇 조건을 만족하면 최적화 기술을 적용해서 효율적인 어셈블리 코드를 생성해줍니다.
gcc -S add.i -o add.S
-S 옵션을 사용하면 소스 코드를 어셈블리 코드로 컴파일을 할 수 있습니다.
어셈블(Assemble)
컴파일로 생성된 어셈블리어 코드를 ELF형식의 목적 파일(Object file)로 변환하는 과정입니다.
여기서 ELF는 리눅스의 실행파일 형식입니다.
윈도우에서 어셈블한다면 목적 파일은 PE형식을 갖게 됩니다.
목적 파일로 변환되고 나면 어셈블리 코드가 기계어로 번역되므로 사람이 해석하기 어려워집니다.
gcc의 "-c" 옵션을 통해서 add.S를 목적 파일로 변환하고, 결과로 나온 파일을 16진수로 출력한 것입니다.
gcc -c add.S -o add.o
file add.o
file 명령어는 지정된 파일의 종류(타입)을 확인하는 명령어 입니다.
링크
여러 목적 파일들을 연결하여 실행 가능한 바이너리로 만드는 과정입니다.
링크가 필요한 이유
#include <stdio.h>
int main()
{
printf("Hello, world!");
}
printf 함수를 호출하지만, printf 함수의 정의는 위의 소스코드에 없습니다. libc라는 공유 라이브러리에 존재합니다. libc는 gcc의 기본 라이브러리 경로에 있는데, 링커는 바이너리가 printf 함수를 호출하면 libc의 함수가 실행될 수 있도록 연결해줍니다. 링크를 거치고 나면 실행할 수 있는 프로그램이 완성됩니다.
gcc add.o -o add -Xlinker --unresolved-symbols=ignore-in-object-files
링크 과정에서 링커는 main함수를 찾는데 add의 소스 코드에서는 main 함수의 정의가 없으므로 에러가 발생할 수 있습니다. 그래서 unresolved-symbols 컴파일 옵션을 추가하였습니다.
디스어셈블
바이너리를 분석하려면 바이너리를 읽을 수 있어야 합니다. 컴파일된 프로그램의 코드는 기계어로 작성되어 있으므로 이를 그 자체로 이해하기는 매우 어렵습니다. 그래서 분석가들은 이를 어셈블리어로 재번역하고자 하였습니다. 어셈블의 역과정이므로 디스어셈블(Disassemble)이라고 부릅니다.
objdump -d ./add -M intel
위에 명령어를 통하여 쉽게 디스어셈블된 결과를 알 수 있습니다.
디컴파일
규모가 큰 바이너리의 동작을 어셈블리 코드만으로 이해하기는 어렵습니다. 어셈블리어보다 고급 언어로 바이너리를 번역하는 디컴파일러(Decompiler)를 개발하였습니다.
하지만 어셈블리어와 기계어는 거의 일대일로 대응되어서 오차 없는 디스어셈블러를 개발할 수 있지만, 고급 언어와 어셈블리어 사이에는 이런 대응 관계가 없습니다. 코드를 작성할 때 사용했던 변수나 함수의 이름 등은 컴파일 과정에서 전부 사라지고, 코드의 일부분은 최적화와 같은 이유로 컴파일러에 의해 완전히 변형되기도 합니다.
이러한 단점으로 인해 디컴파일러는 일반적으로 바이너리의 소스 코드와 동일한 코드를 생성하지는 못합니다.
하지만, 디스어셈블러를 사용하는 것 보다 압도적으로 분석 효율을 높여주기 때문에, 디컴파일러를 사용할 수 있다면 반드시 디컴파일러를 사용하는 것이 유리합니다. Hex Rays, Ghidra를 비롯한 뛰어난 디컴파일러들이 개발되어서 분석의 효율을 더욱 높여주고 있습니다.
드림핵에서는 IDA Freeware를 사용을 합니다.