[Reversing] 1. 바이너리(Binary)

Wonder_Land🛕·2022년 8월 22일
0

[Reversing]

목록 보기
1/6
post-thumbnail

[Reference] : 위 글은 다음 내용을 제가 공부한 후, 인용∙참고∙정리하여 만들어진 게시글입니다.


  1. 서론
  2. 프로그램과 컴파일
  3. 디스어셈블과 디컴파일
  4. Q&A
  5. 마치며

1. 서론

초기 컴퓨터는 '기계어(Machine Language)'를 이용하여 명령을 받고 연산을 수행했습니다.

하지만 기계어는 0과 1로 구성되어,
사람이 이해하기 어렵고, 명령을 내리기가 비효율적이었습니다.

따라서, 컴퓨터 과학자들은 비교적 사람이 이해하기 쉬운 '어셈블리어(Assembly Language)'를 고안했고,
이를 기계어로 번역해주는 '어셈블러(Assembler)'를 개발했습니다.

어셈블리어는 기계어에 비하면 효율적이었으나,
큰 규모의 프로그래밍에는 부적합했습니다.
따라서, C, C++, Rust 등 사람들에게 더욱 친숙한 언어들이 만들어졌고,
이를 기계어로 번역해주는 '컴파일러(Compiler)'가 개발되었습니다.

프로그래밍 언어 중,
사람이 이해하기 쉬운 언어를 '고급 언어(High-Level Language)',
기계어에 가까운 언어를 '저급 언어(Low-Level Language)'라고 합니다.

오늘날에는, 고급 언어로 만든 프로그램이 훨씬 더 효율적이기 때문에,
저급 언어를 이용해 프로그램을 잘 개발하지 않습니다.


2. 프로그램과 컴파일

1) 프로그램

  • 프로그램(Program)
    : 연산 장치가 수행해야 하는 동작을 정의한 일종의 문서

프로그램을 연산장치에 전달하면,
CPU는 정의되어 있는 명령들을 처리하여 프로그래머가 의도한 동작을 수행합니다.

사용자가 정의한 프로그램을 해석하여 명령어를 처리할 수 있는 연산 장치를 'programmable'하다고 합니다.
현대의 컴퓨터가 대표적인 programmable 연산 장치이며,
일반 계산기는 대표적인 non-programmable 연산 장치입니다.

과거에는 프로그램을 내부 장치에 저장할 수 없어서,
사람이 직접 전선을 연결해 컴퓨터에 전달하거나
천공 카드(Punched card)에 프로그램을 기록하여 재사용했습니다.
이 방식을 채택한 컴퓨터가 에니악(ENIAC)인데,
프로그램이 바뀔 때마다 배선을 다시 해야했으므로, 비효율적이었습니다.
(교수님한테서 들었던 내용이네요... 신기방기)

이러한 단점을 해결한 'Stored-Program Computer'가 1950년경에 최초로 상용화되었습니다.
이 컴퓨터는 프로그램을 메모리에 전자적으로, 광학적으로 저장할 수 있었습니다.
이전의 컴퓨터에 비해 상당히 많은 프로그램을 저장할 수 있고 효율적이었습니다.
그래서, 오늘날의 컴퓨터는 'Stored-Program Computer' 방식으로 개발됩니다.

소프트웨어 개발자, 해커 등 많은 정보 분야의 엔지니어들이 프로그램을 '바이너리(Binary)'라고 부르는데,
이는 'Stored-Program Computer' 방식에서 프로그램이 저장될 때, 이진(Binary) 형태로 저장되기 때문입니다.
텍스트가 아닌 다른 데이터들도 바이너리라고 불립니다만, 보통 프로그램을 의미합니다.


2) 컴파일러와 인터프리터

CPU가 수행해야할 명령들을 프로그래밍 언어로 작성한 것을 '소스 코드(Source Code)'라고 합니다.

