어셈블리 동적 분석 실습

2021. 5. 24. 22:24Layer7/Layer7_Reverse Engineering

어셈블리어와 x64 아키텍처에 관해서 잘 모른다면 어셈블리 - Assembly x64 글을 참고하기 바란다.

 

main 함수를 disassemble 한 결과이다.

 



각 영역이 나눠진 대로 하나하나씩 살펴보자.


 

첫 번째 구간

 

 

push rbp

스택의 가장 위(rsp의 값)에 rbp의 값을 넣는 것이다. 그다음 rsp는 한 칸 올라간다.
현재 rbp에는 0x0이라는 값이 담겨 있다. rbp가 rsp의 위치에 rbp를 넣고 난 뒤를 보니, rsp의 위치에 0x0이 들어갔다. 이것은 SFP를 스택 프레임에 넣기 위한 것이다.

 

 

mov rbp, rsp

맨 밑에 있었던 rbp에 rsp가 있는 주소를 복사함으로써 rbp를 rsp와 같은 위치를 가리키게 끌어올린다. 아래 사진에서 rbp가 rsp가 있는 위치로 올라온 것을 확인할 수 있다.

 


sub rsp, 0x10

rsp가 가리키는 메모리 주소에서 16진수 0x10 (16)을 뺌으로써 rsp의 위치가 16 만큼 올라간다. 메모리 공간 하나가 8bit이므로 16 만큼 올라간다는 것은 두 칸을 올라간다는 것이다.

 

 

mov rax, qword ptr fs:[0x28]

rax에 64bit 값인 fs:[0x28]을 넣는다. 이것은 Stack Canary라는 메모리 보호 기법이 프로그램에 자동으로 적용되므로 나오는 것이다. 스택 영역에서부터 다른 메모리 영역까지 침범하는 오버플로우 공격을 예방하기 위한 것이니 우리가 분석하려는 프로그램의 작동에 영향을 미치지 않아 신경 쓸 필요는 없다.

 

mov qword ptr [rbp - 8], rax

rbp로부터 8만큼 위에 있는 64bit 공간에 rax의 값을 복사한다.
먼저 rax의 값을 확인하자.

 

 

이 값이 rbp 위에 8bit 만큼 떨어진 공간에 저장된다.

 

 

xor eax, eax

eax와 eax에 xor 연산을 수행한 뒤 eax에 저장한다. xor 연산은 이진수 연산으로, 비교하는 두 개의 비트가 서로 다를 경우 1, 서로 같을 경우 0이 되는 논리 연산이다. 따라서 같은 수에 xor 연산을 수행하면 모든 비트가 같으므로 무조건 0이 된다.
결과는 mov eax, 0과 같다.

lea rdi, [rip + 0xde1]

rip 레지스터로부터 0xde1 오프셋만큼 떨어진 주소에 있는 값의 주소 rdi에 넣는다.

 

 

rdi에 들어가는 주소에는 "Enter Num : "라는 문자열이 존재한다. 즉, rdi에는 이 문자열의 주소가 들어간다.
저장하는 레지스터가 rdi라는 것에서 유추할 수 있듯이 이는 뒤에서 호출할 함수의 인자 값을 담고 있는 레지스터이다.

 

mov eax, 0

eax에 0을 넣는다. eax, rax의 역할은 함수가 종료될 때 리턴 값을 저장하는 용도인데, 지금 현재 eax에 0을 복사하고 있는 것을 보니 큰 확률로 이는 나중에 함수의 반환 값이 들어갈 수 있도록 0으로 초기화한 것이다.

 

call printf@plt <printf@plt>

최종적으로 printf라는 함수를 호출한다.

 

첫 번째 구간 정리

먼저 rbp의 값을 넣고 push 해준다. 그다음 rbp는 rsp 자리에 올라오고, rsp는 16만큼 올라간다.
이것은 함수를 실행할 준비를 하는 단계인 Function Prologue이다.
eax에 자기 자신을 xor 함으로써 결괏값이 0이 나온다.
그다음 rip로부터 0xde1 만큼 떨어진 주소에 저장되어 있는 값을 rdi에 복사하는데, rdi는 함수 호출 규약에 의해서 인자 값을 저장하는 역할을 한다. eax에 0을 복사함으로써 함수의 반환 값을 설정하고, 마지막으로 printf 함수를 call 함으로써 printf 함수가 실행된다.
첫 번째 구간에서 printf 함수를 호출하기 위한 준비 과정을 살펴볼 수 있었다.


 

두 번째 구간

 

 

lea rax, [rbp - 0x10]

rax에 rbp-0x10를 넣는다. rbp-0x10은 주소 형태이며, 이를 대괄호로 감싸주어 해당 주소에 있는 값을 가리키게 하고, lea 명령어로 가리키는 값의 주소를 rax에 복사한다. 즉 이는 간단히 말하자면 rbp-0x10를 계산한 주소를 rax에 넣는다.

