안녕하세요 파일입니다. 오늘은 리버싱 전통 연습 문제인 CrackMe 를 한번 풀어보겠습니다. 우선 CrackMe는 '나를 크랙해줘!' 라는 이름 그대로 크랙 연습 목적으로 제작된 간단한 프로그램들 입니다. 특히 CrackMe 시리즈 중 유명한 것으로 Abex's Crack Me 시리즈가 5개 정도 존재합니다. 그 중에서 제일 간단한 Abex' CrackMe #1편을 풀이해보도록 합시다.
우선 다운로드는 여기서 할 수 있습니다.
우선 실행파일 용량이 무려 "8KB" 입니다 mb단위가 아닌 kb단위입니다. 실행 파일 용량이 너무 작아서 exe 그대로 올리면 카카오에서 바이러스로 잡지 않을까 걱정이긴 합니다만.. 우선 시작해봅시다.
글 읽기전 알고 있어야 하는 지식
1. x86(또는 x64) 어셈블리어 지식 (필수)
=> 어셈블리어가 뭔지 모르신다면 간략하게 이 글을 읽어보세요. 리버싱 지식에 대한 글도 조금 담았습니다. [다만 전공자 대상으로 글을 작성했기에 비전공자 분들이 보신다면 조금 어려울 수 있습니다.]
* 본 글은 기본적으로 32비트 Intel 어셈블리어 (x86) 는 어느정도 안다고 생각하고 글을 작성합니다. 모르면 볼일도 거의 없겠지만
2. 올리디버거 또는 IDA 등등 디스어셈블러 프로그램 사용 방법에 대한 지식
=> 이건 좀 몰라도 괜찮습니다. 제가 친절하게 프로그램 사용방법에 대한 내용을 생략하지 않고 작성하겠습니다.
실행
프로그램을 키니 하드 드라이브를 CD-ROM으로 인식 시키랍니다. 여기서 하드 드라이브가 어떤 드라이브인지는 알려주지 않는데 분석하면 알겠지만.. 스포해보자면 C드라이브를 CD-ROM으로 인식시켜야 합니다.
당연히 드라이브랑 CD-ROM은 다르니깐 다르다고 나오네요.
그러면 이걸 한번 패치(크랙)해서 HDD(SSD) 를 CD-ROM으로 인식시켜 봅시다.
디스어셈블리 (디버깅, 분석)
저희는 우선 저 프로그램에 대한 소스코드는 없고, 기계어 실행 파일(*.exe) 만 가지고 있습니다. 즉 바이너리 파일밖에 가지고 있지 않으므로, 기계어를 직접 읽으면서 해석하긴 어려우니 우선 기계어를 어셈블리어로 복구해서 읽어야 합니다.
기본적으로 어셈블리어 자체는 기계어에 1:1 대응 되는 언어기 때문에 어셈블리어 <-> 기계어 간 양방향 전환이 자유롭습니다.
올리디버거를 이용해 exe를 열면 알아서 그 프로그램이 실행하는 어셈블리어도 복구해서 보여주고, 그것에 해당하는 기계어도 대응시켜서 보여줍니다. 한번 열어봅시다.
올리디버거 환경 : ODBG200(올리디버거 2.0 버전)
올리디버거를 실행시켰습니다. 아까 CRACK ME 1번 EXE파일을 열어야 하는데 일단 올
리디버거에 드래그 드롭으로 파일 여는 기능이 없어서 수동으로 열어줘야 합니다. (귀찮음)
왼쪽 위에 폴더 아이콘을 클릭해서 crackme 프로그램을 열어줍니다.
짜잔. 이 프로그램이 어떤 명령어를 사용하는지 이제 보이게 됩니다.
우선 보시다 싶이 요건 프로그램의 Main 함수를 찾을 필요 없이 바로 처음부터 시작코드가 보이고, 코드도 길지 않고 아주 깔끔합니다.
만약에 C언어로 "Hello World" 를 찍는 간단한 프로그램을 작성해도, 그걸 디스어셈블리 과정을 거쳐서 어셈블리어로 보면 굉장히 코드가 길고 지저분하게 나옵니다. 이유는 컴파일러가 프로그램을 만들 때 Stub Code라는 것을 삽입해서 그렇습니다. 그래서 C언어의 Main 함수에 해당하는 명령어를 찾으려면 노가다로 한줄씩 한줄씩 실행하면서 찾거나 문자열을 찾거나 하는 식으로 찾아줘야 합니다.
그런데 CrackMe 1번의 경우엔 그럴 필요가 없이 딱 필요한 코드만 들어가 있네요. 이유를 찾아보니깐 이 프로그램이 어셈블리어로 만들어진 프로그램이라고 합니다. 그래서 어셈블리 언어로 작성하면, 어셈블리어 코드가 곧 디스어셈 코드가 되서 코드가 간결하게 보이는것이라고 하네요.
사실 초보자 입장에선 Stub Code 같은걸 건너뛰고 Main 찾는것도 굉장히 힘들어서.. 딱 CrackMe 입문에 맞는 첫번째 문제라고 볼 수 있겠습니다.
* Stub Code : 우리가 작성한 코드가 아닌 컴파일러가 임의로 삽입한 코드로 실행 정보를 가져오고, 프로그램에 필요한 정보를 얻어오는 등의 코드로 구성되어 있습니다. 우리가 리버싱을 할때는 이 Stub Code는 넘기고 프로그램 시작점인 Main 함수를 빠르게 찾아낼 수 있어야 합니다.
우선 올리디버거 창이 굉장히 무섭고 어렵게 생겼죠. 이것 역시 잠깐 알아봅시다.
사실 크게 기능으로보면 별 거 없습니다.
하나씩 알아보자면
1. Code Window
-> 실행 파일의 어셈블리 코드를 보여줍니다. 왼쪽에 그 어셈블리 코드에 해당하는 기계어도 보여줍니다.
2. Register Window
-> CPU 레지스터의 상태를 실시간으로 확인 가능합니다.
3. Dump Window
-> RAM의 상태를 보여줍니다. CPU가 RAM과 상호작용하면서 데이터를 읽는데 RAM에 어떤 데이터가 있는지 이것 역시 실시간으로 보면서 분석 가능합니다.
4. Stack Window
-> 스택 메모리의 상태를 역시 실시간으로 확인 가능합니다.
정리 : CPU 레지스터, 명령어, RAM, 스택 메모리 등 프로그램 실행 분석에 필요한 모든 내용을 한 눈에 볼 수 있음.
올리디버거 사용시 주의할점은, 다루는 대부분의 숫자들이 16진수로 다뤄진다는 것 입니다. 그래서 mov eax, 10 과 같은 명령어를 실행시켜도, 10진수 10을 입력한게 아니라 16진수 10을 입력한 것 입니다. 메모리 덤프에서 보이는 값들이나 주소 역시 전부 16진수 입니다.
그리고 추가적으로 정보를 드리자면, 이렇게 올리디버거를 처음 켜면 명령어의 주소 위치에서 자기 혼자만 검정색으로 칠해져 있는 부분이 보이실건데요. 저것은 아직 실행하진 않았지만, CPU가 명령어 실행시 바로 다음에 시작할 명령어 주소 입니다. 즉 CPU가 명령어 실행을 시작하면 00401000 주소에 있는 PUSH 0 명령어 - 기계어로는 6A 00 을 시작하겠다는 의미가 됩니다.
바로 다음에 시작할 명령어? 하니 어떤 레지스터가 떠오르지 않으시나요. 네 맞습니다 바로 EIP 레지스터가 생각나시죠? EIP레지스터에는 CPU가 다음 실행할 명령어 주소를 저장한다고 배웠으며, 실제로 올리디버거에서 검정색으로 표시되는 저 부분이 EIP 레지스터의 값입니다.
사실 올리디버거에서 EIP 레지스터 값도 보여줘서 CPU가 다음 실행할 명령어 위치를 자연스럽게 알 수 있습니다만, 매번 EIP를 보는게 번거로우니깐 올리디버거가 저렇게 명령어 주소 위치에 EIP 레지스터 값과 일치하는 곳을 검정색으로 색칠해줌으로써, 눈으로 한번에 CPU가 앞으로 여길 실행할거야~ 라고 알려주는 편의기능이라고 보시면 됩니다.
코드 분석
코드 양이 적어서 원하는 부분만 크래킹 하지 않고 하나씩 코드를 봐도 괜찮아보이네요. 그럼 부분 부분 봐봅시다.
우선 맨 위 빨간부분입니다.
보시면 스택에 0, "abex' 1st crackme", "Make me ...", 0 을 Push(삽입) 한다음 MessageBoxA() 라는 윈도우 API를 호출하고 있습니다. 프로그램 처음 시작할때 떴던 아래 메세지 박스를 띄우는 코드입니다.
int MessageBoxA(
[in, optional] HWND hWnd,
[in, optional] LPCSTR lpText,
[in, optional] LPCSTR lpCaption,
[in] UINT uType
);
//https://learn.microsoft.com/ko-kr/windows/win32/api/winuser/nf-winuser-messageboxa
윈도우 메세지 창을 띄우는 MessageBoxA() 함수의 경우 위와 같은 원형을 가집니다.
즉 C언어 코드 한줄로 나타내면 아래 코드겠네요.
MessageBoxA(0, "Make me think you HD is a CD-Rom.", "abex' 1st crackme", 0);
기본적으로 C언어 관점으론 인자던, 매개변수던 어쨌던 스택 메모리에 넣어서 전달해야 하니 함수 인자값을 스택 메모리에 PUSH해서 사용한다고 보시면 되겠네요.
* 스택으로 PUSH 하는거라 인자값 제공 순서는 반대입니다.
일단 저 MessageBoxA 함수 부분까지 실행하고 나면, EAX 레지스터의 값이 1로 설정됩니다.
반환값을 POP 한 코드도 없는데 저 값이 어떻게 설정된걸까요?
결론부터 말하자면 WINAPI 함수는 리턴값을 EAX 레지스터에 저장한다고 합니다.
함수를 실행할때마다 내부적으로 리턴값을 EAX에 자동으로 저장해주게 됩니다.
기본적으로 EAX 레지스터는 산술(덧셈, 곱셈, 나눗셈 등), 논리 연산을 수행하며 함수의 반환값이 이 레지스터에 저장됩니다. 호출 함수의 반환값이 EAX에 저장되므로, 호출 함수의 성공 / 실패 여부를 EAX 레지스터를 보고 쉽게 알 수 있습니다.
int eax = MessageBoxA(0, "abex' 1st crackme", "Make me think you HD is a CD-Rom.", 0);
즉 C언어로 코드를 재현한다고 하면 이런 모습이 되겠군요.
엥? MessageBoxA() 는 메세지 출력 함수인데 반환값이 있어요? 라고 물어보실 수 있습니다만 아까 함수 원형을 잘보시면 반환값이 int로 되어있습니다. 결론부터 말씀드리자면 OK 버튼을 누르면 MessageBoxA() 는 1을 반환합니다. 취소 버튼을 누르면 2를 반환하구요.
일단 메세지 창을 기본적으로 띄우면 취소 버튼은 없고 확인 버튼밖에 없어서 확인 버튼을 누른 순간에야 저 MessageBoxA() 함수에서 리턴되서 EAX 값이 1로 설정됩니다. 저 함수를 실행하니깐 레지스터가 다른값도 바뀌었는데 일단 무시합니다.
그리고 다음, GetDriveTypeA("C:\\"); 를 호출하게 됩니다.
GetDriveTypeA 함수는 드라이브 경로 문자열을 주면 그에 맞는 타입 값을 정수값으로 반환하는 함수라고 합니다. 일단 C드라이브는 저 위에서 봤을때 3번에 해당해서 3값이 반환되겠고, 그게 EAX에 저장될듯 싶습니다.
역시 예상 대로군요.
그리고 이제 증감, 감소 연산을 마구 합니다.
esi++;
eax--;
esi++;
esi++;
eax--;
C언어로 재현해보면 이렇습니다. 중간에 JMP가 있는데 주소가 보시면 바로 다음 라인으로 뛰는 겁니다. 의미가 없는 코드네요. 리버싱 할 때 햇갈리라고 넣어둔 거 같습니다. 이런걸 전문용어로 뭐라 하던데.. 뭐더라.. ㅠㅠ
이제 가장 중요한 분기 부분입니다.
EAX 레지스터와 ESI 값을 CMP 명령어로 비교하고, 그 값에 따라 점프하게 됩니다. JE는 Jump Equal 이라는 의미인데, EAX와 ESI 값을 비교했을때 같으면 저 0040103D 번지로 점프하라는 의미입니다. (또 Short라는 의미는 저 점프할 곳의 명령어 번지가 1바이트 이내로 점프하면 될 때 Short라고 하고, 1바이트를 넘어가서 점프해서 실행해야 하면 Long 또는 표기를 생략합니다.)
보시면 저기 EAX, ESI 값이 같아야지 Error 문구가 없는 우리가 원하는 C드라이브를 CD-ROM 으로 인식시키는 코드를 실행시킬 수 있습니다. 일단은 EAX 값은 앞에서 C드라이브 타입에 대한 반환값 3이 저장되어 있을거고, ESI는 따로 0으로 세팅하는 코드 자체가 없었습니다.
인터넷에 찾아보니깐 MessageBoxA() 할때 ESI를 0으로 세팅한다는데 저는 실행을 해도 ESI 값이 딱히 변동이 없더라구요.
GetDriveTypeA() 까지 호출하고 ESI 값인데, 보시면 ESI기본값이 00401000, 프로그램의 시작 주소를 저장하고 있었고, INC로 값을 올리니 00401001 이 되었습니다 어쨌던 간에 3이랑 저 큰 ESI 값이랑 같은지 비교를 시키니깐 달라서 통과를 못하는거겠죠..
https://security-nanglam.tistory.com/537
사실 이걸 하면서 좀 이상해서 인터넷을 찾아보니깐 저같은 문제가 있으신 분이 있더라구요. 리버싱 책에선 저 부분을 실행시키면 GetDriveTypeA() 이후에 ESI 값이 0으로 세팅된다고 합니다. 이유는 잘 모르겠고 64비트 환경에서 32비트 디버깅 실행시켜서 그런가..?
어쨌던 이걸 해결 하는 방법은 아주 여러가지 방법일 겁니다. 제가 생각한 몇가지 방법으로 프로그램을 패치해보겠습니다.
* 패치 : 파일 혹은 실행중인 프로그램(프로세스) 의 메모리를 변경하는 작업
=> 어떤 코드를 임의의 코드로 덮어버리는 작업을 뜻함, 우리가 흔히 프로그램 패치됐다 하면 어떤 프로그램이 바뀌어서 변경됐다 라고 이해하는데 이거랑 같은 뜻 맞습니다.
또 리버싱 세계에선 크랙이나 패치나 윤리적으로 따져서 그렇지 거의 같은말로 쓰입니다.
머리아프면 그냥 프로그램 패치 == 프로그램 수정이랑 똑같은 말 입니다.
올리디버거로 명령어 수정하는 법
동작 원리를 대충 이해했으니 이제 프로그램이 실행하는 어셈블리어를 바꾸어서 프로그램을 수정(패치)해야 합니다. 올리디버거에서 프로그램이 실행하는 어셈블리 명령어를 수정하는 방법은 매우 간단한데 수정을 원하는 어셈블리 코드 라인을 더블클릭해서 수정하시면 됩니다.
이런 수정 기능을 Assemble 이라고 합니다. 정확히는 명령어 한줄을 바꿔주는거라 Single Line Assemble 이라고 표현하는게 맞겠군요.
유의할 점은 Keep size에 체크를 풀어주시고, Fill reset with NOPs 에 체크해주시는걸 추천드립니다. 기본적으로 어셈블리어(기계어) 명령어 마다 차지하는 공간이 다르기 때문에 이것에 대한 수정을 막는게 Keep size 옵션입니다.
예를 들어서 PUSH 0 어셈블리 명령어에 1:1 대응되는 기계어가 6A 00 으로 총 2바이트인데, 보시면 INC ESI에 대응 되는 기계어는 46(16진수) 로 1바이트 입니다. 만약에 Keep size에 체크하시고 PUSH 명령어를 INC 명령어로 바꾸고자 한다면 2바이트 명령어를 1바이트 크기 명령어로 바꾸어야 하기때문에 Keep size 옵션에 의해 수정이 제한됩니다.
이런 제한을 풀고자 Keep size에 체크를 풀어주는 것이구요, 그리고 Fill rest with NOPs는 만약에 PUSH 명령어를 INC 명령어로 바꾸는데 성공하면 2바이트 명령어가 1바이트 기계어로 바뀌었기 때문에 1바이트가 비게 됩니다. 이 빈 부분을 깔끔하게 NOP을 삽입해서 채워주는 역할을 하게 됩니다.
* NOP : 어셈블리어에서 아무 명령도 수행하지 않으며 1바이트의 크기를 가진다. 명령어 사이에 빈공간을 채워주는 역할을 하게 된다. :: 출처
예시로 보여드리자면, 방금 PUSH 명령어를 제가 INC ESI 명령어로 교체했습니다. 보시면 Keep size 옵션이 체크가 되어 있지 않아서 2바이트 명령어 PUSH를 1바이트 명령어 INC 로 바꿀 수 있구요, 또 Fill Rest With NOPs 에 체크되어 있어서 남은 1바이트를 아무 의미 없는 NOP으로 채운 모습입니다.
풀이
1. JMP 로 무조건 점프 시키기
가장 쉽고 빠른 방법으로, 윈도우 API가 뭐던, EAX 레지스터 반환값이 어떻던 간에 우리가 원하는 코드 위치로 무조건 점프시키는 방법입니다. JMP를 통해 조건없이 무조건 점프시키면 끝납니다.
크게 공부는 안될 수 있는 방법이긴 한데, 혼자 빨리 크래킹 하는게 목적이라면 제일 확실한 방법이 아닐까 싶네요.
2. JNE로 반대로 점프 시키기
기존에 JZ(=Jump Equal)를 통해 점프했기 때문에 EAX 값과 ESI 값이 같아야만 우리가 원하는 코드로 실행될 수 있었습니다. 그러면 그냥 조건을 반대로 바꿔서 점프시키면 끝나겠네요. JNE(=Jump Not Equal) 을 통해 EAX 와 ESI 가 다를 때 원하는 위치로 점프시키면 끝납니다.
참고 : JE == JZ , JNE==JNZ 같은 의미임. (CMP와 제로플래그로 따지기 때문에)
3. GetDriveTypeA 반환값 조작 (EAX값 조작)
사실 아마 문제에서 요구하는 답이 이게 아니였을까 싶은데.. 기본적으로 ESI 값이 GetDriveTypeA() 호출 이후 0으로 잘 설정됐다고 가정한다면 GetDriveTypeA 의 반환값이 EAX에 3으로 저장될건데, 이걸 나중에 와서 5로 변조시키면 C드라이브를 CD-ROM으로 속일 수 있는거죠. (사실 속인다기보단 걍 CPU 명령어를 실행시키는거긴 합니다만..)
그냥 GetDriveTypeA() 밑에 mov eax, 5 정도만 추가하면 끝날겁니다. 다만 올리디버거로 기존 명령어 어셈블해서 변경 하는건 할 수 있어도 추가하는건 어떻게 하는지 잘 모르겠네요 -.-
+ 2022-12-28 추가
밑에 코드를 삽입하는건 못하겠어서 우선 JMP로 밑에 빈 부분으로 점프시켰습니다.
이제 밑에 빈 부분에 원하는 코드를 삽입했습니다.
40107D 라인에서 ESI값을 수동 초기화 하고, EAX 리턴값을 5로 변조한다음 나머지 원래 어셈블리 코드를 실행합니다. 이후 JMP 명령어로 원래 자리로 돌아옵니다.
원래 돌아오는 자리는 40101F, 의미없는 JMP 문 라인으로 다시 돌아와서 실행하게 됩니다.
당연하지만 제대로 초기화 되지 않는 ESI값도 수정했고, 리턴값도 5로 속였기 때문에 C드라이브를 CD-ROM으로 인식시킬 수 있게 됩니다.
C언어 복구
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
int main()
{
int esi, eax;
eax = MessageBoxA(0, "Make me think you HD is a CD-Rom.", "abex' 1st crackme", 0);
eax = GetDriveTypeA("C:\\"); // Return Value : 3
esi = 0; // By GetDriveTypeA()
esi++;
eax--;
esi++;
esi++;
eax--;
// ESI == 3, EAX == 1;
// We need to EAX is 5 not 3
if (eax == esi)
MessageBoxA(0, "Oh!! this is CD Rom!! Congratulate", "abex' 1st crackme", 0);
else
MessageBoxA(0, "Hey, this is not cd rom", "abex' 1st crackme", 0);
return 0;
}
어셈블리로 작성된 abex crackme #1을 심심해서 C언어로 다시 비슷하게 재작성 해봤습니다.
C언어와 어셈블리어를 양방향으로 변환해보는 연습이 리버싱 실력느는데 도움이 많이 되는 거 같네요.
이와중에 어셈블리어랑 가독성 차이보소 ㅋㅋ... C언어 할때 욕하면서 했는데 이제 감사인사 하면서 코드 작성할 수 있을거 같아요..
IDA Pro(번외)
IDA 를 쓰시면 이렇게 코드를 분석해서 그래프 형태로 보여주고, 점프할때 참이면 초록색 화살표로, 거짓이면 빨간색으로 뛴다고 알려줘서 이렇게 한눈에 실행 흐름을 확인할 수 있습니다. 올리디버거랑 비교했을때 비교도 안되게 잘보이죠?
저렇게 그림으로 보니 저기 jz(je) 명령어 하나만 바꿔주면 우리가 원하는 흐름으로 제어할 수 있는게 바로 보이네요!!
보시면 중간에 의미없는 jmp 또한 화살표 한개로 그림으로 바로 보이네요. 코드가 길었다면 쉽게 파악하지 못했을겁니다. 다만 IDA Pro는 비싸니깐.. 제가 알기로 디컴파일 없이 디스어셈블리 x86 분석은 무료로도 사용할 수 있었던걸로 아는데 확인해보셔야... 크랙도 있고
패치(수정)된 파일
위는 풀이 방식중 3번째 방식으로 해결한 crackme1 번에 대한 크랙된 파일입니다
출처
https://everybe-ok.tistory.com/16
http://john-home.iptime.org:8085/xe/index.php?mid=board_XylG00&document_srl=16887
https://blue-shadow.tistory.com/20
https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=cung91&logNo=220196144355
'보안 강좌 > CrackMe' 카테고리의 다른 글
[Solution] Abex' CrackMe #5 풀이 [완결] (0) | 2023.01.01 |
---|---|
[Solution] Abex' CrackMe #4 풀이 (0) | 2023.01.01 |
[Solution] Abex' CrackMe #3 풀이 (IDA, OllyDbg 사용) (0) | 2023.01.01 |
[Solution] Abex' CrackMe #2 풀이 (IDA, OllyDbg 사용) (0) | 2022.12.31 |