PE Password Unpack

@codemaru · October 24, 2008 · 14 min read

어제 회사에서 재미난 프로그램을 보게 되었습니다. 패킹된 프로그램인데요, 암호를 모르면 실행을 할 수 없도록 막아둔 것이더군요. PEiD로 열어보니 Pe Pssword 0.2로 패킹이 되어 있다고 알려주더라고요. 그래서 인터넷을 좀 뒤졌는데 딱히 마땅히 자료를 찾을만한 곳이 없었습니다. 이놈을 실행하면 아래와 같은 패스워드 입력 화면이 나타납니다. 아마도 이 패스워드를 맞춰야 진짜 실행 파일이 실행되는 구조겠죠.

PE Password Unpack md 0
보통 실행 압축이나 보안의 경우에 메모리 덤프를 뜨면 무용지물이 되는 경우가 많습니다. 메모리에 존재하는 동안은 실제 코드를 담고 있기 때문이죠. 물론 그런 것들을 우회하기 위한 다양한 방법도 있지만 그런것도 결국에모든 수를 동원한다면 이론적으로는 푸는 것이 가능합니다. 여튼 그래서 이놈을 좀 살펴봤습니다. 누가 습작으로 만든 것인지 모르겠지만 코드가 복잡하지는 않더군요.

일단 실행을하면 다이알로그를 만듭니다. 해당 다이알로그에서 에디터 메시지가 날라오면 실행되는 부분이 아래 코드입니다. 살펴보면 에디터 상자에 입력한 내용을 구해서 특정 함수(4301d8)을 호출한 다음 값을 비교하는 것을 살펴볼 수 있습니다. 이쯤되면 이건 머... ㅋㅋㅋ 간단하게 점프문을 패치했습니다. 그러면 될 줄 알았죠. ㅋ

PE Password Unpack md 1
점프문을 패치하면 실행은 다음과 같은 코드로 흘러가게 됩니다. 아까 에디터에 입력한 내용을 기반으로 암호화된 코드 영역을 복호화 시키는 것이죠. 핡~ @.@

PE Password Unpack md 2
실제 디크립트 함수로 따라가보면 아래와 같습니다. 먼가 복잡한것 같아 보이지만 실상은 키 두 개를 만들어서 서로를 변경해 가면서 텍스트에 XOR을 수행하는 것이 전부입니다.

PE Password Unpack md 3
결국 우리는 우쨌든둥 암호를 알아야지 패스워드 체크 저 넘어에 있는 것을 볼 수 있다는 건데요. 음... 그렇다면 알아내야겠죠. ㅋㅋ~

제가 처음 생각한 방법은 어차피 해시 값은 기록되어 있으니 무작위 암호 생성을 해서 매칭되는 해시 값을 가진 암호를 알아낸다면 풀 수 있지 않을까하는 생각이었습니다. 앞서 해시를 생성한다고 되어 있는 코드를 출력하면 다음과 같습니다. 먼가 복잡해 보이지만 이 코드를 수행하면 eax에 해시 값이 들어오는 것이죠. 굳~ 우리가 찾아야할 해시 값은 무엇? 0xa31550b4입니다.

PE Password Unpack md 4

\_\_declspec(naked) DWORD Hash(char \*passwd)  
{  
    \_\_asm  
    {  
        mov esi, [esp+4]  
  
        xor eax,eax  
        push ecx   
        push esi   
        push edx  
        xor edx,edx  
        dec esi  
$L1:   
        inc esi  
        xor ah, byte ptr ds:[esi]  
$L2:    
        xor al,dl  
        add eax, 0x434f4445  
        mov cl,al  
        ror eax,cl  
        xor eax, 0x55AA5A5A  
        dec dx  
        jnz $L2  
        cmp byte ptr ds:[esi], 0  
        jnz $L1  
        pop edx   
        pop esi   
        pop ecx  
        ret  
    }  
}  
  
