본문 바로가기

서버운영 (TA, ADMIN)/정보보안

[리버스엔지니어링] 리버스 엔지니어링이란?

리버스 엔지니어링이란?


리버스(reverse)라는 말은 반대, 역의 뜻을 가지고 있는데, 리버스 엔지니어링을 역공학이라고 쓰기도 합니다. 리버스 엔지니어링은 목표가 되는 프로그램이나 프로토콜을 분석하여 똑같은 동작을 만들어 내는 것을 말합니다.



리버스 엔지니어링의 종류


통상적으로 컴파일된 바이너리EXE, DLL, SYS 등)를 디스어셈블러라는 도구를 이용하여 어셈블리 코드를 출력한 후 그것을 C언어 소스형태로 다시 옮겨 적고 적당한 수정을 통해 리버스하고 있는 파일과 동일한 동작을 하는 프로그램을 만드는 것이 있습니다.


모든 어셈블리 코드를 소스 형태로 옮기지 않고 그냥 동작 방식만을 알아낸다거나 일정 부분만 수정하는 것들도 리버스 엔지니어링이라고 할 수 있습니다. 예를 들면 바이러스를 분석하는 일은 모든 코드를 알아낼 필요가 없기 때문에 동작 방식만 알아내면 됩니다. 그리고 크랙처럼 일정 부분만 수정하여 사용제한을 푸는 것 등도 이에 해당됩니다.


실행파일을 디스어셈블 하지 않고도 그 실행파일을 만들어내는 데이터 파일이나 패킷등을 분석하여 똑같이 재구현하는 것도 리버스 엔지니어링입니다. 예를 들면 오래전 PC 게임에서 많이 하던일인데, HEX 에디터 등으로 세브 파일을 분석하여 에디트를 만들거나 게임자체를 조작하는 것이 있고, 당나귀와 호환되는 이뮬 같은 프로그램은 당나귀 프로토콜의 패킷을 분석하여 동일한 동작을 하도록 만들어낸 것입니다.


리버스 엔지니어링에서 가장 많이 사용되는 방식은 첫번"째로 이야기 했던 바이너리를 디스어셈블하여 코드를 얻어내는 것입니다. 이것을 하기 위해서는 먼저 인텔 어셈블리를 배워야 하고, 물론 C언어도 알아야 됩니다.


그런데 여기서 컴파일된 바이너리가 VC++나 gcc등으로 컴파일한 것이 대부분이지만 비주얼 베이직으로 컴파일 한 것도 있고 델파이(파스칼)로 컴파일 한것도 있을 것입니다. 이 바이너리들은 모두 CPU에서 직접 실행되는 것들이기 때문에 디스어셈블 해보면 모두 똑같은 방식으로 되어 있습니다. 그래서 VC++, VB, 델파이(파스칼)등으로 컴파일 된 것도 디스어셈블 한 뒤 C 소스 코드로 옮길 수 있습니다. 물론 리버스하는 사람이 VB이나 파스칼로 옮겨 적을 수도 있을 것입니다. 하지만 C언어로 하는 것이 가장 간편합니다.


대부분 리버스해서 얻어내는 것들은 프로그램 로직(알고리즘)이기 때문에 어느 언어로 표현하든 결과는 똑같기 때문입니다.


자바나 닷넷으로 컴파일된 바이너리는 CPU에서 직접 실행되지 않고 자바 가상머신이나 닷넷 프레임워크를 통해서 실행됩니다. 그래서 자바로 컴파일된 바이너리를 열어보면 자바 바이트코드 문법으로 되어있고 닷넷으로 컴파일 된 것은 MSIL이라는 문법으로 되어 있습니다. 이런 것들은 인텔 어셈블리와 문법이나 명령어가 다르므로 따로 자바 바이트코드나 MSIL을 공부해서 리버스하면 됩니다.



윈도우 바이너리를 리버스한다고 한다면 윈도우 API를 알아야하고 기타 리눅스나 BSD라면 해당 OS의 API를 알아야 합니다. 그리고 당연히 인텔 어셈블리를 알고 있어야 하고, 인텔 CPU가 돌아가는 방식을 알아야 합니다. 프로그램은 단순히 로직만으로 이루어져 있지 않고 시스템의 API를 호출하여 여러가지 동작을 하기 때문에 각 API의 사용방법과 동작결과를 알고 있어야 어셈블리 코드에서 C소스 코드로 옮기고 다시 소스를 재구현할 수 있기 때문입니다. 어플리케이션을 리버스하는 경우 윈도우 커널에 대한 지식이 없어도 리버스가 가능하지만 커널 레벨로 동작하는 드라이버나 기타 서비스는 윈도우 커널에 대한 지식이 필요합니다.


