챌린지/Wargame.kr

bughela - EASY_CrackMe

하루히즘 2020. 12. 31. 17:13

wargame.kr의 15번째 문제인 EASY_CrackMe다. 이번 문제는 이름에서 알 수 있듯이 리버싱 문제다.

Start 버튼을 누르면 문제 파일을 다운로드할 수 있는 링크로 이동하며 윈도우즈 7에서 비주얼 스튜디오 2008을 이용하여 컴파일되었다고 알려준다. 프로그램 아이콘은 MFC며 이를 실행시켜보면 간단한 폼과 버튼이 나타난다.

어떤 글자를 입력하든 아무 문자열이나 입력하든 check 버튼을 누르면 "nono..."라는 메시지와 함께 프로그램이 종료된다. 정보를 더 얻을 순 없으니 디버거로 열어서 확인해보자.

일단 문자열을 확인해보니 MFC 프레임워크에서 사용하는 듯한 여러 문자열들을 제외하고 위처럼 "_my_b", "birth", "G00d!", "nono.." 등 프로그램에서 사용되는 문자열과 어딘가로 이어지는 링크를 볼 수 있었다. 해당 링크에 접속해보면 ":p"라는 문자만 출력될 뿐 별다른 결과를 얻을 수 없었는데 아마 p 파라미터로 알맞는 비밀번호를 넘기면 플래그가 출력되는 것 같다. 현재로서는 어떤 값을 입력해도 플래그를 얻을 수 없었기 때문에 프로그램을 좀 더 분석해보기로 했다.

 

일단 비밀번호가 맞을 경우 출력되는 듯한 "G00d!" 문자열과 틀렸을 때 출력되는 "nono.." 문자열이 사용되는 곳으로 이동해보자.

두 문자열은 좀 떨어진 곳에서 사용되고 있었는데 MFC라 그런지 함수 이름도 번역되지 않아서 일단 0x7A7AAA가 어떤 문자열을 띄우는 메시지 박스라고 메모해두었다. 그 다음으로 check 버튼을 눌렀을 때 어떤 로직이 동작하는지 찾아봐야 하는데 어쨌든 입력한 비밀번호를 비교하려면 user32의 GetDlgItemText같은 함수를 사용하지 않을까? 그래서 현재 모듈에서 모듈간 호출 - GetDlgItem 함수에 모두 중단점을 걸고 check 버튼을 눌러보았다.

check 버튼을 누르자 0x007B3338에서 사용된 GetDlgItem 함수를 발견할 수 있었다.

해당 코드영역에서는 몇 줄 안되는 블록 내에서 GetDlgItem 함수를 호출하고 있었는데 전체적인 흐름을 보기 위해 ret으로 복귀하는 코드로 이동해보았다.

0x00A495C1에서 호출한 0x00A53327이 방금 있었던 곳이므로 나중에 구분하기 쉽도록 call_GetDlgItem 같은 레이블을 붙여두자. 이곳에서도 ret 명령어를 통해 다른 코드로 빠져나가고 있었다. 뭔가 다른것이 보일 때까지 계속 이동해보자.

한두번 더 ret 명령어를 통해 탈출하니 GetWindowTextLengthW, GetWindowTextW 함수를 호출하는 코드 블록에 도달할 수 있었다. GetWindowTextLengthW 함수는 문서에 따르면 매개변수로 받은 핸들에서 문자열의 길이(창이면 창의 제목, 컨트롤이면 컨트롤 내부 문자열)를 반환하는 함수다.

 

GetWindowTextLengthW function (winuser.h) - Win32 apps

Retrieves the length, in characters, of the specified window's title bar text (if the window has a title bar).

docs.microsoft.com

매개변수로 받는 핸들은 그럼 어디서 올까? 아무래도 아까 중단점을 걸어서 확인했던 GetDlgItem 함수일 것이다. 문서를 확인해보면 역시 다이얼로그 박스의 컨트롤에 대한 핸들을 반환한다는 것을 알 수 있다.

 

GetDlgItem function (winuser.h) - Win32 apps

Retrieves a handle to a control in the specified dialog box.

docs.microsoft.com

그렇다면 함수의 호출 순서로 추측해보건대 다이얼로그 아이템, 여기서는 문자열을 입력하는 텍스트 박스가 하나밖에 없었으니 그 컨트롤에 대한 핸들을 얻어와서 해당 컨트롤 내부의 문자열, 즉 우리가 입력한 문자열의 길이를 파악하고 문자열을 얻어와서 어떻게 후처리를 하는 것 같은데 일단 GetWindowText 함수까지 실행시켜보자.

