본문 바로가기

Reverse Engineering

올리디버거(OllyDBG)를 이용한 키젠(Keygen) 문제 풀이

키젠 문제는 프랑스의 haiklr라는 아이디를 쓰는 사람의 홈페이지에서 다운받았다.
문제를 다운로드 받아서 압축을 풀어보면 ReadMe.txt 파일이 있다.


Type : Keygenme
Level : Newbie
Langage : C (console)
Packed : Non

시리얼키들을 생성해내는 문제고, 난이도는 쉬운 편이며, C언어 콘솔어플리케이션이고, 패킹은 하지 않았다고 설명되어 있다.

문제를 실행해보자.


Name과 Serial을 입력하라고 나타난다.
입력을 하니까 '잘못된 패스워드'라는 문자열이 출력된다.

올리디버거로 열어서 어떤 알고리즘에 의해서 잘못된 패스워드로 판단했는지 확인해보고 정확한 시리얼값을 찾아보겠다.


문제 파일을 열고, 마우스 우클릭 메뉴에서 Search for -> All referenced text strings를 선택하여 문자열을 추출한다.


시리얼키가 일치했을 때 나타날 "Yeah, c'es" 부분에서 더블클릭하여 해당 위치로 이동한다.
어셈블리 코드를 보면서 프로그램을 분석해보면 성공 메시지 부분에서 위로 이동하면 Name을 입력받는 부분이 나타난다.


'[x] Name : '이 출력되고, scanf로 입력값을 받고, strlen을 통해서 이름의 길이를 체크한다.


어셈블리 코드를 보면 CMP를 이용해서 조건이 '3 < Name 길이 < 15'와 같은지 비교하고
맞으면 JMP구문에서 004014B0주소로 점프하고, 맞지 않으면 점프해서 printf하고 system으로 pause명령을 실행하게 된다.

004014B0주소로 가면 시리얼키를 넣는 부분이 나타나고, 입력한 값이 반복문을 통해서 변경된다.


시리얼값을 입력받은 다음에 반복되는 형태로 JMP를 하여 위쪽으로 이동되는 것을 볼 수 있다. 시리얼값이 들어온 부분 다음을 Breakpoint(F2)설정하고 StepOver(F8)을 누르면서 Value창을 확인하면서 반복되는 과정을 보면 Name값의 문자열이 한 개씩 들어오면서 중간에 공백 문자가 들어가는 것을 볼 수 있다.

Name에 'test'을 입력하고 값이 변경되는 과정을 주요 어셈블리 코드들을 보며 체크해보겠다.

StepOver(F8)해가면서 하단의 Value창과 우측 상단의 레지스터 값들을 체크하면서 봐야한다.
(Hex dump창의 아래 나온 변수의 주소값으로 이동해서 확인을 해보면 값들이 어떻게 바뀌어 가는지를 볼 수 있다)

004014E0   > /8B45 E8       MOV EAX,DWORD PTR SS:[EBP-18]
// [EBP-18]에는 test의 길이인 4가 들어가 있고, 그것을 EAX에 저장

004014E3   . |89C2          MOV EDX,EAX
// EAX값을 EDX에 저장

004014E5   . |8D0412        LEA EAX,DWORD PTR DS:[EDX+EDX]
// [EDX+EDX]는 EDX의 값을 곱하기 2 하여 8이 된 주소를 EAX에 저장

004014E8   . |3945 FC       CMP DWORD PTR SS:[EBP-4],EAX
// EAX와 [EBP-4]를 비교한다. for문을 언제까지 할지 결정한다.

004014EB   . |7C 03         JL SHORT Babylon_.004014F0
// CMP에 의해 값이 같지 않으면 점프한다.(반복문 진행)

004014ED   . |EB 31         JMP SHORT Babylon_.00401520
// 반복이 완료되면 반복 구문 밖으로 점프하여 반복을 종료한다.

004014EF     |90            NOP
// 아무일도 하지 않는다.

004014F0   > |8D85 A0FDFFFF LEA EAX,DWORD PTR SS:[EBP-260]
// [EBP-260]의 주소를 EAX에 저장

004014F6   . |8B55 FC       MOV EDX,DWORD PTR SS:[EBP-4]
// EDX = 0

004014F9   . |8D8D E0FEFFFF LEA ECX,DWORD PTR SS:[EBP-120]
// 이름의 주소를 ECX에 저장

004014FF   . |8B5D F8       MOV EBX,DWORD PTR SS:[EBP-8]
// EBX = 0