어셈블리에서 C소스 코드로 옮기는 작업은 한마디로 단순 반복작업입니다. 디스어셈블된 코드에서 call은 함수이고 각종 점프 명령어들은 if, for, while, switch, goto 등의 C언어 제어문입니다. mov 명령어는 변수에 값을 대입한다거나 하는 것이고 push, pop 명령은 함수를 호출하면서 인자값을 넘겨줄 때 사용합니다. 이 어셈블리 명령의 조합을 읽어 C코드로 구현을 하면됩니다.



각종 도구들


리버스 엔지니어링에서는 디스어셈블러라는 도구가 매우 필수적입니다. 자주 쓰이는 것들로는 리버스계에서 아주 유명한 소프트아이스(SoftICE, 이하 소아)라는 프로그램이 있습니다. 소아의 특징으로는 윈도우가 돌고 있는 상태에서 Ctrl+D를 누르면 윈도우가 멈추고 소아 창이 떠서 현재 실행되고 있는 어셈블리 코드를 보여줍니다. 정말 강력하고 편리한 기능입니다. 이 기능이 소아를 쓰는 이유이기도 합니다. 물론 그냥 바이너리를 열어서 디스어셈블도 가능하며 요즘은 비주얼 소프트아이스라고 나와서 원격 디버깅도 가능합니다.


WinDBG(Windows Debugger)는 MS에서 배포하는 무료 디버거인데 윈도우 내부를 분석하는데 매우 유용한 도구입니다. 심볼 서버에서 심볼 파일을 받아와서 윈도우 내부 DLL들의 함수이름과 구조체 등을 볼 수 있습니다. 물론 이것도 그냥 바이너리를 열어 디스어셈블이 가능합니다.


OllyDBG, 아주 편리한 도구입니다. 리버스하기에 딱 알맞은 도구가 아닌가 싶습니다. 디스어셈블 뿐만 아니라 이 디버거가 어셈블리 코드를 분석하여 사용자에게 많은 정보(함수 이름, 인자값 이름, 서브루틴끼리 묶어주는 기능, 점프명령의 도착점 표시 기능 등)를 제공해 줍니다.


W32dasm는 OllyDBG처럼 인터렉티브 하지는 않지만 상당히 쓸만한 도구입니다. 기타 IDA나 PE Browse등의 프로그램이 있는데 사용자 취향에 따라 골라 쓰면 되겠습니다.


거의 모든 디버거에서 레지스터, 스택, 메모리 상태 등을 표시해 주고 있으므로 코드 분석에 많은 도움이 됩니다.


마지막으로 주의할 점은 같은 바이너리를 디스어셈블한다고 하더라도 디스어셈블러마다 분석해 내는 코드가 조금씩 다른 경우가 있습니다. 그래서 한가지 디버거만 쓰다 보면 엉뚱한 코드를 보기 쉽습니다. 여러가지 디버거를 돌려 가면서 코드를 분석하는 것도 좋은 방법입니다.



어셈블리 명령어


이번에는 어셈블리 명령어에 대해서 간단히 알아보겠습니다.


사실 리버스를 하기 위해서는 어셈블리 명령어만 알면 되는 것이 아니라 인텔 CPU의 동작 방식과 리버스하려는 시스템의 운영체제의 내부 구조메모리 관리 방식을 모두 알아야 합니다.


어셈블리는 어셈블리어라고도 부르는데 이 어셈블리어는 명령어들의 조합입니다. 인텔 CPU 안에는 이 명령어들이 회로로 구현되어 있어서 우리가 작성한 어셈블리 코드를 실행할 수 있습니다. CPU는 2진수로 모든것을 처리하는데 어셈블리 명령어들도 당연히 2진수로 되어 있습니다. 하지만 2진수로 된 것은 우리가 눈으로 읽기 어려우므로 디스어셈브러나 디버거 같은 프로그램에서 mov, add, push 같이 사람이 읽기 좋은 형태로 변환하여 보여줍니다.


어셈블리 명령어를 영어로는 instruction이라고 합니다. 그리고 명령어 다음에 오는 레지스터 이름이나 값들은 operand라고 합니다. mov eax, 0x100에서 eax와 0x100이 오퍼랜드입니다. 


