Information Security Study

Binary 본문

Dreamhack/Reversing

Binary

gayeon_ 2023. 9. 24. 19:55

프로그램

: 연산 장치가 수행해야 하는 동작을 정의한 일종의 문서

 

프로그램을 연산 장치에 전달 -> CPU가 적혀있는 명령들을 처리해 프로그래머가 의도한 동작을 수행

 

사용자가 정의한 프로그램을 해석해 명령어를 처리할 수 있는 연산 장치를 programmable하다고 한다.

현대의 컴퓨터가 대표적인 programmable 연산 장치이다.

 

Stroed-Program Computer

- 프로그램을 메모리에 전자적 또는 광학적으로 저장할 수 있다.

- 기존의 컴퓨터보다 월등히 많은 프로그램을 저장할 수 있다.

- 저장된 프로그램의 사용이 간편하다.

 

소프트웨어 개발자, 해커 등 많은 정보 분야의 엔지니어들이 프로그램을 바이너리라고 부르곤 하는데,

이는 Stored-Programe Computer에서 프로그램이 저장 장치에 이진(Binary) 형태로 저장되기 때문이다.

대개 바이너리라고 하면 프로그램을 의미한다. (텍스트가 아닌 다른 데이터도 바이너리로 불리긴 한다.)

 

프로그래밍 언어

: 프로그램을 개발하기 위해 사용되는 언어

예) C, C++, Go, Rust 등 고급언어, 어셈블리어와 기계어 등의 저급 언어

 

소스 코드

: CPU가 수행해야 할 명령들을 프로그래밍 언어로 작성한 것

 

컴파일

: 소스 코드를 컴퓨터가 이해할 수 있는 기계어의 형식으로 번역하는 것

: 어떤 언어로 작성된 소스 코드를 다른 언어의 목적 코드(Object Code)로 번역하는 것

 

컴파일러

: 컴파일을 해주는 소프트웨어

예) GCC, Clang, MSVC

한번 컴파일되면 결과물이 프로그램으로 남기 때문에 언제든지 실행해 같은 명령을 처리할 수 있다.

 

컴파일을 필요로 하지 않는 언어

- Python

- Javascript

사용자의 입력 또는 사용자가 작성한 스크립트를 그때 그때 번역해 CPU로 전달한다.

이 동작을 인터프리팅(interpreting)이라 한다.

이를 처리하는 프로그램을 인터프리터(interpreter)라 한다.

 

컴파일과 인터프리팅의 차이점

컴파일은 결과물(프로그램)이 남아 언제든 실행할 수 있지만 한 번 컴파일 시 시간이 많이 필요하고 인터프리팅은 번역 시간이 짧지만 같은 명령을 처리하더라도 매번 인터프리터를 사용해야 한다.

 

 

컴파일 과정

 

C언어로 작성된 코드는 일반적으로

전처리, 컴파일, 어셈블, 링크의 과정을 거쳐 바이너리로 번역된다.

 

 

예시 소스 코드

// 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);

 

전처리

: 컴파일러가 소스 코드를 어셈블리어로 컴파일하기 전, 필요한 형식으로 가공하는 과정

 

컴파일 언어 대부분의 전처리 과정

 

1) 주석 제거

    : 프로그램의 동작과 상관 없는 주석 삭제

 

2) 매크로 치환

    : #define으로 정의한 매크로(자주 쓰이는 코드나 상숫값을 단어로 정의한 것)의 이름을 값으로 치환

 

3) 파일 병합

    : 일반적인 프로그램은 여러 개의 소스와 헤더 파일로 이루어져 있다. 컴파일러는 이를 따로 컴파일해 합치기도 하지만,        어떠한 경우는 전처리 단계에서 파일을 합치고 컴파일한다.

 

$ gcc -E add.c > add.i
$ cat add.i

add.i는 add.c를 전처리한 결과이다.

gcc에서 -E 옵션을 사용해 소스 코드의 전처리 결과를 확인한다.

 

# 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; }

소스 코드의 주석이었던 // return a+b 삭제, HI가 3으로 치환, add.h의 내용이 #include에 의해 병합되었다.

 

컴파일(Compile)

: C로 작성된 소스 코드를 어셈블리어로 번역하는 것

: 컴파일 과정에서 컴파일러는 소스 코드의 문법을 검사, 코드에 문법적 오류가 있다면 컴파일 정지 후 에러 출력

 

컴파일 최적화 옵션(gcc)

 -O -O0 -O1 -O2 -O3 -Os -Ofast -Og

 

// 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);
}

opt.c를 최적화해 컴파일하면 컴파일러는 반복문을 어셈블리어로 옮기는 것이 아닌,

반복문의 결과로 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

어셈블리 코드

 

$ gcc -S add.i -o add.S
$ cat add.S

-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)로 변환하는 과정

 

ELF

: 리눅스의 실행파일 형식

 

PE

: 윈도우에서 어셈블해 생성된 목적 파일

 

목적 파일로 변환되고 나면 어셈블리 코드는 기계어로 번역된다.

 

$ 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..............|
...

gcc의 -c 옵션을 통해 add.S를 목적 파일로 변환 후 결과로 나온 파일을 16진수로 출력한 것

 

링크(Link)

: 여러 목적 파일들을 연결하여 실행 가능한 바이너리로 만드는 과정

 

// Name: hello-world.c
// Compile: gcc -o hello-world hello-world.c
#include <stdio.h>
int main() { printf("Hello, world!"); }

위 코드에서 printf함수를 호출하지만 해당 함수의 정의는 hello-world.c에 없으며 libc라는 공유 라이브러리에 존재한다.

libc는 gcc의 기본 라이브러리 경로에 있다.

링커는 바이너리가 printf를 호출하면 libc의 함수가 실행될 수 있도록 연결한다.

링크를 거치고 나면 실행할 수 있는 프로그램이 완성된다.

 

$ 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, ...

add.o를 링크하는 명령어

링크 과정에서 링커는 main함수를 찾지만 add 소스 코드에는 main함수 정의가 없으므로 에러가 발생할 수 있다.

이를 방지하기 위해 --unresolved-symbols를 컴파일 옵션에 추가했다.

 

디스어셈블(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)

: 어셈블리어보다 고급 언어로 바이너리를 번역

: 규모가 큰 바이너리의 동작을 어셈블리 코드만으로 이해하기는 어려워 개발된 것

 

어셈블리어와 기계어는 일대일로 대응 -> 오차없는 디스어셈블러

고급 언어와 어셈블리어는 대응 관계 x

코드 작성 시 사용했던 변수, 함수의 이름 등은 컴파일 과정에서 전부 사라짐 / 최적화를 이유로 코드의 일부분은 컴파일러에 의해 완전히 변형

-> 이러한 어려움으로 디컴파일러는 일반적으로 바이너리의 소스 코드와 동일한 코드를 생성하기 어려움

 

* 하지만 이 오차가 바이너리의 동작을 왜곡하지는 않으며 디스어셈블러를 사용하는 것보다 압도적으로 분석 효율을 높여주기 때문에 디컴파일러 사용이 유리하다!

 

Hex Rays, Ghidra 등과 같은 디컴파일러로 분석 효율 상승

드림핵에서는 IDA Freeware 사용

 

'Dreamhack > Reversing' 카테고리의 다른 글

Computer Architecture  (0) 2023.11.23
Static Analysis vs. Dynamic Analysis  (1) 2023.11.23
Introduction: Reverse Engineering  (0) 2023.09.24
Reversing Basic Challenge #2  (0) 2023.08.16
Reversing Basic Challenge #1  (0) 2023.08.16