리버스 엔지니어링이란?
완성된 제품을 해체하고 분석하여 구조와 기능, 디자인을 파악한다.
리버싱을 막기 위해 도입된 안티 리버싱 기술들을 우회해야 한다.
Good Case
각종 프로그램의 보안성을 평가하거나 악성코드를 분석할 때도 리버싱이 널리 쓰인다. 리버싱을 통해 대상 프로그램이 얼마나 안전하게 설계되었는지, 혹은 대상 악성코드가 어떤 방식으로 동작하고 시스템에 피해를 입히는지 알아낼 수 있다.
Bad Case
상용 프로그램을 구매하지 않고 무료로 이용하기 위해서 쓰는 키젠 프로그램이나 시리얼 넘버 생성기, 크랙 등의 불법 프로그램도 리버싱을 통해 만들어진다. 유로로 판매되는 프로그램들이 어떤 방식으로 정품 인증을 하는지를 리버싱을 통해 알아낼 수 있다. 게임핵을 만들 때도 리버싱을 이용한다.
기계어
컴퓨터에게 명령을 내리기 위한 컴퓨터 언어 , 0과 1
어셈블리어
사람이 이해하기 쉬운 새로운 언어, 이를 기계어로 번역해주는 어셈블러라고 한다.
어셈블리어는 기계어에 비하면 효율적이었으나, 여전히 규모가 큰 프로그램을 개발하기에는 부족했다. 따라서 c, c++ 등을 비롯하여 어셈블리어보다 더욱 사람이 이해하기 쉬운 언어들을 만들었고, 이들을 기계어로 번역해주는 컴파일러를 개발했다.
프로그램
프로그램은 연산 장치가 수행해야 하는 동작을 정의한 일종의 문서이다. 프로그램을 연산 장치에 전달하면, CPU는 적혀있는 명령들을 처리하여 프로그래머가 의도한 동작을 수행한다.
프로그램을 바이너리라고 부르곤 하는데, 프로그램이 저장 장치에 이진 형태로 저장되기 때문이다.
소스 코드
CPU가 수행해야 할 명령들을 프로그래밍 언어로 작성한 것을 소스 코드라고 한다.
컴파일
소스 코드를 컴퓨터가 이해할 수 있는 기계어의 형식으로 번역하는 것을 컴파일이라고 한다. 컴파일을 해주는 소프트웨어는 컴파일러라고 불린다. - gcc, Clang, MSVC
한번 컴파일되면 결과물이 프로그램으로 남기 때문에 언제든지 이를 실행하여 같은 명령을 처리하게 할 수 있다.
Python, JavaScript 등의 언어는 컴파일을 필요로 하지 않는다. → 사용자가 작성한 스크립트를 그때그때 번역하여 CPU에 전달한다. 이를 인터 프리팅이라 부른다.
컴파일의 정확한 의미는 어떤 언어로 작성된 소스 코드를 다른 언어의 목적 코드로 번역하는 것입니다.
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. 파일 병합
일반적인 프로그램은 여러 개의 소스와 헤더 파일로 이루어져 있습니다. 컴파일러는 이를 따로 컴파일해 합치기도 하지만, 어떠한 경우는 전처리 단계에서 파일을 합치고 컴파일하기도 한다.
"add.c"
"<built-in>"
"<command-line>"
"<command-line>"
"/usr/include/stdc-predef.h"
"<command-line>"
"add.c"
"add.h"
int add(int a, int b);
"add.c"
int add(int a, int b) { return a + b + 3; }
add.c를 전처리한 결과이다. 주석이었던 // return a+b가 사라졌고, HI가 3으로 치환되었다. 그리고 add.h의 내용이 #include에 의해 병합되었다.
컴파일
c로 작성된 소스 코드를 어셈블리어로 번역하는 것입니다. 이 과정에서 컴파일러는 소스 코드의 문법을 검사하는데, 코드에 문법적 오류가 있다면 컴파일을 멈추고 에러를 출력한다.
또한 컴파일러는 코드를 번역할 때, 몇몇 조건을 만족하면 최적화 기술을 적용하여 효율적인 어셈블리 코드를 생성해준다.
// 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가 가질 값을 직접 계산하여, 이를 대입하는 코드를 생성한다. 이를 통해 사용자가 작성한 소스 코드와 연산 결과는 같으면서도, 최적화를 적용하지 않았을 때보다 더 짧고, 실행 시간도 단축되는 어셈블리 코드가 만들어지게 된다.
어셈블
컴파일로 생성된 어셈블리어 코드를 ELF형식의 목적 파일로 변환하는 과정입니다. 여기서 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..............|
...
링크
링크는 여러 목적 파일들을 연결하여 실행 가능한 바이너리로 만드는 과정입니다. 링크가 필요한 이유를 다음의 코드를 통해 설명하겠습니다.
// Name: hello-world.c
// Compile: gcc -o hello-world hello-world.c
#include <stdio.h>
int main() { printf("Hello, world!"); }
위 코드에서 printf함수를 호출하지만, printf 함수의 정의는 hello-world.c에 없으며, libc라는 공유 라이브러리에 존재합니다. libc는 gcc의 기본 라이브러리 경로에 있는데, 링크는 바이너리가 printf를 호출하면 libc의 함수가 실행될 수 있도록 연결해줍니다. 링크를 거치고 나면 실행할 수 있는 프로그램이 완성됩니다.
디스 어셈블
바이너리를 분석하려면 바이너리를 읽을 수 있어야 합니다. 그런데 컴파일된 프로그램의 코드는 기계어로 작성되어 있으므로 이를 그 자체로 이해하기는 매우 어렵다. 그래서 분석가들은 이를 어셈블리어로 재번역하고자 했습니다. 이 과정은 앞서 살펴본 어셈블의 역과정이므로 디스 어셈블이라고 부릅니다.
디컴파일
디스어셈블 기술의 등장으로 예전보다는 바이너리를 분석하기 쉬워졌지만, 여전히 규모가 큰 바이너리의 동작을 어셈블리 코드만으로 이해하기는 어려웠습니다. 그래서 리버스 엔지니어들은 어셈블리어보다 고급 언어로 바이너리를 번역하는 디컴파일러를 개발하였습니다.
그런데 어셈블리어와 기계어는 거의 일대일로 대응되어서 오차 없는 디스 어셈블러를 개발할 수 있었지만, 고급 언어와 어셈블리어 사이에는 이런 대응 관계가 없습니다. 또한, 코드를 작성할 때 사용했던 변수나 함수의 이름등은 컴파일 과정에서 전부 사라지고, 코드의 일부분은 최적화와 같은 이유로 컴파일러에 의해 완전히 변형되기도 합니다.
이런 어려움으로 인해 디컴파일러는 일반적으로 바이너리의 소스 코드와 동일한 코드를 생성하지는 못합니다. 그러나, 이 오차가 바이너리의 동작을 왜곡하지는 않으며, 디스어셈블러를 사용하는 것보다 압도적으로 분석 효율을 높여주기 때문에, 디컴파일러를 사용할 수 있다면 반드시 디컴파일러를 사용하는 것이 유리합니다.
리버싱에서는 소프트웨어를 분석하기 위해 사용하는 분석 방법들을 크게 정적 분석과 동적 분석으로 구분합니다. 정적 분석은 외적인 관찰만을 통해 정보를 알아내는 것을 의미하며, 동적 분석은 실행을 통해 동작을 분석하는 것을 의미
정적 분석
프로그램을 실행시키지 않고 분석하는 방법이다.
장점
정적 분석을 사용하면 프로그램의 전체 구조를 파악하기 쉽다.
프로그램이 어떤 함수로 구성됐고 함수들은 서로 어떤 호출 관계를 갖는지, 어떤 API를 사용하고 어떤 문자열을 포함하는지 등을 종합적으로 살펴볼 수 있다. 분석자는 이 정보들을 바탕으로 프로그램을 큰 관점에서 이해할 수 있습니다.
또한, 분석 환경의 제약에도 비교적 자유롭습니다. 안드로이드의 apk 파일은 별도의 소프트웨어를 사용하지 않는 한 윈도 시스템에서 실행할 수 없다. 따라서 실행을 전제로 하는 동적 분석을 윈도우 환경에서 apk를 대상으로 하기는 다소 번거롭다. 하지만 정적 분석은 프로그램을 실행하지 않아도 되므로 분석을 지원하는 적절한 도구만 갖춘다면 시도할 수 있다.
바이러스와 같은 악성 프로그램의 위협으로부터 안전
만약 바이러스를 동적 분석할 경우, 바이러스를 실제로 실행해야 하므로 자신의 컴퓨터가 감염될 우려가 있다.
단점
정적 분석은 프로그램에 난독화가 적용되면 분석이 매우 어려워집니다.
최근에 많은 개발자가 자신의 소프트웨어를 리버스 엔지니어링으로부터 보호하기 위해 난독화 기법을 적용합니다. 난독화가 적용되면 프로그램 코드가 심하게 변형돼서 이를 읽고, 실행 흐름을 파악하기가 어려워집니다. 이를 해제하기 위한 여러 연구가 진행되고 있지만, 여전히 많은 상용 난독화 서비스들은 무력화할 수 있는 방법이 알려지지 않았습니다.
그리고 정적 분석만으로는 다양한 동적 요소를 고려하기 어렵습니다.
프로그램은 실행 중에 영향을 주고받는 여러 함수로 구성됩니다. 이런 문제는 프로그램의 실행 흐름이 복잡할수록 더욱 심각해집니다.
동적 분석
프로그램을 실행시키면서 분석하는 방법
장점
코드를 자세히 분석해보지 않고도 프로그램의 개략적인 동작을 파악할 수 있다.
어떤 입력에 대한 개별 함수 또는 프로그램의 출력을 빠르게 확인할 수 있으므로, 이 출력 값들을 기반으로 동작을 추록해 볼 수 있습니다.
단점
분석 환경을 구축하기 어려울 수 있다는 것이다.
프로그램을 실행하면서 분석하는 것이므로, 프로그램을 실행하지 못하면 동적 분석을 진행할 수 없다. 그래서 다른 환경의 프로그램을 동적 분석할 때에는 가상 머신을 구축하거나 프로그램을 실행할 수 있는 장치를 구매해야 한다.
앞서 정적 분석에서 소개한 난독화처럼 동적 분석에 대해서도 이를 어렵게 하는 여러 기법이 개발되어 있다. 그중 대표적인 것이 동적 분석의 일종인 디버깅을 방해하는 안티 디버깅입니다. 단순한 안티 디버깅의 예로, 아래의 코드처럼 자신이 디버깅당하고 있는지 검사하고, 디버깅 중이면 프로그램을 강제로 종료시키는 방법이 있습니다.
if (is_debugging()) // 디버깅인지 확인
exit(-1); // 프로그램 종료
Func();
'Dreamhack - Reverse Engineering' 카테고리의 다른 글
Exercise: Helloworld (0) | 2022.02.18 |
---|---|
x86 Assembly🤖: Essential Part(2) (0) | 2022.02.18 |
x86 Assembly🤖: Essential Part (0) | 2022.02.18 |
Windows Memory Layout (0) | 2022.02.18 |
Background: Computer Architecture (0) | 2022.02.18 |