Win32 Global API Hook - 제3강 Win32 어셈블리 프로그래밍 (1)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
날씨가 많이 쌀쌀해졌죠? 혹시 감기들 안걸리셨는지... 자, 프로그래밍하기에 좋은 계절이 돌아왔습니다.
가을은 프로그래머의 계절이죠. 무더운 여름날, 모니터와 본체에서 뿜어내는 열기를 온몸으로 느끼며, 밤
을 잊던 나날들은 가고 서늘한 바람과 파란하늘이 유난히 서러운 가을이 왔습니다. 지금 이시간에도 모니터
를 연인의 얼굴삼아, 키보드, 마우스를 연인의 손목삼아 밤을 잊고 연구개발에 열중하고 있는 이땅의 수많
은 프로그래머에게 한줌의 소금이 되고자 불타는 사명감(?)에 저도 밤을 잊었습니다. ^^
지난 강좌까지 잘 따라오셨는지 궁금하군요. 지난 예제를 수정해서 다른 프로세스의 API 후킹에 성공하신
분들도 계실테고, 아직도 헤메는 분들도 계실지 모르겠습니다. 어쨋든 지난 강좌까지가 개념적인 내용의 기
초라면 오늘부터 진행할 강좌는 실질적인 기초라고 할수 있겠습니다. 그러나 기초가 제대로 잡히면 그 다음
의 응용은 아주 간단한것(물론, 거짓말이란거 다 아시죠?)들이니까 기초부터 튼튼히(!!)하자구요. ^^ 그
럼, 어느 성인(?)의 말을 인용하면서 강좌나갑니다.
"무릇 프로그래밍이라 하는것은 현자가 우매한 중생들에게 진리를 설파하는것과 같다. 오로지 그 수의 많
고 적음에 상관없이 소유와 무소유만을 기대하는 우매한 자들에게 그들만이 이해할수 있는 방법으로 세상
을 바르게 살아가는 법을 깨우쳐주는것이다."
(강좌의 진행상 존칭을 생략하겠습니다.)
1. 어셈블리, 참을수 없는 구조의 단순함 !!
흔히들 어셈블리를 어렵다고들 한다. 그러나 이것은 정확히 말하면 어셈블리로 프로그래밍하기가 어렵다는
의미이다. 왜냐면 어셈블리 자체는 매우 단순하기 때문이다. 어셈블리 자체가 매우 단순하기때문에 그 단순
한 기능만으로 요즘 추세에 맞는 복잡한 어플리케이션을 작성하기가 매우 번거롭다는것이다. (물론 최근에
나온 어셈블러들은 고급언어에 버금갈만한(물론 이것도 사실은 아니다. 예전에 비해서는 그렇다는 말이
다.) 코딩의 편의를 제공하기는 하지만) 필자가 예전에 처음 프로그래밍을 공부한지 얼마되지 않았을때 어
셈블리와 C언어의 차이점을 선배프로그래머가 이렇게 얘기했다. 여기서는 C언어를 얘기하지만 대부분의 고
급언어로 이해해도 무난할것이다.
"C언어는 "냉장고에서 사과를 꺼내고 바나나를 넣어두어라!!"라는 식으로 명령한다면, 어셈블러는 "바나나
를 들고 냉장고 앞으로 이동한다음, 냉장고 문을 열고, 사과를 꺼낸다음, 그 자리에 바나나를 넣고, 냉장
고 문을 닫아라!!" 이런식으로 명령되어져야 한다"
지금 생각하면 정말 명쾌한 설명이 아닐수없다. 실제로 C언어는 "냉장고에서 사과를 꺼내고 바나나를 넣어
라"라고만 하면 스스로 냉장고 앞으로 이동해야 한다는것, 냉장고 문을 열어야 한다는것, 사과를 꺼내고,
바나나를 넣어야 하며, 마지막으로 냉장고문을 닫아야 하는것을 이해하지만, 어셈블러는 이러한 일련의 작
업을 일일이 지정해주어야 한다는 것이다. 그렇지만 C언어든 어셈블리든 결국 행동하는 패턴은 같을수밖에
없으며, 단지 명령하는 사람의 명령을 어디까지 이해할것인가의 문제인것이다. 결과적으로 C언어가 어셈블
리보다는 더욱 똑똑하다는 것이다.
모든것은 상대적인 것일수밖에 없다. 그 옛날 어셈블러도 없이 기계어로 프로그래밍하던 세대에게 어셈블러
는 매우 편리한 도구였다. 실제로 DOS용 "LOTUS123"같은 OA프로그램이나, 당시에 엄청난 인기를 끌었던 "페
르시아왕자" 1탄 같은 프로그램들이 순전히 어셈블리로 제작되어었다는 것만 보더라도 그 시대에서는 어셈
블리가 지금처럼 어렵고 생소한 언어가 아니었을것이다. 단지 우리는 어셈블리보다는 똑똑한 C언어나 파스
칼 등의 고급언어에 익숙해져있으니 어셈블리가 생소하고 어려워보이는 것이다. (물론 예전과는 비교도 할
수없을 정도로 지금의 프로그램들은 복잡, 다양해지고 있지만) 서두에 언급했듯이 우매한 어셈블리가 세상
에서 바르게 살아나갈수 있도록 우리는 조금더 현명해져야 하며, 그들을 이해하며 눈높이를 맞출수있는 열
린마음을 가져야할때다.
어셈블리가 별것아니라는 얘기만 했는데 그렇다고 어셈블리가 정말로 별것 아닌것은 아니다. 아마 개발환경
이 아무리 편해지더라도 어셈블리가 세상에서 사라지지는 않을것이라는 필자의 생각이다. 그 이유는 다름아
닌 CPU가 명령을 처리하는 가장 하위단계는 잘 알다시피 기계어코드이다. 그러나 사람이 기계어만으로 프로
그래밍을 하거나, 기계어코드만 가지고 명령을 이해하기는 매우 힘든작업이므로(불가능한것은 아니다. 예전
에는 사람이 이런 작업을 하기도 했었다. 또 우리도 그러한 일을 곧 하게될것이다.) 기계어를 사람이 이해
하는 형식으로 표현해주어야 하는데 이것이 바로 어셈블리이다.
조금 과장해서 말한다면 어셈블리를 알면 프로그램의 흐름을 분석할 수 있으며, 원한다면 프로그램을 원하
는대로 수정할수도 있다. 실제로 디바이스드라이버의 경우, 바이너리를 역어셈블(바이너리파일을 어셈블리
코드로 변환하는것)해서 C코드를 얻어내는 작업을 실제로 행하기도 한다. 이러한 작업들을 통틀어서 역공학
(reverse engineering)이라고 하는데 실제로 쉐어웨어를 크랙하거나 패치하는 작업들도 어찌보면 간단한 수
준의 역공학이라고 부를수 있겠다. 물론 이러한일들이 쉬운일은 아니다. 완벽한 역공학을 위해서는 어셈블
리는 물론 바이너리가 생성된 환경(보통 컴파일러를 예로 들수 있겠다.)에 대한 깊은 이해를 필요로 하며,
디버깅 심볼이 제거된 릴리즈 바이너리일 경우, 데이터와 함수주소등을 오로지 단순한 메모리주소로만 사용
하게 되며, MFC등의 클래스라이브러리를 이용하는 경우에는 그 구조의 복잡함으로 생성되는 바이너리 또한
더더욱 이해하기 힘든구조가 되어버린다. 엎친데 덮친격으로 어떠한 바이너리들은 역공학을 할수없게하거
나 힘들게 만드는 anti-debugging 코드를 삽입하는 경우도 있으니 모든 바이너리를 완벽하게 역공학하기란
정말로 힘든 일이 아닐수 없다.
다시 어셈블리로 돌아와보자. 우리가 이번 강좌에서 배우고자 하는것은 어셈블리로 프로그램을 작성하는 방
법이라기보다는 실제로 컴파일러가 어떠한 기계어코드를 만들어내는지와 CPU가 어떻게 프로그램을 해석하
고 실행하는지를 이해하기 위한 방법으로 어셈블리를 배우고자 한다. 물론 어셈블리만으로 윈도 어플리케이
션을 작성할수는 있다. 그러나 시스템 사양이 높아지고 컴파일러의 성능이 향상됨에 따라 굳이 어셈블리를
통해서 전체 프로젝트를 수행할만한 명분이 점점 사라져가는것 또한 사실이다. 실제로 예전에 많이 사용하
던 인라인 어셈블리의 경우에도 자칫하면 수행속도를 떨어뜨리는 결과를 가져오는 경우도 종종 있다.(인라
인 어셈블리나 레지스터변수의 사용등이 컴파일러의 최적화기능을 방해하는 경우가 발생할수 있기때문이
다.)
실제로 어셈블리로 프로그래밍한다는 것은 어셈블러의 기능을 익히는것이 어셈블리를 익히는것보다 크게 작
용한다. 예를 들면 아래와 같다.
; 메시지 루프
@@: ; start of loop
invoke GetMessage, addr msg, NULL, 0, 0
or eax, eax
je @f
invoke TranslateMessage, addr msg
invoke DispatchMessage, addr msg
jmp @b
@@: ; end of loop
필자가 사용하는 어셈블리 템플리트 코드의 일부분으로서, 우리가 API로 윈도프로그램을 작성할때 대부분
공통적으로 적용되는 메시지루프의 로직이다. 대충 훑어보면 C로 작성했을때와 비슷한점을 많이 볼수 있
다. 이것은 어셈블러가 고급언어의 특성을 지원하기 위한 의사명령을 제공하기 때문이다. 예를 들면 invoke
라는 단어를 보자. 이것은 실제로 기계어로 1:1 대응되는 명령어가 아니다. 확인하고 싶다면 C로 동일한 기
능의 프로그램을 작성한다음, 디버거의 디스어셈블리창을 통해서 실제로 기계어(어셈블리)로 변환된 코드
를 확인해보자. 분명 invoke라는 명령어는 찾을 수 없을 것이다. 이러한 것들을 통틀어서 의사명령이라고
한다. 실제로 기계어로 변환되지 않지만 어셈블러가 어셈블을 하기위한 환경이나 조건을 제시해주는 것이
다.(바로 고급언어의 특성이다.) 그뿐 아니라 @@, @f, @b 등의 라벨들 또한 실제 기계어로 어셈블될때에는
실제 주소로 치환될것이다. 이렇듯 실제로 어셈블리 프로그래밍을 배운다는 것은 단순히 어셈블리(기계어
로 치환되는)를 익히는것보다는 어셈블러가 제공하는 기능이나 문법을 배운다는 의미가 크다. 그러나 우리
는 어셈블리로 프로그램을 작성하려는 목적이 아니므로 실제로 어셈블리가 어떻게 쓰여지는가를 주로 다루
어보도록 한다.
2. CPU를 알면 프로그램이 보인다 !!
또 다시 CPU에 관한 얘기를 해야할때가 왔다. 결국 프로그램을 수행하는 주체는 CPU이므로 CPU를 모르고서
는 프로그래밍을 말할 수 없다. 어셈블리나 시스템 프로그래밍을 다루는 많은 책에서 CPU 아키텍쳐에 대한
설명이 많이 있지만, 그 모든것을 여기서 다룰수는 없으므로 자세한것들은 참고서적을 이용하도록 하고, 필
자의 생각으로 꼭 짚고 넘어가야할 몇가지만 언급하겠다. CPU를 알기위해서 CPU가 다루는 데이터와 명령을
알아야 할것이다. CPU가 다룰수 있는 데이터와 명령의 종류는 CPU마다 차이가 있고 명령의 종류 또한 한두
개가 아니지만, 우리는 Intel x86 계열의 CPU 위주로 설명해나가자. CPU가 처리하는 명령의 종류는 CPU가
업그레이드될때마다 추가되기도 확장되기도 하는데(좋은예로 MMX 코드를 들수있다. MMX 코드는 CPU가 멀티
미디어 데이터처리를 빠르게 하기위해서 지원되는 명령어세트이다.) 필요한 명령어들은 조금 있다가 알아보
도록 하고, 일단 CPU가 다루는 데이터에 대해 얘기해보자. 지난 강좌에서 CPU는 메모리만을 다룬다고 했었
는데, 사실은 메모리와 레지스터라는 기억장소를 데이터로써 다룬다. 레지스터는 CPU 내부의 임시기억장소
라고 보면 될것이다. 따라서 CPU가 다루는 가장 빠른 매체라고 보면될것이다. x86 레지스터에는 8개의 범용
레지스터와 6개의 세그먼트레지스터, 인스트럭션 포인터 레지스터와 플래그레지스터, 디버그 레지스터등,
여러가지 종류가 있는데 보통 사용자가 다룰수 있는 레지스터의 종류와 그 용도는 다음과 같다.
<레지스터의 종류와 용도>
eax : 정수 함수의 반환값들을 담는용도로 사용된다.
ebx : 뭐 특별한건 없는것 같다. 그냥 범용으로 사용된다.
ecx : 반복문에서 카운터로 사용된다.
edx : 64비트(large integer) 값들의 상위 32비트를 담는 용도로 사용된다.
esi : 보통 메모리 이동이나 비교시에 원본주소를 담는 용도로 사용된다.
edi : 보통 메모리 이동이나 비교시에 대상(타겟)주소를 담는 용도로 사용된다.
esp : 스택포인터, 스택의 꼭대기(x86은 스택이 아래로 자라므로 실제로는 바닥이 되겠다.)를 가리킨다. 스
택에 데이터가 푸쉬하거나 팝될때마다 증감된다.
ebp : 스택프레임, 프로시저(함수)에 대한 스택프레임을 담는 용도로 사용된다.
cs : 코드세그먼트, 코드영역의 세그먼트를 지닌다. 32비트 어드레싱에서는 별의미가 없다.
ds : 데이터세그먼트, 데이터영역의 세그먼트를 지닌다. 역시 32비트 어드레싱에서는 별의미가 없다.
eip : 인스트럭션 포인터, 실행중인(정확히는 실행되어질) 코드의 주소를 가지고 있다.
이밖에도 몇가지 레지스터가 더 존재하지만 CPU가 내부적으로 사용하거나, 사용자가 건드릴수 없는것들이므
로 일단 위에 리스트된 레지스터만이라도 숙지하고 넘어가자. 어셈블리가 처음인 사람에게는 저게 무슨얘긴
가 하겠지만, 일단 외워두던가 아님 한쪽에 잘 프린트해서 붙여놓자. 나중에 실제 코드를 보게되면 이해가
될것이므로... 참고로 접두어 e가 붙은 레지스터이름은(eax, esi, eip 등...) extended의 약자로서 32비트
확장레지스터라는 의미이며, 4바이트(32비트)크기를 가지며, 기존의 16비트로 엑세스하려면 e를 뺀 ax 등
의 형태로 쓰여지며, 명시적으로 상위/하위 영역을 지정하려면 ah, al의 형태로 사용된다.
3. 구조화의 시작, 서브루틴(함수) !!
서브루틴은 보통 함수(반환값이 있는)와 프로시저(반환값이 없는)로 구분하는데 아시다시피 C언어는 프로시
저와 함수의 구별이 없이 무조건 다 함수로 취급한다. 그러므로 C언어에서는 프로시저와 함수는 동일한 의
미로 사용한다. 실제로 프로그램의 실행이라는 것이 main함수가 시작됨으로써 시작되며, main함수가 종료됨
으로써 프로그램도 종료된다고 알고있다. 이렇듯 프로그램 자체도 하나의 함수로 취급되는것이 일반적이므
로 함수가 어떻게 구현되는지에 안다는 것은 프로그램의 흐름을 이해하는데 매우 중요한 의미를 가진다.
함수(또는 프로시저)는 특정한 목적을 위해 구현된 코드덩어리이다. 함수호출은 실제로 프로그램의 지시포
인터(인스트럭션 포인터, ip)를 함수가 구현된 코드로 옮겨서 실행한뒤 원래의 호출한쪽의 코드로 되돌아
오는 일련의 작업이다. 백문이 불여일견!! 실제로 함수호출이 어떻게 구현되는지 VC++ 디스어셈블러를 통해
서 확인해보도록 하자.
간단히 아래와 같은 코드를 작성한뒤 디버거에서 디스어셈블리코드를 확인해보자.
int TestFunc(int a, char b)
{
return a+1;
}
int main()
{
int ret;
ret = TestFunc(3, "a");
return 0;
}
일단 main()함수내에서 TestFunc()을 호출하는 부분을 살펴보자.
19: ret = TestFunc(3, "a");
00401098 push 61h
0040109A push 3
0040109C call @ILT+5(TestFunc) (0040100a)
004010A1 add esp,8
004010A4 mov dword ptr [ebp-4],eax
어셈블리는 [명령어] [매개변수], ... 의 형식을 갖는데 일단 위에서 나오는 명령어를 살펴보자.
push 명령으로 정수 3과 문자 "a"를 스택에 집어넣는다. 이 작업으로 스택포인터는 8바이트 감소하는데, 스
택은 4바이트로 정렬되기때문에 1바이트인 문자를 넣더라도 4바이트가 감소한다. 스택포인터가 감소하는 이
유는 스택이 아래로 자라기때문이다.
call 명령으로 TestFunc 함수로 실행주소를 옮긴다(점프한다). call 명령은 jmp 명령과 더불어 실행흐름
(eip)을 변경할수 있는 명령이다. jmp는 단순히 실행위치를 옮기는데 그치지만, call은 돌아올 주소를 스
택에 백업한뒤 실행위치를 옮긴다. 따라서 TestFunc이 호출되는 순간에는 호출되기 전보다 스택포인터는 12
만큼 감소되어 있을것이다.(직접 디버거로 확인해보라.)
add 명령으로 esp(스택포인터)를 8바이트만큼 증가시킨다. 스택을 함수를 부르기전으로 맞춰놓는작업을 해
주는 것이다. 어라? 그런데 이상하다. 아까 매개변수 8바이트 + 리턴주소 4바이트, 분명 12바이트가 감소되
었다고 했었는데? 그 이유는 다음에 나올 함수코드를 보면 이해할수 있다. 함수의 마지막에 리턴주소로 돌
아가는 ret 명령을 만나는데 이작업으로 스택에 저장된 리턴주소를 pop해주게 된다. 결국 스택은 정확하게
복원된다.(많은 책에서는 내부적으로 call 명령과 ret 명령이 리턴주소를 저장하기위해 스택을 사용한다고
설명하는데, 실제로 디버거로 따라가보면 이를 명확하게 확인할 수 있다.)
다음코드는 TestFunc 함수의 내부코드이다. 눈으로 쭉 따라가보자.
9: int TestFunc(int a, char b)
10: {
00401020 push ebp ; (1) 함수 시작코드 !!
00401021 mov ebp,esp
00401023 sub esp,40h ; (2) 지역변수를 위한 공간확보 !!
00401026 push ebx
00401027 push esi
00401028 push edi
00401029 lea edi,[ebp-40h]
0040102C mov ecx,10h
00401031 mov eax,0CCCCCCCCh
00401036 rep stos dword ptr [edi]
11: return a+1;
00401038 mov eax,dword ptr [ebp+8] ; (3) 반환값 설정 !!
0040103B add eax,1
12: }
0040103E pop edi
0040103F pop esi
00401040 pop ebx
00401041 mov esp,ebp ; (4) 함수 종료코드 !!
00401043 pop ebp
00401044 ret
일단 현재 TestFunc이 호출된 시점에서의 스택의 상태는 매개변수와 되돌아갈 주소가 담겨져있다. (아까 위
에서 매개변수 61h, 3과 call 명령이 리턴주소를 푸쉬했다.) 이것도 한번 확인해보자. 함수의 시작에 중단
점을 걸고(함수의 시작에 중단점을 설정하려면 중괄호 열기("{")에다가 중단점을 지정하면 된다.), 레지스
터윈도우에서 esp의 값을 복사한뒤 메모리윈도우에서 붙여넣어보자. 그냥 보면 틀림없이 바이트 형태로 보
여질테니까 메모리윈도에서 오른쪽 버튼을 눌러서 long hex format(4바이트 형식)을 선택해서 보자. (메모
리 형식을 변경하면 갑자기 엉뚱한 곳으로 메모리가 튀는경우가 종종있는데, 이것은 VC++ 디버거의 버그인
것 같다. 그럴땐 다시 주소를 지정해주면 된다.)
0012FF24 004010A1 00000003 00000061
0012FF30 00000000 00000000 7FFDF000
...
필자의 시스템에서는 esp의 값이 0x0012FF24이다. 스택은 아래로 자란다고 했으니 실제 메모리상에는 리턴
주소, 3, "a" 순서로 들어있을 것이다. 보아하니 리턴주소는 0x004010a1이고, 정수 3과 "a"(아스키코드
0x61)이 올바르게 들어있는것을 볼수 있을것이다.
자, 이제 매개변수와 리턴주소가 어떤 형식으로 스택에 보관되어 있는지를 알았으니 함수를 시작해보
자. "(1)함수 시작코드"를 보자.
00401020 push ebp ; (1) 함수 시작코드 !!
00401021 mov ebp,esp
일단, ebp의 값을 스택에 백업한다. 그런다음 mov 명령으로 esp의 값을 ebp로 복사(이동)한다. mov 명령은
오른쪽에 있는 값을 왼쪽으로 이동하는 명령이다. 현재 esp는 리턴주소를 가리키고 있으므로 ebp도 리턴주
소를 가리키고 있을것이다. 이것이 일반적으로 C언어에서 함수를 기술할때 사용되는 형태의 함수 시작코드
이다. 일단 이상태에서 매개변수를 접근하려면 어떻게 해야할까? esp+알파, 또는 ebp+알파의 형태로 매개변
수를 접근할수 있을것이다(위에서 확인했던것처럼). 그러나 esp(스택포인터) 함수내부에서 또 다른 함수를
호출한다든가 하는 경우에 얼마든지 변할수 있으므로 함수내부에서는 ebp를 기준으로 매개변수를 접근한
다. 자, 잘 이해가 안된다면 일단은 외워서라도 알고 있자. "esp+알파의 형식으로 함수의 매개변수(파라미
터)를 접근한다."
중간은 다음에 보도록 하고 "(4)함수의 종료코드"를 먼저 보도록 하자.
00401041 mov esp,ebp ; (4) 함수 종료코드 !!
00401043 pop ebp
00401044 ret
함수의 종료코드는 함수의 시작코드와 반대의 작업을 해준다. esp의 값을 ebp로 복원한뒤, 스택에 백업된
ebp를 복원한다. pop명령은 push와 반대로 스택에 있는 데이터를 꺼내오는 일은 한다. (혹시 헷갈릴까봐 노
파심에서 얘기하면 pop ebp는 ebp를 꺼내온다는 의미가 아니고 스택에서 꺼내온 데이터를 ebp에 집어넣는다
는 의미이다. 필자는 처음에 이것을 헷갈렸던것 같은데... 다른사람들은 안그렇나?) 마지막으로 ret 명령으
로 함수를 호출한 쪽으로 되돌아간다. (이것도 정확히 말하면 함수를 호출한 바로 다음주소로 돌아간다.)
아까 call명령이 리턴주소를 스택에 푸쉬한다음 점프한다고 했는데, ret은 반대로 스택에서 리턴주소를 팝
하고 그 주소로 점프한다.
자, 그러면 나머지를 보도록하자. 먼저 "(2)지역변수를 위한 공간확보"를 살펴보자.
00401023 sub esp,40h ; (2) 지역변수를 위한 공간확보 !!
sub 명령은 add 명령과 반대의 기능을 수행하는 명령으로 스택포인터(esp)를 40h 만큼 감소시킨다. 이것은
스택에 지역변수를 위한 공간을 할당하는 것으로 위 명령의 실행되는 시점에서의 esp와 esp-40h의 40h만큼
의 공간을 지역변수를 위해 사용할것이라는 의미이다. 지난강좌에서 LoadLibrary를 호출할때 사용할 매개변
수인 문자열을 위해서 지역변수 공간을 사용했었다. (잘 생각이 안난다면 지난강좌에서
FAKE_LOADLIBRARY_CODE 구조체를 설정하는 InjectSpyDll() 함수를 다시 보도록 하자.) 어쨋든 여기서 또하
나 중요한 사실은 지역변수는 ebp-알파의 형태로 접근된다는 사실이다.(역시 잘 이해가 안된다면 외워서라
고 알고있자.)
여기서 재미있는 사실하나를 발견하게 된다. 사실 위의 코드는 디버그 버전으로 빌드된 바이너리의 실행코
드이다. 실제로 TestFunc()은 지역변수를 사용하지 않는데도 불구하고 지역변수를 위한 공간을 확보하고
그 공간을 온통 0xcc의 값으로 채우는것을 볼수있다. 사실 릴리즈 모드로 빌드하게 되면 TestFunc() 아래처
럼 간단한 어셈블리코드로 변환된다.
00401000 mov eax,dword ptr [esp+4]
00401004 inc eax
00401005 ret
어쨋든 굳이 지역변수 공간을 잡고 그 안을 0xcc로 채우 는이유를 생각해보면 먼저 0xcc의 값에 대해 알아
볼 필요가 있다. 0xcc는 어셈블리 코드로 int 3 이 다. (이전 강좌에서 본적이 있다. 바로 중단점이다 !!)
초기화되지 않은 지역변수의 값이 0xcccccccc인 것 또한 우연이 아니다. 컴파일러가 디버그 모드일때에 수
행할 에러처리를 위해 위와 같은 검사루틴을 삽인한것으로 유추할 수 있다. (일단 저런 메모리 공간으로 뛰
어들게 되면 중단점으로 인해서 디버거가 활성화될것이며 이벤트처리기는 적절한 처리를 해줄수 있을것이
다.)
마지막으로 "(3)반환값 설정"을 보자.
00401038 mov eax,dword ptr [ebp+8] ; (3) 반환값 설정 !!
0040103B add eax,1
앞에서 ebp+알파의 형식으로 함수의 매개변수를 접근한다고 했다. 대괄호("[]") 표시는 C언어의 *(간접지정
연산자)와 비슷한 용도로 사용된다. 대괄호안의 값을 포인터로 인식하고 포인터가 가리키는 값을 가져온
다. 어쨌든 첫번째 매개변수 정수 a를 eax 레지스터에 싣고, add 명령으로 1 증가시킨다. eax 포인터는 일
반적으로 리턴값을 담는용도로 사용된다. 따라서 a+1의 값이 eax에 담기면서 리턴값으로 처리된다.
4. 끝으로
오늘은 어셈블리에 대한 기본적인 설명과 더불어 C언어에서 함수호출이 어떻게 구현되는지를 어셈블리를 통
해 상세히 알아보았다. 오늘 강좌에서 가장 중요한 것은 뭐니뭐니 해도 C언어의 함수호출 메카니즘일 것이
다. 매개변수와 지역변수가 어떻게 쓰여지며, 함수의 반환값은 또 어떻게 구현되는지. 이것들만큼은 반드
시 이해하고 넘어가길 바란다. 필자의 경험으로는 함수호출 메카니즘을 이해하는 가장 좋은 방법은 VC++ 디
버거를 이용하는것이다. 디버거의 레지스터윈도, 메모리윈도, 디스어셈블리윈도를 띄어놓고서 한줄한줄
(Step-by-Step) 진행하면서 레지스터와 메모리의 값들을 비교하면서 따라가다보면 이해하기가 한결 수월할
것이다. 물론 옆에 연습장 가져다 놓구 그림도 그려가면서 말이다.
5. 다음강좌에서는
다음강좌는 Win32 어셈블리 두번째 시간이 될것이다. 다음시간에는 오늘 배운 함수호출 메카니즘을 토대로
각각의 호출규약에 따른 함수들의 형태를 다루며, 조건문과 반복문이 어떻게 어셈블리로 쓰여지는지와 시간
이 된다면 Win32 구조화 예외처리(SEH)가 실제로 어떻게 구현되는지를 다뤄보도록 하자. 느끼고 있는지는
모르지만 다음 강좌까지만 마스터한다면 "시스템프로그래밍의 사생아"라 불리는 바이러스 제작에 대해서도
심도있게 다루어볼수 있을것이다. (물론 강좌를 진행할 생각은 아직 없지만 말이다.) 사실 바이러스와 전
역 API 후킹은 여러모로 비슷한 점이 많이 있다. 크게 다른것은 사용자의 인증을 받은것인지 그렇지 않은지
의 차이가 있을뿐이다. 이쯤되면 귀가 번쩍 트이는 독자분도 여럿될텐데 추천할만한 서적을 하나 소개하고
오늘 강좌를 마치고자 한다.
시스템프로그래밍에 대한 국내서적은 거의 전무하다시피한데 재미있는 책한권을 소개한다. 노파심에서 말씀
드리면 이책의 저자나 출판사와 필자는 아무런 상관관계가 없음을 밝힌다. (주)정보게이트 라는 출판사에
서 나온 "파괴의 광학"이라는 책이다. 이책의 저자인 김성우님은 월간 마이크로소프트웨어에서 윈도 시스
템 해킹을 주제로 강좌를 진행한적이 있는데, 그 강좌의 내용을 모아 한권의 책으로 출간하게 되었다. 주제
가 시스템 해킹이고 심지어는 바이러스제작까지 다루고 있지만, 기존의 해킹관련 서적들이 단순한 프로그
램 사용법을 소개하는데 그치는 수준인데 비해 이 책의 내용은 프로그래머를 위한 내용을 상당히 알차게 구
성하고 있다. 난이도는 상당히 높은 편이지만 본강좌의 내용을 이해할 정도라면 어렵지않게 따라갈수 있을
것이다.
<잡설 하나.>
윈도 XP의 출시가 코앞으로 다가왔더군요. 저희 회사에서도 이미 예약구매를 마친 상태이고, 몇몇 동료들
은 개인적으로도 예약을 해놓은 상태라더군요. XP가 몰고올 파장에 대해서 염려하는 분위기는 여러분들도
이미 잘 아실테지만, MS가 새 운영체제를 내놓을때마다 경쟁력있는 소프트웨어 회사가 하나씩 문을 닫거나
문을 닫을 위기에 처하는걸 보면서 이번에는 또 누가? 라는 조심스런 추측을 하곤 합니다. 인터넷 메신저,
인터넷 전화, CD Writer, DVD 플레이어, 개인방화벽 등등 이 포함된다고 하던데... 운영체제가 많은 지원을
한다는 것은 사용자입장에서는 편리한 것임에는 틀림없지만... 무언가 찜찜한것은 저만의 생각일까요?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
날씨가 많이 쌀쌀해졌죠? 혹시 감기들 안걸리셨는지... 자, 프로그래밍하기에 좋은 계절이 돌아왔습니다.
가을은 프로그래머의 계절이죠. 무더운 여름날, 모니터와 본체에서 뿜어내는 열기를 온몸으로 느끼며, 밤
을 잊던 나날들은 가고 서늘한 바람과 파란하늘이 유난히 서러운 가을이 왔습니다. 지금 이시간에도 모니터
를 연인의 얼굴삼아, 키보드, 마우스를 연인의 손목삼아 밤을 잊고 연구개발에 열중하고 있는 이땅의 수많
은 프로그래머에게 한줌의 소금이 되고자 불타는 사명감(?)에 저도 밤을 잊었습니다. ^^
지난 강좌까지 잘 따라오셨는지 궁금하군요. 지난 예제를 수정해서 다른 프로세스의 API 후킹에 성공하신
분들도 계실테고, 아직도 헤메는 분들도 계실지 모르겠습니다. 어쨋든 지난 강좌까지가 개념적인 내용의 기
초라면 오늘부터 진행할 강좌는 실질적인 기초라고 할수 있겠습니다. 그러나 기초가 제대로 잡히면 그 다음
의 응용은 아주 간단한것(물론, 거짓말이란거 다 아시죠?)들이니까 기초부터 튼튼히(!!)하자구요. ^^ 그
럼, 어느 성인(?)의 말을 인용하면서 강좌나갑니다.
"무릇 프로그래밍이라 하는것은 현자가 우매한 중생들에게 진리를 설파하는것과 같다. 오로지 그 수의 많
고 적음에 상관없이 소유와 무소유만을 기대하는 우매한 자들에게 그들만이 이해할수 있는 방법으로 세상
을 바르게 살아가는 법을 깨우쳐주는것이다."
(강좌의 진행상 존칭을 생략하겠습니다.)
1. 어셈블리, 참을수 없는 구조의 단순함 !!
흔히들 어셈블리를 어렵다고들 한다. 그러나 이것은 정확히 말하면 어셈블리로 프로그래밍하기가 어렵다는
의미이다. 왜냐면 어셈블리 자체는 매우 단순하기 때문이다. 어셈블리 자체가 매우 단순하기때문에 그 단순
한 기능만으로 요즘 추세에 맞는 복잡한 어플리케이션을 작성하기가 매우 번거롭다는것이다. (물론 최근에
나온 어셈블러들은 고급언어에 버금갈만한(물론 이것도 사실은 아니다. 예전에 비해서는 그렇다는 말이
다.) 코딩의 편의를 제공하기는 하지만) 필자가 예전에 처음 프로그래밍을 공부한지 얼마되지 않았을때 어
셈블리와 C언어의 차이점을 선배프로그래머가 이렇게 얘기했다. 여기서는 C언어를 얘기하지만 대부분의 고
급언어로 이해해도 무난할것이다.
"C언어는 "냉장고에서 사과를 꺼내고 바나나를 넣어두어라!!"라는 식으로 명령한다면, 어셈블러는 "바나나
를 들고 냉장고 앞으로 이동한다음, 냉장고 문을 열고, 사과를 꺼낸다음, 그 자리에 바나나를 넣고, 냉장
고 문을 닫아라!!" 이런식으로 명령되어져야 한다"
지금 생각하면 정말 명쾌한 설명이 아닐수없다. 실제로 C언어는 "냉장고에서 사과를 꺼내고 바나나를 넣어
라"라고만 하면 스스로 냉장고 앞으로 이동해야 한다는것, 냉장고 문을 열어야 한다는것, 사과를 꺼내고,
바나나를 넣어야 하며, 마지막으로 냉장고문을 닫아야 하는것을 이해하지만, 어셈블러는 이러한 일련의 작
업을 일일이 지정해주어야 한다는 것이다. 그렇지만 C언어든 어셈블리든 결국 행동하는 패턴은 같을수밖에
없으며, 단지 명령하는 사람의 명령을 어디까지 이해할것인가의 문제인것이다. 결과적으로 C언어가 어셈블
리보다는 더욱 똑똑하다는 것이다.
모든것은 상대적인 것일수밖에 없다. 그 옛날 어셈블러도 없이 기계어로 프로그래밍하던 세대에게 어셈블러
는 매우 편리한 도구였다. 실제로 DOS용 "LOTUS123"같은 OA프로그램이나, 당시에 엄청난 인기를 끌었던 "페
르시아왕자" 1탄 같은 프로그램들이 순전히 어셈블리로 제작되어었다는 것만 보더라도 그 시대에서는 어셈
블리가 지금처럼 어렵고 생소한 언어가 아니었을것이다. 단지 우리는 어셈블리보다는 똑똑한 C언어나 파스
칼 등의 고급언어에 익숙해져있으니 어셈블리가 생소하고 어려워보이는 것이다. (물론 예전과는 비교도 할
수없을 정도로 지금의 프로그램들은 복잡, 다양해지고 있지만) 서두에 언급했듯이 우매한 어셈블리가 세상
에서 바르게 살아나갈수 있도록 우리는 조금더 현명해져야 하며, 그들을 이해하며 눈높이를 맞출수있는 열
린마음을 가져야할때다.
어셈블리가 별것아니라는 얘기만 했는데 그렇다고 어셈블리가 정말로 별것 아닌것은 아니다. 아마 개발환경
이 아무리 편해지더라도 어셈블리가 세상에서 사라지지는 않을것이라는 필자의 생각이다. 그 이유는 다름아
닌 CPU가 명령을 처리하는 가장 하위단계는 잘 알다시피 기계어코드이다. 그러나 사람이 기계어만으로 프로
그래밍을 하거나, 기계어코드만 가지고 명령을 이해하기는 매우 힘든작업이므로(불가능한것은 아니다. 예전
에는 사람이 이런 작업을 하기도 했었다. 또 우리도 그러한 일을 곧 하게될것이다.) 기계어를 사람이 이해
하는 형식으로 표현해주어야 하는데 이것이 바로 어셈블리이다.
조금 과장해서 말한다면 어셈블리를 알면 프로그램의 흐름을 분석할 수 있으며, 원한다면 프로그램을 원하
는대로 수정할수도 있다. 실제로 디바이스드라이버의 경우, 바이너리를 역어셈블(바이너리파일을 어셈블리
코드로 변환하는것)해서 C코드를 얻어내는 작업을 실제로 행하기도 한다. 이러한 작업들을 통틀어서 역공학
(reverse engineering)이라고 하는데 실제로 쉐어웨어를 크랙하거나 패치하는 작업들도 어찌보면 간단한 수
준의 역공학이라고 부를수 있겠다. 물론 이러한일들이 쉬운일은 아니다. 완벽한 역공학을 위해서는 어셈블
리는 물론 바이너리가 생성된 환경(보통 컴파일러를 예로 들수 있겠다.)에 대한 깊은 이해를 필요로 하며,
디버깅 심볼이 제거된 릴리즈 바이너리일 경우, 데이터와 함수주소등을 오로지 단순한 메모리주소로만 사용
하게 되며, MFC등의 클래스라이브러리를 이용하는 경우에는 그 구조의 복잡함으로 생성되는 바이너리 또한
더더욱 이해하기 힘든구조가 되어버린다. 엎친데 덮친격으로 어떠한 바이너리들은 역공학을 할수없게하거
나 힘들게 만드는 anti-debugging 코드를 삽입하는 경우도 있으니 모든 바이너리를 완벽하게 역공학하기란
정말로 힘든 일이 아닐수 없다.
다시 어셈블리로 돌아와보자. 우리가 이번 강좌에서 배우고자 하는것은 어셈블리로 프로그램을 작성하는 방
법이라기보다는 실제로 컴파일러가 어떠한 기계어코드를 만들어내는지와 CPU가 어떻게 프로그램을 해석하
고 실행하는지를 이해하기 위한 방법으로 어셈블리를 배우고자 한다. 물론 어셈블리만으로 윈도 어플리케이
션을 작성할수는 있다. 그러나 시스템 사양이 높아지고 컴파일러의 성능이 향상됨에 따라 굳이 어셈블리를
통해서 전체 프로젝트를 수행할만한 명분이 점점 사라져가는것 또한 사실이다. 실제로 예전에 많이 사용하
던 인라인 어셈블리의 경우에도 자칫하면 수행속도를 떨어뜨리는 결과를 가져오는 경우도 종종 있다.(인라
인 어셈블리나 레지스터변수의 사용등이 컴파일러의 최적화기능을 방해하는 경우가 발생할수 있기때문이
다.)
실제로 어셈블리로 프로그래밍한다는 것은 어셈블러의 기능을 익히는것이 어셈블리를 익히는것보다 크게 작
용한다. 예를 들면 아래와 같다.
; 메시지 루프
@@: ; start of loop
invoke GetMessage, addr msg, NULL, 0, 0
or eax, eax
je @f
invoke TranslateMessage, addr msg
invoke DispatchMessage, addr msg
jmp @b
@@: ; end of loop
필자가 사용하는 어셈블리 템플리트 코드의 일부분으로서, 우리가 API로 윈도프로그램을 작성할때 대부분
공통적으로 적용되는 메시지루프의 로직이다. 대충 훑어보면 C로 작성했을때와 비슷한점을 많이 볼수 있
다. 이것은 어셈블러가 고급언어의 특성을 지원하기 위한 의사명령을 제공하기 때문이다. 예를 들면 invoke
라는 단어를 보자. 이것은 실제로 기계어로 1:1 대응되는 명령어가 아니다. 확인하고 싶다면 C로 동일한 기
능의 프로그램을 작성한다음, 디버거의 디스어셈블리창을 통해서 실제로 기계어(어셈블리)로 변환된 코드
를 확인해보자. 분명 invoke라는 명령어는 찾을 수 없을 것이다. 이러한 것들을 통틀어서 의사명령이라고
한다. 실제로 기계어로 변환되지 않지만 어셈블러가 어셈블을 하기위한 환경이나 조건을 제시해주는 것이
다.(바로 고급언어의 특성이다.) 그뿐 아니라 @@, @f, @b 등의 라벨들 또한 실제 기계어로 어셈블될때에는
실제 주소로 치환될것이다. 이렇듯 실제로 어셈블리 프로그래밍을 배운다는 것은 단순히 어셈블리(기계어
로 치환되는)를 익히는것보다는 어셈블러가 제공하는 기능이나 문법을 배운다는 의미가 크다. 그러나 우리
는 어셈블리로 프로그램을 작성하려는 목적이 아니므로 실제로 어셈블리가 어떻게 쓰여지는가를 주로 다루
어보도록 한다.
2. CPU를 알면 프로그램이 보인다 !!
또 다시 CPU에 관한 얘기를 해야할때가 왔다. 결국 프로그램을 수행하는 주체는 CPU이므로 CPU를 모르고서
는 프로그래밍을 말할 수 없다. 어셈블리나 시스템 프로그래밍을 다루는 많은 책에서 CPU 아키텍쳐에 대한
설명이 많이 있지만, 그 모든것을 여기서 다룰수는 없으므로 자세한것들은 참고서적을 이용하도록 하고, 필
자의 생각으로 꼭 짚고 넘어가야할 몇가지만 언급하겠다. CPU를 알기위해서 CPU가 다루는 데이터와 명령을
알아야 할것이다. CPU가 다룰수 있는 데이터와 명령의 종류는 CPU마다 차이가 있고 명령의 종류 또한 한두
개가 아니지만, 우리는 Intel x86 계열의 CPU 위주로 설명해나가자. CPU가 처리하는 명령의 종류는 CPU가
업그레이드될때마다 추가되기도 확장되기도 하는데(좋은예로 MMX 코드를 들수있다. MMX 코드는 CPU가 멀티
미디어 데이터처리를 빠르게 하기위해서 지원되는 명령어세트이다.) 필요한 명령어들은 조금 있다가 알아보
도록 하고, 일단 CPU가 다루는 데이터에 대해 얘기해보자. 지난 강좌에서 CPU는 메모리만을 다룬다고 했었
는데, 사실은 메모리와 레지스터라는 기억장소를 데이터로써 다룬다. 레지스터는 CPU 내부의 임시기억장소
라고 보면 될것이다. 따라서 CPU가 다루는 가장 빠른 매체라고 보면될것이다. x86 레지스터에는 8개의 범용
레지스터와 6개의 세그먼트레지스터, 인스트럭션 포인터 레지스터와 플래그레지스터, 디버그 레지스터등,
여러가지 종류가 있는데 보통 사용자가 다룰수 있는 레지스터의 종류와 그 용도는 다음과 같다.
<레지스터의 종류와 용도>
eax : 정수 함수의 반환값들을 담는용도로 사용된다.
ebx : 뭐 특별한건 없는것 같다. 그냥 범용으로 사용된다.
ecx : 반복문에서 카운터로 사용된다.
edx : 64비트(large integer) 값들의 상위 32비트를 담는 용도로 사용된다.
esi : 보통 메모리 이동이나 비교시에 원본주소를 담는 용도로 사용된다.
edi : 보통 메모리 이동이나 비교시에 대상(타겟)주소를 담는 용도로 사용된다.
esp : 스택포인터, 스택의 꼭대기(x86은 스택이 아래로 자라므로 실제로는 바닥이 되겠다.)를 가리킨다. 스
택에 데이터가 푸쉬하거나 팝될때마다 증감된다.
ebp : 스택프레임, 프로시저(함수)에 대한 스택프레임을 담는 용도로 사용된다.
cs : 코드세그먼트, 코드영역의 세그먼트를 지닌다. 32비트 어드레싱에서는 별의미가 없다.
ds : 데이터세그먼트, 데이터영역의 세그먼트를 지닌다. 역시 32비트 어드레싱에서는 별의미가 없다.
eip : 인스트럭션 포인터, 실행중인(정확히는 실행되어질) 코드의 주소를 가지고 있다.
이밖에도 몇가지 레지스터가 더 존재하지만 CPU가 내부적으로 사용하거나, 사용자가 건드릴수 없는것들이므
로 일단 위에 리스트된 레지스터만이라도 숙지하고 넘어가자. 어셈블리가 처음인 사람에게는 저게 무슨얘긴
가 하겠지만, 일단 외워두던가 아님 한쪽에 잘 프린트해서 붙여놓자. 나중에 실제 코드를 보게되면 이해가
될것이므로... 참고로 접두어 e가 붙은 레지스터이름은(eax, esi, eip 등...) extended의 약자로서 32비트
확장레지스터라는 의미이며, 4바이트(32비트)크기를 가지며, 기존의 16비트로 엑세스하려면 e를 뺀 ax 등
의 형태로 쓰여지며, 명시적으로 상위/하위 영역을 지정하려면 ah, al의 형태로 사용된다.
3. 구조화의 시작, 서브루틴(함수) !!
서브루틴은 보통 함수(반환값이 있는)와 프로시저(반환값이 없는)로 구분하는데 아시다시피 C언어는 프로시
저와 함수의 구별이 없이 무조건 다 함수로 취급한다. 그러므로 C언어에서는 프로시저와 함수는 동일한 의
미로 사용한다. 실제로 프로그램의 실행이라는 것이 main함수가 시작됨으로써 시작되며, main함수가 종료됨
으로써 프로그램도 종료된다고 알고있다. 이렇듯 프로그램 자체도 하나의 함수로 취급되는것이 일반적이므
로 함수가 어떻게 구현되는지에 안다는 것은 프로그램의 흐름을 이해하는데 매우 중요한 의미를 가진다.
함수(또는 프로시저)는 특정한 목적을 위해 구현된 코드덩어리이다. 함수호출은 실제로 프로그램의 지시포
인터(인스트럭션 포인터, ip)를 함수가 구현된 코드로 옮겨서 실행한뒤 원래의 호출한쪽의 코드로 되돌아
오는 일련의 작업이다. 백문이 불여일견!! 실제로 함수호출이 어떻게 구현되는지 VC++ 디스어셈블러를 통해
서 확인해보도록 하자.
간단히 아래와 같은 코드를 작성한뒤 디버거에서 디스어셈블리코드를 확인해보자.
int TestFunc(int a, char b)
{
return a+1;
}
int main()
{
int ret;
ret = TestFunc(3, "a");
return 0;
}
일단 main()함수내에서 TestFunc()을 호출하는 부분을 살펴보자.
19: ret = TestFunc(3, "a");
00401098 push 61h
0040109A push 3
0040109C call @ILT+5(TestFunc) (0040100a)
004010A1 add esp,8
004010A4 mov dword ptr [ebp-4],eax
어셈블리는 [명령어] [매개변수], ... 의 형식을 갖는데 일단 위에서 나오는 명령어를 살펴보자.
push 명령으로 정수 3과 문자 "a"를 스택에 집어넣는다. 이 작업으로 스택포인터는 8바이트 감소하는데, 스
택은 4바이트로 정렬되기때문에 1바이트인 문자를 넣더라도 4바이트가 감소한다. 스택포인터가 감소하는 이
유는 스택이 아래로 자라기때문이다.
call 명령으로 TestFunc 함수로 실행주소를 옮긴다(점프한다). call 명령은 jmp 명령과 더불어 실행흐름
(eip)을 변경할수 있는 명령이다. jmp는 단순히 실행위치를 옮기는데 그치지만, call은 돌아올 주소를 스
택에 백업한뒤 실행위치를 옮긴다. 따라서 TestFunc이 호출되는 순간에는 호출되기 전보다 스택포인터는 12
만큼 감소되어 있을것이다.(직접 디버거로 확인해보라.)
add 명령으로 esp(스택포인터)를 8바이트만큼 증가시킨다. 스택을 함수를 부르기전으로 맞춰놓는작업을 해
주는 것이다. 어라? 그런데 이상하다. 아까 매개변수 8바이트 + 리턴주소 4바이트, 분명 12바이트가 감소되
었다고 했었는데? 그 이유는 다음에 나올 함수코드를 보면 이해할수 있다. 함수의 마지막에 리턴주소로 돌
아가는 ret 명령을 만나는데 이작업으로 스택에 저장된 리턴주소를 pop해주게 된다. 결국 스택은 정확하게
복원된다.(많은 책에서는 내부적으로 call 명령과 ret 명령이 리턴주소를 저장하기위해 스택을 사용한다고
설명하는데, 실제로 디버거로 따라가보면 이를 명확하게 확인할 수 있다.)
다음코드는 TestFunc 함수의 내부코드이다. 눈으로 쭉 따라가보자.
9: int TestFunc(int a, char b)
10: {
00401020 push ebp ; (1) 함수 시작코드 !!
00401021 mov ebp,esp
00401023 sub esp,40h ; (2) 지역변수를 위한 공간확보 !!
00401026 push ebx
00401027 push esi
00401028 push edi
00401029 lea edi,[ebp-40h]
0040102C mov ecx,10h
00401031 mov eax,0CCCCCCCCh
00401036 rep stos dword ptr [edi]
11: return a+1;
00401038 mov eax,dword ptr [ebp+8] ; (3) 반환값 설정 !!
0040103B add eax,1
12: }
0040103E pop edi
0040103F pop esi
00401040 pop ebx
00401041 mov esp,ebp ; (4) 함수 종료코드 !!
00401043 pop ebp
00401044 ret
일단 현재 TestFunc이 호출된 시점에서의 스택의 상태는 매개변수와 되돌아갈 주소가 담겨져있다. (아까 위
에서 매개변수 61h, 3과 call 명령이 리턴주소를 푸쉬했다.) 이것도 한번 확인해보자. 함수의 시작에 중단
점을 걸고(함수의 시작에 중단점을 설정하려면 중괄호 열기("{")에다가 중단점을 지정하면 된다.), 레지스
터윈도우에서 esp의 값을 복사한뒤 메모리윈도우에서 붙여넣어보자. 그냥 보면 틀림없이 바이트 형태로 보
여질테니까 메모리윈도에서 오른쪽 버튼을 눌러서 long hex format(4바이트 형식)을 선택해서 보자. (메모
리 형식을 변경하면 갑자기 엉뚱한 곳으로 메모리가 튀는경우가 종종있는데, 이것은 VC++ 디버거의 버그인
것 같다. 그럴땐 다시 주소를 지정해주면 된다.)
0012FF24 004010A1 00000003 00000061
0012FF30 00000000 00000000 7FFDF000
...
필자의 시스템에서는 esp의 값이 0x0012FF24이다. 스택은 아래로 자란다고 했으니 실제 메모리상에는 리턴
주소, 3, "a" 순서로 들어있을 것이다. 보아하니 리턴주소는 0x004010a1이고, 정수 3과 "a"(아스키코드
0x61)이 올바르게 들어있는것을 볼수 있을것이다.
자, 이제 매개변수와 리턴주소가 어떤 형식으로 스택에 보관되어 있는지를 알았으니 함수를 시작해보
자. "(1)함수 시작코드"를 보자.
00401020 push ebp ; (1) 함수 시작코드 !!
00401021 mov ebp,esp
일단, ebp의 값을 스택에 백업한다. 그런다음 mov 명령으로 esp의 값을 ebp로 복사(이동)한다. mov 명령은
오른쪽에 있는 값을 왼쪽으로 이동하는 명령이다. 현재 esp는 리턴주소를 가리키고 있으므로 ebp도 리턴주
소를 가리키고 있을것이다. 이것이 일반적으로 C언어에서 함수를 기술할때 사용되는 형태의 함수 시작코드
이다. 일단 이상태에서 매개변수를 접근하려면 어떻게 해야할까? esp+알파, 또는 ebp+알파의 형태로 매개변
수를 접근할수 있을것이다(위에서 확인했던것처럼). 그러나 esp(스택포인터) 함수내부에서 또 다른 함수를
호출한다든가 하는 경우에 얼마든지 변할수 있으므로 함수내부에서는 ebp를 기준으로 매개변수를 접근한
다. 자, 잘 이해가 안된다면 일단은 외워서라도 알고 있자. "esp+알파의 형식으로 함수의 매개변수(파라미
터)를 접근한다."
중간은 다음에 보도록 하고 "(4)함수의 종료코드"를 먼저 보도록 하자.
00401041 mov esp,ebp ; (4) 함수 종료코드 !!
00401043 pop ebp
00401044 ret
함수의 종료코드는 함수의 시작코드와 반대의 작업을 해준다. esp의 값을 ebp로 복원한뒤, 스택에 백업된
ebp를 복원한다. pop명령은 push와 반대로 스택에 있는 데이터를 꺼내오는 일은 한다. (혹시 헷갈릴까봐 노
파심에서 얘기하면 pop ebp는 ebp를 꺼내온다는 의미가 아니고 스택에서 꺼내온 데이터를 ebp에 집어넣는다
는 의미이다. 필자는 처음에 이것을 헷갈렸던것 같은데... 다른사람들은 안그렇나?) 마지막으로 ret 명령으
로 함수를 호출한 쪽으로 되돌아간다. (이것도 정확히 말하면 함수를 호출한 바로 다음주소로 돌아간다.)
아까 call명령이 리턴주소를 스택에 푸쉬한다음 점프한다고 했는데, ret은 반대로 스택에서 리턴주소를 팝
하고 그 주소로 점프한다.
자, 그러면 나머지를 보도록하자. 먼저 "(2)지역변수를 위한 공간확보"를 살펴보자.
00401023 sub esp,40h ; (2) 지역변수를 위한 공간확보 !!
sub 명령은 add 명령과 반대의 기능을 수행하는 명령으로 스택포인터(esp)를 40h 만큼 감소시킨다. 이것은
스택에 지역변수를 위한 공간을 할당하는 것으로 위 명령의 실행되는 시점에서의 esp와 esp-40h의 40h만큼
의 공간을 지역변수를 위해 사용할것이라는 의미이다. 지난강좌에서 LoadLibrary를 호출할때 사용할 매개변
수인 문자열을 위해서 지역변수 공간을 사용했었다. (잘 생각이 안난다면 지난강좌에서
FAKE_LOADLIBRARY_CODE 구조체를 설정하는 InjectSpyDll() 함수를 다시 보도록 하자.) 어쨋든 여기서 또하
나 중요한 사실은 지역변수는 ebp-알파의 형태로 접근된다는 사실이다.(역시 잘 이해가 안된다면 외워서라
고 알고있자.)
여기서 재미있는 사실하나를 발견하게 된다. 사실 위의 코드는 디버그 버전으로 빌드된 바이너리의 실행코
드이다. 실제로 TestFunc()은 지역변수를 사용하지 않는데도 불구하고 지역변수를 위한 공간을 확보하고
그 공간을 온통 0xcc의 값으로 채우는것을 볼수있다. 사실 릴리즈 모드로 빌드하게 되면 TestFunc() 아래처
럼 간단한 어셈블리코드로 변환된다.
00401000 mov eax,dword ptr [esp+4]
00401004 inc eax
00401005 ret
어쨋든 굳이 지역변수 공간을 잡고 그 안을 0xcc로 채우 는이유를 생각해보면 먼저 0xcc의 값에 대해 알아
볼 필요가 있다. 0xcc는 어셈블리 코드로 int 3 이 다. (이전 강좌에서 본적이 있다. 바로 중단점이다 !!)
초기화되지 않은 지역변수의 값이 0xcccccccc인 것 또한 우연이 아니다. 컴파일러가 디버그 모드일때에 수
행할 에러처리를 위해 위와 같은 검사루틴을 삽인한것으로 유추할 수 있다. (일단 저런 메모리 공간으로 뛰
어들게 되면 중단점으로 인해서 디버거가 활성화될것이며 이벤트처리기는 적절한 처리를 해줄수 있을것이
다.)
마지막으로 "(3)반환값 설정"을 보자.
00401038 mov eax,dword ptr [ebp+8] ; (3) 반환값 설정 !!
0040103B add eax,1
앞에서 ebp+알파의 형식으로 함수의 매개변수를 접근한다고 했다. 대괄호("[]") 표시는 C언어의 *(간접지정
연산자)와 비슷한 용도로 사용된다. 대괄호안의 값을 포인터로 인식하고 포인터가 가리키는 값을 가져온
다. 어쨌든 첫번째 매개변수 정수 a를 eax 레지스터에 싣고, add 명령으로 1 증가시킨다. eax 포인터는 일
반적으로 리턴값을 담는용도로 사용된다. 따라서 a+1의 값이 eax에 담기면서 리턴값으로 처리된다.
4. 끝으로
오늘은 어셈블리에 대한 기본적인 설명과 더불어 C언어에서 함수호출이 어떻게 구현되는지를 어셈블리를 통
해 상세히 알아보았다. 오늘 강좌에서 가장 중요한 것은 뭐니뭐니 해도 C언어의 함수호출 메카니즘일 것이
다. 매개변수와 지역변수가 어떻게 쓰여지며, 함수의 반환값은 또 어떻게 구현되는지. 이것들만큼은 반드
시 이해하고 넘어가길 바란다. 필자의 경험으로는 함수호출 메카니즘을 이해하는 가장 좋은 방법은 VC++ 디
버거를 이용하는것이다. 디버거의 레지스터윈도, 메모리윈도, 디스어셈블리윈도를 띄어놓고서 한줄한줄
(Step-by-Step) 진행하면서 레지스터와 메모리의 값들을 비교하면서 따라가다보면 이해하기가 한결 수월할
것이다. 물론 옆에 연습장 가져다 놓구 그림도 그려가면서 말이다.
5. 다음강좌에서는
다음강좌는 Win32 어셈블리 두번째 시간이 될것이다. 다음시간에는 오늘 배운 함수호출 메카니즘을 토대로
각각의 호출규약에 따른 함수들의 형태를 다루며, 조건문과 반복문이 어떻게 어셈블리로 쓰여지는지와 시간
이 된다면 Win32 구조화 예외처리(SEH)가 실제로 어떻게 구현되는지를 다뤄보도록 하자. 느끼고 있는지는
모르지만 다음 강좌까지만 마스터한다면 "시스템프로그래밍의 사생아"라 불리는 바이러스 제작에 대해서도
심도있게 다루어볼수 있을것이다. (물론 강좌를 진행할 생각은 아직 없지만 말이다.) 사실 바이러스와 전
역 API 후킹은 여러모로 비슷한 점이 많이 있다. 크게 다른것은 사용자의 인증을 받은것인지 그렇지 않은지
의 차이가 있을뿐이다. 이쯤되면 귀가 번쩍 트이는 독자분도 여럿될텐데 추천할만한 서적을 하나 소개하고
오늘 강좌를 마치고자 한다.
시스템프로그래밍에 대한 국내서적은 거의 전무하다시피한데 재미있는 책한권을 소개한다. 노파심에서 말씀
드리면 이책의 저자나 출판사와 필자는 아무런 상관관계가 없음을 밝힌다. (주)정보게이트 라는 출판사에
서 나온 "파괴의 광학"이라는 책이다. 이책의 저자인 김성우님은 월간 마이크로소프트웨어에서 윈도 시스
템 해킹을 주제로 강좌를 진행한적이 있는데, 그 강좌의 내용을 모아 한권의 책으로 출간하게 되었다. 주제
가 시스템 해킹이고 심지어는 바이러스제작까지 다루고 있지만, 기존의 해킹관련 서적들이 단순한 프로그
램 사용법을 소개하는데 그치는 수준인데 비해 이 책의 내용은 프로그래머를 위한 내용을 상당히 알차게 구
성하고 있다. 난이도는 상당히 높은 편이지만 본강좌의 내용을 이해할 정도라면 어렵지않게 따라갈수 있을
것이다.
<잡설 하나.>
윈도 XP의 출시가 코앞으로 다가왔더군요. 저희 회사에서도 이미 예약구매를 마친 상태이고, 몇몇 동료들
은 개인적으로도 예약을 해놓은 상태라더군요. XP가 몰고올 파장에 대해서 염려하는 분위기는 여러분들도
이미 잘 아실테지만, MS가 새 운영체제를 내놓을때마다 경쟁력있는 소프트웨어 회사가 하나씩 문을 닫거나
문을 닫을 위기에 처하는걸 보면서 이번에는 또 누가? 라는 조심스런 추측을 하곤 합니다. 인터넷 메신저,
인터넷 전화, CD Writer, DVD 플레이어, 개인방화벽 등등 이 포함된다고 하던데... 운영체제가 많은 지원을
한다는 것은 사용자입장에서는 편리한 것임에는 틀림없지만... 무언가 찜찜한것은 저만의 생각일까요?