Security_RNRF
1장. 개요 - PC 내부 구조 본문
1. PC 내부 구조
해킹에 당장 활용할 수 있는 Expoilt 코드부터 작성하고 싶지만 기초 지식이 없는 해킹 공부는 모래성과 같이 약할 수 밖에 없다.
그렇기 때문에 컴퓨터 아키텍처(구조)에 대한 기초 지식과 디버거 및 디스어셈블러 등과 같은 툴의 사용법을 익혀볼 것이다.
1.1 컴퓨터의 언어
사람은 컴퓨터와 대화하기 위해 프로그래밍 언어를 사용하나 컴퓨터는 실제로 소스 코드를 이해하지 못하기 때문에 내부적으로 컴파일을 거쳐 CPU가 이해할 수 있는 기계어로 대화한다.
컴퓨터가 사용하는 기계어, 즉 어셈블리어와 컴퓨터의 내부 동작 구조를 이해해야 한다.
1.2 CPU와 레지스터
CPU는 컴퓨터에서 인간의 두뇌에 해당하는 부품이다. 우리의 목표는 윈도우 운영체제에서 동작하는 프로그램에 대한 공격이므로, 이를 위해서는 컴퓨터의 두뇌인 CPU의 동작 매커니즘을 이해해야 한다.
일반적으로 CPU는 연산 장치, 제어 장치, 레지스터로 구성된다.
연산 장치 : 말 그대로 수칙, 논리 연산 등의 수학적 연산을 담당.
제어 장치 : 메모리에서 기계어 코드를 읽고 해석한 뒤 실행하는 역할을 담당.
레지스터 : 연산을 위해 CPU가 사용하는 데이터 저장소, 주로 메모리의 특정 주소를 저장하거나 특정 값을 저장한다. (공격 코드 작성시 레지스터에 대한 부분은 반드시 알고 있어야 하므로 조금 더 자세히 알아봐야한다.)
여기서는 인텔의 IA-32 구조에서 사용되는 레지스터를 살펴볼 것이다. 32bit 레지스터는 기본적으로 아래와 같이 32비트이며, 세그먼트 레지스터의 경우 16비트이다.
: 간단히 얘기하면 레지스터는 어떤 물건을 담을 수 있는 그릇이라고 생각하면 된다.

레지스터는 아래 그림과 같이 범용 레지스터 8개, 명령어 포인터, 세그먼트 레지스터, 플래그 레지스터 등으로 나누어진다.
: 레지스터는 CPU가 연산할 때 사용하는 일종의 "임시 저장소"이다.

