Reverse Engineering

Background: Binary

박연준 2023. 7. 2. 14:53

프로그램

  • 프로그램은 연산 장치가 수행해야 하는 동작을 정의한 일종의 문서이다.
  • 과거에는 프로그램을 애니악(ENIAC) 컴퓨터를 사용하며, 프로그램이 바뀔 때마다 배선을 재배치해야 했으므로 매우 비효율적이었고, 크기가 큰 프로그램을 사용하기도 어려웠다.
  • 이런 단점을 해결한 Stored-Program Computer가 1950년경에 최초로 상용화 되었다.
  • 소프트웨어 개발자, 해커 등 많은 정보 분야의 엔지니어들이 프로그램을 **바이너리(Binary)**라고 부르곤 하는데, 이는 Stored-Program Computer에서 프로그램이 저장 장치에 이진(Binary) 형태로 저장되기 때문이다.
  • 텍스트가 아닌 다른 데이터들도 바이너리라고 불리긴 하지만, 많은 경우에 바이너리라고 하면 프로그램을 의미한다.

 

컴파일러와 인터프리터

  • 컴퓨터가 이해할 수 있는 기계어의 형식으로 번역하는 것을 컴파일이라고 한다.
  • 컴파일을 해주는 소프트웨어는 컴파일러라고 불리며 대표적으로 GCC, Clang, MSVC 등이 있다.
  • 대표적으로 Python, Javascript 등의 언어는 작성한 스크립트를 그때 그때 번역하여 CPU에 전달한다. 이 동작이 통역과 비슷하여 인터프리팅(Interpreting)이라고 불리며, 마찬가지로 이를 처리해주는 프로그램을 인터프리터(Interpreter)라고 한다.

 

컴파일 과정

  • C언어로 작성된 코드는 일반적으로 전처리(Preprocess), 컴파일(Compile), 어셈블(Assemble), 링크(Link)의 과정을 거쳐 바이너리로 번역된다.

컴파일 과정 속 컴파일

컴파일 (Compile)의 정확한 의미는 어떤 언어로 작성된 소스 코드를 다른 언어의 목적 코드(Object Code)로 번역하는 것이다. 이런 맥락에서, 소스 코드를 어셈블리어로, 또는 소스 코드를 기계어로 번역하는 것 모두 컴파일이라고 볼 수 있다.

 

전처리

  • 전처리(Preprocessing)는 컴파일러가 소스 코드를 어셈블리어로 컴파일하기 전에, 필요한 형식으로 가공하는 과정이다.
  • 언어마다 조금씩 다르지만, 컴파일 언어의 대부분은 다음의 전처리 과정을 거친다.
    1. 주석 제거
      • 주석은 프로그램의 동작과 상관이 없으므로 전처리 단계에서 모두 제거된다.
    2. 매크로 치환
      • #define 으로 정의한 매크로는 자주 쓰이는 코드나 상숫값을 단어로 정의한 것이다.
      • 전처리 과정에서 매크로의 이름은 값으로 치환된다.
    3. 파일 병합
      • 일반적인 프로그램은 여러 개의 소스와 헤더 파일로 이루어져 있다.
      • 컴파일러는 이를 따로 컴파일해 합치기도 하지만, 어떠한 경우는 전처리 단계에서 파일을 합치고 컴파일하기도 한다.
  • gcc에서는 -E 옵션을 사용하여 소스 코드의 전처리 결과를 확인할 수 있다.

 

컴파일

  • 컴파일은 C로 작성된 소스 코드를 어셈블리어로 번역하는 것이다.
  • 이 과정에서 컴파일러는 소스 코드의 문법을 검사하는데, 코드에 문법적 오류가 있다면 컴파일을 멈추고 에러를 출력한다.
  • gcc에서는 -O -O0 -O1 -O2 -O3 -Os -Ofast -Og 등의 옵션을 사용하여 최적화를 적용할 수 있다.
  • gcc의 -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

 

어셈블

  • 어셈블은 컴파일로 생성된 어셈블리어 코드를 ELF 형식의 목적 파일(Object file)로 변환하는 과정이다.
  • 여기서 ELF는 리눅스의 실행파일 형식이고, 윈도우에서 어셈블한다면 목적 파일은 PE형식을 갖게 된다.
  • 목적 파일로 변환되고 나면 어셈블리 코드가 기계어로 번역되므로 더이상 사람이 해석하기 어려워진다.
  • gcc 의 -c 옵션을 이용해 .S 확장자의 파일을 목적 파일로 변환할 수도 있다.

 

링크

  • 링크는 여러 목적 파일들을 연결하여 실행 가능한 바이너리로 만드는 과정이다.
  • 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, ...

 

디스어셈블

  • 바이너리를 분석하려면 바이너리를 읽을 수 있어야 한다. 이 과정은 앞서 살펴본 어셈블의 역과정이므로 디스어셈블이라고 부른다.
  • 다음 명령어로 쉽게 디스어셈블된 결과를 확인할 수 있다.
$ 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
...

 

 

디컴파일

  • 디스어셈블 기술의 등장으로 예전보다는 바이너리를 분석하기 쉬워졌지만, 규모가 큰 바이너리의 동작을 어셈블리 코드만으로 이해하기 어려웠다.
  • 그래서 리버스 엔지니어들은 어셈블리어보다 고급 언어로 바이너리를 번역하는 디컴파일러를 개발하였다.
  • 어셈블리어와 기계어는 일대일 대응이지만 고급 언어와 어셈블리어 사이에는 대응 관계가 없다.
  • 또한, 코드를 작성할 때 사용했던 변수나 함수의 이름 등은 컴파일 과정에서 전부 사라지고, 코드의 일부분은 최적화와 같은 이유로 컴파일러에 의해 완전히 변형되기도 한다.
  • 이런 어려움으로 인해 디컴파일러는 일반적으로 바이너리의 소스코드와 동일한 코드를 생성하지는 못한다.
  • 그러나, 이 오차가 바이너리의 동작을 왜곡하지는 않으며, 디스어셈블러를 사용하는 것 보다 압도적으로 분석 효율을 높여주기 때문에, 디컴파일러를 사용할 수 있다면 반드시 디컴파일러를 사용하는 것이 유리하다.
  • 특히 최근에는 Hex Rays, Ghidra를 비롯한 뛰어난 디컴파일러들이 개발되어서 분석의 효율을 더욱 높여주고 있다.

'Reverse Engineering' 카테고리의 다른 글

x86 Assembly: Essential Part(2)  (0) 2023.07.02
x86 Assembly: Essential Part(1)  (0) 2023.07.02
Background: Windows Memory Layout  (0) 2023.07.02
Background: Computer Architecture  (0) 2023.07.02
Static Analysis vs. Dynamic Analysis  (0) 2023.07.02