- Visual Studio로 배우는 어셈블리어(x86) 학습 Part 1 정리2023년 09월 08일
- 묭묭.cpp
- 작성자
- 2023.09.08.:41
http://ndcreplay.nexon.com/NDC2014/sessions/NDC2014_0065.html
위의 문서를 보고 디스어셈블리 디버깅의 매력을 느껴 디스어셈블리 디버깅과 관련된 강의 혹은 문서를 찾던 도중 관련 강의를 찾을 수 있게 되었다.
IOCP 실습 마지막 단계를 앞둔 상태에서도 상당한 흥미를 느껴 먼저 강의를 시청하게 되었다.
지금부터 해당 강의의 내용 정리를 시작하겠다.
#출처 https://www.youtube.com/watch?v=cEnpeDMAw_Y
1. 어셈블리를 하는 이유
강의자의 말에 따르면 요즘은 어셈블리로 프로그래밍을 하는 일은 거의 없다고 한다.
어셈블리를 왜 써요? 라고 묻는다면 딱히 할말이 없다고 한다.
그럼 어셈블리를 왜 배울까??
- 어셈블리를 알면 C/C++ 코드가 돌아가는 원리를 알 수 있고 훨씬 괜찮은 코드를 짤 수 있다.
- 특히 디버깅에 있어서 어셈블리를 알고 모르고의 차이가 아주 크다.
- 심지어 Visual Studio의 디버깅 환경이 엄청 좋은데도 불구하고 엄청난 차이를 보인다고 한다.
위 설명에 엄청난 매력을 느껴 많은 시간을 할애하여 어셈블리 강의를 시청하고 이해하는데 노력을 쏟았다.
2. 어셈블리를 시작하기전 배경지식
1. Turing Machine
Turing Machine이란 초기 프로그래밍을 하던 방식이다.
바로 종이 테이프에 프로그래밍을 적어서 돌리면 1줄 씩 처리를 해서 결과를 뱉는 장치라고 한다.
- 이것이 발전해서 현대 컴퓨터의 CPU까지 발전해왔다.
2. 기계어
위에서 Turing Machine에 적은 것이 바로 기계어이다.
- 명령어와 데이터의 스트림
- 그리고 기계어와 어셈블리어는 1:1 대응한다.
- 당연히 성능 차이도 없다고 한다.
3. 고급언어 (C/C++)
어셈블리 위에 고급언어가 있다고 한다.
고급언어로 프로그래밍하면 컴파일러가 어셈블리어로 번역을하고 컴퓨터 CPU는 이것을 통해 프로그램을 실행한다.
- 고급언어는 당연히 어셈블리어와 1:1 대응되지 않는다.
- 단, C언어는 거의 1:1 대응이 된다. (단, 신경을 써서 코드를 짰을 때)
- C++로 작성하는 경우도 거의 1:1 대응에 가깝다.
- 어셈블리어의 성능 / 저수준 제어 + 이식성이 필요할 때 C로 작성한다.
4. Register
Register는 대학 강의 과목인 컴퓨터구조 시간에 배운적이 있다. CPU와 가장 가까운 기억 장소이다.
- 즉, 컴퓨터 프로세서 내에서 자료를 보관하는 아주 빠른 기억장소이다.
- 일반적으로 현재 계산을 수행중인 값을 저장하는데 사용한다.
- 현대 프로세서는 메인 메모리 -> 레지스터 -> 데이터 처리 이후
- 레지스터 -> 메인 메모리로 저장한다.
- 이것을 로드-스토어 설계라고 한다.
5. x86의 주요 레지스터
x86의 주요 레지스터에 대해 알아보겠다.
본 강의에서는 철저히 user 모드의 레지스터만 다룬다고 한다.
커널 모드로 내려가면 더 많은 레지스터가 있지만 그것은 프로그래머가 직접 다룰 영역도 아닐 뿐더러 본 강의에서 다룰 내용이 아니라고 한다.
- 범용 레지스터
- ax, bx, cx, dx - 16비트
- eax, ebx, ecx, edx - 32비트
- rax, rbx, rcx, rdx - 64비트
- 범용 레지스터는 8비트 16비트 32비트 단위로 쪼갤 수 있다.
- 주소지정 레지스터
- esi - 32비트로 정해져 있다. 쪼개쓸 수 없다. (S : Source)
- edi - 32비트로 정해져 있다. 쪼개쓸 수 없다. (D : Destination)
- Flags Register
- eflags : CPU 연산의 결과를 저장한다.
- Carry나 Overflow가 올라간다.
- 프로그램 카운터
- EIP : 일반적으로 직접 컨트롤하진 않는다. - 불가능한 것은 아니다.
- 현재 프로그램을 실행하고 있는 주소를 저장한다.
- 스택 포인터
- esp : 스택의 가장 높은 주소를 가리킨다.
- CPU에서 컴퓨터가 부팅되고부터 반드시 해당 스택에 세팅이 들어가야 한다.
- 스택 프레임 베이스 포인터
- ebp : 일종의 마커 같은 것이다.
- 보통 어셈블리가지고 함수를 구현했을 때 함수 안의 로컬 변수 영역과 파라미터 영역에 엑세스할 수 있는 기준이 된다.
C/C++ 프로그래밍을 할 때 항상 디스어셈블리 창을 켜놓고 하는 것이 좋다고 한다.
이때 Listing File을 활용할 수 있다.
Listing File은 순수 어셈블리 코드를 뽑아줄 수 있는 기능이다. - 해당 파일을 보고 공부하면 된다.
3. Visual Studio에서 inline 어셈블리 작성
이제부터 본격적으로 inline 어셈블리를 하는 방법에 대해 알아보겠다.
- C/C++ 함수 내부에서 _asm {} 블록 내부에 작성하면 된다.
- 훌륭한 VS 디버거의 도움으로 무척 쉽게 작성할 수 있다.
- C/C++ 함수로 전달받은 파라미터, 로컬 변수 그대로 asm 코드에서 사용 가능하다.
- naked call이 아니라면 레지스터의 보호가 필요없다.
x64에서는 사용이 불가하다고는 하는데 해당 내용은 다음 강의에서 다룬다고 한다.
4. 어셈블리 학습
1. 데이터 전송
데이터 전송은 다음과 같은 상황에서 사용가능하다.
- 메모리 -> 레지스터
- 레지스터 -> 메모리
- 메모리 -> 메모리
이제부터 메모리 전송 명령에 대해 본격적으로 알아보겠다.
mov 같은 사이즈의 레지스터와 메모리 간의 카피를 진행한다. movzx 1 byte -> 2 bytes, 2bytes -> 4bytes로 카피하되 빈 영역을 0으로 채운다. movsx movzx의 특징을 모두 갖고 부호를 유지한다는 특성을 추가로 갖는다. movs ESI - src, EDI - dest 일 때 메모리 -> 메모리 카피한다.
뒤에 b가 붙으면 1byte, w - 2 bytes, d - 4bytes, q - 8 bytes
각각 사이즈 별로 사용하면 된다.추가적으로 레지스터의 크기에 대해 알아보겠다.
AH AL AX EAX RAX 위 표에서 한 칸은 8비트를 의미한다.
- AL : AX를 쪼갠 하위 8 비트
- AH : AX를 쪼갠 상위 8 비트
- AX : 16 비트
- EAX : 32 비트
- RAX : 64 비트
이제 데이터 전송 예제를 분석해보겠다.
__declspec(align(16)) char szSrc[64] = "ABCDEFGHABCDEFGHABCDEFGHABCDEFGHABCDEFGHABCDEFGHABCDEFGHABCDEF."; __declspec(align(16)) char szDest[64] = { }; __asm { lea esi, dword ptr[szSrc] lea edi, dword ptr[szDest] ; copy 4byte, szDest[0 - 3] = szSrc[0 - 3] mov eax, dword ptr[esi] mov dword ptr[edi], eax ; copy 4byte * 16, szDest[0 - 63] = szSrc[0 - 63] mov ecx, 64 shr ecx, 2 rep movsd nop }
esi에 전송을 할 데이터를 넣고 edi에 전송을 받을 데이터를 넣고 있다.
이후 4 bytes 데이터 전송 코드를 분석해보겠다.
- 4bytes 만큼 전송을 할 것이므로 eax (32비트 레지스터)에 esi의 데이터를 넣는다.
- edi (목적지 레지스터)에 eax의 데이터를 넣는다.
4바이트 전송의 예제는 간단하다.
다음 코드인 64바이트 전송 예제를 알아보겠다.
- ecx 레지스터에 64를 넣는다. 여기서 ecx 레지스터는 카운터 레지스터이다.
- shr 명령은 쉬프트 명령이다. 2번 우측 쉬프트 진행했으니 ecx는 16이 된다.
- rep 명령은 ecx 레지스터가 0이 될 때까지 반복 수행하는 명령이다.
- movsd 명령을 16번 수행한다. d는 4bytes 이므로 4 * 16 총 64 bytes의 데이터를 전송한다.
여기서 64를 div 명령으로 나누지 않는 이유는 나눗셈 연산보다 쉬프트 연산이 훨씬 빨리 작동하기 때문이다.
2. 산술 연산
어셈블리어의 산술 연산을 알아보겠다.
add 덧셈 - 덧셈 연산만으로도 뺄셈이 가능하다. sub 빼기 inc 1 증가 dec 1 감소 mul 부호를 유지하는 곱셈 imul 부호를 무시하는 곱셈 div 부호를 유지하는 나눗셈 idiv 부호를 무시하는 나눗셈 여기서 나눗셈 연산의 예제를 분석하고 가겠다.
해당 코드는 idiv 연산을 이용하여 나눗셈을 진행한다.
eax 레지스터에 있는 값을 ecx의 값으로 나누고 있다.
eax의 값은 13, ecx의 값은 4이다.
연산을 마친 후 eax 레지스터에는 몫이 edx 레지스터에 나머지가 저장된 것을 확인할 수 있다.
다른 연산의 예제는 너무 간단하므로 넘어가도록 하겠다.
3. 비교 연산
비교 연산은 기본적으로 C/C++의 if문과 유사하다.
__asm { mov eax, dword ptr[a] mov edx, dword ptr[b] cmp eax, edx je lb_a_equal_b jg lb_a_greator_b jl lb_a_less_b lb_a_equal_b: mov DWORD ptr[r], A_EQUAL_B jmp lb_exit lb_a_greator_b: mov DWORD ptr[r], A_GREATOR_B jmp lb_exit lb_a_less_b: mov DWORD ptr[r], A_LESS_B jmp lb_exit lb_exit: nop }
기본적으로 cmp 연산을 통해 값을 비교하고 이후 C언어의 goto 문과 비슷한 방식으로 처리한다.
비교 연산은 아래의 표를 참고하자.
cmp eax, ebx 를 진행했을 때를 가정하고 설명하겠다.
je eax와 ebx가 같다면 jump한다. jg eax가 ebx보다 크다면 jump한다. ji eax가 ebx보다 작다면 jump한다. ja 부호를 무시하고 eax가 ebx보다 크다면 jump한다. jb 부호를 무시하고 eax가 ebx보다 작다면 jump한다. 4. 함수 호출
__declspec(naked)를 붙이면 컴파일러가 직접 작성한 어셈블리 코드를 수정하지 않는다.
일반적으로는 필요하지 않다.
아래의 코드를 수동으로 하겠다는 의미이다.
스텍 프레임을 생성하는 과정
// 시작부분
push ebp
mov ebp, esp
// 끝부분
mov esp, ebp
pop ebp
ret위 스텍 프레임을 생성하는 것은 C언어에서 블록 {}을 지정하는 것과 의미가 비슷하다.
이제 함수의 호출 방식에 대해 알아보자.
1. cdecl
- C 표준 calling convention 이다.
- 인자를 뒤에서부터 push 한다.
- 인자를 넣고 변경한 sp는 호출한 쪽에서 직접 복구해주어야 한다.
예시로 인자를 3개를 사용하면
push를 3번 진행하고
함수의 호출이 끝난 후에 sp 레지스터에 3 * 4를 더 해주면 된다.
2. stdcall
- Win32 API 등 DLL에 정의된 함수 등의 calling convention이다.
- cdecl과 마찬가지로 인자를 뒤에서부터 push한다.
- 인자를 넣느라 변경한 sp 레지스터는 호출한 함수 내부에서 복구한다.
const WCHAR* wchCaption = L"Caption"; const WCHAR* wchText = L"Text"; __asm { mov eax, dword ptr[wchCaption] mov edx, dword ptr[wchText] push MB_OK push eax push edx push 0 call dword ptr[MessageBox] }
windows api를 호출하는 예제이다.
여기서 인자를 3개 push 해줬고
call을 할 때 ptr을 통해 함수를 불러오고 있다.
이것의 의미는 함수가 바로 함수 포인터기 때문이다.
Win32 함수 같은 것은 .dll 프로세스 내부에 함수 테이블이 있다! -> 이름 자체가 함수 포인터다.
3. thiscall
- C++의 클래스 멤버 함수를 호출할 때 사용한다.
- cdecl, stdcall 과 기본적으로 같으나 this(클래스) 포인터는 cx 레지스터로 전달한다.
- 정확히는 함수안에서 this 포인터를 cx 레지스터에서 유지하고 있다.
4. fastcall
- 인자를 전달할 때 cx, dx 레지스터를 우선적으로 사용한다.
5. fastcall(x64)
- x64에선 기본 calling convention이다.
- 인자를 전달할 때 rcx, rdx, r8, r9 레지스터를 우선적으로 사용한다.
6. vectorcall
- 레지스터를 최대한 활용하기 위한 호출 규약이다.
- fastcall 규칙을 적용하고 벡터 형식 및 HVA 인수에는 SSH 벡터 레지스터를 활용한다.
- 정수형은 왼쪽에서 오른쪽, xmm or ymm은 오른쪽에서 왼쪽으로 인자를 받는다.
5. Stack Frame
이번 강의에서 가장 중요하다고 설명하신 Stack Frame에 대한 설명이다.
- 완전히 똑같지는 않지만 C에서의 함수 블록 {}에 해당하는 스택 영역이다.
- 함수 진입시 스택 메모리를 확보하고 함수에서 나갈 때 해제한다.
__asm { ; 스택 메모리 할당 push ebp mov ebp, esp mov eax,dword ptr[b] mov edx,dword ptr[a] add eax,edx ; 스택 메모리 해제 mov esp,ebp pop ebp ret }
1. Stack Frame 내부에서 NakedCall 함수를 호출하는 예제
함수의 인자는 2개이다.
초기 스택 포인터의 위치는 최상단이다.
주소 위치 0x esp 0x 0x 0x 여기서 인자 a와 b를 넣는다.
함수의 인자는 (a, b) 이다.
오른쪽의 인자부터 push b, push a를 진행한 후의 위치이다.
주소 위치 0x 0x b 0x a, esp 0x 여기서 함수를 호출한다.
주소 위치 0x 0x b 0x a 0x ret addr, esp 인자를 넣은 후 다음 위치가 바로 return address이다. call을 호출하면 ret addr이 esp의 위치가 된다.
함수의 코드로 이동해서 스택 프레임의 세팅이 시작된다.
스택프레임의 초기 세팅 단계이다.
push ebp
mov ebp, esp
명령을 수행한다.
주소 위치 0x 0x b 0x a 0x ret addr 0x ebp, esp 0x 0x ebp에 esp를 백업하고 esp와 ebp의 위치는 같다.
해당 함수에서 인자를 꺼내서 사용한다.
mov eax, dword ptr [a] (ebp + 8)
mov edx, dword ptr [b] (ebp + 12)
주소 위치 0x 0x b (ebp + 12) 0x a (ebp + 8) 0x ret addr 0x ebp, esp 0x 0x 스택 프레임을 되돌려 놓는 작업을 수행한다.
스택 프레임 초기화의 역순이다.
mov esp,ebp
pop ebp
주소 위치 0x 0x b (ebp + 12) 0x a (ebp + 8) 0x ret addr, esp 0x 0x 0x 그리고 함수를 호출한 코드로 돌아와서
사용한 스택 포인터를 되돌려놓는 작업을 수행한다.
add esp,8
주소 위치 0x esp 0x 0x 0x 0x 0x 0x 완전히 코드의 실행이 끝난 뒤에는 esp 위치가 실행하기 전으로 원상복구 되어야 한다.
이 작업이 제대로 수행되지 않은 경우 버그의 원인으로 볼 수 있다.
2. Local 변수가 있을 경우 - Release 모드 기준
초기 상태이다.
주소 위치 0x esp 0x 0x 0x 여기서 인자 a와 b를 넣는다.
함수의 인자는 (a, b) 이다.
오른쪽의 인자부터 push b, push a를 진행한 후의 위치이다.
주소 위치 0x 0x b 0x a, esp 0x 여기서 함수를 호출한다.
주소 위치 0x 0x b 0x a 0x ret addr, esp 인자를 넣은 후 다음 위치가 바로 return address이다. call을 호출하면 ret addr이 esp의 위치가 된다.
함수의 코드로 이동해서 스택 프레임의 세팅이 시작된다.
스택프레임의 초기 세팅 단계이다.
push ebp
mov ebp, esp
명령을 수행한다.
주소 위치 0x 0x b 0x a 0x ret addr 0x ebp, esp 0x 0x 로컬 변수로 크기가 4인 배열을 사용한다.
sub esp, 10h
주소 위치 0x 0x b 0x a 0x ret addr 0x ebp 0x local variable[3] (ebp-8) 0x local variable[2] (ebp-12) 0x local variable[1] (ebp-16) 0x local variable[0] (ebp-20), esp 0x 0x 주소지정 레지스터인 edi(목적지) 레지스터를 push 한다.
push edi
주소 위치 0x 0x b 0x a 0x ret addr 0x ebp 0x local variable[3] (ebp-8) 0x local variable[2] (ebp-12) 0x local variable[1] (ebp-16) 0x local variable[0] (ebp-20) 0x edi, esp 0x 지역변수의 사용을 완전히 마치고
edi 레지스터를 pop 한다.
pop edi
주소 위치 0x 0x b 0x a 0x ret addr 0x ebp 0x local variable[3] (ebp-8) 0x local variable[2] (ebp-12) 0x local variable[1] (ebp-16) 0x local variable[0] (ebp-20), esp 0x 0x 백업해뒀던 ebp에 있는 값을 esp에 넣고 ebp의 값을 꺼낸다.
스택 프레임 초기화의 역순이다.
mov esp, ebp
pop ebp
주소 위치 0x 0x b 0x a 0x ret addr, esp 0x 0x 그리고 함수를 호출한 코드로 돌아와서
사용한 스택 포인터를 되돌려놓는 작업을 수행한다.
add esp, 8
주소 위치 0x esp 0x 0x 0x 0x 0x 초기 상태로 되돌아왔다.
함수의 호출과 코드가 완벽하게 종료되었다.
이로써 Visual Studio를 활용하여 어셈블리 학습 Part1의 내용 정리가 마무리 되었다.
일전에 어셈블리어를 잠깐 공부한 적이 있었지만 이렇게 자세하게까지는 공부하지 못했었는데
이번 기회를 바탕으로 어셈블리 디버깅 연습을 열심히 진행해볼 예정이다.
좋은 강의를 제공해주신 유영천님께 감사드립니다.
반응형다음글이전글이전 글이 없습니다.댓글