각 레지스터에는 메모리 주소 혹은 값이 들어 있고, CPU는 이 레지스터들을 이용해서 연산을 수행한다.
: 각 레지스터는 아래 표와 같은 역할을 담당한다.
| 레지스터 | 설명 |
| EAX | 산술 계산 및 리턴 값 전달에 사용되며, 가장 자주 사용된다. |
| EBX | 범용적으로 사용 가능한 추가적인 레지스터이다. |
| ECX | 일반적으로 반복문이나 문자열 복사 시 카운트에 사용된다. |
| EDX | EAX와 비슷하게 주로 연산에 사용된다. |
| ESI | 주로 문자열, 메모리 값을 복사할 때 "원본" 주소를 가리킨다. |
| EDI | 주로 문자열, 메모리 값을 복사할 때 "목표" 주소를 가리킨다. |
| EBP | 스택 프레임의 "시작 주소"(Base)가 저장된다. |
| ESP | 스택 프레임의 "끝" 지점이 저장된다. |
| EIP | 다음에 실행할 명령어의 주소가 저장된다. ("손가락으로 가리키는 느낌 = 지정") |
| 세그먼트 레지스터 | 각 세그먼트의 오프셋이 저장된다. |
| 플래그 레지스터 | 연산의 결과에 따라 다양한 상태 값을 표시한다. |
Ex.) sum 함수
int sum(int a, int b){
return a+b;
}
sum 함수는 두 개의 인자 값을 받아서 더한 값을 리턴한다. 이를 위해 더한 계산 값을 어딘가에 저장해 두어야 하는데, 레지스터가 이 역할을 수행한다.
sum:
012A1350 55 PUSH ebp
012A1351 8B EC mov ebp,esp
012A1353 81 EC C0 00 00 00 sub esp,0C0h
012A1359 53 push ebx
012A135A 56 push esi
012A135B 57 push edi
012A135C 8D BD 40 FF FF FF lea edi,[ebp-0C0h]
012A1362 B9 30 00 00 00 mov ecx,30h
012A1367 B8 CC CC CC CC mov eax,0CCCCCCCCh
012A136C F3 AB rep stos dword ptr es:[edi]
012A136E 8B 45 08 mov eax,dword ptr[a]
012A1371 03 45 0C add eax,dword ptr[b]
012A1374 5F pop edi
012A1375 5E pop esi
012A1376 5B pop ebx
012A1377 8B E5 mov esp,ebp
012A1379 5D pop ebp
012A137A C3 ret
mov 명령어로 a 변수에 해당하는 값을 EAX 레지스터에 넣은 뒤, 바로 아래에서 add 명령어로 b 변수의 값을 EAX에 더하고 있다는 것을 알 수 있다. 이제 a+b의 값은 EAX 변수에 저장되어 있을 것이며, 가장 아래의 ret 코드가 실행되면 sum 함수가 호출된 곳으로 돌아가게 되고, 함수를 호출했던 곳에서는 EAX에 저장된 리턴 값을 이용한다.
012A17E4 E8 C5 F9 FF FF call sum (12A11AEh)
012A17E9 83 C4 08 add esp,8
012A17EC 8B F4 mov esi,esp
012A17EE 50 push eax
012A17EF 68 08 58 2A 01 push offset string "sum : %d \n" (12A5808h)
012A17F4 FF 15 AC 82 2A 01 call dword ptr [__imp__printf (12A82ACh)]
012A17FA 83 C4 08 add esp,8
012A17FD 3B F4 cmp esi,esp
EBP, ESP 등의 레지스터는 뒤에서 스택에 대한 내용을 살펴볼 때 자세히 알아볼 것이므로, 우선은 특수한 목적에 사용되는 포인터 정도로 알고 넘긴다.
1.3 메모리 구조
윈도우 운영체제의 메모리 구조에 대해 간단히 살펴본다. 윈도우 운영체제를 이해함에 있어 메모리 구조는 상당히 중요하다.
: 모든 프로그램은 실행하기 전에 메모리에 로드되어야 하며, 취약점이 발생하는 곳도 메모리이며 취약점에 대한 공격이 이루어지는 곳도 메모리이기 때문이다.
윈도우의 메모리 구조는 32bit의 경우 기본적으로 프로세스별로 4GB로 구성된다. PC의 메모리가 일반적으로 2~8GB 정도인데 어떻게 프로세스마다 4GB를 할당할 수 있을까?
이를 위해 윈도우 운영체제는 가상 메모리를 사용한다. 다음 그림과 같이 프로세스별로 유저 영역 2GB, 커널 영역 2GB로 총 4GB의 독립된 메모리 공간을 가지고 있으며, 실제로 커널 영역 2GB는 모든 프로세스가 공유한다.