rbp-0x10를 계산한 주소를 살펴보자 (파란색으로 되어 있는 값):

 

 

이 주소는 다음 명령어에 의해서 rsi에 들어갈 값이다. 즉, 이 주소는 scanf를 호출할 때 사용되는 인자로 사용될 것이라고 추측할 수 있고, 주소 값이므로 scanf("%d", &num); 에서 뒤에 주소를 인자로 주는 부분이라고 생각할 수 있다.


rax에 이러한 값이 들어가는 것을 확인할 수 있다:

 

 

mov rsi, rax

rax의 값을 rsi에 넣는다.
방금 전에 확인했던 rax의 값을 rsi에 똑같이 넣는다.

 

 

lea rdi, [rip + 0xdd6]

rip + 0xdd6의 값을 확인했더니 "Num : "이라는 문자열이 담긴 것을 알 수 있게 되었다. 이 문자열의 주소가 rdi에 들어가는 것이다.

 

 

mov eax, 0

eax의 값을 0으로 설정함으로써 다음에 실행될 scanf 함수의 반환 값을 초기화한다.

 

call __isoc99_scanf@plt <__isoc99_scanf@plt>

본격적으로 scanf 함수를 호출한다.

 

두 번째 구간 정리

rsi에 특정 주소를 넣고 (입력을 받을 변수의 주소, 즉 &num과 같은 것들), rdi에는 "Num : "이라는 문자열의 주소가 담겼다. eax의 값을 0으로 설정하여 반환값을 초기화하고 scanf 함수를 호출한다. 두 번째 구간은 scanf 함수 호출을 위한 준비였다.


 

세 번째 구간

 

 

mov eax, dword ptr [rbp - 0x10]

rbp - 0x10은 우리가 scanf를 통해 받은 입력을 저장하는 변수의 메모리 주소이다. 아래 사진과 같은 값을 가지고 있다:

 

 

이 값의 dword ptr, 즉 하위 2바이트를 eax에 복사한다. 그래서 실질적으로 eax에 들어가는 값은 다음과 같다.

 

 

mov edi, eax

앞서 eax에 저장했던 주소 값 0xffffe0f0을 edi에 복사한다. edi에 복사하는 이유는 edi는 함수의 매개변수로 사용되기 때문에, 인수 전달을 할 때 인자를 edi에 설정해야 하기 때문이다.

 

call test <test>

최종적으로 test 함수를 호출한다.

 

세 번째 구간 정리

0xffffe0f0 주소를 edi에 넣어서 test를 호출한다.


 

네 번째 구간

 

 

mov dword ptr [rbp - 0xc], eax

rbp로부터 16진수 C (12) 만큼 위쪽으로 떨어진 주소에 eax를 넣으라는 뜻. eax와 rax는 보통 함수의 반환 값을 저장하는 레지스터이므로 현재 eax에는 앞서 실행했던 test 함수의 반환 값이 담길 것이다. 현재 eax는 0이다.

 

 

cmp dword ptr [rbp - 0xc], 1

rbp로부터 16진수 C (12) 만큼 위쪽으로 떨어진 주소에 있는 값 (0)과 1을 비교하여 플래그 레지스터를 설정한다.

 

jne main+120 <main+120>

jne는 jump if not equal를 의미한다. 위의 cmp 검사에 의해 플래그 레지스터가 바뀌었는데, jmp는 항상 플래그에 기반하여 점프의 여부를 결정한다. 만약 비교하는 두 값이 서로 다르면 jne를 만족시킨다. 따라서 main+120라는 주소로 점프할 것이다. 그렇지 않은 경우에는 아무것도 하지 않고 원래 실행하던 데로 프로그램의 진행이 이어질 것이다.

 

네 번째 구간 정리

앞서 실행했던 test 함수의 반환 값이 담긴 eax와 1을 비교하여 eax가 1이면 그대로 실행하고, 0이면 main+120으로 점프한다.





다섯 번째 구간 (분기문에 의해서 실행되지 않을 수 있음)

 

 

만약 앞서 test의 반환값이 0이 되면 main+120으로 점프하므로 main+120까지의 모든 명령어들을 건너뛴다. 따라서 이 부분은 test의 반환값이 1이 되어 점프하지 않은 경우에만 실행된다.

 

mov r8d, 5

r8d에 값을 넣는다. r8d는 함수의 매개변수로서 사용할 시 다섯 번째 인자 값을 저장하는 매개변수이다. r8d에는 5라는 값이 들어간다.

 

mov ecx, 4

네 번째 인자 값을 저장하는 매개변수로 사용되는 ecx에 4라는 값이 들어간다.

 

mov edx, 3

세 번째 인자 값을 저장하는 매개변수로 사용되는 edx에 3이라는 값이 들어간다.

 

mov esi, 2

두 번째 인자 값을 저장하는 매개변수로 사용되는 esi에 2라는 값이 들어간다.

 