함수가 호출되는 모습을 보니 아마 문자열의 길이를 구한 이유는 일정 이하 길이의 문자열이면 실패 조건으로 분기하는게 아니라 문자열의 길이 + 1 만큼 GetWindowTextW 함수의 nMaxCount 매개변수로 쓰기 위한 것 같다. 이 매개변수는 해당 컨트롤의 문자열을 버퍼로 몇글자나 복사할지 지정하는 역할이며 마지막 널 문자까지 복사하기 위한 것일까? 아무튼 [ebp+10] 위치에 우리가 입력한 "1324"라는 문자열이 저장된 것을 볼 수 있었다.

 

이후에도 몇번의 함수 호출이 있었지만 딱히 어떤 로직을 파악하진 못했기 때문에 일단 프로그램이 종료될 때까지 진행하며 어디서 분기가 갈리는지 확인해보도록 했다. 아마 우리가 입력한 문자열을 특정 시리얼과 비교하는 로직이 있을 텐데 그렇다면 그 특정 시리얼, 여기서는 아까 프로그램 내부에서 탐색한 문자열인 "_my_b"나 "birth"를 사용하는 코드까지 진행해보기로 했다. 해당 문자열이 있는 코드로 이동하여 F4를 눌러서 금방 그 부근으로 이동할 수 있었다.

이미 풀었던 문제기 때문에 오른쪽에 주석이 달려있다.

우리가 입력한 값인 "1324"뿐 아니라 "_my_b", 사진에서는 안보이지만 아래로 내려가면 "birth"가 있으며 조금만 더 내려가면 "http://wargame.kr:8080/prob/18/ps.php?p="라는 플래그 입력 URL까지 나와있다. 즉 이 부근에서 어떤 로직을 수행한 후 URL과 조합하여 파라미터로 HTTP 요청을 전송해서 플래그를 받아오거나 아니면 ":p"를 받아올 것이라 추측할 수 있는다.

일단 이곳에 중단점을 걸고 프로그램을 몇번 실행시키며 확인해보면 [edi+78]에는 우리가 입력한 값이 저장되어 있으며 이는 eax 레지스터로 복사된다는 것이다. 0x00A416FC에서 [eax-C]에 있는 값을 esi 레지스터에 저장하여 0xC, 12와 비교하고 있는데 이는 입력한 문자열의 길이기 때문에 문제에서는 최소 12글자의 비밀번호를 요구하고 있다. 만약 이를 충족하지 못한다면 0x00A41702의 jg 분기를 타지 않는데 jg는 jump if greater기 때문에 13글자를 넘어야 한다. 즉 최소 13글자의 비밀번호를 입력해야 한다.

 

그런데 분기를 타지 않으면 xor bl, bl을 통해 ebx 레지스터의 하위 1바이트를 초기화시키고 있다. 현재 ebx 레지스터에는 정수 1이 저장되어 있는데 혹시 이게 입력한 비밀번호가 옳고 그름을 판단하는 플래그는 아닐까? 확인을 위해 나머지 조건도 xor bl, bl 구문이 있는지 살펴보았다.

여러군데에서 xor bl, bl을 하는 것을 볼 수 있었고 마지막에는 test bl, bl을 통해 ebx 레지스터가 양수인지 확인하고 있었다. 아래쪽에서 ebx 레지스터를 활용해 분기를 타는 곳이 있는지 확인해보았다. 실제로 실행시키면서 확인해 본 결과 xor bl, bl로 인해 0으로 설정된 ebx 레지스터가 다시 0이 아닌 값을 갖게 되는 경우는 없었기 때문에 이 test bl, bl은 ebx 레지스터가 처음에 갖고 있던 1 값을 아직도 갖고있는지 확인하는 것 같다. 즉 이런저런 비밀번호 검증 로직을 통과하여 je 분기를 타지 않는다면 올바른 플래그를 얻을 수 있을 것이다.

 

다시 위로 돌아가서 비밀번호 검증 로직을 분석해보자. 일단 비밀번호는 최소 13글자여야 한다. 그렇기 때문에 1234567890123를 입력해서 실행해보았다. 아까와는 다르게 jg 분기를 타는것을 확인할 수 있었다. 이로써 1차 검증 로직(비밀번호 글자 길이)은 통과했다.

그 다음으로는 cmp dword ptr ds:[eax-C], 0으로 입력한 비밀번호 근처의 값을 검사하고 있는데 현재 0xD가 저장되어 있기 때문에 이 검증 로직도 통과할 수 있었다.