이렇게 메모리 가상화를 통해 프로그램은 자신이 모든 메모리를 소유한 것처럼 주소 값에 신경쓰지 않고 메모리를 사용할 수 있으며, 오류가 발생하더라도 다른 프로세스의 메모리와 격리되어 있으므로 안정성을 높일 수 있다. 각 가상 메모리에 대한 물리 메모리 매핑 과정은 윈도우 운영체제가 맡아서 한다.
그렇다면 각 프로세스별 메모리는 어떻게 사용될까? 실제 프로세스 메모리를 한번 살펴보도록 한다.
| 실습 | 윈도우 메모리 구조 확인 |
| Immunity Debugger를 이용한 윈도우 메모리 구조 확인 | |
| 구분 | 내용 |
| 테스트 환경 | Windows 7 / 32bit |
| 테스트 대상 | C:\Program Files\Internet Explorer\iexplore.exe |
| 테스트 도구 | Immunity Debugger (debugger.immunityinc.com/ID_register.py) |
디버거를 실행한 뒤 임의의 EXE 파일을 오픈해본다. (iexplore.exe = 인터넷 익스플로러)

메모리 뷰 화면을 보면 유저 모드 2GB 영역에 로드된 다양한 PE 파일 이미지와 스택 등을 확인할 수 있다.
( 참고. PE(Portable Executable)는 윈도우 운영체제에서 사용하는 실행 파일 구조이다. PE는 윈도우 실행 파일의 구조를 나타내며, 윈도우의 실행 파일 로더는 파일에 저장된 이 PE 헤더를 파싱하여 메모리에 실행 파일 및 DLL들을 로딩한다.)
아래로 조금 내려가 보면 공용으로 사용되는 kernel32.dll, user32.dll 등의 DLL들을 확인할 수 있다.

프로그램 하나가 동작하기 위해서는 연동된 다양한 모듈들이 함께 로드되며, 향후 Exploit을 작성함에 있어 유용한 코드 조각들을 사용할 수 있다. 공격 코드 작성에 필요한 바이트 코드가 공격 대상 파일에 없더라도 함께 로딩되는 다른 모듈 내에서 찾아서 쓸 수 있다. (= 공용 영역이기 때문이다.)
디버거에서 직접 메모리를 살펴본 결과 크게 스택, 힙 영역, 프로그램 이미지(PE), DLL, 공유 영역이 존재하는 것을 확인할 수 있다.
이 중에서 한가지 기억해야 할 것은 스택 메모리는 높은 주소에서 낮은 주소로, 힙 메모리는 낮은 주소에서 높은 주소로 할당된다는 점이다.

1.4 스택과 힙
그렇다면 로드된 프로세스에서 메모리는 실제로 프로그램에 어떻게 사용될까? 프로그램에서 메모리를 사용할 때 스택과 힙을 할당받아 사용하게 된다.
먼저, 스택은 무엇이고, 언제, 왜 사용하는 것일까? 스택은 아래와 같이 "후입선출"의 구조를 갖는 자료 구조의 한 종류이다. 즉, PUSH로 스택의 끝에 데이터를 넣고, POP으로 스택 끝의 데이터를 가져온다.


이러한 스택 구조는 메모리 관리를 위해서 사용되며, 이를 위해 ESP(스택 포인터) 레지스터와 EBP 레지스터가 사용된다.
덧셈과 뺄셈 함수 호출 예제의 소스를 살펴보면 메인 함수 내부에서 sum 함수와 minus 함수를 호출하고 있다.
int sum(int a, int b){
int result = 0;
result = a+b;
return result;
}
int minus(int a, int b){
int result = 0;
result = a-b;
return result;
}
void main(int argc, char* argv[]){
int x = 9;
int y = 4;
printf("sum : %d \n", sum(x,y));
printf("minus : %d \n", minus(x,y));
}
위에서 sum 함수와 minus 함수 내부에는 계산 값을 임시로 저장하기 위한 "result" 변수가 존재한다. 하지만 이 변수는 함수가 종료된 후에는 존재할 필요가 없는 변수이다. 이러한 변수들이 메모리에 할당되어 남아 있다면 메모리를 쓸데없이 차지하게 된다. 이렇게 함수 내부에서만 사용되는 지역 변수들이 주로 스택에 할당된다.
힙은 스택과는 조금 다르게 힙 관리자 및 힙 구조체를 통해 관리되며, 프로그래머가 필요시 할당 및 해제를 할 수 있다. 힙은 스택보다 큰 메모리가 필요할 때 사용하여, API를 통해 할당받거나 반환할 수 있다.
1.5 함수 호출과 리턴
함수가 호출되고 리턴되는 과정을 이해하는 것은 매우 핵심적인 부분이다. 각 함수는 호출된 후 필요한만큼 메모리를 할당받아 사용하고, 함수가 종료된 후에는 메모리를 반환하고 종료된다.