소스 코드를 컴퓨터가 이해할 수 있는 기계어로 변역하는 것을 '컴파일(Compile)'이라고 합니다.

  • 컴파일(Compile)
    : 특정 언어로 작성된 '소스 코드(Source Code)'를 다른 언어의 '목적 코드(Object Code)'로 변역하는 것
    : 따라서 소스코드에서 어셈블리어로, 소스 코드를 기계어로 번역하는 것 모두 컴파일이라고 볼 수 있습니다.

컴파일을 해주는 소프트웨어를 '컴파일러(Compiler)'라고 합니다.
대표적인 컴파일러로 GCC, Clang, MSVC 등이 있습니다.

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

하지만, 모든 언어가 컴파일을 해야하는 것은 아닙니다.
Python, Javascript 등은 컴파일이 필요 없습니다.
이 언어들은 사용자의 입력, 또는 사용자가 작성한 스크립트를 그때 그때 번역하여 CPU에 전달합니다.
그리고 이 과정을 '인터프리팅(Interpreting)'이라고 하며,
이를 수행하는 프로그램을 '인터프리터(Interpreter)'라고 합니다.

컴파일은 아무 배경지식이 없는 사람이 책을 읽을 수 있도록 배경지식을 엮고, 번역하여 하나의 번역본을 만드는 과정입니다.
인터프리팅은 동시 통역사를 거쳐 대화하는 과정입니다.
(이렇게 들으니까 컴파일이 더 잘 와닿네요😉)

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

아래에서 컴파일 과정을 봅시다.

아래의 예시를 봅시다.

// Name: add.c

#include "add.h"

#define HI 3

// return a+b
int add(int a, int b) { return a + b + HI; } 
// Name: add.h

int add(int a, int b);

(1) 전처리(Preprocessing)

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

언어마다 다르지만,
보통 다음과 같은 과정을 거칩니다.

(1.1) 주석 제거

주석은 개발자가 자신과 개발자들의 이해를 돕기위해 작성하는 메모입니다.
주석은 프로그램의 동작과 상관없으므로 전처리 단계에서 모두 제거됩니다.

(1.2) 매크로 치환

#define으로 정의한 매크로는 자주 쓰이는 코드나 상숫값을 단어로 정의한 것입니다.
전처리 과정에서 매크로 이름은 값으로 치환됩니다.

(1.3) 파일 병합

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

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

-E옵션을 통해 소스코드의 전처리 결과를 확인할 수 있습니다.
.i확장자는 SWIG 소스이거나 C preprocessor 출력파일입니다.

# 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);
# 4 "add.c" 2

int add(int a, int b) { return a + b + 3; }

위의 add.iadd.c를 전처리한 결과입니다.

살펴보면,
소스코드의 주석이었던 //return a+b사라졌고,
HI3으로 치환되었으며,
add.h의 내용인 #include에 의해 병합되었습니다.


(2) 컴파일(Compile)

  • 컴파일(Compile)
    : C로 작성된 소스코드를 어셈블리어로 번역하는 과정

이 과정에서 컴파일러는 소스 코드의 문법을 검사합니다.
만약 코드에 문법적인 오류가 있다면 컴파일을 멈추고 에러를 출력합니다.

또한, 컴파일러는 코드를 번역할 때,
몇몇 조건을 만족하면 최적화 기술을 이용해 효율적인 어셈블리 코드를 생성합니다.
gcc에서는 -O -O0 -O1 -O2 -O3 -Os -Ofast -Og 등의 옵션을 사용할 수 있습니다.

예를 들어, 아래의 코드를 최적하여 컴파일 하면,
컴파일러는 반복문을 어셈블리어로 옮기는 게 아닌,
반복문의 결과로 x가 가질 값을 직접 계산하여, 이를 대입하는 코드를 생성합니다.

이를 통해, 사용자가 작성한 소스 코드와 결과는 같으며, 더 짧고 실행 시간도 단축된 어셈블리 코드가 만들어집니다.

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

-S옵션을 이용하여 소스 코드를 어셈 블리 코드로 컴파일 할 수 있습니다.

        .file   "add.c"
        .text
        .globl  add
        .type   add, @function