이번 검증 로직에서는 바로 xor bl, bl하는 것이 아니라 다른곳으로 분기한 후 xor bl, bl하기 때문에 다른 분기들도 잘 살펴보고 점프해야 하는지 하지 말아야 하는지 잘 확인해야 할 것이다.

그 다음 로직에서는 "_my_b" 문자열과 우리가 입력한 문자열을 스택에 삽입하고 어떤 함수를 호출하고 있다. 함수 호출 이후 eax 레지스터에는 0이 저장되는데 이는 test eax, eax 구문에 의해 ZF가 세워져 je 분기를 타서 결과적으로 xor bl, bl이 호출된다. 그렇기 때문에 이 0x00B424D3 함수 호출이 0이 아닌 값을 반환해야 하는데 이 함수에서 무슨일을 하는지 살펴보자.

일단 우리가 입력한 문자열 "1234567890123"과 "_my_b" 문자열이 각각 eax, ebx 레지스터에 저장되었다.

맨 처음 수행하는 로직은 ecx 레지스터에 저장된 사용자 입력의 첫 글자가 0x00이 아닌지 파악하는 것이다. 당연히 0x31('1')이기 때문에 이 로직은 문제없이 통과하는데 0x00B4252A로 점프할 경우 xor eax, eax를 통해 eax 레지스터를 0으로 초기화하고 반환한다. 그렇기 때문에 이 부분으로 점프하지 않도록 해야 할 것이다.

그 다음으로는 ecx 레지스터에 저장된 첫 글자를 edx 레지스터에 복사한다. 그리고 eax 레지스터에서 ebx 레지스터를 빼는데 별 의미를 알 수 없는 값이기 때문에 분석하지 않는다. ecx 레지스터에는 [ebp+C]에 저장된 값을 복사하는데 이는 "_my_b" 문자열의 주소다. 그리고 edx 레지스터에 저장된 첫 글자를 다시 test dx, dx하여 je 분기를 타지 않는다. 만약 이 0x00B42518 분기를 탔다면 일정 로직을 스킵하고 다시 위로 돌아가서 반복문을 수행하는데 이 분기를 탈 조건이면 이미 위에서 eax를 초기화하는 로직에 걸리기 때문에 신경쓰지 않는 게 좋다.

비슷하게 edx 레지스터에 ecx 레지스터가 가리키는 값을 word 만큼, 즉 2바이트만큼 복사한다. 2바이트를 복사했는데 "_" 한 글자만 복사된 이유는 문자열의 문자들이 각각 2바이트씩 차지하고 있기 때문이다. MFC라서 그런지 문자가 기존 char 형의 1바이트가 아니라 한글이나 다른 언어도 표현할 수 있도록 유니코드 2바이트씩 차지하고 있는 것 같다. 아무튼 test dx, dx는 0이 아니기 때문에 ZF를 세우지 않아 je 분기를 타지 않아 계속 진행할 수 있다. 이 0x00B42530은 함수를 종료하는 분기기 때문에 아직은 타지 않는 것이 좋다.

이후에는 ebx 레지스터에 eax, ecx 레지스터를 더한 곳이 가리키는 곳에서 word만큼 복사하는데 eax 레지스터에는 아까 eax(사용자 입력 문자열) - ebx("_my_b") 연산된 알 수 없는 값이 저장되어 있다. 여기에 ecx 레지스터("_my_b")를 다시 더하면 eax+ecx 레지스터는 원래 값인 사용자 입력 문자열을 가리키게 되며 여기서 word만큼 복사하여 ebx 레지스터에 저장한다는 것은 사용자 입력의 첫 글자를 저장한다는 것이다. 그리고 ebx 레지스터에서 edx 레지스터를 빼서 jne로 분기를 타는데 0x00B42518로 점프하게 되면 일정 로직을 스킵하기 때문에 일단 이 분기를 타지 않는 방향으로 진행하도록 한다. 그러려면 어떻게 해야 할까? 일단 ebx 레지스터에서 edx 레지스터를 뺀다는 것이 무엇인지 생각해봐야 한다.

 

이는 ebx 레지스터가 가리키고 있는 우리가 입력한 문자열의 첫번째 글자와 edx 레지스터가 가리키고 있는 "_my_b" 문자열의 첫번째 글자를 비교하는 것이다. 만약 두 글자가 같다면 sub ebx, edx를 해서 0이 되면서 ZF가 세워지기 때문에 jne는 분기하지 않을 것이다. 그렇다면 이렇게 한 글자씩 비교한다는 것은 "_my_b"와 일정부분 매칭을 하는 게 아닐까? 추측이지만 한번 확인해보기 위해 기존에 입력한 문자열 "1234567890123" 대신 "_my_b67890123"을 입력해보았다.