함수마다 별도의 스택 공간을 가지며, 이를 스택 프레임이라 부른다. 이렇게 스택 스레임을 별도로 갖는 이유는 스택 메모리의 효율적인 관리뿐 아니라 함수가 호출되기 전과 후에 스택은 변경이 없어야만 하기 때문이다. 이렇게 하지 않으면 함수가 호출된 후 메모리 값이 엉망이 되어 에러가 발생할 것이다.
함수 호출 시 스택에 돌아올 주소를 저장하고, 스택 프레임을 생성한다. 그리고 함수 종료 시 스택 프레임을 제거하고 호출한 주소로 복귀한다. 다음 그림은 main 함수 내부에서 sum 함수를 호출할 때 스택 프레임이 어떻게 변화되는지 나타낸다.

위 그림을 보면 sum 함수 호출 전과 호출 후에 main 함수 스택 프레임에 변화가 없다는 것을 알 수 있다. 함수 종료 후 간단히 메모리를 반환할 수 있으며, 함수 내부에서 스택 메모리를 사용하는 것과 관계없이 종료 후에는 호출 전과 동일한 상태로 돌아가게 된다.
또한 이렇게 스택 프레임을 구분함으로써 sum 함수 내부에서는 ESP의 변경과 상관없이 EBP를 기준으로 간단하게 인자 값 x, y에 접근할 수 있게 된다.
01383232 B9 33 00 00 00 mov ecx,33h
01383237 B8 CC CC CC CC mov eax,0CCCCCCCCh
0138323C F3 AB rep stos dword ptr es:[edi]
0138323F C7 45 E8 00 00 00 00 mov dword ptr [ebp-8],0
01383245 8B 45 08 mov eax,dword ptr [ebp+8]
01383248 03 45 0C add eax,dword ptr [ebp+0Ch]
0138324B 89 45 F8 mov dword ptr [ebp-8],eax
0138324E 8B 45 F8 mov eax,dword ptr [ebp-8]
01383251 5F pop edi
01383252 5E pop esi
: EBP를 통한 인자 값 접근
01383245 8B 45 08 mov eax,dword ptr [ebp+8]
01383248 03 45 0C add eax,dword ptr [ebp+0Ch]
코드를 보면 EBP+8의 값을 EAX에 저장한 뒤, EBP+C의 값을 더해 주고 있다. 이 코드는 소스 코드에서 보았던 result = a+b 코드이다. 즉, 함수 내부에서 인자 값에 접근할 때는 EBP 레지스터를 이용함을 알 수 있다.

함수의 시작 부분에서 스택 프레임을 만들어주는 코드를 "함수 프롤로그"라 하며, 스택 프레임을 해제하고 돌아가는 코드를 "함수 에필로그"라 한다.

한 줄씩 코드를 보면서 스택 프레임이 만들어지고 제거되는 과정을 살펴본다. 아직은 어셈블리 명령어를 언급하지 않으므로 PUSH는 스택에 넣는 명령어, MOV는 이동하는 명령어라고 알고있는다.
: 함수 프롤로그 과정
1) 함수 CALL - 함수 호출 시 복귀 주소를 스택에 저장한 뒤 해당 함수 코드로 점프한다.

2) PUSH EBP - 이전 스택 프레임의 EBP를 SFP(Stack Frame Pointer)에 저장한다. 향후 스택 프레임 제거 후 원래 스택 프레임으로 복귀하기 위해 이전 스택 프레임의 EBP를 저장해 두어야 하기 때문이다.

