안녕하세요. 이번 포스팅의 주제는 프로그래밍 카테고리에서 직접 제작한 abex crack me를 리버싱해보겠습니다.
그 전에 잠깐만 소스코드를 수정하겠습니다.
변수를 EAX와 ESI로 쓰니 나중에 설명할 때 레지스터 EAX,ESI랑 혼동할 가능성이 있어 변수를 그냥 a, b로 바꿨습니다.
자 소스코드수정이 완료되었으면 올리디버거로 열어봅시다.
일반 abex crack me랑 다르게 나오는 군요. 당연합니다. 일반 abex cracke me는 델파이로 만들어졌고 우리가 만든 abex cracke me는 C로 만들어졌으니까요.
문자열을 찾아봅시다.
해당하는 문자열을 찾았으면 주소로 점프합시다.
우리가 작성한 메세지박스와 겟드라이브타입 함수가 있는 것을 볼 수 있습니다. 즉 여기가 c언어의 WinMain부분인 것을 알 수 있습니다. 이 WinMain의 시작주소는 012A13D0이군요. C언어로 작성되어서 그런지 오리지널 crack me와 많이 다릅니다.
한줄씩 실행해 봅시다.
빨간박스의 명령어들은 스택프레임을 할당하기 위한 명령어입니다. 어떤 함수든 호출되는 순간 스택에 그 함수를 위한 새로운 영역이 할당되는데 이 할당된 영역을 스택프레임이라고 합니다. 여기에서는 WinMain이란 함수 영역이므로 WinMain을 위한 스택프레임을 할당하는 작업이라고 볼 수 있습니다. 여기서 잠깐 스택을 살펴봅시다.
먼저 WinMain함수를 호출하기 전입니다.
위 스택은 WinMain함수를 호출하기 전의 함수 스택 프레임입니다. F7키를 눌러 함수를 호출해봅시다.
WinMain함수를 호출하게 되면 스택에 리턴주소가 들어가게 됩니다. 이 리턴주소는 나중에 CPU가 WinMain 함수의 처리를 다 끝내고 실행할 다음 주소를 의미합니다. 즉 CPU는 이 스택에 들어있는 주소로 이동해 명령어를 계속 수행하는 것이지요.
이 리턴주소를 이용하는 해킹을 버퍼오버플로우 해킹이라고 합니다. 이것은 나중에 자세히 설명드리겠습니다.
이제 명령어들을 하나씩 살펴봅시다.
*PUSH EBP
EBP레지스터의 값을 스택에 넣습니다. 현재 EBP값은 WinMain함수를 호출하기 전의 함수 시작주소입니다. EBP가 함수의 시작주소를 저장하는 레지스터인것은 다들 아시죠? 그런데 이제 WinMain을 호출하였으니 WinMain함수의 시작주소를 EBP에 넣어야 합니다. 하지만 그냥 넣으면 나중에 WinMain함수 처리가 끝나고 WinMain함수를 호출하기 전의 함수를 처리할 때 EBP값을 찾지 못해 에러가 생겨버립니다.
그러므로 WinMain함수 주소를 넣기전에 스택에 값을 넣어 나중에 WinMain함수처리를 끝내고 다시 가져가는 식으로 합니다. 쉽게 말해 EBP를 백업하는 것이지요.
*MOV EBP, ESP
EBP백업을 끝냈으면 WinMain함수시작주소를 넣어야 합니다. 현재 WinMain함수시작주소는 ESP에 들어있으므로 ESP의 값을 EBP에 넣어줍니다. 이 명령어를 실행하면 EBP의 값이 WinMain함수시작주소가 되므로 스택프레임이 생성됩니다. 현재 스택을 봅시다.
MOV EBP, ESP 실행 전, 즉 WinMain의 스택프레임 생성 전의 스택
MOV EBP, ESP 실행 후, 즉 WinMain의 스택프레임 생성 후의 스택
사진의 체크된 부분을 잘 보시면 선이 끊겨져 있는 것을 볼 수 있습니다.
즉 저 끊어진 선을 기준으로 스택프레임이 나뉘어져 버린 겁니다. 위의 두줄은 WinMain의 스택프레임, 아래는 WinMain을 호출하기 전 함수의 스택프레임입니다.
* SUB ESP, 0D8
SUB는 뺀다는 것이지요. ESP에서 16진수 0D8만큼의 값을 빼라는 것입니다. 그럼 대체 왜 값을 뺸다는 것일까요?
사실 이부분은 스택을 할당받는 부분입니다. 스택을 할당받을 때 스택주소는 작아집니다. 즉 ESP가 0019F800일 때 스택을 하나 할당하면 현 스택의 주소는
0019F800-4 = 0019F7FC가 된다는 것이지요.
0D8은 10진수로 216이고 하나의 스택은 4바이트이므로
SUB ESP, 0D8은 216/4 = 54개의 스택을 할당받는 명령어입니다.
실제로 명령어를 실행하면 54개의 스택이 늘어난 것을 확인할 수 있습니다.
자 이제 다음 의미있는 명령어를 살펴봅시다.
잘 보이실지 모르겠네요. 빨간 박스 친 부분이 의미있는 부분이니까 하나씩 살펴봅시다.
* MOV DWORD PTR SS:[EBP-14],0
주소가 [EBP-14]인 스택에 0을 대입합니다. 이 명령어가 왜 있을까요? 정답은 소스코드에 있습니다.
int a,b = 0;
소스를 보시면 변수 b에 0을 대입하라고 되어있습니다. 즉 여기서 주소가 [EBP-14]인 스택은 b를 의미하는 것을 알 수 있습니다. 그러면 하나 의문이 생깁니다.
왜 a는 없죠?
답을 드리자면 a는 선언만 되어있기 때문에 스택을 할당받지 않습니다.
int a,b = 0 라는 것은
int a
int b = 0
을 뜻하기 때문에 선언만 된 a는 스택을 할당받지 않고 0이 들어간 b만 스택에 할당받습니다. 즉 주소가 [EBP-14]인 스택은 b를 의미합니다. 다음으로 넘어갑시다.
* PUSH 0, PUSH OFFSET 00EC588C ~~~~~~
이 부분은 Messagebox함수를 호출하는 부분입니다. 소스코드를 보면
MessageBox (NULL, L"Make me think your HD is a CD-Rom.",L"abex' 1st crackme", MB_OK);
입니다. 그런데 뭔가 이상하지 않습니까?
분명 첫 인자는 HWND인데 왜 int형인 type이 먼저 들어갈까요?
이것은 스택의 구조가 후입선출 구조이기 때문입니다.
후입선출이란 나중에 들어온 값이 먼저 나가는 구조를 뜻합니다. 그런데 후입선출이랑 인자전달이랑 무슨 관계가 있을까요?
c언어에서 함수를 실행할 때 인자가 왼쪽부터 전달되는 것은 다들 아실 겁니다. 즉 여기서 Messagebox함수를 실행하면 인자를 hwnd부터 받습니다. 헌데 스택은 후입선출구조입니다. 스택에 값을 hwnd부터 넣어버리면 나중에 Messagebox가 실행되서 스택에서 값을 꺼낼 때 맨 나중에 넣은 MB_OK(type)가 먼저 꺼내지게 됩니다. 이렇게 되면 오류가 생겨버립니다.
그래서 HWND가 먼저 스택에 꺼내질 수 있도록 가장 나중에 넣는 것입니다.
아 그리고 명령어를 보시면 OFFSET이라는 글자가 들어있습니다. 이것은 상수를 의미합니다. C언어로 따지면 const 비슷한 겁니다.
OFFSET 주소 식으로 적혀있으니 이 주소에 있는 값들은 상수이다 라는 것을 의미합니다. 주소가 00EC588C 라고 되어있으니 어떤 값이 들어있는지 한번 봅시다.
우리가 입력한 문자열이 들어있습니다. 유니코드 상수 문자열이므로 영어임에도 불구하고 2바이트로 잡혀있습니다. 만약 문자열 내용을 바꾸시고 싶으시면 여기서 수정하실 수 있습니다.
그 다음 명령어는 GetDrivetype함수를 호출합니다. 이 함수가 호출되고 나면 결과값은 EAX에 저장됩니다. 간단하니까 넘어갑시다.
첫줄을 보면 MOV DWORD PTR SS:[EBP-8],EAX 라고 되어있습니다.
아까 EAX가 GetDrivetype함수의 결과값이 저장되어있다고 했죠? 그 EAX값을
주소가 [EBP-8] 인 스택에 저장시킨다고 나와있습니다. 소스코드로 보면
a = GetDriveType(L"C:\\"); 입니다. 즉 [EBP-8] 주소의 스택이 a라는 것을 알 수 있습니다. 해당스택을 확인하시면 결과값이 저장되어 있는 것을 알 수 있습니다.
2~4번째 줄은 차례대로
MOV EAX,DWORD PTR SS:[EBP-14],
ADD EAX,1,
MOV DWORD PTR SS:[EBP-14],EAX 인 것을 확인 할 수 있습니다.
아까 [EBP-14]인 스택은 b라고 설명했죠? 그 b의 값을 EAX에 저장시키고 EAX의 값을 1 증가시킨 다음 다시 b에 저장시키는 명령어입니다. 소스코드로 보면
b += 1; 입니다. c에선 한줄로 되는 것을 어셈블리어에선 3줄로 처리합니다.
5~7번째 줄을 봅시다.
MOV EAX,DWORD PTR SS:[EBP-8]
SUB EAX,1
MOV DWORD PTR SS:[EBP-8],EAX
[EBP-8]의 스택은 a라고 앞에서 말씀드렸죠? 이 a의 값을 EAX에 저장시키고 EAX값을 하나 뺀 다음 이 EAX값을 a에 저장시키는 명령어입니다. 소스코드로 보면
a -= 1 입니다.
그 다음은 아까 설명했던 것들이니 넘어갑시다. 차례대로 b += 1 b += 1 a -= 1 을 실행합니다.
자 밑에서 3번째 줄부터 한번 봅시다.
MOV EAX,DWORD PTR SS:[EBP-8]
CMP EAX,DWORD PTR SS:[EBP-14]
JE SHORT 00EC147D
[EBP-8]의 스택은 a이니까 a의 값을 EAX에 저장시키고 그 EAX와 [EBP-14]값의 스택을 비교합니다. [EBP-14]의 스택은 b이므로 a의 값과 b의 값을 비교하는 명령어입니다.
CMP명령어와 두 값을 비교해서 같으면 ZF를 1로 아니면 ZF를 0으로 만들어 버립니다. 그리고 JE명령어는 ZF가 1이면 점프 0이면 점프를 하지 않습니다.
소스코드로 확인하면 if(a != b) else입니다
그런데 말입니다. 이 CMP라는 명령어가 단순이 비교하는 명령어가 아닙니다. 좀더 정확히 말하면 CMP 값1 값2 라는 명령어는 둘을 비교해서 값이 같으면 ZF = 1 아니면 ZF 0 이렇게 연산하는 것이 아니라 값1에서 값2를 뺀 값이 0이냐 아니냐로 계산합니다.
현재 EAX값이 1이지요? 그리고 DWORD PTR SS:[EBP-14]값은 3입니다.
즉 이 두 값을 CMP한다는 것은 1에서 3을 뺀 값을 이용해 연산을 하는 것을 의미합니다.
1에서 3을 빼면 -2가 나오죠? 즉 -2는 0이 아니기 때문에 ZF를 0으로 설정합니다.
반대로 EAX가 3이라면 3에서 3을 빼니까 값이 0이 나오죠? 결과값이 0이기 때문에 ZF를 1로 설정하는 식입니다.
좋은 예를 한번 들어봅시다. 지금부터 밑의 JE명령어를 JB로 바꿔봅시다.
JB명령어는 CMP연산 결과의 값이 0보다 작을 경우 점프하는 명령어입니다.
ZF를 한번 봅시다.
CMP결과값이 0이 아니므로 ZF도 0입니다.
이번에는 CMP명령어 부분에서 EAX값을 4로 바꿔봅시다.
둘다 ZF가 0임에도 불구하고 EAX가 1인 경우에는 점프하고 EAX가 4인 경우에는 점프를 하지 않습니다. 이는 결과값이 음수일 경우에만 JB에서 점프하기 때문입니다.
즉 ZF에 의해 점프를 하는 명령어가 있고 아닌 명령어가 있다는 것입니다.
그런데 왜 이런것을 설명하냐면 if문의 조건이 여러 종류로 나올 수 있기 때문입니다.
소스에서는 if(a != b) 였지요? 하지만 if(a>b) 일 수도 있고 if(a<=b)일 수도 있습니다. 이런식의 조건문을 연산할 때에는 (CMP,JE) (CMP,JNE) 가 아닌 (CMP,JB) (CMP,JA) 등 다양하게 나올 수 있기 때문에 CMP가 나왔는데 무조건 ZF만 보는 식은 코드를 분석하는데 어려움을 겪을 수 있다는 것을 알아두시기 바랍니다.
조금 이야기가 샜습니다. 다음으로 넘어갑시다.
이제 분기에 따라 메세지함수를 출력하면 처리는 다 끝난 것입니다. 이제 스택 정리를 해야합니다. 한번 봅시다.
* XOR EAX,EAX
EAX를 초기화시켜주는 명령어입니다. 이 명령어가 수행되면 EAX는 0이 됩니다.
소스코드는 return 0 입니다.
EAX가 함수의 반환값을 저장시키는 곳이니 0을 반환하겠다는 뜻입니다.
* ADD ESP, 0D8
아까 SUB ESP, 0D8로 스택프레임을 할당한 것 기억하시나요? 이제 WinMain함수 처리가 끝났으므로 WinMain의 스택프레임은 필요가 없습니다. 그러므로 할당된 스택프레임을 해제하는 것입니다.
*MOV ESP,EBP
0D8 스택프레임은 해제되었으나 아직 약간의 스택프레임이 남아있을 수 있으니 완전히 해제하기 위해 스택위치를 돌려놔야합니다.. 현재 함수시작위치의 주소값을 ESP에 저장시킵니다.
*POP EBP
이전 스택프레임으로 돌아가기 위해 백업해놓은 EBP를 빼냅니다.
*RETN 10
WinMain함수를 호출할 때 리턴주소를 저장시켜 놓은 것 기억하십니까? 이제 그주소로 가는 것입니다. 그런데 뒤쪽에 숫자가 있죠? 이 숫자 10은 ESP+10을 뜻합니다.
16진수 10은 10진수로 16이므로 ESP+10은 스택 4개를 해제하겠다는 뜻인데요. 왜 스택 4개를 해제하는 것일까요?
우리는 함수를 호출할 때 인자를 스택에 푸쉬한다는 것을 알 수 있습니다. 그리고 WinMain의 인자는 4개 입니다. 즉 각 인자를 푸쉬한 스택을 해제한다는 것을 알 수 있습니다. 이 것을 이용하면 이 함수가 인자를 몇개 받았는지 단박에 알 수 있지요.
RETN 4 -> 인자 1개 RETN 8 -> 인자2개 RETN C -> 인자 3개
이해하셨지요?
그러면 여기서 의문이 하나 생깁니다. 숫자가 없는 RETN은 뭔가요? 하고 말이지요.
그런 경우는 함수호출방식이 __cdecl이거나 __fastcall방식일 경우입니다.
__cdecl은 푸쉬한 스택을 해제할 때 함수내부에서 하지 않고 함수처리가 끝나고 리턴주소로 되돌아왔을 때 스택을 해제합니다. 예를 하나 들어봅시다. 우리가 전에 만든 콘솔 기반 abex crack me를 리버싱해보죠
RETN 뒤에 숫자가 없습니다. RETN 명령어를 실행해 봅시다.
우리가 만든 콘솔기반 abex crack me의 main함수는 __cdecl방식입니다. 그렇기 때문에 함수내부에서 처리를 하지 않고 밖으로 나온 뒤 푸쉬한 스택만큼 스택을 해제합니다.
응용기반 abex crack me는 함수내부에서 스택을 정리하기 때문에 밖으로 나왔을 경우에는 따로 스택 정리를 하지 않습니다. WinMain이 __stdcall방식이기 때문이죠.
__fastcall방식은 나중에 자세히 알려드리겠습니다.
네 리버싱 분석이 끝났군요. 분명 같은 동작을 하는 프로그램이지만 분석할 때는 조금 다르지요? 게다가 직접 만들었으니 더욱 재미가 있는 것 같습니다.
이상으로 포스팅을 마치겠습니다.