00401502   . |8A0C0B        MOV CL,BYTE PTR DS:[EBX+ECX]
// 이름의 첫번째 값의 아스키 코드를 CL에 저장, test의 t가 먼저 저장

00401505   . |880C02        MOV BYTE PTR DS:[EDX+EAX],CL
// CL의 값을 [EDX+EAX]에 저장

00401508   . |8B45 FC       MOV EAX,DWORD PTR SS:[EBP-4]
// EAX = 0

0040150B   . |40            INC EAX
// EAX를 INC로 1증가 EAX = 1

0040150C   . |8D95 A0FDFFFF LEA EDX,DWORD PTR SS:[EBP-260]
// [EBP-260]의 주소를 EDX에 저장

00401512   . |C60410 20     MOV BYTE PTR DS:[EAX+EDX],20
// [EAX+EDX]에 16진수로 20 -> 0x20 아스키값으로 공백이다.

00401516   . |FF45 F8       INC DWORD PTR SS:[EBP-8]
// [EBP-8]값에 INC로 1증가

00401519   . |8345 FC 02    ADD DWORD PTR SS:[EBP-4],2
// [EBP-4]값에 2를 더함

0040151D   .^\EB C1         JMP SHORT Babylon_.004014E0
// 다시 반복을 시도한다.

이해를 돕기 위해 위의 어셈블리 코드들을 C언어로 재구현했다. C언어와 비교해보면서
어셈블리 코드들을 다시 한번 분석해보자.

char str[5] = "test";  // 4 = [EBP-18] (test의 길이) ,
                             // 'test' 문자열의 인덱스(str[0])주소 = [EBP-120]
char str1[8] = {0};   // 변경된 문자열을 저장할 배열의 인덱스(str1[0])주소 = [EBP-260]

for( int i =0 , int j = 0 ; i < 8 ; i+=2, j++ )  // i = [EBP-4], j = [EBP-8]
{
            str1[i] = str[j];                                  // str[j] = CL -> str1[i]에 대입.
            str1[i+1] = 0x20;
}

이렇게 반복문을 지나고 나면 SS:[EBP-260]에는 "t e s t "가 들어가게 된다.
그 후에 이상한 값들이 추가되는데, 위치와 값을 체크해보면


이상한 값이 들어가고 길이를 체크하며, 반복문을 통해 문자열들의 길이만큼 반복을 하면서 BL에 한 글자씩 넣고 00401564   .  FEC3          INC BL 을 통하여 값을 변경한다.
이 반복문을 C언어로 재구현하면,

char str2[35] = "-[#]]=}&&&+(=$*,,)&.*/+++[][;/..?" [EBP-160]

for( int i = 0 ; i < 34 ; i++ ) // i = [EBP-4]
{
            str2[i] = str2[i]+1;
}

아래로 내려오면 또 반복문이 나오면서 XOR연산을 한다.
004015C9   .  321C37        XOR BL,BYTE PTR DS:[EDI+ESI]
이 반복문을 C언어로 재구현하면,

char str2[35] = ".\$^^>~''',)>%+--*'/+0,,,\^\<0//?" [EBP-160]
char str1[8] = "t e s t ";                                          [EBP-260]

for( int i = 0 ; i < 8 ; i++ ) // i = [EBP-4]
{
            str2[i] = str2[i]^str1[i];                   // '^' 는 XOR 연산
}

아래로 내려오면 또 반복문이 나온다.
이 반복문을 C언어로 재구현하면,

char str2[35] = "Z|A~-'',)>%+--*'/+0,,,\^\<0//?" [EBP-160]
char str3[35] = {0};                                             [EBP-360]

for( int i = 33, int j = 0 ; i >= 0 ; i--, j++ ) // i = [EBP-4], j = [EBP-C]
{
             str3[j] = str2[i];
}

아래로 내려오면 또 반복문을 만난다.
이 반복문을 C언어로 재구현하면,

char str2[35] = "Z|A~-'',)>%+--*'/+0,,,\^\<0//?" [EBP-160]
char str3[35] = "1?/0<\^\,,,0+/'*--+%>),''-~A|Z" [EBP-360]
char str4[35] = {0};                                              [EBP-460]

for( int i = 0, int j = 0, int k = 0 ; i < 34 ; j++, k++ ) i = [EBP-4], j = [EBP-10], k = [EBP-14]
{
           str4[i] = str3[j];
           i++;
           str4[i] = str2[k];
           i++;
}