3) MOV EBP,ESP - Stack Pointer를 EBP 레지스터에 저장한다.

이 몇 줄 안되는 코드로 간단히 하나의 스택 프레임이 생성된다. 많은 함수를 호출해도 이 과정을 반복적으로 할뿐이다.
: 함수 에필로그 과정
1) MOV ESP,EBP - 사용 중이던 ESP를 EBP 레지스터에 저장된 스택 프레임 시작 주소로 복구한다.

2) POP EBP - ESP가 가리키는 주소에 저장된 이전 EBP 주소를 꺼내서 EBP 레지스터에 저장한다.

3) RET - POP EIP와 동일하며, 스택에 저장된 복귀 주소를 꺼내서 EIP에 저장한다.

역시 스택 프레임이 간단하게 제거되고, 함수 종료 후의 스택은 호출 전과 동일하다는 것을 알 수 있다.
인자 값을 전달하고 스택을 정리하는 과정에서 함수를 호출하는 쪽과 호출당하는 함수 사이의 혼란을 방지하기 위해 함수 호출 과정에는 일정한 규약이 존재하는데, 이를 "함수 호출 규약(Calling Convention)"이라고 한다.
함수 호출 규약에는 "_cdecl, _stdcall, _fastcall" 등이 존재하며, 각 특징은 아래와 같다.
1) _cdecl - 인자값 전달은 오른쪽부터, 스택 정리는 caller(Add esp,n)
2) _stdcall - 인자값 전달은 오른쪽부터, 스택 정리는 callee(ret n)
3) _fastcall - 인자값 전달은 레지스터+스택, 속도가 빠르나, 경우에 따라 오히려 코드가 길어진다.
C언어의 호출 규약은 기본적으로 "_cdecl"이며, WINAPI의 경우 대부분 "_stdcall"이다.
이전의 더하기 실습 프로그램은 C로 작성되었으므로, _cdecl 호출 규약을 따르도록 컴파일되어 있다. 호출 규약은 아래와 같이 컴파일 시 [프로젝트 속성] -> [구성 속성] -> [C/C++] -> [고급] -> [호출 규칙] 옵션을 통해 지정할 수 있다.

호출 규약에 따른 어셈블리어 변화를 살펴본다.
먼저, "_cdecl" 규약을 살펴보면 push로 인자 값을 역순으로 넣은 뒤 sum() 함수를 호출하는 것을 확인할 수 있다. 또한 함수 호출 후 add esp, 8을 통해 인자 값 2개를 push하며 늘어났던 8바이트의 스택을 함수 호출 이전의 상태로 만들어 주는 것을 알 수 있다.

01382D17 B8 CC CC CC CC mov eax,0CCCCCCCCh
01382D1C F3 AB rep stos dword ptr es:[edi]
01382D1E C7 45 F8 09 00 00 00 mov dword ptr [ebp-8],9
01382D25 C7 45 EC 04 00 00 00 mov dword ptr [ebp-14h],4
01382D2C 8B 45 EC mov eax,dword ptr [ebp-14h]
01382D2F 50 push eax
01382D30 8B 40 F8 mov ecx,dword ptr [ebp-8]
01382D33 51 push ecx
01382D34 E8 8E E4 FF FF call 013811C7
01382D39 83 C4 08 add esp,8
: _cdecl 호출 규약에 따른 sum(a,b) 함수 호출
01382D2F 50 push eax
01382D33 51 push ecx
01382D34 E8 8E E4 FF FF call 013811C7
다음으로 "_stdcall"을 살펴보면 함수 내부에서 스택 정리를 직접한다는 특징이 있다.