void Attack(char buf[], int ml, int cl)  
{  
    if(ml != cl)  
    {  
        for(char ch=0x20; ch < 0x7e; ++ch)  
        {  
            buf[cl] = ch;  
            Attack(buf, ml, cl+1);  
        }  
  
        return;  
    }  
  
    for(char ch=0x20; ch < 0x7e; ++ch)  
    {  
        buf[cl] = ch;  
        buf[cl+1] = '\0';  
        printf("\"%s\" => %08X\n", buf, Hash(buf));  
    }  
  
}  
  
int main()  
{  
    char buf[80];  
    Attack(buf, 3, 0);  
    return 0;  
}

C&P 신공으로 급조한 프로그램입니다. Attack함수를 수행하면 맥시멈 글자수로 된 암호를 생성해서 해당 암호에 대한 해시 값을 출력하는 것이죠. 영화를 너무 많이 본 탓일까요? 이런 걸로 전 암호를 찾을 수 있다는 순진한 생각을 했답니다. ㅋㅋㅋ 실행하면 아래 화면처럼 주루룩 올라가면서 해시 값이 출력된답니다.

PE Password Unpack md 5
수십 메가의 텍스트 파일이 완성되어지고 동일한 해시 값을 가진 암호를 여럿 찾을 수 있었습니다. 하지만 안타깝게도 모두 패스워드 체크 저 넘어를 보여주진 못하더군요. 아마도 해시 값이 아닌 다른 값으로 복호화를 수행하기 때문인 것 같습니다. 위의 디버거 덤프 중에 디크립트 함수 부분을 보면 [ESI]를 사용하는 것을 볼 수 있죠. 즉, 그냥 해시가 아니라 디크립트를 할 때에는 암호의 첫 글자를 이용한 해시 값을 생성하는 것을 알 수 있습니다. 즉, 이 해시 값은 우리가 단지 점프 문을 통과하기 위한 값 이상도 이하도 아닌 것이죠.

그렇다면 정녕 우리는 패스워드 체크 넘어에 있는 곳에 도달할 수 없을까요? 없으면 이 글도 없겠죠 ㅋㅋ~ 생각의 고리를 좀 돌려봅시다. 해시 함수가 아닌 디크립트 함수를 좀 살펴보는 것이죠. 앞선 디버거 덤프 중에 430200부분을 보시면 됩니다. 복잡하지도 않은 어셈블리 루틴이죠. 살피고 또 살피면 결국 아래와 같은 일을 수행하는 것을 알 수 있습니다.

