1. 기초리버싱
1. Hello World! 리버싱
#include "windows.h"
#include "tchar.h"
int _tmain(int argc, TCHAR *argv[])
{
MessageBox(NULL,
L"Hello World!",
L"www.reversecore.com",
MB_OK);
return 0;
}
소스코드는 위와 같으며, Release 모드로 빌드(최적화 빌드) 해서 실행 파일을 만들어본다.
처음으로 OllyDbg를 실행하면 아래와 같은 이미지를 볼 수 있다.
여기서 OllyDbg의 화면 구성에 대해서 알아보자
Code Window | 기본적으로 disassembly code를 표시하여 각종 commeny, label을 보여주며, 코드를 분석하여 loop, jump 위치 등의 정보를 표시한다. |
Register Window | CPU register 값을 실시간으로 표시하며 특정 register들은 수정도 가능하다. |
Dump Window | 프로세스에서 원하는 memory 주소 위치를 Hex와 ASCII/유니코드 값으로 표시하고 수정도 가능하다. |
Stack Window | ESP register가 가리키는 프로세스 stack memory를 실시간으로 표시하고 수정도 가능하다. |
1.1. EP
디버거가 멈춘 곳은 EP(Entry Point) 코드로, HelloWorld.exe 가 실행되는 시작 주소를 의미한다.
[그림2-1] 에서 EP는 4011A0인 것을 확인할 수 있다.
EP 포인트의 내용을 살펴보면 아래와 같다.
Address Instruction Disassembled Code comment
-----------------------------------------------------------------------------------
004011A0 E8 67150000 CALL HelloWor.0040270C ; EP 시작
004011A5 E9 A5FEFFFF JMP HelloWor.0040104F
Address | 프로세스의 가상 메모리(Virtual Address: VA) 내의 주소 |
Instruction | IA32(또는 x86) CPU 명령어 |
Disassembled Code | OP code 를 보기 쉽게 어셈블리로 변환한 코드 |
comment | 디버거에서 추가한 주석(옵션에 따라 상이함) |
따라서 위의 어셈블리코드는 간단하다.
"0040270C를 호출하고 0040104F 주소로 넘어가라"를 의미한다.
1.2. main 함수 찾기
함수를 따라가기 위해서는 OllyDbg의 기본 명령어를 숙지하고 있어야한다.
명령어 | 단축키 | 설명 |
Restart | [Ctrl + F2] | 다시 처음부터 디버깅 시작 (디버깅을 당하는 프로세스를 종료하고 재실행) |
Step Into | [F7] | 하나의 OP code 실행 (CALL 명령을 만나면, 그 함수 코드 내부로 따라 들어감) |
Step Over | [F8] | 하나의 OP code 실행 (CALL 명령을 만나면, 따라 들어가지 않고 함수 자체를 실행) |
Execute till Return | [Ctrl + F9] | 함수 코드 내에서 RETN 명령어까지 실행 (함수 탈출 목적) |
위의 단축키를 사용하여, main( ) 함수까지 들어가도록 하자.
여기서의 main( ) 함수는 Windows API의 MessageBox( ) API 호출 코드를 포함하고 있으므로, MessageBox( ) 함수를 찾는다고 생각하면 된다.
추가적인 디버거 동작 명령은 아래와 같다.
명령어 | 단축키 | 설명 |
Go to | [Ctrl + G] | 원하는 주소로 이동 (코드/메모리를 확인할 때 사용, 실행되는 것은 아니다) |
Execute till Cursor | [F4] | cursor 위치까지 실행 (디버깅하고 싶은 주소까지 바로 갈 수 있음) |
Comment | ; | Comment 추가 |
User-defined comment | 마우스 우측 메뉴 Search for User-defined comment | |
Label | : | Label 추가 |
User-defined label | 마우스 우측 메뉴 Search for User-defined label | |
Set/Reset BreakPoint | [F2] | BP 설정/해제 |
Run | [F9] | 실행(BP가 걸려있으면 해당BP에서 실행이 정지됨) |
Show the current EIP | * | 현재 EIP 위치를 보여줌 |
Show the previous Cursor | - | 직전 커서 위치를 다시 보여줌 |
Preview CALL/JMP address | [Enter] | 커서에 CALL/JMP 등의 명령어가 위치해 있다면, 해당 주소를 따라가서 보여줌 (실행되는 것이 아닌, 함수 내용을 확인하기 위한 용도) |
1.3. 베이스 캠프를 설치하는 4가지 방법
디버거를 재실행할 때마다 처음(EP 코드)부터 새로 시작하기 때문에 불편하다.
그래서 디버깅을 진행하면서 중요 포인트(주소)를 지정해 놓은 후 그 포인트로 빠르게 갈 수 있도록 기록한다.
이렇게 중요 포인트를 기록하는 과정이 히말라야 고봉 등정 과정과 비슷하여 이름이 붙여진다.
'베이스캠프' - '전진캠프 1' - ' 전진캠프 2' - '최종 공격 캠프' - '정상' 으로 진행된다.
이제 베이스 캠프를 설정하는 방법을 보여준다.
1) Goto 명령
베이스 캠프 주소를 기억한 상태에서 Goto 명령인 [Ctrl + G]으로 주소를 입력하여 이동하는 방법
2) BP 설치
BP(Break Point)를 설치[F2] 하고 실행[F9]하는 것이다. (보편적으로 사용됨)
디버거는 현재 실행위치에서 프로세스를 실행하다가 BP가 있는 곳에서 멈추게 된다.
단축키 [ALT + B]이며, 아래의 그림과 같이 BP를 선정한 곳을 보여준다. 더블클릭하면 해당 주소로 이동한다.
3) 주석
[;] 단축키로 주석(Comment)을 달고, 주석을 찾아 가는 방법도 존재한다.
마우스 우측 메뉴의 Search for - User-defined comment 항목을 선택하면 아래와 같이 사용자가 입력한 주석들을 확인할 수 있다.
4) 레이블
레이블(Label)은 원하는 주소에 특정 이름을 붙여주는 기능이다.
단축키 [:]를 이용해 레이블을 입력할 수 있다.
주석과 마찬가지로 마우스 우측 메뉴의 Search for - User-defined Label을 검색하면 사용자가 입력한 라벨들을 확인할 수 있다.
1.4. 원하는 코드를 찾는 4가지 방법
원하는 코드란 보통 main( ) 함수를 의미할 것이다.
main( ) 함수는 처음 시작하는 EP 코드에서 한참 떨어져 있다는 것을 알 수 있기 때문이다.
그렇기에 원하는 함수를 찾는 방법이 중요하므로, 4가지 방법을 아래에 소개한다.
1) 코드 실행 방법
우리가 만약 계속하여 디버깅을 시행할 경우 main( ) 함수의 MessageBox( ) 함수를 실행시켜, "Hello World"를 출력할 것이다.
프로그램의 기능이 명확한 경우, 명령어 하나하나 실행해가며 원하는 위치를 찾는 것을 의미한다.
2) 문자열 검색 방법
마우스 우측 메뉴 - Search for - All referenced text strings
OllyDbg가 디버깅할 프로그램을 로딩할 때 사전 분석 과정을 진행한다.
이때, 프로세스 메모리를 흝고 참조되는 문자열과 호출되는 API들을 뽑아내어 목록을 정리해놓는다.
위 Disassembly 코드를 확인하면 004092A0번에 PUSH 한 것을 알 수 있다. 이는 Dump에서 확인이 가능하다.
Dump에서 [Ctrl + G] 명령을 사용하여 4092A0 을 확인해보도록 한다.
유니코드로 된 "Hellow World!" 문자열을 확인할 수 있다.
여기서 중요한 점은 PUSH로 데이터를 저장시킨 영역의 주소가 409XXX 주소라는 것 입니다.
지금까지 본 코드 영역의 주소(401XXX)와 다른 영역입니다.
코드와 데이터 영역이 나뉘어져 있는 것을 확인할 수 있습니다.
3) API 검색 방법(1) - 호출 코드에 BP
마우스 우측 메뉴 - Search for - All intermodular calls
Windows 프로그래밍에서 모니터 화면에 뭔가를 출력하기 위해서는 Win32 API를 사용하여 OS에 화면출력을 진행해야 합니다.
즉, '화면에 무언가를 출력했다.' 라는 것은 프로그램 내부에서 Win32 API를 사용했다는 이야기입니다.
그러므로, 프로그램의 기능에 사용되었을 법한 Win32 API 호출을 예상하여 해당 위치를 찾는다면, 디버깅이 간편해 질 것이다.
(MessageBox( )도 user32.MessageBoxW( ) API를 사용한다.)
4) API 검색 방법(2) - API 코드에 직접 BP
마우스 우측 메뉴 - Search for - Name in all modules
OllyDbg가 모든 실행 파일에 대해서 API 함수 호출 목록을 추출할 수 있는 것은 아니다.
Packer/Protecter(암호화 등의 소스코드 보호)를 사용하여 실행 파일을 압축(또는 보호)하면, 파일 구조가 변경되어 API 호출 목록이 보이지 않을 경우가 존재한다.
이런 경우에는 프로세스 메모리에 로딩된 라이브러리(DLL 코드)에 직접 BP를 걸어둔다.
API는 C:\Windows\system32 폴더에 *.dll 파일 내부에 구현되어 있다.
간단하게 말해서 프로그램이 I/O를 하려면 OS에서 제공된 API를 사용해서 OS에게 요청하며, 해당 API가 구현된 시스템 DLL 파일들은 우리 프로그램의 프로세스 메모리에 로딩된다.
이는 View - Memory 메뉴를 선택하면 볼 수 있다. (단축키 [ALT + M])
위의 그림에서 USER32 라이브러리가 로딩되어 있는 메모리 영역을 보여준다.
OllyDbg의 해석 기능을 이용한다. 프로세스 실행을 위해서 같이 로딩된 시스템 DLL 파일이 제공하는 모든 API 목록을 보여주는 것이다.
'Name in all modules' 명령이며, Name을 정렬시키고 MessageBoxW를 찾는다.
HelloWorld 와 USER32 가 노출되며, HelloWorld는 프로그램 내에 사용되는 주소의 MessageBoxW를 의미한다.
USER32의 MessageBoxW는 USER32.dll 에 구현된 실제 MessageBoxW 함수를 의미한다.
HellowWorld.exe에서 사용되는 주소와 다른 것을 확인할 수 있다.
이번에는 USER32.dll 의 MessageBoxW에 BP를 걸어 확인을 진행한다.
결과는 아래와 같다.
우측 하단 스택 윈도우를 아래에 풀어서 작성했다.
Stack
address Value Comment
------------------------------------------------------------------------------------
0019FF1C 00401014 CALL to MessageBoxW form HelloWor.0040100E
= MessageBoxW는 40100E주소에서 호출되었으며,
함수 실행이 종료되면 401014 주소로 리턴한다.
0019FF20 00000000 hOwner = NULL
0019FF24 004092A0 Text = "Hello World!"
0019FF28 00409278 Title = "www.reversecore.com"
0019FF2C 00000000 Style = MB_OK|MB_APPLMODAL
1.5. 문자열 패치
"Hello World!"의 문자열을 다른 문자열로 변경시키는 것을 말한다.
문자열 패치 방법은 아래와 같다.
- 문자열 버퍼를 직접 수정하는 방법
- 다른 메모리 영역에 새로운 문자열을 생성하여 전달하는 방법
으로 총 2가지 방법이 존재한다.
1) 문자열 버퍼를 직접 수정
4092A0 주소의 Hex dump에서 원하는 크기만큼 선택 후 [space] 를 누르면 위와 같은 Edit 모달창을 확인할 수 있다.
"Hellow World!" 를 "Hello Reversing!!!" 으로 변경하도록 한다.
문자열이 변경되어 있는 것을 확인할 수 있다.
이 후, 실행을 진행한다.
정상적으로 작동하는 것을 확인할 수 있다.
번외) 문자열 패치 후 파일로 생성
위에서 수행한 패치 작업은 임시적으로 시행한 파일이므로 디버거가 종료되면 해당 패치 내용은 사라지게 된다.
이번엔, 변경한 내용의 보존을 위해 별도의 실행파일로 저장한다.
변경된 내용("Hello Reversing!!!" 문자열)을 선택하여 우클릭 > Copy to executable 메뉴 선택
Hex 창 > 변경된 문자열 우클릭 > Save file > 파일 이름 변경 > 저장 순으로 저장이 가능하다.
2) 다른 메모리 영역에 새로운 문자열을 생성하여 전달
만약 원본 문자열보다 더 긴 문자열("Hello Reversing World!!!")로 패치해야 한다면, 1번의 방식과는 맞지 않는다.
이때에는 다른 방법을 사용한다.
401007 주소의 PUSH 004092A0 명령은 문자열 "Hello World!" 문자열을 파라미터로 MessageBoxW( ) 함수에 전달하고 있다.
만약 이 문자열 주소를 변경해서 전달할 경우, 메세지 박스에는 변경된 문자열이 출력될 것이다.
여기서 중요한 점은 4092A0 과 같은 메모리 영역에서 지정을 해줘야한다.
고로, 4092A0을 dump에서 찾고 아래로 스크롤을 하면, 프로그램에서 사용하지 않는 NULL padding 영역을 확인할 수 있다.
이 영역중 임의의 영역을 선택하여 사용하고자 하는 문자열을 작성한다. (단축키 [Ctrl + E])
이 후, MessageBoxW( ) 함수에 새로운 버퍼 주소(00409C00)를 파라미터로 전달해야한다.
이번에는 Code 창에서 Assemble 명령을 사용해서 코드를 수정한다.
401007 주소 위치에 놓고 Assemble 명령(단축키 [Space])를 눌러 Assemble 창에서 명령을 변경해준다.
이 후, 실행하면 문자열이 정상적으로 변경된 것을 확인할 수 있다.
2. 리틀 엔디언 표기법
컴퓨터에서 메모리에 데이터를 저장하는 방식을 의미하는 바이트 오더링의 리틀 엔디언 표기법과 빅 엔디언 표기법에 대해서 설명한다.
2.1. 바이트 오더링
바이트 오더링은 데이터를 저장하는 방식을 의미한다.
이는 어플리케이션의 디버깅 할 때 알아야하는 기본 개념 중 하나이다.
바이트 오더링은 크게 두가지가 존재하며, 빅 엔디언 과 리틀 엔디언 방식이다.
Byte b = 0x12;
WORD w = 0x1234;
DWORD dw = 0x12345678;
char str[] = "abcde";
총 4개의 크기가 다른 자료형이 있다.
각 엔디언 방식에 따라서 같은 데이터를 각각 어떤 식으로 저장하는지 비교한다.
TYPE | Name | SIZE | 빅 엔디언 Style | 리틀 엔디언 Style |
Byte | b | 1 | [12] | [12] |
WORD | w | 2 | [12][34] | [34][12] |
DWORD | dw | 4 | [12][34][56][78] | [78][56][34][12] |
char [] | str | 6 | [61][62][63][64][65][00] | [61][62][63][64][65][00] |
Unix와 Linux 계통은 빅 엔디언을 사용하지만 Intel x86 CPU에서는 리틀 엔디언 방식을 사용하고 있다.
OllyDbg에서 리틀 엔디언 확인
#include "windows.h"
BYTE b = 0x12;
WORD w = 0x1234;
DWORD dw = 0x12345678;
char str[] = "abcde";
int main(int argc, char *argv[])
{
BYTE lb = b;
WORD lw = w;
DWORD ldw = dw;
char *lstr = str;
return 0;
}
위의 코드를 빌드하여 OllyDbg로 디버깅을 진행한다.
[34][12] / [78][56][34][12] / [61][62][63][64] 형태로 리틀 엔디언 방식을 사용하는 것을 볼 수 있다.
3. IA-32 Register 기본 설명
레지스터란 CPU 내부에 존재하는 다목적 저장 공간이다.
일반적으로 메모리라고 이야기하는 RAM은 데이터를 엑세스하기 위해 물리적으로 돌아와야한다.
하지만, 레지스터는 CPU와 한 몸이기 때문에 고속으로 데이터를 처리할 수 있다.
어셈블리 명령어의 대부분은 레지스터를 조작하고 그내용을 검사한다.
IA-32의 레지스터의 종류는 아래와 같이 구성되어 있다.
그중에서도 우리는 기초적인 Basic program execution registers를 볼 예정이다.
3.1. Basic program execution registers
Basic program execution registers는 4개의 그룹으로 나눌 수 있다.
- General Purpose Registers (32비트 - 8개)
- Segment Registers (16비트 - 6개)
- Program Status and Control Register (32비트 - 1개)
- Instruction Pointer (32비트 - 1개)
기본적인 프로그램 실행 레지스터로 IA-32 아키텍처는 일반적인 시스템 및 응용 프로그램의 사용을 위해 16개의 기본 프로그램 실행 레지스터를 제공한다.
분류 | 설명 |
General-Purpose Register (범용 레지스터) |
피연산자와 포인터를 저장하여 사용하는 레지스터 (32 비트, 8개) |
Segment Register (세그먼트 레지스터) |
세그먼트 셀렉터 (16 비트, 6개) |
EFLAGS Register | 프로그램을 실행하거나 프로세스를 제한을 제어하는 레지스터 (32 비트 , 1개) |
EIP Regsiter | 다음 명령어 실행을 저장하는 포인터 레지스터 (32 비트, 1개) |
3.2. General-Purpose Register (범용 레지스터)
범용 레지스터는 이름처럼 범용적으로 사용되는 레지스터이다.(막 쓰이는 레지스터들)
IA-32에서 각각의 범용 레지스터들의 크기는 32비트(4바이트)이다.
보통은 상수/주소 등을 저장할 때 사용되며, 특정 어셈블리 명령어에서는 특정 레지스터를 조작하기도 한다.
분류 | 설명 |
EAX | Extended Accumulator Register의 약자 산술, 논리 연산을 수행하며 함수의 리턴 값에 사용된다. 즉, 사칙연산 등의 명령은 모두 EAX 레지스터를 사용하며, 함수의 리턴 값이 EAX 레지스터에 저장되므로 호출 함수의 성공 여부, 실패 여부를 쉽게 파악할 수 있다. 모든 Win32 API 함수들은 리턴 값을 EAX에 저장한 후 리턴한다. |
EBX | Extended Base Register 의 약자 ESI 레지스터나 EDI 레지스터와 결합될 수 있다. 메모리 주소를 저장하기 위한 용도로 사용된다. |
ECX | Extended Counter Register 의 약자 ECX 레지스터에 반복할 횟수를 지정하고 반복 작업을 수행한다. 반복문 명령어(LOOP)에서 반복 카운터(LOOP Counter)로 사용된다. |
EDX | Extended Data Register 의 약자 EAX와 같이 쓰이고 부호 확장 명령 등에 쓰인다. 큰 수의 곱셈 또는 나눗셈 등의 연산이 이루어질 때 EDX 레지스터가 사용되어 EAX 레지스터와 함께 쓰인다. |
ESI | Extended Source Index 의 약자 데이터를 조작하거나 복사시에 소스 데이터의 주소가 저장된다. ESI 레지스터가 가리키는 주소의 데이터를 EDI 레지스터가 가리키는 주소로 복사하는 용도로 많이 사용된다. |
EDI | Extended DestinationIndex 의 약자 데이터를 조작하거나 복사시에 목적지의 주소가 저장된다. 주로 ESI 레지스터가 가리키는 주소의 데이터가 복사될 곳의 주소 |
EBP | Extended Base Pointer 의 약자 현재 사용되는 스택 프레임이 소멸되지 않는 동안 EBP의 값은 불변 현재의 스택 프레임이 소멸되면 이전에 사용되던 스택 프레임을 가리키게 된다. 하나의 스택 프레임의 시작 지점 주소를 저장한다. |
ESP | Extended Stack Pointer 의 약자 PUSH, POP 명령어에 따라서 ESP의 값이 4 바이트씩 변한다. 하나의 스택 프레임의 끝 지정 주소를 저장한다. |
Tip)
Win32 API 함수들은 내부에서 ECX와 EDX를 사용한다. 따라서 이런 API가 호출되면 ECX와 EDX의 값이 변경된다.
ECX와 EDX에 중요한 값이 저장되어 있다면, API 함수 호출 전에 다른 레지스터나 스택에 백업해야한다.
3.3. Segment Register (세그먼트 레지스터)
세그먼트(Segment) 기법은 IA-32 보호 모드에서 메모리를 조각내어 각 조각마다 시작 주소, 범위, 접근 권한 등을 부여해서 메모리를 보호하는 기법을 말함. 또한 페이징(Paging) 기법과 함께 가상 메모리를 실제 물리 메모리로 변경할 때 사용됨. 세그먼트 메모리는 SDT(Segment Descriptor Table) 이라고 하는 곳에 기술되어 있는데 세그먼트 레지스터는 바로 이 SDT 의 Index 를 가짐.
세그먼트 레지스터는 총 6개(CS, SS, DS, ES, FS, GS)이며 각각의 크기는 16 비트(2 바이트)
각 세그먼트 레지스터가 가리키는 세그먼트 디스크립터(Segment Descriptor)와 가상 메모리가 조합되어 선형 주소(Linear Address)가 페이징 기법에 의해서 선형 주소가 최종적으로 물리 주소(Physical Address)로 변환됨.
분류 | 설명 |
CS | Code Segment 의 약자 프로그램의 코드 세그먼트의 시작 주소를 포함한다. |
SS | Stack Segment 의 약자 메모리 상에 스택의 구현을 가능하게 한다. |
DS | Data Segment 의 약자 프로그램의 데이터 세그먼트의 시작 주소를 포함한다. 명령어는 이 주소를 사용하여 데이터의 위치를 알아낸다. |
ES | Extra(Data) Segment의 약자 메모리 주소 지정을 다루는 스트림(문자 데이터) 연산에서 사용된다. 이 경우 ES 레지스터는 DI(Destination Index) 레지스터와 연관된다. |
FS | 추가적인 데이터 세그먼트 어플리케이션 디버깅에도 등장하는 SEH(Structured Exception Handling), TEB(Tread Environment Bloc), PEB(Process Environment Block) 등의 주소를 계산할 때 사용된다. |
GS | 추가적인 데이터 세그먼트이다. |
3.4. EFLAGS Register
EFLAGS Register 는 16 비트의 FLAGS 레지스터의 32 비트 확장 형태로 목적에 따라 상태 플래그, 제어 플래그, 시스템 플래그로 나뉨.
전부 이해한다는 것은 상당히 어려운 일이며 리버싱 입문 단계에서는 어플리케이션 디버깅에 필요한 상태 플래그에 대해서만 잘 이해하면 됨.
상태 플래그가 중요한 이유는 특히 조건 분기 명령어에서 이들 플래그의 값을 확인하고 그에 따라 동작 수행 여부를 결정하기 때문임.
이름 | 비트 번호 | 설명 |
CF (Carry Flag) |
0 | 부호 없는 수(unsigned integer)의 오버플로가 발생했을 때 1로 세팅된다. |
OF (Overflow Flag) |
11 | 부호 있는 수(signed integer)의 오버플로가 발생했을 때 1로 세팅된다. 또한, MSB(Most Significant Bit)가 변경되었을 때 1로 세팅된다. |
ZF (Zero Flag) |
6 | 연산 명령 후에 결과 값이 0이 되면 ZF가 1(True)로 세팅된다. |
4. 스택
프로세스에서 스택 메모리의 역할은 아래와 같다.
- 함수 내의 로컬 변수 임시 저장
- 함수 호출 시 파라미터 전달
- 복귀 주소(return address) 저장
위와 같은 역할을 수행하기에는 스택의 FILO(First In Last Out) 구조가 아주 유용하다.
뒤에서 실습 예제를 통해서 확인한다.
기존의 ESP 값은 19FF78이다.
PUSH 100 을 해서 Stack에 넣는다. ESP 값은 19FF74로 변경되었으며, 4바이트 만큼 줄어들은 것을 확인할 수 있다.
또한, ESP가 위쪽 방향으로 이동한 것을 볼 수 있다.
이 후, 스택을 꺼내면 이전과 동일하게 변경된다.
5. abex' crackme #1 분석
디버깅 전 먼저 파일을 실행시켜 어떤 프로그램인지 확인한다.
여기까지는 프로그램에 대한 이해도가 부족하므로, 디버깅을 진행한다.
코드를 살펴보면 EP 코드가 짧은 것을 확인할 수 있는 것을 볼 수 있다.
이를 보고 우리는 abex' crackme 파일이 어셈블리 언어로 만들어진 실행 파일임을 알 수 있다.
VC++, VC, Delphi 등의 개발툴을 사용하면 자신이 작성한 소스코드 외에 컴파일러가 stub Code를 추가시키기 때문에 디스어셈을 하면 복잡하게 보인다. 하지만 어셈블리 언어로 작성하면 어셈 코드가 곧 디스어셈 코드가 된다.
군더더기 없는 직관적인 코드(EP에 main 함수가 바로 나타남)가 바로 어셈블리 언어로 개발했다는 증거이다.
GetDriveType( ) API로 C 드라이브의 타입을 얻어오는데, 이걸 조작해서 CD-ROM 타입으로 인식하도록 만든다.
인식이 성공할 경우, "OK, I really think that your HD is a CD-ROM!:p" 메세지 박스가 출력된다.
5.1. 크랙
코드를 패치해서 크랙을 진행한다.
단순 패치가 목적일 경우 JMP로 로직을 건너뛰어 MB_OK(확인 버튼)을 보여주는 곳인 40103D로 변경한다.
5.2. 스택에 파라미터를 전달하는 방법
MessageBoxA( ) 함수를 호출하기 전에 4번의 PUSH 명령어를 사용하여 필요한 파라미터를 역순으로 입력하고 있다.
0040103D |> 6A 00 PUSH 0 ; | Style = MB_OK|MB_APPLMODAL
0040103F |. 68 5E204000 PUSH abexcm1-.0040205E ; | Title = "YEAH!"
00401044 |. 68 64204000 PUSH abexcm1-.00402064 ; | Text = "Ok, I really think that your HD is a CD-ROM! :p"
00401049 |. 6A 00 PUSH 0 ; | hOwner = NULL
0040104B |. E8 11000000 CALL <JMP.&USER32.MessageBoxA> ; | MessageBoxA
위의 어셈블리 코드를 C언어로 변역하면 아래와 같다.
MessageBoxA(NULL, "Ok, I really think that your HD is a CD-ROM! :p", "YEAH", MB_OK|MB_APPLMODAL);
스택은 FILO 구조이기 때문에 파라미터를 역순으로 넣어주면 받는 쪽(MessageBoxA 함수 내부)에서 올바른 순서로 꺼낼 수 있다.
6. 스택 프레임
스택 프레임이란 ESP(스택 포인터) 가 아닌 EBP(베이스 포인터) 레지스터를 사용하여 스택 내의 로컬 변수, 파라미터, 복귀 주소에 접근하는 기법
스택 프레임의 구조
PUSH EBP ; 함수 시작(EBP를 사용하기 전에 기존의 값을 스택에 저장
MOV EBP, ESP ; 현재의 ESP(스택포인터)를 EBP에 저장
... ; 함수 본체
... ; 여기서 ESP가 변경되더라도 EBP가 변경되지 않으므로
... ; 안전하게 로컬 변수와 파라미터를 엑세스할 수 있음
MOV ESP, EBP ; ESP를 정리(함수 시작했을 때의 값으로 복원시킴)
POP EBP ; 리턴되기 전에 저장해 놓았던 원래 EBP 값으로 복원
RETN ; 함수 종료
스택 프레임을 이용해서 함수 호출을 관리하면, 함수 호출 depth가 깊고 복잡해져도 스택을 완벽하게 관리할 수 있다.
스택 프레임의 설명을 위해 예제를 진행한다.
// StackFrame.cpp
#include "stdio.h"
long add(long a, long b)
{
long x = a, y = b;
return (x + y);
}
int main(int argc, char* argv[])
{
long a =1, b = 2;
printf("%d\n", add(a, b));
return 0;
}
위 예제를 OllyDbg에서 확인한다.
6.1. main( ) 함수 시작 & 스택 프레임 생성
main( ) 함수에 BP를 설치하고 실행한다.
현재의 ESP = 0019FF2C이고, EBP = 0019FF70 이다.
특히 주소(19FF2C)에 저장된 값 401250은 main( ) 함수의 실행이 끝난 후 돌아갈 리턴 주소를 의미한다.
현재 EBP 값은 12FF28으로 ESP와 동일하고, 12FF28 주소에는 19FF70이라는 값이 저장되어 있다.
19FF70은 main( ) 함수 시작할 때 EBP가 가지고 있던 초기 값이다.
6.2. 로컬 변수 세팅
스택에 main( ) 함수의 로컬 변수(a, b)를 위한 공간을 만들고 값을 입력한다.
00401023 SUB ESP,8
함수의 로컬 변수는 스택에 저장한다. main( ) 함수의 로컬 변수는 'a', 'b' 이다.
그리고 'a'와 'b'는 long 타입으로 각 4바이트 크기를 가진다. 이 두 변수를 스택에 저장하기 위해서 8바이트가 필요하다.
그래서 ESP에서 8을 빼서 두 변수에 필요한 메모리 공간을 확보한 것이다.
이제 main( ) 함수 내에서 ESP 값이 아무리 변해도 'a'와 'b' 변수를 위해서 확보한 스택 영역은 훼손되지 않습니다.
EBP 값은 main( ) 함수 내에서 고정이므로 이를 기준으로 삼아서 로컬 변수에 엑세스할 수 있습니다.
00401026 MOV DWORD PTR SS:[EBP-4],1 ; [EBP-4] = local 'a'
0040102D MOV DWORD PTR SS:[EBP-8],1 ; [EBP-8] = local 'b'
어셈블리 언어 | C 언어 | Type casting |
DWORD PTR SS:[EBP-4] | *(DWORD*)(EBP-4) | DWORD (4 바이트) |
WORD PTR SS:[EBP-4] | *(WORD*)(EBP-4) | WORD (2 바이트) |
BYTE PTR SS:[EBP-4] | *(BYTE*)(EBP-4) | BYTE |
위의 두 MOV 명령어를 해석해보면 [EBP-4]에는 1을 넣고(로컬 변수 a), [EBP-8]에는 2를 넣는다.(로컬 변수 b)
6.3. add( ) 함수 파라미터 입력 및 add( ) 함수 호출
아래 코드 부분에 해당되는 내용을 설명한다.
printf("%d\n", add(a, b));
40103C 주소의 CALL 401000 명령어에서 401000 함수가 add( ) 함수이다.
복귀 주소
CALL 명령어가 실해오디어 해당 함수로 들어가기 전에 CPU는 무조건 해당 함수가 종료될 때 복귀할 주소(return address)를 스택에 저장한다.
진행 시, StackFrame.00401041 로 복귀가 설정된 것을 확인할 수 있다.
6.4. add( ) 함수 시작 & 스택 프레임 생성
아래 코드 부분에 해당되는 내용을 설명한다.
long add(long a, long b) { ... }
add( ) 함수가 시작되면 자신만의 스택 프레임을 생성한다.
6.5. add( ) 함수의 로컬 변수(x, y) 세팅
아래 코드 부분에 해당되는 내용을 설명한다.
long x = a, y = b;
add( ) 함수의 로컬 변수 x, y에 각각 파라미터 a, b 를 대입한다.
이전과 마찬가지로 변수 x, y에 대한 스택 메모리 영역(8 바이트)를 확보한다.
00401006 MOV EAX,DWORD PTR SS:[EBP+8] ; [EBP+8] = param a
00401009 MOV DWORD PTR SS:[EBP-8],EAX ; [EBP-8] = local x
0040100C MOV ECX,DWORD PTR SS:[EBP+C] ; [EBP+C] = param b
0040100F MOV DWORD PTR SS:[EBP-4],ECX ; [EBP-4] = local y
add( ) 함수에 진입하며, 새롭게 스택 프레임이 생성되어 EBP 값이 변경된다.
따라서, [EBP+8], [EBP+C] 는 각각 파라미터 a, b를 가리킨다.
그리고 [EBP-8], [EBP-4]는 각각 add( ) 함수의 로컬 변수 x, y를 의미한다.
6.6. add( ) 함수의 로컬 변수(x, y) 세팅
아래 코드 부분에 해당되는 내용을 설명한다.
return (x + y);
}
add( ) 함수가 리턴되어야 하므로, 스택 프레임을 해제해야 한다.
00401018 MOV ESP,EBP
현재의 EBP 값을 ESP에 대입한다.
이 명령어는 앞에서 실행된 401001 주소의 MOV EBP, ESP 명령어에 대응하는 것이다.
add( ) 함수 시작할 때의 ESP 값(12FF28)을 EBP에 넣어두었다가 함수가 종료될 때 ESP에 복원시킨다.
6.7. add( ) 함수 파라미터 제거(스택 정리)
이제 main( ) 함수 코드로 복귀했다.
00401041 ADD ESP,8
add( ) 함수에 파라미터 a와 b 를 넘겨주었고, add( ) 함수는 완전히 종료되었다.
때문에, 파라미터 a, b도 필요가 없으므로 ESP에 8을 더하여 스택을 정리한다.
6.8. printf( ) 함수 호출
아래 코드 부분에 해당되는 내용을 설명한다.
printf("%d\n", add(a, b));
printf( ) 함수의 호출 코드이다.
00401044 PUSH EAX ; add( ) 함수의 리턴 값
00401045 PUSH 0040B384 ; "%d\n"
0040104A CALL 00401067 ; printf( )
0040104F ADD ESP,8
마찬가지로 CALL로 printf( ) 함수 호출 후 ESP에 8바이트를 더해 스택을 정리한다.
6.9. 리턴 값 세팅
아래 코드 부분에 해당되는 내용을 설명한다.
return 0;
main( ) 함수의 리턴 값(0)을 세팅한다.
00401052 XOR EAX,EAX
XOR 연산은 같은 값끼리 연산 시, 0이 되는 특징이 있다.
7. abex' crackme #2
우선 프로그램을 실행시켜본다.
7.1. 디버깅
프로그램이 시작되면 EP(Entry Point) 코드에서 처음 하는 일은 VB엔진의 메인함수를 호출하는 일이다.
EP 주소는 401238이다. 401238 주소의 PUSH 401E14 명령에 의해 RT_MainStruct 구조체 주소(401E14)를 스택에 입력한다. 그리고 40123D주소의 CALL 00401232 명령에 의해 401232 주소의 명령어가 실행된다.
401232 주소의 JMP DWORD PTR DS:[4010A0] 명령어에 의해서 VB 엔진의 메인 함수인 ThunRTMain( ) 함수로 이동한다.
(앞에서 스택에 입력한 401E14 값은 ThunRTMain( ) 함수의 파라미터이다.)
간접호출)
40123D 주소의 CALL 401232명령은 ThunRTMain( ) 함수 호출이다.
여기서 조금 특이한 기법을 사용하는데, MSVBVM60.dll!ThunRTMain( )으로 직접 가는 것이 아니라,
중간의 401232 주소의 JMP 명령을 통해서 가는 방식이다.
실제 401232 주소에서 실행하면 이동하게 되며 아래와 같이 시행된다.
RT_MainStruct 구조체)
401E14 주소에 RT_MainStruct가 존재한다.
RT_MainStruct 구조체의 맴버는 또 다른 구조체의 주소들이다.
즉 VB 엔진은 파라미터로 넘어온 RT_MainStruct 구조체를 가지고 프로그램의 실행에 모든 정보를 얻는다는 것을 알 수 있다.
ThunRTMain( ) 함수)
[그림 7.1-3]의 코드는ThunRTMain( ) 코드의 시작 부분이다.
메모리가 완전히 달라진 것이 보이며, 이 주소는 MSVBVM60.dll 모듈의 주소 영역이다.
즉, 우리가 분석하는 프로그램의 코드가 아니라 VB 엔진의 코드이다.
7.2. 문자열 검색
OllyDbg의 문자열 검색 기능(All referenced text strings)을 사용하면 아래와 같은 창이 나타난다.
해당 주소로 이동해보자
위 코드를 확인하면 JE 조건분기를 통해 문자열 비교를 확인 후, Wrong serial 을 출력했다고 유추할 수 있다.
조건 분기를 찾아보도록 한다.
403329 주소의 __vbaVarTstEq( ) 함수를 호출해서 리턴 값(AX)을 비교(TEST 명령)한 후 403332 주소의
조건 분기(JE 명령)에 의해서 참, 거짓 코드로 분기하게 됩니다.
7.3. 문자열 주소 찾기
403329 주소의 __vbaVarTstEq( ) 함수가 문자열 비교 함수라면 그위에 있는 두 개의 PUSH 명령어는 비교 함수의
파라미터, 즉 비교 문자열이 될 것이다.
00403321 LEA EDX,DWORD PTR SS:[EBP-44]
00403324 LEA EAX,DWORD PTR SS:[EBP-34]
00403327 PUSH EDX ; 0019F274
00403328 PUSH EAX ; 0019F284
00403329 CALL DWORD PTR DS:[&MSVBVM60.__ ; MSVBVM60.__vbaVarTstEq
00403321 주소의 SS:[EBP-44] 가 의미하는 것은 스택 내의 주소를 말한다.
함수에서 선언된 객체의 주소이다.
OllyDbg의 메모리(덤프) 창에서 마우스 우측 메뉴의 'Long - Address with ASCII dump' 명령을 선택한다.
이 명령은 메모리 창의 보기 형식을 마치 스택창처럼 변경하고, 특히 문자열 주소인 경우 해당 문자열을 표시해준다.
[그림 7.3-2] 의 내용을 보면 0019F274는 실제 serial의 값이고, 0012FBEC는 사용자가 입력한 serial 값이다.
Serial을 찾았으니 크랙은 생공했다. 하지만 Name에 다른 값을 주고 동일한 Serial을 입력해보니 틀렸다고 나온다.
Name 문자열을 기반으로 Serial을 그때그때 생성하는 알고리즘이다.
7.4. Serial 생성 알고리즘
JE로 작성했던 조건 분기 코드는 어떤 함수에 속해있을 것이다.
아마도 그 함수는 [Check] 버튼의 event handler일 것이다. 이유는 [Check] 버튼을 선택했을 대 위 함수가 호출되었고, 성공/실패 메시지 박스를 출력하는 사용자 코드를 포함하고 있기 때문이다.
함수의 시작 부분으로 거슬러 올라가서 확인을 해야한다.
위쪽 방향으로 스크롤하다 보면 아래의 그림과 같은 코드를 만나게 된다. 402ED0 주소 명령을 자세히 보도록 한다.
00402ED0 PUSH EBP ; = [Check] button event handler
00402ED1 MOV EBP,ESP
위 코드는 함수가 시작할 때 나타나는 스택 프레임을 구성하는 (전형적인) 코드이다.
이 위치가 함수의 시작임을 알 수 있고, [Check] 버튼의 event hanlder 이다.
7.5. 코드 예측하기
serial key 생성 방법에 대해서 예측이 가능해진다.
만약 Win32 API 프로그램이라면 아래와 비슷할 것이다.
- Name 문자열 읽기(GETWindowText, GetDlgItemText 등의 API 사용)
- 루프를 돌면서 문자를 암호화하기(XOR, ADD, SUB 등)
7.6. Name 문자열 읽는 코드
API를 이용하여 사용자가 입력한 문자열을 가져올 것으로 예상되므로 CALL 명령어 위주로 디버깅을 해본다.
(이때 API에 전달되는 파라미터와 리턴 값을 유심히 관찰해야한다.)
디버깅을 진행하면 아래와 같이 4번째 CALL 명령어를 만나게 된다.
00402F8E 코드를 보면 함수의 로컬 객체 SS:[EBP-88] 주소를 함수의 파라미터로 전달(PUSH)하고 있다.
주소를 확인한다.
이 상태로 402F98 주소의 CALL 명령까지 실행하면 스트링 객체의 값이 저장된다.
저장된 값은 아래와 같다.
[EBP-88] 주소에 Name 문자열이 (스트링 객체 형태로) 저장된다.
7.7. 암호화 루프
계속 디버깅을 해나가면 아래와 같이 루프(Loop), 즉 반복문을 만난다.
(추후... 추가 예정)
8. 함수 호출 규약
함수 호출 후에 ESP(스택 포인터)를 어떻게 정리하는지에 대한 약속이 바로 함수 호출 규약이다.
함수 호출 규약은 아래와 같다.
- cdecl
- stdcall
- fastcall
어플리케이션 디버깅에서는 cdecl과 stdcall의 차이점을 숙지해야한다.
어떤 방식이든지 파라미터를 스택을 통해 전달한다는 사실은 동일하다.
8.1. cdecl
#include "stdio.h"
int add(int a, int b)
{
return (a + b);
}
int main(int argc, char* argv[])
{
return add(1, 2);
}
cdecl 방식은 주로 C 언어에서 사용되는 방식이며, Caller에서 스택을 정리하는 특징을 가지고 있다.
add( ) 함수의 파라미터 1, 2를 역순으로 스택에 입력하고, add( ) 함수(401000)를 호출한 후 ADD ESP, 8 명령으로 스택을 정리하고 있다.
이와 같이 Caller인 main( ) 함수가 자신이 스택에 입력한 함수 파라미터를 직접 정리하는 방식이 cdecl이다.
cdecl의 장점은 C 언어의 printf( ) 함수와 같이 가변 길이 파라미터를 전달할 수 있다는 것이다.
이러한 가변 길이 파라미터는 다른 Calling Convention에서는 구현이 어렵다.
8.2. stdcall
#include "stdio.h"
int _stdcall add(int a, int b)
{
return (a + b);
}
int main(int argc, char* argv[])
{
return add(1, 2);
}
stdcall 방식은 Win32 API에서 사용되며, Callee에서 스택을 정리하는 것이 특징이다.
stdcall 방식으로 컴파일하고 싶을 때는 '_stdcall' 키워드를 붙여주면 된다.
main( ) 함수에서 add( ) 함수 호출 후에 스택 정리 코드 (ADD ESP, 8) 이 생략되어 있다.
스택의 정리는 함수 마지막(0040100A)의 RETN 8 명령에서 수행된다.
RETN 8 명령의 의미는 RETN + POP 8바이트이다. 즉, 리턴 후 지정된 크기만큼 ESP를 증가시킨다.
stdcall의 장점은 호출되는 함수(Callee) 내부에 스택 정리 코드가 존재하므로 함수를 호출할 때마다 ADD ESP, XXX 명령을 써줘야하는 cdecl방식에 비해서 크기가 작아진다.
또한 Win32 API는 C 언어로 된 라이브러리이지만 기본 cdecl 방식이 아닌 stdcall 방식을 사용한다.
8.3. fastcall
fastcall 방식은 기본적으로 stdcall 방식과 같다.
함수에 전달하는 파라미터 일부(2개까지)를 스택 메모리가 아닌 레지스터를 이용하여 전달하는 것이 특징이다.
어떤 함수의 파라미터가 4개라면, 앞의 두 개의 파라미터는 각각 ECX, EDX 파라미터를 이용하여 전달한다.
9. Lena's Reversing for Newbies
파일 실행을 해본다.
메시지 박스에는 두가지를 제시한다.
- 모든 성가신 Nags(잔소리?)를 제거해라
- registration code를 찾아라
확인을 누를 시, 다음 메인으로 이동된다.
전형적인 serial crackme이다.
화면의 글씨에서는 SmartCheck라는 유틸리티를 사용하라고 나오지만 여기서는 단순 디버거만 사용한다.
9.1. 메시지 박스 제거
첫 번째 목표인 Nag 메시지를 제거해보도록 한다.
여기서 40116D 주소의 CALL 을 보면 생각나는게 있어야한다.
위에 사용했던 Visual Basic 코드이다. MS사의 Visual Basic VM머신이라고 사료된다.
메시지 박스 제거를 위해서는 모듈 검색을 시행해야 한다.
Visual Basic에서 메시지 박스 출력함수는 MSVBVM50.rtcMsgBox 이다.
rtcMsgBox를 찾고 rtcMsgBox를 호출하는 곳에 BP가 적용되도록 설정한다.
설정된 BP 인 주소(00402CFE)에 보면 상단에 UNICODE "Get rid of all Nags and find the right" 가 존재한다.
( [그림 9.1-3]에는 잘린상태이므로 참고바란다.)
계속 실행 시, [그림 9-1] 의 메시지가 출력되고 [확인] 버튼을 클릭 시, 메인 화면이 출력된다.
메인 화면에서 [Nag?] 버튼을 클릭 시, 다시 BP인 402CFE에 멈추게 된다.
1차 시도)
402CFE 주소의 CALL 명령을 아래와 같이 수정한다.
원본
00402CFE E8 1DE4FFFF CALL <JMP.&MSVBVM50.#595> ; <= rtcMsgBox( )
수정
00402CFE 83C4 14 ADD ESP, 14 ; 스택 정리
00402D01 90 NOP ; No Operation
00402D02 90 NOP ; No Operation
402CFE 주소의 ADD ESP, 14 명령어의 의미는 rtcMsgBox( ) 에 전달되는 파라미터의 크기(14)