00E0140E C7 45 F8 09 00 00 00 mov dword ptr [x],9
00E01415 C7 45 EC 04 00 00 00 mov dword ptr [y],4
00E0141C 8B 45 EC mov eax,dword ptr [y]
00E0141F 50 push eax
00E01420 8B 4D F8 mov ecx,dword ptr [x]
00E01423 51 push ecx
00E01424 E8 B2 FD FF FF call sum (0E011D8h)
00E01429 33 C0 xor eax,eax
: _stdcall 호출 규약에 의한 sum(a,b) 함수 호출
00E0141F 50 push eax
00E01423 51 push ecx
00E01424 E8 B2 FD FF FF call sum (0E011D8h)
"_cdecl"과는 다르게 함수 호출 이후에도 add esp, 8 코드가 없는 것을 볼 수 있으며, 스택 정리는 함수 내부에서 리턴과 동시에 수행된다. sum 함수를 살펴본다.
00E013CE 8B 45 08 mov eax,dword ptr [a]
00E013D1 03 45 0C add eax,dword ptr [b]
00E013D4 5F pop edi
00E013D5 5E pop esi
00E013D6 5B pop ebx
00E013D7 8B E5 mov esp,ebp
00E013D9 5D pop ebp
00E013DA C2 08 00 ret 8
: _stdcall 호출 규약에 따른 sum 함수 리턴 코드 변화
00E013DA C2 08 00 ret 8
함수 복귀 시 ret 명령어가 아닌 ret 8 명령어가 실행되는 것을 볼 수 있으며, 함수 호출 전 두번의 push로 늘어난 스택을 ret 8 명령어로 복귀와 동시에 줄여주는 것이다.
마지막으로 "_fastcall"를 살펴보면 레지스터를 이용하여 인자 값을 전달한다. 다른 호출 규약에서는 인자 값을 레지스터에 옮긴 후 스택에 push하는데, 이러한 push 과정이 없으므로 다른 호출 규약에 비해 속도가 빠르나, 인자 값의 수가 많아지면 다른 호출 규약과 마찬가지로 스택을 사용한다.
010E140C F3 AB rep stos dword ptr es:[edi]
010E140E C7 45 F8 09 00 00 00 mov dword ptr [x],9
010E1415 C7 45 EC 04 00 00 00 mov dword ptr [y],4
010E141C 8B 55 EC1 mov edx,dword ptr [y]
010E141F 8B 40 F8 mov ecx,dword ptr [x]
010E1422 E8 B9 FD FF FF call sum (10E11E0h)
010E1427 33 C0 xor eax,eax
010E1429 5F pop edi
010E142A 5E pop esi
: _fastcall 호출 규약에 따른 sum(a,b) 함수 호출
010E141C 8B 55 EC1 mov edx,dword ptr [y]
010E141F 8B 40 F8 mov ecx,dword ptr [x]
010E1422 E8 B9 FD FF FF call sum (10E11E0h)
edx 레지스터와 ecx 레지스터에 인자 값을 넣고 바로 sum 함수를 호출하는 것을 볼 수 있다.
이제 시스템 내부 동작에 대한 기초 설명은 끝났다.
다시 한번 강조하지만 기초를 쌓는 것이 지겹게 느껴질 수 있으나 향후 취약점 분석과 공격 코드 작성 시 응용력과 창의력을 가질 수 있게 할 것이다. 윈도우 시스템 해킹에 있어서 꼭 알아야 할 부분들 위주의 내용이며 최소한 이 장의 내용을 이해 해야한다. 아직 이해가 잘 되지 않는다면 그림과 개념 위주로 이해하고, 이후 나오는 장들을 공부하며 관련된 내용이 다시 나오면 앞으로 돌아와 내용을 되짚어보아야 한다.
'Exploit > 윈도우 버그헌팅 및 익스플로잇' 카테고리의 다른 글
| 1장. 개요 - 디스어셈블러와 디버거 (0) | 2021.02.14 |
|---|---|
| 1장. 개요 - 어셈블리어 기본 (0) | 2020.12.23 |