아래로 내려오면 또 반복문을 만난다.
이 반복문을 C언어로 재구현하면,

char str4[35] =  "1Z?/A/~0-<\.^\',',,,)0>+%/+'-*-" [EBP-460]

for(int i = 0 ; i < 34 ; i++ )
{
        if(str4[i]<=1F)
        {
                     str4[i]=0x36;
        }
        
        if(str4[i]>'z')
        {
                     str4[i]=0x36           
        }
}

분기문을 만났다.
여기까지 정리를 하면 (이표에 나와있는 문자열값은 정확하지 않다.
올리디버거에 Hex dump창의 아래 나온 변수의 주소값으로 이동해서 확인을 해보자)

 [EBP-160] = "-[#]]=}&&&+(=$*,,)&.*/+++[][;/..?"
                                    ↓
 [EBP-160] = ".\$^^>~''',)>%+--*'/+0,,,\^\<0//?"
                                    ↓
 [EBP-160] = "Z|A~-'',)>%+--*'/+0,,,\^\<0//?"
                                    ↓ 
 [EBP-360] = "1?/0<\^\,,,0+/'*--+%>),''-~A|Z"
                                    ↓
 [EBP-460] = "1Z?/A/~0-<\.^\',',,,)0>+%/+'-*-"
                                    ↓
 [EBP-460] = "1Z66/A/60-<6\6^6\',',,,)0>+%/+'-*-"

004016E3   .  3B45 E8       CMP EAX,DWORD PTR SS:[EBP-18]  // [EBP-18] = 4
004016E6   .  7C 08          JL SHORT Babylon_.004016F0
004016E8   .  EB 4B         JMP SHORT Babylon_.00401735

JL구문에서 점프를 하지 않으면 JMP를 만나 성공 메시지가 있는 곳으로 간다.

마지막 반복문을 진행하여 성공 or 실패 메시지로 점프하는데 이 반복문을 C언어로 재구현하면,

char str5[5] = "1234";                                                    [EBP - 560]
char str4[35] = "1Z66/A/60-<6\6^6\',',,,)0>+%/+'-*-";    [EBP - 460]

for ( int i = 0 ; i < 4 ; i++ ) // i = [EBP-4]
{
        if(str5[i]!=str4[i])
        {
                    실패메시지 호출;
        }
}
성공메시지 호출;


여기까지의 분석을 통해 우리는 Name : test 를 입력한 경우에 시리얼값은 1Z66 이라는 것을 찾아냈다.


이제 시리얼값이 어떻게 만들어지는지 정리해보겠다. 이러한 알고리즘을 프로그래밍하면 그것을 키젠이라고 한다.

1. Name 입력 : "test"

2. [EBP-160]에 "-[#]]=}&&&+(=$*,,)&.*/+++[][;/..?"값이 들어간다.

3. [EBP-160]에 값을 INC한다. ".\$^^>~''',)>%+--*'/+0,,,\^\<0//?"

4. Name 문자열 사이에 공백을 추가한다. "t e s t "

5. 공백이 들어간 Name의 길이만큼 반복하면서 INC된 [EBP-160]값과 XOR 연산한다.

6. [EBP-360]에는 INC된 [EBP-160]값이 거꾸로 뒤집혀서 들어간다.

7. [EBP-460]에는 [EBP-360]값과 [EBP-160]값을 번갈아 가며 넣는다.
    ex. [EBP-460][1] = [EBP-360][1]
          [EBP-460][2] = [EBP-160][1]

8. [EBP-460]의 문자중에 'z'보다 크거나 0x1F보다 작거나 같으면 6으로 변경한다.

9. [EBP-18](Name의 길이)만큼 [EBP-460]을 앞에서부터 잘라낸다.

다음 내용들을 정리하여 짜본 소스를 첨부한다.
이해가 안되는 부분은 질문을 해주시기 바랍니다.


키젠 문제를 풀어보았는데, 일반 CrackMe 문제보다는 더 꼼꼼히 정리하고, 값들이 어떻게 변화되는지를 살펴봐야 한다. 문제 자체가 중요한 것이 아니라 문제를 풀면서 어느 정도 어셈블리어에 익숙해지고, 분석하는 방법을 배워가는게 중요하다.

(참고로 아스키 값과 10진수, 16진수 코드 값은 여기에 정리된 것이 있다
                                         
                                           http://www.asciitable.com/)