...(생략)

(3) 어셈블(Assemble)

  • 어셈블(Assemble)
    : 컴파일로 생성된 어셈블리어 코드를 ELF형식의 목적 파일(Object File)로 변환하는 과정

  • ELF
    : 리눅스의 실행파일 형식
    (윈도우에서는, PE형식)

목적 파일로 변환되고 나면,
어셈블리 코드가 기계어로 변역되었기 때문에,
사람이 보기에는 어렵습니다.

$ gcc -c add.S -o add.o

-c옵션은 add.S를 목적 파일로 변환합니다.


  • 링크(Link)
    : 여러 목적 파일들을 연결하여 실행 가능한 바이너리로 만드는 과정
// Name: hello-world.c
// Compile: gcc -o hello-world hello-world.c

#include <stdio.h>

int main() { printf("Hello, world!"); }

printf함수를 호출하지만,
위 파일에서는 printf함수의 정의는 없습니다.
libc라는 공유 라이브러리에 존재합니다.

libc는 gcc의 기본 라이브러리 경로에 있는데,
링커는 바이너리가 printf함수를 호출하면,
libc의 함수가 실행될 수 있도록 연결해줍니다.

링크 과정을 거치고 나면 실행할 수 있는 프로그램이 만들어집니다.


3. 디스어셈블과 디컴파일

1) 디스어셈블

바이너리를 분석하려면 당연히 바이너리를 읽을 수 있어야 합니다.

그런데 컴파일된 프로그램의 코드는 기계어로 작성되어 있으므로, 이 자체를 이해하기는 매우 어렵습니다.

그래서 분석가들은, 이를 어셈블리어로 재번역하고자 했습니다.

이 과정은 어셈블의 역과정이므로 '디스어셈블(Disassemble)'이라고 부릅니다.

디스어셈블은 objdump -d명령으로 할 수 있습니다.

$ objdump -d ./add -M intel

2) 디컴파일

디어셈블을 통해 바이너리를 분석할 수 있게 되었지만,
규모가 큰 바이너리에게는 아직 무리입니다...

따라서 어셈블리어보다 고급 언어로 바이너리를 번역하는 '디컴파일러(Decompiler)'를 개발했습니다.

그러나, 어셈블리어와 기계어는 거의 일대일로 대응되어 오차없는 디스어셈블러를 만들 수 있지만,
고급 언어와 어셈블리어 사이에는 이러한 대응 관계가 없습니다.

또한, 코드를 작성할 때 사용한 변수나 함수의 이름등은 컴파일 과정에서 전부 사라집니다.
심지어, 코드 일부분은 컴파일러의 최적화와 같은 이유로 변형되기도 합니다.

따라서, 바이너리의 소스 코드와 완전히 일치하는 코드를 디컴파일러는 만들 수 없습니다.

하지만, 이 오차가 바이너리의 동작을 왜곡하지 않고, 디스어셈블러를 사용하는 것보다 압도적으로 더 효율적이므로,
디컴파일러를 사용할 수 있다면 사용하는 것이 유리합니다.
Hex Rays, Ghindra와 같은 뛰어난 디컴파일러가 개발되어 분석의 효율을 높이고 있습니다!


4. Q&A

-


5. 마치며

오늘은 리버싱 처음으로 공부해보았습니다!
드디어 보안 공부를 하게 되었네요!!

사실은........몇 주 전부터 포너블을 공부했었습니다만...........
도저히 알아먹을 수가 없네요 😢
(특히나 어셈블리어 부분은 정말 돌아버리겠어요. 나머지 부분도 마찬가지....)

그래서 리눅스 Bandit wargame을 하고,
리버싱 과정이 꼭 필요하다고 느꼈습니다...

포너블은 잠시 두고
리버싱부터 시작해봐야겠습니다.

[Reference] : 위 글은 다음 내용을 제가 공부한 후, 인용∙참고∙정리하여 만들어진 게시글입니다.

profile
아무것도 모르는 컴공 학생의 Wonder_Land

0개의 댓글