Reversing Engineering - 7. 쉬운 crackme를 통한 디버거 사용법 - 2
# 프롤로그
- 문제는 어떤 값을 입력을 받아 특정 조건을 만족하면 correct!을 출력하고 틀리면 wrong!을 출력함
# 문제 풀이 - 0
- 처음 문제를 실행하면 input: 이라는 문자열이 출력됨
- 문제 지문에서 어떠한 형식으로 값을 입력받는지에 대해 알려주지 않았기 때문에 적당히 아무 문자열을 넣고 enter를 누르면 wrong!이 뜨는 것을 볼 수 있음
- 어떠한 형식으로 입력을 받는지는 모르지만, 문제의 형태가 이전 강의에서 풀이한 문제와 비슷
> 다음과 같은 방식으로 분석할 수 있을 것임
1. 메인함수를 찾아낸다
2. 입력의 형식이 어떤 형식인지 알아낸다
3. 입력받은 값은 어떤 방식으로 처리하는지 알아낸다
# 문제 풀이 - 1, 메인 함수 찾기
- 문제 분석에 앞서 ctrl + a 또는 마우스 오른쪽 클릭 시 나오는 분석 - 모듈 분석을 통해서 모듈 분석을 해주어야 함
- 문자열 찾기를 통해 메인 함수를 찾을 수 있음
> 문자열 분석을 실행하면 input: 이라는 문자열이 있는 것을 확인할 수 있음
> 더블 클릭으로 해당 문자열을 참조하는 부분으로 이동하여 해당 함수를 살펴보면 correct!, wrong!
의 문자열도 참조하는 부분을 볼 수 있음
# 문제 풀이 - 2, 메인 함수 분석
1. 스택을 확장함
#1
1400012F0 | push rdi |
1400012F2 | sub rsp,30 |
2. malloc(0x10)을 호출하고, 그 결과를 [rsp+0x20]에 저장함. 해당 값을 임시로 buf로 이름짓겠음
#2
1400012F6 | mov ecx,10 |
1400012FB | call qword ptr ds:[<&malloc>] |
140001301 | mov qword ptr ss:[rsp+20],rax |
- 코드로 나타내면 아래와 같음
char* buf = malloc(0x10);
3. rdi에 buf 주소를 복사한 뒤, eax를 0으로 설정하고 ecx를 0x10으로 설정한 다음 rep stosb 명령어를 실행
#3
140001306 | mov rdi,qword ptr ss:[rsp+20] |
14000130B | xor eax,eax |
14000130D | mov ecx,10 |
140001312 | rep stosb |
> rep stosb 명령어를 찾아보면 Fill (E)CX bytes at ES:[(E)DI] with AL라고 나옴
> #3의 경우를 생각해보면 buf주소에 0x10 바이트만큼 0으로 채우는 명령어가 됨
> 이를 버퍼를 초기화 할 때 자주 쓰이는 memset 함수가 최적화가 적용된 형태로 기억해두는 것이 좋음
memset(buf, 0, 0x10);
4. 인자에 input: 주소를 넣고 sub_140001060를 호출
#4
140001314 | lea rcx,qword ptr ds:[140003250] | 0000000140003250:"input: "
14000131B | call <easy-crackme2.sub_140001060> |
> 프로그램의 실행결과를 통해 sub_140001060을 분석하지 않아도 문자열을 출력하는 함수라는 것을 알 수 있음
sub_140001060("input: "); // sub_140001060 is function like printf
5. _fgetchar를 호출함
#5
140001320 | call qword ptr ds:[<&_fgetchar>] |
> getchar와 동일한 작동을 하는 함수라는 것을 알 수 있음
> 이때 입력받은 값은 al에 저장되게 되는데 이 값은 140001334에서 사용하게 됨
6. 복잡해 보이지만 하나씩 분석해보면 간단함
#6
140001326 | mov ecx,1 |
14000132B | imul rcx,rcx,0 |
14000132F | mov rdx,qword ptr ss:[rsp+20] | rdx:EntryPoint
140001334 | mov byte ptr ds:[rdx+rcx],al |
- 간단하게 바꿔본 결과
ecx = 1
rcx = rcx * 0
rdx = buf // [rsp+0x20]
[rdx + ecx] = al // _fgetchar() return value
- 더 간단하게 표현한 결과
buf[0] = _fgetchar();
7. #6과 전부 비슷한 형태
#7
140001337 | mov eax,1 |
14000133C | imul rax,rax,1 | rax:EntryPoint
140001340 | mov rcx,qword ptr ss:[rsp+20] |
140001345 | mov byte ptr ds:[rcx+rax],64 | 64:'d'
140001349 | mov eax,1 |
14000134E | imul rax,rax,2 | rax:EntryPoint
140001352 | mov rcx,qword ptr ss:[rsp+20] |
140001357 | mov byte ptr ds:[rcx+rax],77 | 77:'w'
14000135B | mov eax,1 |
140001360 | imul rax,rax,3 | rax:EntryPoint
140001364 | mov rcx,qword ptr ss:[rsp+20] |
140001369 | mov byte ptr ds:[rcx+rax],73 | 73:'s'
14000136D | mov eax,1 |
140001372 | imul rax,rax,4 | rax:EntryPoint
140001376 | mov rcx,qword ptr ss:[rsp+20] |
14000137B | mov byte ptr ds:[rcx+rax],71 | 71:'q'
14000137F | mov eax,1 |
140001384 | imul rax,rax,5 | rax:EntryPoint
140001388 | mov rcx,qword ptr ss:[rsp+20] |
14000138D | mov byte ptr ds:[rcx+rax],61 | 61:'a'
140001391 | mov eax,1 |
140001396 | imul rax,rax,6 | rax:EntryPoint
14000139A | mov rcx,qword ptr ss:[rsp+20] |
14000139F | mov byte ptr ds:[rcx+rax],77 | 77:'w'
1400013A3 | mov eax,1 |
1400013A8 | imul rax,rax,7 | rax:EntryPoint
1400013AC | mov rcx,qword ptr ss:[rsp+20] |
1400013B1 | mov byte ptr ds:[rcx+rax],64 | 64:'d'
1400013B5 | mov eax,1 |
1400013BA | imul rax,rax,8 | rax:EntryPoint
1400013BE | mov rcx,qword ptr ss:[rsp+20] |
1400013C3 | mov byte ptr ds:[rcx+rax],75 | 75:'u'
> #6에서는 _fgetchar 함수의 결과를 넣었다면 #7에서는 각각 고정된 값을 넣는다는 것
buf[1] = 'd';
buf[2] = 'w';
buf[3] = 's';
buf[4] = 'q';
buf[5] = 'a';
buf[6] = 'w';
buf[7] = 'd';
buf[8] = 'u';
8. 첫 번째 인자에 buf([rsp+0x20])를 넣고 sub_1400011E0를 호출한 다음 그 결과가 0보다 크면 correct!, 0이면 wrong!을 출력함
#8
1400013C7 | mov rcx,qword ptr ss:[rsp+20] |
1400013CC | call <easy-crackme2.sub_1400011E0> |
1400013D1 | test eax,eax |
1400013D3 | je easy-crackme2.1400013E4 |
1400013D5 | lea rcx,qword ptr ds:[140003258] | 0000000140003258:"correct!"
1400013DC | call qword ptr ds:[<&puts>] |
1400013E2 | jmp easy-crackme2.1400013F1 |
1400013E4 | lea rcx,qword ptr ds:[140003264] | 0000000140003264:"wrong!"
1400013EB | call qword ptr ds:[<&puts>] |
- 코드로 나타내면 아래와 같음
if(sub_1400011E0(buf)) {
puts("correct!");
}
else {
puts("wrong!");
}
9. 메인 함수의 리턴값을 0으로 설정하고 스택을 정리한 뒤 리턴함
#9
1400013F1 | xor eax,eax |
1400013F3 | add rsp,30 |
1400013F7 | pop rdi |
1400013F8 | ret
- 코드로 나타내면 아래와 같음
return 0;
# 문제 풀이 - 3, 메인 함수 분석
- 분석한 것을 정리하면 아래와 같음
int main() {
char* buf = malloc(0x10);
memset(buf, 0, 0x10);
sub_140001060("input: "); // sub_140001060 is printf like function
buf[0] = _fgetchar();
buf[1] = 'd';
buf[2] = 'w';
buf[3] = 's';
buf[4] = 'q';
buf[5] = 'a';
buf[6] = 'w';
buf[7] = 'd';
buf[8] = 'u';
if(sub_1400011E0(buf)) {
puts("correct!");
}
else {
puts("wrong!");
}
return 0;
}
> 입력은 한 글자(_fgetchar)만을 받게 되며 그 위에 정해진 문자열(dwsqawdu)을 이어붙인 다음
sub_140011E0를 호출하여 그 결과를 가지고 correct!와 wrong!을 출력하게 됨
# 문제 풀이 - 4, sub_1400011E0 분석
- 그래프의 크기 상 디스어셈 결과를 가지고 설명할 것이지만, 그래프 창을 띄워놓고 흐름이 어떤 식으로 진행되는지 살펴보는 것이 좋음
문제 풀이 - 5, sub_1400011E0 분석
1. 인자로 받은 rcx를 스택에 저장하고 스택을 확장
#1
1400011E0 | mov qword ptr ss:[rsp+8],rcx |
1400011E5 | sub rsp,48 |
> 스택에 저장될 때는 [rsp+8]에 저장됐지만, 스택을 확장하면서 rsp의 값이 바뀌기 때문에
확장 이후부터는 [rsp+8]이 아닌 [rsp+0x50]로 접근해야 해당 인자에 접근할 수 있음
> 강의에서는 임의로 인자를 arg1이라 명명함
> 이때 arg1이 메인함수에서 문자열 포인터가 들어왔기 때문에 char* 타입이라는 것을 알 수 있음
int sub_1400011E0(char* arg1) {
...
}
2. 변수([rsp+0x24], 4바이트)를 0으로 초기화한 후 8바이트로 부호 있는 확장(movsxd)을 거친 후 arg1의 인덱스로 사용하여 값을 가져옴
#2
1400011E9 | mov dword ptr ss:[rsp+24],0 |
1400011F1 | movsxd rax,dword ptr ss:[rsp+24] |
1400011F6 | mov rcx,qword ptr ss:[rsp+50] |
1400011FB | movzx ecx,byte ptr ds:[rcx+rax] |
1400011FF | call <easy-crackme2.sub_1400010C0>|
140001204 | mov dword ptr ss:[rsp+20],eax |
> 그 후 가져온 값을 첫 번째 인자로 넣어 sub_1400010C0을 호출한 다음,
sub_1400010C0 함수의 리턴값을 [rsp+0x20]에 저장
> [rsp+0x20]을 tmp1, [rsp+0x24]를 tmp2로 정함
int tmp2 = 0;
int tmp1 = sub_1400010C0(arg1[tmp2]);
3. tmp2([rsp+0x24])를 가져와 1을 증가한 후 다시 저장
#3
140001208 | mov eax,dword ptr ss:[rsp+24] |
14000120C | inc eax |
14000120E | mov dword ptr ss:[rsp+24],eax |
> 이러한 형태의 어셈블리어가 보이면 해당 변수가 반복문에 사용되는 카운터일 가능성이 매우 높음
> 따라서 편의를 위해 tmp2 변수의 이름을 counter로 변경할 것임
int counter = 0;
int tmp1 = sub_1400010C0(arg1[counter]);
counter++;
# 문제 풀이 - 6, sub_1400011E0
4. counter([rsp+0x24])를 가져와서 arg1([rsp+0x50])의 인덱스로 사용하여 값을 가져온 다음 해당 값이 0인지 확인
#4
140001212 | movsxd rax,dword ptr ss:[rsp+24] |
140001217 | mov rcx,qword ptr ss:[rsp+50] |
14000121C | movzx eax,byte ptr ds:[rcx+rax] |
140001220 | test eax,eax |
140001222 | je easy-crackme2.1400012CA |
...
> 만약 0일 경우 1400012CA로 점프를 뛰게 됨
if(arg1[counter] == 0) {
...
}
else {
...
}
5. #4에서 arg1[counter]==0 일 때 이 부분으로 오게 됨
#5
1400012CA | cmp dword ptr ss:[rsp+20],5B | 5B:'['
1400012CF | jne easy-crackme2.1400012DB |
1400012D1 | mov dword ptr ss:[rsp+30],1 |
1400012D9 | jmp easy-crackme2.1400012E3 |
1400012DB | mov dword ptr ss:[rsp+30],0 |
1400012E3 | mov eax,dword ptr ss:[rsp+30] |
1400012E7 | add rsp,48 |
1400012EB | ret
> 해당 부분은 tmp1([rsp+0x20])의 값이 0x5b(91)일 경우 1을, 아닐 경우 0을 리턴하는 코드
> 이 문제에서 sub_1400011E0 함수가 1을 리턴해야 메인함수에서 correct!을 출력하기 때문에
tmp1의 값은 0x5b가 되어야 함
if(arg1[counter] == 0) {
if(tmp1 == 0x5b)
return 1;
else
return 0;
}
else {
...
}
# 문제 풀이 - 7, sub_1400011E0 분석
6. #4에서 arg1[counter]==0 을 만족하지 않을 때 이 부분으로 오게 됨
#6
140001228 | movsxd rax,dword ptr ss:[rsp+24] |
14000122D | mov rcx,qword ptr ss:[rsp+50] |
140001232 | movzx eax,byte ptr ds:[rcx+rax] |
140001236 | mov dword ptr ss:[rsp+34],eax |
> counter([rsp+0x24])를 가져와서 arg1([rsp+0x50])의 인덱스로 사용하여 값을 가져온 다음
해당 값을 [rsp+0x34]에 넣음
> 편의를 위해 [rsp+0x34] 값을 tmp3으로 명명함
int tmp3 = arg1[counter];
7. counter([rsp+0x24])를 가져와 1 증가한 후 arg1([rsp+0x50])의 인덱스로 사용하여 값을 가져옴
#7
14000123A | mov eax,dword ptr ss:[rsp+24] |
14000123E | inc eax |
140001240 | cdqe |
140001242 | mov rcx,qword ptr ss:[rsp+50] |
140001247 | movzx ecx,byte ptr ds:[rcx+rax] |
14000124B | call <easy-crackme2.sub_1400010C0> |
140001250 | mov dword ptr ss:[rsp+2C],eax |
> 이후 해당 값을 첫 번째 인자로 하여 sub_1400010C0을 호출
> 그리고 함수의 결과값을 [rsp+0x2c]에 저장
> 편의를 위해 [rsp+0x2c]를 tmp4로 명명
int tmp4 = sub_1400010C0(arg1[counter + 1]);
8. 아까 [rsp+0x34]에 저장했던 값을 [rsp+0x28]에 저장
#8
140001254 | mov eax,dword ptr ss:[rsp+34] |
140001258 | mov dword ptr ss:[rsp+28],eax |
> 편의를 위해 [rsp+0x28] 값을 tmp5라고 명명
int tmp5 = tmp3;
# 문제 풀이 - 8, sub_1400011E0 분석
9. 이 부분은 분기문들의 집합임
14000125C | cmp dword ptr ss:[rsp+28],61 | 61:'a'
140001261 | je easy-crackme2.14000127A | -> #10
140001263 | cmp dword ptr ss:[rsp+28],64 | 64:'d'
140001268 | je easy-crackme2.14000129E | -> #12
14000126A | cmp dword ptr ss:[rsp+28],66 | 66:'f'
14000126F | je easy-crackme2.1400012AD | -> #13
140001271 | cmp dword ptr ss:[rsp+28],73 | 73:'s'
140001276 | je easy-crackme2.14000128C | -> #11
140001278 | jmp easy-crackme2.1400012BA | -> #14
> tmp5([rsp+0x28]) 값을 비교하며 다음과 같이 분기함
if(tmp5 == 'a') goto #10;
else if(tmp5 == 'd') goto #12;
else if(tmp5 == 'f') goto #13;
else if(tmp5 == 's') goto #11;
else goto #14;
* #10 ~ #13 부분을 살펴보면 전부 각각의 동작을 한 다음 #14로 이동하는 것을 확인할 수 있음
10. tmp4([rsp+0x2c])와 tmp1([rsp+0x20]) 값을 가져와 두 값을 더한 후 다시 tmp1([rsp+0x20])에 저장
#10
14000127A | mov eax,dword ptr ss:[rsp+2C] |
14000127E | mov ecx,dword ptr ss:[rsp+20] |
140001282 | add ecx,eax |
140001284 | mov eax,ecx |
140001286 | mov dword ptr ss:[rsp+20],eax |
14000128A | jmp easy-crackme2.1400012BA | -> #14
> 이후 #14로 이동함
tmp1 += tmp4;
11. tmp4([rsp+0x2c])와 tmp1([rsp+0x20]) 값을 가져와 두 값을 뺀 다음 다시 tmp1([rsp+0x20])에 저장
#11
14000128C | mov eax,dword ptr ss:[rsp+2C] |
140001290 | mov ecx,dword ptr ss:[rsp+20] |
140001294 | sub ecx,eax |
140001296 | mov eax,ecx |
140001298 | mov dword ptr ss:[rsp+20],eax |
14000129C | jmp easy-crackme2.1400012BA | -> #14
> 이후 #14로 이동함
tmp1 -= tmp4;
12. tmp4([rsp+0x2c])와 tmp1([rsp+0x20]) 값을 가져와 두 값을 곱한 다음 다시 tmp1([rsp+0x20])에 저장
#12
14000129E | mov eax,dword ptr ss:[rsp+20] |
1400012A2 | imul eax,dword ptr ss:[rsp+2C] |
1400012A7 | mov dword ptr ss:[rsp+20],eax |
1400012AB | jmp easy-crackme2.1400012BA | -> #14
> 이후 #14로 다시 이동
tmp1 *= tmp4;
13. tmp4([rsp+0x2c])와 tmp1([rsp+0x20]) 값을 가져와 두 값을 나눈 다음 다시 tmp1([rsp+0x20])에 저장
#13
1400012AD | mov eax,dword ptr ss:[rsp+20] |
1400012B1 | cdq |
1400012B2 | idiv dword ptr ss:[rsp+2C] |
1400012B6 | mov dword ptr ss:[rsp+20],eax | -> #14
> 이후 #14로 이어짐
tmp1 /= tmp4;
14. counter([rsp+0x24])를 가져와 2를 더한 후 다시 counter에 저장
#14
1400012BA | mov eax,dword ptr ss:[rsp+24] |
1400012BE | add eax,2 |
1400012C1 | mov dword ptr ss:[rsp+24],eax |
1400012C5 | jmp easy-crackme2.140001212 | -> #4
> 이후 #4로 점프
counter += 2;
goto #4;
# 문제 풀이 - 9, sub_1400011E0 분석
- 지금까지 분석한 결과
int sub_1400011E0(char* arg1) {
int counter = 0;
int tmp1 = sub_1400010C0(arg1[counter]);
counter++;
label1: // #4
if(arg1[counter] == 0) {
if(tmp1 == 0x5b)
return 1;
else
return 0;
}
else {
int tmp3 = arg1[counter];
int tmp4 = sub_1400010C0(arg1[counter + 1]);
int tmp5 = tmp3;
if(tmp5 == 'a') tmp1 += tmp4; goto label2;
else if(tmp5 == 'd') tmp1 *= tmp4; goto label2;
else if(tmp5 == 'f') tmp1 /= tmp4; goto label2;
else if(tmp5 == 's') tmp1 -= tmp4; goto label2;
else goto label2;
label2: // #14
counter += 2;
goto label1;
}
}
- 위의 코드를 더 쉽게 정리할 수 있는 부분만 정리하면 다음과 같음
int sub_1400011E0(char* arg1) {
int counter = 0;
int tmp1 = sub_1400010C0(arg1[counter]);
counter++;
label1: // #4
if(arg1[counter] == 0) {
return tmp1 == 0x5b;
}
else {
int tmp3 = arg1[counter];
int tmp4 = sub_1400010C0(arg1[counter + 1]);
if(tmp3 == 'a') tmp1 += tmp4; goto label2;
else if(tmp3 == 'd') tmp1 *= tmp4; goto label2;
else if(tmp3 == 'f') tmp1 /= tmp4; goto label2;
else if(tmp3 == 's') tmp1 -= tmp4; goto label2;
else goto label2;
label2: // #14
counter += 2;
goto label1;
}
}
> 9번째 라인부터 20번째 라인까지는 항상 label1로 돌아오게 되는 코드
> 이러한 형태의 코드는 goto 없이 반복문으로 표현 가능
- 다시 정리한 코드
int sub_1400011E0(char* arg1) {
int counter = 0;
int tmp1 = sub_1400010C0(arg1[counter]);
counter++;
while(1) {
if(arg1[counter] == 0) {
return tmp1 == 0x5b;
}
int tmp3 = arg1[counter];
int tmp4 = sub_1400010C0(arg1[counter + 1]);
if(tmp3 == 'a') tmp1 += tmp4; goto label2;
else if(tmp3 == 'd') tmp1 *= tmp4; goto label2;
else if(tmp3 == 'f') tmp1 /= tmp4; goto label2;
else if(tmp3 == 's') tmp1 -= tmp4; goto label2;
else goto label2;
label2: // #14
counter += 2;
}
}
> 6번째 ~ 8번째 라인의 코드가 while문의 탈출 조건임을 알 수 있음
- 위의 사실을 반영하여 다시 정리한 코드
int sub_1400011E0(char* arg1) {
int counter = 0;
int tmp1 = sub_1400010C0(arg1[counter]);
counter++;
while(arg1[counter]) {
int tmp3 = arg1[counter];
int tmp4 = sub_1400010C0(arg1[counter + 1]);
if(tmp3 == 'a') tmp1 += tmp4; goto label2;
else if(tmp3 == 'd') tmp1 *= tmp4; goto label2;
else if(tmp3 == 'f') tmp1 /= tmp4; goto label2;
else if(tmp3 == 's') tmp1 -= tmp4; goto label2;
else goto label2;
label2: // #14
counter += 2;
}
return tmp1 == 0x5b;
}
# 문제 풀이 - 10, sub_1400011E0 분석
- 이전 코드에서 8~12번째 라인을 살펴보면 항상 label2로 점프하며, 조건문에서 항상 tmp3를 변수로 사용
> 이는 C언어 문법에서 switch문과 가장 가깝다고 생각할 수 있음
int sub_1400011E0(char* arg1) {
int counter = 0;
int tmp1 = sub_1400010C0(arg1[counter]);
counter++;
while(arg1[counter]) {
int tmp3 = arg1[counter];
int tmp4 = sub_1400010C0(arg1[counter + 1]);
switch(tmp3){
case 'a': tmp1 += tmp4; break;
case 'd': tmp1 *= tmp4; break;
case 'f': tmp1 /= tmp4; break;
case 's': tmp1 -= tmp4; break;
}
counter += 2;
}
return tmp1 == 0x5b;
}
# 문제 풀이 - 11, sub_1400010C0 분석
1. 인자로 받은 cl을 [rsp+8]에 저장하고 스택을 확장
#1
1400010C0 | mov byte ptr ss:[rsp+8],cl |
1400010C4 | sub rsp,38 |
> [rsp+8]에 저장되었지만 스택이 확장되었기 때문에 이후 인자에 접근하려면 [rsp+0x40]으로 접근해야 함
> 편의를 위해 해당 변수를 arg1으로 명명
int sub_1400010C0(char arg1) {
...
}
2. [rsp+0x20]을 0으로 초기화하고 arg1([rsp+0x40])을 가져와 [rsp+0x24]에 넣고 다시 해당 값을 가져와 0x65만큼 뺀 다음 다시 [rsp+0x24]에 저장
#2
1400010C8 | mov dword ptr ss:[rsp+20],0 |
1400010D0 | movsx eax,byte ptr ss:[rsp+40] |
1400010D5 | mov dword ptr ss:[rsp+24],eax |
1400010D9 | mov eax,dword ptr ss:[rsp+24] |
1400010DD | sub eax,65 |
1400010E0 | mov dword ptr ss:[rsp+24],eax |
> 편의를 위해 [rsp+0x20]를 tmp1, [rsp+0x24]를 tmp2라 명명
int tmp1 = 0;
int tmp2 = arg1;
tmp2 -= 0x65;
3. tmp2([rsp+0x24])를 가져와 0x14보다 크면 #4(140001167)로 점프
#3
1400010E4 | cmp dword ptr ss:[rsp+24],14 |
1400010E9 | ja easy-crackme2.140001167 | -> #4
- 코드로 나타내면 아래와 같음
if(tmp2 > 0x14) goto #4;
4. 첫 번째 인자로 wrong input!\n 문자열의 주소를 지정한 후 sub_140001060 를 호출한 다음 exit 함수를 호출
#4
140001167 | lea rcx,qword ptr ds:[140003240] | 140003240:"wrong input!\n"
14000116E | call <easy-crackme2.sub_140001060> |
140001173 | xor ecx,ecx |
140001175 | call qword ptr ds:[<&exit>] |
> 따라서 이 부분으로 실행 흐름이 오면 안된다는 것을 알 수 있음
sub_140001060("wrong input!\n");
exit(0);
5. 살짝 어려울 수 있는 부분
#5
1400010EB | movsxd rax,dword ptr ss:[rsp+24] |
1400010F0 | lea rcx,qword ptr ds:[140000000] |
1400010F7 | mov eax,dword ptr ds:[rcx+rax*4+1184] |
1400010FE | add rax,rcx |
140001101 | jmp rax |
...
- 어셈블리어 하나씩 의사 코드로 변경
rax = tmp2 // [rsp+24]
rcx = 0x140000000
eax = [rcx+rax*4+0x1184]
rax += rcx
goto rax
- 다시 정리
goto 0x140000000 + [0x140000000 + tmp2 * 4 + 0x118
6. tmp1([rsp+0x20])을 리턴값으로 설정하고 스택을 정리한 후 리턴
#6
14000117B | mov eax,dword ptr ss:[rsp+20] |
14000117F | add rsp,38 |
140001183 | ret
- 코드로 나타내면 아래와 같음
return tmp1;
# 문제 풀이 - 12, sub_1400010C0 분석
goto 0x140000000 + [0x140000000 + tmp2 * 4 + 0x1184]
- 0x140000000의 의미는 언뜻 보면 무엇인지 알 수 없지만 지금까지 분석한 코드가 전부 0x14000...으로 시작하는 걸 통해 분석하고 있는 프로그램의 베이스 주소라는 것을 눈치챌 수 있음
- 코드의 의미
> 0x140000000 + tmp2 * 4 + 0x1184에서 4바이트 값을 가져와서 다시 0x140000000를 더한 후 점프해라
- tmp2를 0x10이라고 가정
> 0x140000000 + tmp2 *4 + 0x1184의 값은 0x1400011c4임
1400011C4 3F 11 00 00 67 11 00 00 0D 11 00 00 67 11 00 00 ?...g.......g...
> 앞 4바이트를 리틀엔디안으로 읽어오면 0x113f
> 다시 이 값에 0x140000000을 더하면 0x14000113f임
> 해당 값으로 가보면 다음과 같은 코드가 있음
14000113F | mov dword ptr ss:[rsp+20],7 |
140001147 | jmp easy-crackme2.14000117B | -> #6
> tmp1([rsp+0x20])을 7로 설정한후 #6 부분으로 점프
> #6 부분은 tmp1을 리턴하는 코드
→ 정리하자면 goto 0x140000000 + [0x140000000 + tmp2 * 4 + 0x1184] 코드는, 0x140001148에 있는 점프 테이블을 통해 tmp2의 값에 따라 어디론가 점프를 뛰는 코드임
# 문제 풀이 - 13, sub_1400010C0 분석
- 0x140001184에 있는 점프테이블을 하나씩 따라가 보면 전부 오른쪽과 같은 코드 영역으로 점프를 뛰는 형태임을 알 수 있음
140001103 | mov dword ptr ss:[rsp+20],1 |
14000110B | jmp easy-crackme2.14000117B | -> #6
14000110D | mov dword ptr ss:[rsp+20],2 |
140001115 | jmp easy-crackme2.14000117B | -> #6
140001117 | mov dword ptr ss:[rsp+20],3 |
14000111F | jmp easy-crackme2.14000117B | -> #6
140001121 | mov dword ptr ss:[rsp+20],4 |
140001129 | jmp easy-crackme2.14000117B | -> #6
14000112B | mov dword ptr ss:[rsp+20],5 |
140001133 | jmp easy-crackme2.14000117B | -> #6
140001135 | mov dword ptr ss:[rsp+20],6 |
14000113D | jmp easy-crackme2.14000117B | -> #6
14000113F | mov dword ptr ss:[rsp+20],7 |
140001147 | jmp easy-crackme2.14000117B | -> #6
140001149 | mov dword ptr ss:[rsp+20],8 |
140001151 | jmp easy-crackme2.14000117B | -> #6
140001153 | mov dword ptr ss:[rsp+20],9 |
14000115B | jmp easy-crackme2.14000117B | -> #6
14000115D | mov dword ptr ss:[rsp+20],0 |
140001165 | jmp easy-crackme2.14000117B | -> #6
- 모두 tmp1([rsp+0x20])을 어떤 값으로 설정한 다음 #6으로 점프를 뛰는 코드
- tmp2의 값에 따라 어떤 값이 tmp1에 들어가는지 살펴보면 다음과 같음
tmp2 | tmp1 |
0 | 3 |
1 | x |
2 | x |
3 | x |
4 | 8 |
5 | x |
6 | x |
7 | x |
8 | x |
9 | x |
10 | 9 |
11 | 0 |
12 | 1 |
13 | 4 |
14 | x |
15 | 5 |
16 | 7 |
17 | x |
18 | 2 |
19 | x |
20 | 6 |
* x: #4(wrong input을 출력하는 코드)로 점프를 뛰는 부분
# 문제 풀이 - 14, sub_1400010C0 분석
- 지금까지 분석한 sub_1400010C0을 정리하면 다음과 같음
1 int sub_1400010C0(char arg1) {
2 int tmp1 = 0;
3 int tmp2 = arg1;
4 tmp2 -= 0x65;
5 if(tmp2 > 0x14) goto fail;
6 if (tmp2 == 0) goto set3;
7 else if(tmp2 == 4) goto set8;
8 else if(tmp2 == 10) goto set9;
9 else if(tmp2 == 11) goto set0;
10 else if(tmp2 == 12) goto set1;
11 else if(tmp2 == 13) goto set4;
12 else if(tmp2 == 15) goto set5;
13 else if(tmp2 == 16) goto set7;
14 else if(tmp2 == 18) goto set2;
15 else if(tmp2 == 20) goto set6;
16 else goto fail; // tmp2 == 1, 2, 3, 5, 6, 7, 8, 9, 14, 17, 19
17 set0:
18 tmp1 = 0; goto ret;
19 set1:
20 tmp1 = 1; goto ret;
21 set2:
22 tmp1 = 2; goto ret;
23 set3:
24 tmp1 = 3; goto ret;
25 set4:
26 tmp1 = 4; goto ret;
27 set5:
28 tmp1 = 5; goto ret;
29 set6:
30 tmp1 = 6; goto ret;
31 set7:
32 tmp1 = 7; goto ret;
33 set8:
34 tmp1 = 8; goto ret;
35 set9:
36 tmp1 = 9; goto ret;
37 ret:
38 return tmp1;
39 fail:
40 sub_140001060("wrong input!\n");
41 exit(0);
42 }
> 6 ~ 38번째 라인이 점프 테이블 관련 코드
> 특정 값들(0, 4, 10, 11, 12, 13, 15, 16, 18, 20)은 전부 tmp1을 어떤 값으로 설정하게 되고,
나머지 값들은 fail 라벨로 점프를 뛰는 것을 확인할 수 있음
> C언어에서의 switch-case-default 구문과 비슷하다나는 것을 알 수 있음
# 문제 풀이 - 15, sub_1400010C0 분석
- switch-case-default 구문으로 바꾼 코드
1 int sub_1400010C0(char arg1) {
2 int tmp1 = 0;
3 int tmp2 = arg1;
4 tmp2 -= 0x65;
5 if(tmp2 > 0x14) goto fail;
6 switch(tmp2) {
7 case 0: tmp1 = 3; break;
8 case 4: tmp1 = 8; break;
9 case 10: tmp1 = 9; break;
10 case 11: tmp1 = 0; break;
11 case 12: tmp1 = 1; break;
12 case 13: tmp1 = 4; break;
13 case 15: tmp1 = 5; break;
14 case 16: tmp1 = 7; break;
15 case 18: tmp1 = 2; break;
16 case 20: tmp1 = 6; break;
17 default: goto fail
18 }
19 return tmp1;
20 fail:
21 sub_140001060("wrong input!\n");
22 exit(0);
23 }
> 6번 라인의 조건문을 삭제해도 됨
→ 0x14(20)보다 큰 값이 switch문으로 들어가면 무조건 default구문으로 이동하기 때문
- 6번 라인을 삭제한 코드
int sub_1400010C0(char arg1) {
int tmp1 = 0;
int tmp2 = arg1;
tmp2 -= 0x65;
switch(tmp2) {
case 0: tmp1 = 3; break;
case 4: tmp1 = 8; break;
case 10: tmp1 = 9; break;
case 11: tmp1 = 0; break;
case 12: tmp1 = 1; break;
case 13: tmp1 = 4; break;
case 15: tmp1 = 5; break;
case 16: tmp1 = 7; break;
case 18: tmp1 = 2; break;
case 20: tmp1 = 6; break;
default: {
sub_140001060("wrong input!\n");
exit(0);
}
}
return tmp1;
}
# 문제 풀이 - 14, sub_1400010C0 분석
- tmp2의 변수와 0x65를 빼는 부분을 삭제하면 코드가 더 간결해짐
> 즉, 이 함수는 어떤 문자 한 글자를 받아 다음과 같은 규칙에 따라 숫자를 리턴하는 함수
q | 1 |
w | 2 |
e | 3 |
r | 4 |
t | 5 |
y | 6 |
u | 7 |
i | 8 |
o | 9 |
p | 0 |
int sub_1400010C0(char arg1) {
int tmp1 = 0;
switch(arg1) {
case 0x65: tmp1 = 3; break; // e
case 0x69: tmp1 = 8; break; // i
case 0x6f: tmp1 = 9; break; // o
case 0x70: tmp1 = 0; break; // p
case 0x71: tmp1 = 1; break; // q
case 0x72: tmp1 = 4; break; // r
case 0x74: tmp1 = 5; break; // t
case 0x75: tmp1 = 7; break; // u
case 0x77: tmp1 = 2; break; // w
case 0x79: tmp1 = 6; break; // y
default: {
sub_140001060("wrong input!\n");
exit(0);
}
}
return tmp1;
}
# 문제 풀이 - 17, 코드 해석
- 지금까지 분석한 내용
int sub_1400010C0(char arg1) {
int tmp1 = 0;
switch(arg1) {
case 0x65: tmp1 = 3; break; // e
case 0x69: tmp1 = 8; break; // i
case 0x6f: tmp1 = 9; break; // o
case 0x70: tmp1 = 0; break; // p
case 0x71: tmp1 = 1; break; // q
case 0x72: tmp1 = 4; break; // r
case 0x74: tmp1 = 5; break; // t
case 0x75: tmp1 = 7; break; // u
case 0x77: tmp1 = 2; break; // w
case 0x79: tmp1 = 6; break; // y
default: {
sub_140001060("wrong input!\n");
exit(0);
}
}
return tmp1;
}
int sub_1400011E0(char* arg1) {
int counter = 0;
int tmp1 = sub_1400010C0(arg1[counter]);
counter++;
while(arg1[counter]) {
int tmp3 = arg1[counter];
int tmp4 = sub_1400010C0(arg1[counter + 1]);
switch(tmp3){
case 'a': tmp1 += tmp4; break;
case 'd': tmp1 *= tmp4; break;
case 'f': tmp1 /= tmp4; break;
case 's': tmp1 -= tmp4; break;
}
counter += 2;
}
return tmp1 == 0x5b;
}
int main(){
char* buf = malloc(0x10);
memset(buf, 0, 0x10);
sub_140001060("input: "); // sub_140001060 is function like printf
buf[0] = _fgetchar();
buf[1] = 'd';
buf[2] = 'w';
buf[3] = 's';
buf[4] = 'q';
buf[5] = 'a';
buf[6] = 'w';
buf[7] = 'd';
buf[8] = 'u';
if(sub_1400011E0(buf)) {
puts("correct!");
}
else {
puts("wrong!");
}
return 0;
}
> 메인함수에서 사용자로부터 입력을 한 글자 받아 dwsqawdu 문자열과 합친 다음, sub_1400011E0을 호출
> 각 문자열을 숫자와 +*/-로 변환하여 연산을 진행
> 그 다음 연산의 결과가 0x5b(91)과 같다면 correct!을 출력
- 사용자의 입력을 ?로 표시하고 각 글자를 변환
? d w s q a w d u
? * 2 - 1 + 2 * 7
- 위의 계산 결과 값이 91이어야 하고 연산은 사칙연산 규칙을 따르지 않고 무조건 앞에서부터 연산하는 방식이기 때문에 역으로 계산해보면 6이 나오는 것을 알 수 있음
((91 / 7) - 2 + 1) / 2 = 6
> 6을 다시 문자열로 변환하면 y가 됨
> 사용자가 y를 입력하면 모든 조건이 충족되어 correct!가 출력됨
- 실행결과
# 문제 풀이 - 18, +a
- 정석적인 풀이대로라면 sub_1400011E0을 분석한 것을 바탕으로 어떤 입력을 넣어야 통과할지를 알아내 문제를 풀어야하지만, 한 글자만을 입력받기 때문에 간단한 스크립트를 이용해 0x00부터 0xff까지 256번 프로그램을 실행하면서 한 글자씩 모두 입력해보는 것으로도 문제를 풀 수 있음
- 이와 같이 문제의 의도와는 별개로 일명 '꼼수'를 이용해서 푸는 경우 굉장히 시간을 단축할 수 있어서 어떻게 하면 좀 더 쉽게 문제를 풀 수 있을지 생각하는 것도 좋은 리버싱 실력 향상 방법 중 하나임
'Hacking Tech > Reversing' 카테고리의 다른 글
[Dreamhack] Reversing Engineering - 6. 쉬운 crackme를 통한 디버거 사용법 - 1 (0) | 2021.12.21 |
---|---|
[Dreamhack] Reversing Engineering - 5. hello-world로 배우는 x64dbg 사용법 (0) | 2021.10.04 |
[Dreamhack] Reversing Engineering - 4. x64dbg (0) | 2021.10.03 |
[Dreamhack] Reversing Engineering - 3. puts("hello world!\n") → x86_64 asm (0) | 2021.10.03 |
[Dreamhack] Reversing Engineering - 2. x64 기초 (0) | 2021.09.19 |