리버스할때 자주 등장하는 명령어들부터 알아보겠습니다.


  • mov: move, 이름 그대로 데이터를 이동하는 명령어입니다. mov a, b라고 한다면 b의 값을 a에 대입합니다. a <- b 형태입니다. 메모리와 레지스터(CPU 안의 기억공간, 모든 연산은 이 레지스터에 저장한 뒤 이루어집니다) 사이의 데이터 이동, 레지스터와 레지스터 사이의 데이터 이동이나 값을 메모리나 레지스터에 대입할 때 사용합니다. 단 메모리와 메모리 사이의 데이터 이동은 할 수 없고 세그먼트 레지스터와 세그먼트 레지스터 사이의 데이터 이동에도 사용할 수 없습니다. 메모리와 메모리 사이의 데이터 이동은 movs (string move) 명령어를 사용합니다.
  • cmp: compare, 비교 명령어입니다. cmp eax, 1 형식으로 사용합니다. cmp 명령은 혼자 사용되지 않고 언제나 조건 점프 명령어나 조건 이동(mov) 명령어와 함께 사용됩니다. 조건 점프 명령어는 밑에서 설명하겠습니다.
  • jmp: jump, 점프 명령어입니다. 프로그램의 흐름을 바꿀 때 사용합니다. 이 점프계열 명령어들을 이용해서 C언어의 if, for, while, goto 등을 구현합니다. jmp 00403000이라면 00403000의 주소로 가서 그곳의 명령어를 실행합니다. 이 점프 명령어는 다음에 설명할 call 명령어와는 달리 되돌아 오지 않고 뛰어넘어간 주소의 다음 명령어를 계속 실행해 나갑니다. jmp 00403000 형태처럼 주소를 직접 지정하는 방법과 jmp eax처럼 레지스터에 저장된 주소로 점프하는 방법이 있습니다.
  • 조건 점프 명령어: 조건 점프 명령어는 위의 cmp 명령어의 결과에 따라 점프하는 명령어입니다. 아래 코드는 eax 저장된 값과 1이 같다면 00403000의 주소로 점프합니다. 같지 않다면 je 다음에 오는 명령어를 실행합니다.

cmp     eax, 1

je        00403000

mov     ebx, 1   


Unsigned 계열(부호가 없는 값)

   je: jump equal - 비교 결과가 같을 때 점프

   jne: jump not equal - 비교 결과가 다를 때 점프

   jz: jump zero - 결과가 0일 때 점프, je와 같음(cmp 명령에서 결과가 같으면 0을 출력합니다).

   jnz: jump not zero- 결과가 0이 아닐때 점프


   ja: jump above - cmp a, b에서 a가 클 때 점프

   jae: jump above or equal - 크거나 같을 때 점프

   juna: jump not above or equal - 크지 않거나 같지 않을 때 점프


   jb: jump below - cmp a, b에서 a가 작을 때 점프

   jbe: jump below or equal - 작거나 같을 때 점프

   jnb: jump not below - 작지 않을 때 점프

   jnbe: jump not below or equal - 작지 않거나 같지 않을 때 점프


   jc: jump carry - 캐리 플래그가 1일 때 점프

   jnc: jump not carry - 캐리 플래그가 0일 때 점프


   jnp/jpo: jump not parity / parity odd - 패리티 플래그가 0일 때 / 홀수일 때 점프

   jp/jpe: jump parity / parity even - 패리티 플래그가 1일 때 / 짝수일 때 점프


   jecxz: jump ecx zero - ecx 레지스터가 0일때 점프


Signed 계열(부호가 있는 값)

   jg: jump greater - cmp a, b에서 a가 클 때 점프

   jge: jump greater or equal - 크거나 같은 때 점프

   jng: jump not greater - 크지 않을 때 점프

   jnge: jump not greater or equal - 크지 않거나 같지 않을 때 점프


   jl: jump less - cmp a, b에서 a가 작을 때 점프

   jle: jump less or equal - 작거나 같을 때 점프

   jnl: jump not less - 작지 않을 때 점프

   jnle: jump not less or equal - 작지 않거나 같지 않을 때 점프


   jo/jno: jump overflow / not overflow - 오버플로 플래그가 1일 때 / 0일 때 점프

   js/jns: jump sign / not sign - 사인(부호) 플래그가 1일 때(음수) / 0일 때(양수) 점프



조건 점프 명령을 조합하여 if (a > b), if (a >= b), if (a < b), if (a == b), for, while ... 등의 조건문을 구현합니다.

  • push: 메모리상에 설정된 스택이라는 공간에 데이터를 저장합니다. 스택의 자료구조는 접시를 쌓는 것과 같다고 생각하면 됩니다. 접시는 순서대로 쌓고, 절대 중간에 있는 접시를 빼낼 수 없습니다. 맨 위에 쌓인 접시부터 차례차례 빼내는 것입니다. push 명령어는 스택의 가장 윗부분에 자료를 추가합니다. 추가하면서 스택 포인터도 가장 나중에 추가된 쪽을 가리키게 합니다. 일반적으로 접시를 쌓는 동작은 아래에서 위로 스택이 커지지만 x86 아키텍쳐에서는 스택이 위에서 아래로 커집니다. 
  • pop: 스택에서 자료를 빼냅니다. pop eax이면 스택에서 가장 나중에 추가된 데이터를 eax에 저장합니다. 그리고 스택 포인터를 그 바로 전에 추가된 데이터 쪽을 가리키게 합니다.
  • call: 함수 호출 명령어입니다. 점프 명령어는 한번에 점프한 곳에서 돌아오지 않지만, 이 call 명령어는 점프했던 곳에서 명령어들을 실행한 뒤 ret 명령어를 사용하여 call 명령어를 사용한 곳으로 되돌아옵니다. call 명령어가 실행되면 복귀주소를 스택에 저장(push)합니다. 그래서 ret(return) 명령어가 실행되면 다시 되돌아 올 수 있습니다. 우리가 함수로 구현한 부분을 호출할 때 사용합니다.
  • ret: 스택에 저장된 복귀 주소로 돌아갑니다. ret 10과 같이 오퍼랜드가 같이 있는 경우가 있는데 이것은 스택 포인터를 지정된 오퍼랜드 만큼 위로 올리는 것입니다. push 등으로 스택을 사용한 뒤 초기화 할 때 사용합니다.