일단 확실히 문자열은 같기 때문에 분기는 타지 않게됐다. 계속 진행해보자.

이번엔 ecx 레지스터를 2 증가시키고 eax 레지스터 + ecx 레지스터가 가리키는 곳과 bx 레지스터를 비교하고 있다. 일단 eax 레지스터에는 아까처럼 합산되기 전 값이 들어있기 때문에 ecx 레지스터와 합하면 우리가 입력한 문자열을 가리키게 되는데 이번에는 ecx 레지스터가 2 증가했기 때문에 [eax+ecx]는 두번째 글자(한 글자는 2바이트씩 차지하고 있다!)를 가리키게 된다. 이를 0x00을 저장하고 있는 ebx 레지스터와 비교하면 당연히 다르기 때문에 jne 분기로 0x00B424FD로 이동하게 되는데 이는 위쪽에서 "_my_b"를 한 글자씩 가져와서 test dx, dx하던 곳이다. 저곳도 그렇고 지금도 그렇고 한 글자씩 가져오면서 가져온 글자가 0x00인 경우를 비교하거나 test로 0x00인지 확인하는게 많은데 왜 그럴까? 아마 이는 null-termination string으로 확인하기 때문일 것이다.

C언어에서도 그렇지만 문자열은 어디까지가 문자열의 끝인지 구문할 수 없기 때문에 맨 마지막을 어느 경우에서나 입력 불가능한, 그리고 입력할 필요가 없는 null 문자(\x00)으로 채워서 해당 문자열의 끝을 표시한다. 이번 MFC 프로그램의 경우도 "_my_b"나 "birth" 문자열이 저장된 곳을 확인해보면 2바이트씩 문자가 저장된 공간에 0x00이 저장된 것을 확인할 수 있다(편의상 \x00, 0x00 등 여러 방식으로 16진수 문자 표기).

 

그렇다면 어셈블리에서 이 문자열의 끝을 확인할 수 있는 로직은 무엇일까? 바로 한 글자씩 가져오면서 가져온 값이 0인지 확인하는 것이다. 문자열 "0"은 ASCII로 0x30이지만 값 0은 null이다. 그러므로 이 값을 test하거나 0x00과 cmp하여 문자열의 맨 끝을 만났는지 확인하는 것이다.

 

그럼 일단 이 아래쪽의 cmp word ptr ds:[eax+ecx], bx는 우리가 입력한 문자열의 끝을 확인하는 것 같고 위의 test dx, dx에서는 "_my_b" 문자열의 끝을 확인하는 것 같다. 일단 "_my_b"를 모두 확인하고 널을 가져올 때까지 코드를 진행시켜보자.

분기에 의해 ret 명령어까지 수행하면서 해당 함수를 종료하게 되었다. 그럼 남은 문자열은 어떻게 되는 걸까? 아마 다른 함수에서 한번 더 비교하리라 추측된다. 일단 이전과는 다르게 함수가 0이 아닌 값을 반환하게 되면서 test eax, eax는 ZF를 세우지 않는다. 이후 함수가 반환한 값을 특정 값과 비교하여 일치하는지 확인하는 단순한 작업을 거친 후 xor bl, bl을 건너뛰고 계속 진행함으로써 2차 검증 로직("_my_b" 문자열 탐지)을 통과할 수 있었다.

 

이후 우리가 입력한 문자열을 스택에 삽입하고 다른 함수를 호출한다. 이를 따라가면 다시 다른곳에 위치한 함수를 호출하는데 몇번이고 함수 내에서 함수를 호출하며 경로가 복잡해지기 때문에 분석하는 것은 그만두었다.

일단 함수의 결과값은 0x45A, 즉 1114와 비교하고 있기 때문에 해당 함수에서 어떤 동작을 하는지는 모르겠지만 수행 결과가 1114가 나와야 한다는 것은 알아두고 분기를 je에서 jne로 어셈블하여 넘어갔다.

그 다음에는 "birth" 문자열과 우리가 입력한 문자열을 다시 스택에 삽입하고 0x00B424D3 함수를 호출하고 있다. 그런데 이 함수는 아까 위에서 한참동안 분석했던 그 함수가 아닌가? 그때는 "_my_b" 문자열과 비교했다면 이번에는 "birth" 문자열과 비교하고 있다. 아까는 우리가 입력한 문자열이 "_my_b"로 시작했기 때문에 해당 함수를 통과한 줄 알았는데 이번 "birth"는 어떻게 통과할 수 있는 걸까? 여기서 떠오른 생각은 이 0x00B424D3 함수가 우리가 입력한 문자열의 시작부터 한 글자씩 위치도 동일하게 비교하는게 아니라 우리가 입력한 문자열이 "_my_b"나 "birth" 문자열을 단순히 포함하고 있는지 검사하고 있는 중일지도 모른다는 것이다.

 