P = Hash(?)
Q = Hash(?')

$L:
src = src xor P
P = P expr Q
Q = Q expr' P
jmp $L
우와 쉽죠. 그럼 이것을 풀기 위해서는 무엇을 알아야 할까요? 네 그렇죠. 루프에 진입하는 당시의 P, Q값을 알아야 겠죠. P와 Q는 결국 32비트 숫자니깐 그 두 개가 가질 수 있는 조합는 4G * 4G = 안드로겠군요. 하지만 몇 가지 중요한 특징만 더 알아낸다면 우리는 가능성 있는 조합을 극적으로 낮출 수 있을지도 모릅니다.

저 디크립트 함수의 가장 큰 특징은 복잡해 보이지만 사실은 P, Q가 단지 서로에게 영향을 줄 뿐 다른 것에는 전혀 영향을 받지 않는다는 사실입니다. 즉, 특정 시점의 값만 알수 있다면 수학적으로 모든 단계의 P와 Q를 찾아내는 건 식은 죽먹기라는 것이죠. 두 번째 특징은 원본 문자열을 암호화 하는 수단이 단지 단지 단지 XOR이라는 것 입니다. 이는 중요한 사실이죠. 코드 섹션을 암호화 했는데 XOR로만 되어있다. 여기까지 생각이 미치면 이제 게임은 끝난 것이죠. 왜냐? 우리에겐 아래와 같은 놀라운 사실이 존재하기 때문입니다.

0 xor A = A
0 xor B = B

그럼 이제 좀 제대로 된 Brute Force 코드를 작성해 볼까요.

\_\_declspec(naked) DWORD NextP(DWORD P, DWORD Q)  
{  
    \_\_asm  
    {  
        mov eax, [esp+4]  
        mov ebx, [esp+8]  
        mov cl, al  
        rol ebx, cl  
        xor eax, ebx  
        mov cl, bh  
        ror eax, cl  
        add ebx, eax  
        ret  
    }  
}  
  
\_\_declspec(naked) DWORD NextQ(DWORD P, DWORD Q)  
{  
    \_\_asm  
    {  
        mov eax, [esp+4]  
        mov ebx, [esp+8]  
        mov cl, al  
        rol ebx, cl  
        xor eax, ebx  
        mov cl, bh  
        ror eax, cl  
        add ebx, eax  
        mov eax, ebx  
        ret  
    }  
}  
  
int main()  
{  
    DWORD P[] = { 0x45b59c96, 0x0acf7827, 0x48f9897d, 0xd9fc6c62 };  
    int cnt = sizeof(P) / sizeof(P[0]) - 1;  
  
    for(int j=0; j<cnt; ++j)  
    {  
        for(\_\_int64 i=0; i<0xffffffff; ++i)  
        {  
            if(NextP(P[j], (DWORD) i) == P[j+1])  
                printf("Q = %08X  NextQ = %08X\n"  
                        , (DWORD) i  
                        , NextQ(P[j], (DWORD) i));  
        }  
  
        printf("\n\n");  
    }  
  
    return 0;  
}

NextP, NextQ는 암호화 함수에서 긁어온 것들입니다. 말 그대로 다음 단계의 P와 Q를 리턴하는 함수입니다. 이 프로그램은 P 목록으로 부터 다음 단계의 P가 되기 위해서 가능성있는 Q를 추정하는 기능을 수행합니다. 실행을 하면 아래와 같은 화면이 출력됩니다.

PE Password Unpack md 6
이 단계를 보면 일관되게 나오는 Q의 흐름을 찾을 수 있습니다. DFB0EAC8 => BD076461 => CCABBA5B => 5391E3AD죠. 즉, 최종 단계의 P와 Q는 D9FC6C62와 5391E3AD가 된다는 것을 알 수 있습니다. 그러면 시작 지점의 P와 Q는 루프를 거꾸로 돌려버리면 나오겠죠. ror을 rol로 add를 sub로 교체하면 직전단계의 P와 Q로 가는 함수를 만들수 있습니다. 시작 지점의 P와 Q를 알 수 있다면 머 암호는 풀린거나 다름이 없죠.

아마도 아직 정확하게 무엇이 어떻게 진행된다는 건지 이해를 못하신 분들도 계실것 같네요. 가장 중요한 단계의 설명을 일부로 누락했기 때문입니다. 바로 저 P 목록이 어디서 왔냐하는 것이죠. 그것은 코드 섹션의 마지막 부분에서 긁어온 것 입니다. 일반적으로 코드 섹션은 정렬 기준을 맞추기 위해서 0으로 패딩이 됩니다. 0으로 패딩이 된다는 사실은 거기 암호화된 값이 암호화 당시의 P 값이라는 사실을 말해주죠. 따라서 마지막 몇 바이트를 사용하면 손쉽게 마지막 Q를 추정할 수 있고, 그렇다면 돌려서 시작 지점의 P, Q를 찾을 수 있게 되는 겁니다. 일반적으로 컴파일되는 코드를 정렬 기준에 딱 맞추어 생성시키기는 굉장히 어려운 일이기 때문에 거의 모든 실행 파일에 적용할 수 있는 방법이겠죠.

@codemaru
돌아보니 좋은 날도 있었고, 나쁜 날도 있었다. 그런 나의 모든 소소한 일상과 배움을 기록한다. 여기에 기록된 모든 내용은 한 개인의 관점이고 의견이다. 내가 속한 조직과는 1도 상관이 없다.
(C) 2001 YoungJin Shin, 0일째 운영 중