C언어의 sum(1, 2) 함수를 어셈블리 명령어로 표현하면 대략 다음과 같습니다.


push 2            ; 두번째 인자를 스택에 저장합니다.

push 1            ; 첫번째 인자를 스택에 저장합니다.

call 00403010     ; 함수 sum의 시작 주소로 이동합니다.

00403010:

...               ; 1과 2를 더함

ret               ; su, 함수를 호출한 곳으로 되돌아 갑니다.




기계어를 어셈블리로 해석하기


CPU는 mov eax, 0x100 같은 명령을 그대로 알아듣지 못합니다. 그래서 CPU가 알 수 있는 2진수 형태의 기계어로 변환해서 실행을 하게 됩니다.


오늘은 그 CPU가 실행하는 기계어를 사람이 알아볼 수 있는 어셈블리 형태로 해석하는 방법을 알아보겠습니다.


먼저 인텔 CPU 매뉴얼이 필요합니다(이건 인텔 웹사이트에서 다운 가능합니다. Volume 2A: Instruction Set Reference, A-M를 보면 됩니다). 각 기계어가 의미하는 뜻을 알아보려면 매뉴얼을 봐야합니다. 이 글에서는 mov 명령을 예로 들면서 설명하겠습니다.



B8 00 01 00 00    mov            eax, 100h


mov 명령을 인텔 매뉴얼에서 찾아보면 다음과 같이 B8입니다.



mov 명령도 B8만 있는 것이 아니고 뒤에 오는 오퍼랜드의 종류에 따라서 여러 가지가 있습니다. 이것은 인텔 매뉴얼 Instruction Set Reference의 mov 부분에 목록이 나와 있습니다. 그런데 mov 도표에 보면 B8+라고 되어 있는데 이 +는 목적지 오퍼랜드의 종류에 따라 바뀐다는 것을 뜻합니다. 이 순서는 eax, ecx, edx, ebx, esp, ebp, esi, edi 순으로 지정되어 있는데 mov eax는 B8, ecx는 B9, edx는 BA, ... edi는 BF가 됩니다.


그런데 mov 명령 중에서도 뒤에 오퍼랜드가 eax, ecx등 여러 가지로 바뀌는데도 기계어는 똑같은 경우가 있습니다. 이것은 레지스터에 주소의 값을 대입하는 경우인데, ModR/M 바이트를 보고 판별합니다.


ModR/M 바이트는 3부분으로 구성되어 있는데, Mod 2비트, Reg/Opcode 3비트, R/M 3비트로 구성되어 있습니다. 이것은 인텔 매뉴얼 Instruction Set Reference의 앞부분을 보면 32Bit Addressing Forms with the ModR/M Byte라고 표 형태로 되어 있습니다.


8B 07            mov         eax, [edi]



이것 역시 mov는 8B이고 ebx, [ecx+] 부분은 59입니다. 4는 그 바로 뒤에 바로 나오고 있습니다. 이걸 ModR/M 표에서 찾으면 ebx 줄에서 [ecx]+disp8에 있습니다. +disp8은 +4와 같은 오프셋을 뜻합니다. 8은 8비트 숫자를 나타냅니다. +disp32는 32비트 오프셋이겠죠.


8B 0C 50         mov         ecx, [eax+edx*2]



이번에 것을 찾아보면 ecx 줄에 [eax+edx*2]라는 것이 없을 겁니다. 이런 경우는 [--][--]를 찾으면 됩니다. 이건 0C인데 eax+ebx*2는 어디에 있느냐? 50이 바로 eax+ebx+2를 뜻합니다. 이것은 ModR/M 표에서는 안나오고 ModR/M 바로 뒤에 보면 32Bit Addressing Forms with the SIB Byte 표에 나와있습니다. eax 줄에서 edx*2를 찾으면 50이 나옵니다.



이 표를 찾는 것만 알고 있으면 어떤 기계어가 나와도 인텔 매뉴얼에서 다 찾을 수 있을 것입니다.