아까 "_my_b" 문자열을 검색했을 때 우리가 입력한 문자열을 다 검사하지 않고도 "_my_b" 문자열 만큼 매칭하자마자 함수가 종료되었다. 만약 초반 5글자만 비교하고자 했다면 5를 기록하는 카운터나 문자열을 잘라서 비교하지 않았을까? 이 추측을 실제로 확인해보기 위해 "_my_b"와 "birth"를 합친 "_my_birth"를 포함한 문자열을 입력해보았다.

추측대로 해당 문자열을 포함하고 있으면 0이 아닌 값을 반환하기 때문에 test eax, eax를 통과할 수 있었다. 추측이 맞아떨어졌는데 반환값도 살펴보면 strstr() 같은 함수처럼 탐색 대상 문자열 내의 탐색 문자열의 위치를 반환하고 있었다. 그렇기 때문에 더욱 더 이 함수가 문자열 탐색 함수라는 확신을 가질 수 있었다.

하지만 esi 레지스터와 0xE를 비교하는 맨 마지막 루틴에서 막히게 되었다. 아마 위에서 0x45A와 비교하던 함수를 스킵하고 넘어왔기 때문에 하나가 부족한 게 아닌가 생각되는데 그렇다면 어떻게 통과할 수 있을까? 여기서 또 하나의 추측은 지금 우리가 입력한 문자열을 보고 생각할 수 있었다.

 

지금 입력한 문자열은 "_my_birth1234"다. 영어를 해석하면"_내_생일1234"정도가 되는데 지금 내가 1234를 적은것은 프로그램에서 요구하는 문자열의 길이를 맞추기 위해 패딩으로 집어넣은 것이다. 하지만 이 4글자의 숫자와 '생일'이란 단어를 생각하면 무엇이 떠오르는가? 나는 생일의 월과 일이 생각났다. 예를 들어 12월 20일 생일이면 1220처럼 표현하는 방식 말이다. 아까 위에 분석하지 못한 함수에서 요구되는 반환값은 0x45A, 즉 1114다. 그렇다면 이는 11월 14일을 의미하는 게 아닐까? 비밀번호가 말하고자 하는 것은 "11월 14일은 내 생일", 즉 "1114_my_birth"일지도 모른다. 이를 입력해봤더니 다음처럼 플래그를 얻을 수 있었다.

그렇다면 아까 위에서 분석하지 못했던 함수는 문자열에서 정수를 파싱해서 반환하는 함수였던 게 아닐까? 확인을 위해 "1234_my_birth"처럼 입력하고 아까 그 함수의 반환값을 확인해보았다.

함수의 실행 결과는 0x4D2, 즉 정수로 1234가 나왔다. 그렇기 때문에 이 0x00B424A9 함수는 우리가 입력한 문자열에서 정수를 파싱해서 반환하는 함수라고 확신할 수 있었다. 함수 내부 코드를 직접 분석하지 않고 이런저런 값을 넣어보면서 반환값을 통해 함수의 동작을 파악하는 방법도 시간 절약도 되고 괜찮은 것 같다.

 

 

이번 문제는 게싱(guessing)이 문제 해결의 중요한 열쇠가 되었다. 아까 헷갈렸던 문자열 포함 검사 함수라던지 0x45A의 의미 등 birth와 관련된 데이터를 생각하면서 추측으로 입력해봤는데 문제를 풀 수 있었기 때문에 프로그램을 완전하게 분석하지 않고도 풀 수 있었다. 만약 프로그램의 주요 로직을 100% 분석했다면 더 좋았겠지만 100%로 분석하든 게싱으로 분석하든 어쨌든 플래그를 얻었으면 동일한 게 아닐까?

리버스 엔지니어링 오픈채팅방에서 봤던 "우리가 굳이 알아야 할 필요가 있을까요?" 라는 채팅이 떠오르는 문제였다.

'챌린지 > Wargame.kr' 카테고리의 다른 글

bughela - pyc decompile  (0) 2021.01.06
bughela - web chatting  (0) 2021.01.04
bughela - php? c?  (0) 2020.12.29
bughela - img recovery  (0) 2020.12.28
bughela - type confusion  (0) 2020.12.27