mov edi, 1

첫 번째 인자 값을 저장하는 매개변수로 사용되는 edi에 1이라는 값이 들어간다.

 

call win <win>

win라는 함수를 호출한다. 앞서 5개의 매개변수(edi, esi, ecx, edx, r8)에 인수를 설정하는 과정은 win 함수를 호출할 때 win 함수에게 넘어갈 수 있게 하기 위해서였다.

 

다섯 번째 구간 정리

5개의 매개변수에 값을 넣고 win 함수를 호출했다.

 


 

여섯, 일곱, 여덟 번째 구간

 

 

jmp main+136         // rbp-0xc가 1일 때만 실행, 0일 때 건너뜀

main+126이라는 주소로 무조건 점프한다.

뒤에서 lose라는 함수가 등장한다. 무조건 점프하는 이유는 만약 test의 결과가 1이 되어 win 함수가 실행되었는데 여기서 무조건 점프를 하지 않으면 lose 함수도 실행해 버리기 때문이다.

만약 test의 결과가 0이 되어서 lose를 실행해야 하는 경우에도 무조건 점프를 하면 어떡하냐는 의문이 들을 수도 있는데, 사실 네 번째 구간에서 이미 test의 반환값이 0이 될 경우에는 main+120으로 점프를 하라고 지정을 했기 때문에 이 명령어를 실행하기는커녕, 건너뛰어서 만나지도 못 한다.

 

cmp dword ptr [rbp - 0xc], 0

rbp - 0xc에 있는 값이 0인지 검사한다. rbp - 0xc에는 아직도 test 함수의 반환값이 남아 있다.

이것이 0인지 아닌지에 따라서 다음 명령어의 실행 여부를 결정한다.

 

jne main+136 <main+136>

만약 두 값이 다르면 (반환 값이 0이 아니면) main+136으로 점프한다.

 

mov eax, 0

호출할 lose 함수의 반환값이 들어갈 레지스터를 초기화한다.

 

call lose <lose>

lose 함수를 실행한다.

 

여섯, 일곱, 여덟 번째 구간 정리

test의 리턴 값이 1이면 무조건 main+136으로 점프한다. 그렇지 않고 리턴값이 0이면 lose 함수의 리턴값이 들어갈 레지스터를 초기화한 후 lose 함수를 호출한다.

 


 

마지막 구간

 

 

mov eax, 0

마침내 드디어 main 함수의 리턴 값을 설정한다. 익숙한 return 0.... 그 부분....

 

mov rdx, qword ptr [rbp - 8]

xor rdx, qword ptr fs:[0x28]

je main+161

call __stack_chk_fail@plt <__stack_chk_fail@plt>

위와 같은 명령어들은 글의 맨 처음에 소개했던 메모리 보호 기법들이다. 버퍼 오버플로우가 발생하지 않았는지 검사하는 코드이다. 프로그램 분석에 영향을 주지 않으므로 무시!

 

leave

leave는 mov rsp, rbp와 pop rbp가 함께 한 번에 실행되는 명령어이다. rsp가 rbp의 위치로 내려오고, rbp 값을 스택에서 빼어서 스택이 깔끔하게 정리된다.

 

ret

ret는 pop rip와 jmp rip가 함께 실행되는 명령어이다. rip를 빼오고 rip가 가리키는 값으로 점프한다.

 

 


 

정리

 

첫 번째 구간  :     "Enter Num : " 문자열을 출력하기 위한 printf 함수의 호출한다.

 

두 번째 구간  :     scanf() 함수를 호출하여 특정 변수에 입력을 받는다.

 

세 번째 구간  :     scanf()로 입력받은 값을 test() 함수의 인자로 준다.

 

네 번째 구간  :     test() 함수에서 내부 처리 과정을 거친 후 test의 반환 값이 0이면 일곱 번째 구간으로 점프한다.

 

다섯 번째 구간  :     win() 함수를 호출한다.

 

여섯 번째 구간  :     test의 반환값이 0이 아니어서 win() 함수를 실행했으면 마지막 구간으로 점프한다.

 

일곱 번째 구간  :     test의 반환값이 0이 아니면 마지막 구간으로 점프하라. 그렇지 않으면 계속 실행한다.

 

여덟 번째 구간  :     lose() 함수를 호출한다.

 

마지막 구간  :    스택 정리, main() 함수를 종료한다. 

 

 

 

 

 

 

 

-끝-

(힘들었다)

 

 

 

메인 사진 출처: Unsplash

 

© 남찬우, 2021

'Layer7 > Layer7_Reverse Engineering' 카테고리의 다른 글

ELF 파일 포맷  (0) 2021.05.31
리버싱 실습  (0) 2021.05.27
어셈블리 - Assembly x64  (0) 2021.05.18
Pwndbg 명령어  (0) 2021.05.16
실행파일이 만들어지는 과정  (0) 2021.05.13