목차


0. 여는 말


1. 메모리 조작에 쓰는 타입

0) builtin 네이티브 자료형

1) "C"

2) "unsafe"

3) "syscall"

4) "reflect"


2. API 후킹

1) gominhook 패키지 소개

2) gominhook 설치

3) gominhook 사용 방법

4) gominhook을 이용한 API 후킹 예제

5) MinHook 작동 원리


3. 닫는 말


0. 여는 말


게임 핵이라든지, 스타크래프트에서 원순철씨의 그것과 같은 런처 따위를 만들 때, 타겟 프로세스의 가상 메모리를 열람하고 (열고) 읽고 쓰는 따위의 짓거리를 많이 하실 겁니다.


그럴 때 많이 쓰는 것이 C나 C++입니다. 어셈블리 다음으로 원시 기계어에 가장 가까운 표현 방식이며, 주소가 있는 메모리(입출력)를 읽고 쓰는 것에 극도로 특화된 언어니까요.


삼천포1 - 주소가 있는 메모리?


삼천포2 - C/C++는 어렵다?


저는 C++는 표현방식이 약간 지저분하다고 생각하기 때문에 사용을 최대한 지양하고 싶습니다.


그래서, 구닥다리 C++ 대신에 따끈따끈한 Go 언어를 C 언어에 연동시켜서 메모리 핵을 구현하는 방법을 개척해 봤습니다.


64비트 윈도우즈 환경에서 적용할 수 있는 최신 기술만을 다루며, 모든 정보 획득 출처는 인터넷 어딘가의 오픈소스나 도큐먼트입니다.


1. 메모리 조작에 쓰는 타입


쓸 만한 기본 패키지들을 중심으로 해서 유용한 데이터 형식과 구조를 살펴봅니다.


0) builtin 네이티브 자료형


Ref.


① type uintptr


Windows.h에 정의된 (윈도우 API) DWORD나 DWORD64 같은 느낌으로, C 언어 타입과 잘 호환됩니다.


변수 크기는 정확히 모르겠지만, 생각해 보면 사실 알 필요가 없어요.


공식 문서에 의하면 어떤 것이든 포인터를 담기에는 충분한 크기라고 합니다.


이 형식의 변수는 포인터로서 주소도 들어갈 수 있고 그냥 정수형 값을 담을 수도 있어요.


잘 보면 uintptr 이거 그냥 uint나 다름 없고 * 기호(참조 연산자)로 참조할 수도 없네요.


다만 어떤 주소값을 담기에 좋은 자료형이라는 것이 그 용도일 뿐이죠.


저는 uintptr라고 하면 그건 그냥 어떤 부호 없는 정수를 담기 위한 타입이고, 특히 포인터(주소값)를 넣기에 좋다 하는 정도로 인식하고 있습니다.


uintptr 형식의 주소값을 다른 포인터 형식으로 캐스팅하고 싶을 때는, uintptr과 완벽 호환되며 일종의 어댑터로 쓰이는 unsafe.Pointer 타입을 이용합니다.


사용 예시

var foo uintptr = 0x400000 // foo는 0x400000
var bar uint32 = uint32(foo) // bar도 0x400000


② type uint32


DWORD32랑 같은 그냥 32비트 타입입니다.


상호 변환을 지원하는 uint나 uintptr과 함께 써 주세요.


③ type uint64


DWORD64랑 같은 그냥 64비트 타입입니다.


상호 변환을 지원하는 uint나 uintptr과 함께 써 주세요.


④ type string


Go의 string 타입은 GoString이라고 해서 C의 문자열 타입이랑은 전혀 다르지만, 서로 변환하는 방법은 충분히 많이 있습니다.


C에서 주로 쓰는 문자열 형식은 'Null-terminated AOB'(널 문자로 종료되는 바이트 배열)입니다.


Go의 string도 비슷한데, 문자열의 끝에 "\x00"로 표현하는 널 문자가 없고, 인코딩으로 UTF-8을 쓴다는 정도가 다른 것 같습니다.


Windows나 C에서 주로 쓰는 문자 인코딩은 각 문자가 1바이트를 차지하는 ASCII 아니면 2바이트씩을 차지하는 UTF-16으로 통일되어 있습니다.


전자는 char[size_t] 타입이며, 후자는 양옆으로 넓은, 와이드(W;wide) 문자 표현을 쓴다고 해서 wchar[size_t] 타입입니다.


TCHAR[size_t] 타입도 있는데 템플릿처럼 컴파일러가 와이드 문자인지 그냥 1바이트 캐릭터인지 판단해서 둘 중 하나로 알아서 처리해 주는 거에요.


그런 반면 Go에서의 문자 표현은 모두 UTF-8로 통일되어 있어서, char 대신 rune 타입으로 크기가 쉽게 가변하는 UTF-8 문자 하나를 표현할 수 있습니다.


UTF-8을 채택하는 Go의 string은 []byte나 []rune 타입과 같고, (string <=> []rune)이나 (string <=> []byte)으로 서로 타입 캐스팅도 자유롭습니다.


대충 그런 점들을 감안해서 문자열을 형식에 맞게 잘 변환해서 쓰면 좋습니다.


1) "C"


Ref.


"C"는 cgo 패키지를 가리킵니다.


"C"라는 이름은 pseudo 패키지라고 해서, 보통의 패키지와 다른 특수한 기능을 합니다.


import "C" 위에 주석으로 C 코드를 적어 넣으면 그 C 프로그램을 컴파일해서 Go 프로그램과 링크를 시켜 준다는 터무니 없는 기능이죠.


또 한 가지 좋나 터무니 없는 기능은 Go 언어로 작성된 함수 정의문 위에 주석을 달아서 C 타입으로 쓸 수 있게 그 함수를 내보낼 수도 있다는 것입니다.


그 터무니 없는 기능 덕분에 Go 언어는 C++의 대체재처럼도 쓰입니다.


cgo의 자세한 용법은 공식 레퍼런스를 참고해 주세요.


아무튼 'C 패키지'에도 쓸 만한 Go 언어의 네이티브 구현 함수가 좀 있습니다.


① func C.CString(string) *C.char


C.CString()은 Go의 string을 C언어식 char 배열로 변환해 줍니다.


정확히 말하면 변환한 다음 힙에 할당해서 그 포인터를 반환으로 넘겨 줘요.


C 형식으로 생성된 데이터는 Go 형식 객체랑 다르게 GC가 주워 담지 않기 때문에 stdlib.h의 free() 같은 것으로 메모리 할당을 해제해 줘야 해요.


물론 저는 귀찮아서 소멸 따위 신경 쓰지 않습니다.


요즘 메모리 넘쳐나잖아요.


사용 예시

cstr := C.CString("The type of this is GoString.")
C.free(unsafe.Pointer(cstr))


② func C.CBytes([]byte) unsafe.Pointer


C.CBytes()는 Go의 바이트 슬라이스를 C언어식 바이트 배열로 변환해서 그 배열의 포인터를 반환합니다.


C 형식으로 생성된 데이터는 Go 형식 객체랑 다르게 GC가 주워 담지 않기 때문에 stdlib.h의 free() 같은 것으로 메모리 할당을 해제해 줘야 해요.


물론 저는 귀찮아서 소멸 따위 신경 쓰지 않습니다.


요즘 메모리 넘쳐나잖아요.


③ GoString으로 변환


저는 거의 쓰는 일이 없었지만 C언어식 문자열을 Go의 string 타입으로 변환해 주는 이런 함수들도 있습니다.


func C.GoString(*C.char) string
func C.GoStringN(*C.char, C.int) string
func C.GoBytes(unsafe.Pointer, C.int) []byte


2) "unsafe"


Go에서 C 형식의 포인터를 다룰 수 있게 하는 패키지입니다.


① type unsafe.Pointer


unsafe.Pointer는 C의 (void *)와 같은 것입니다.


Go에서 unsafe.Pointer 타입은 어떤 포인터를 형변환하기 위해서 쓰는 일종의 어댑터이며 중간 자료형입니다.


템플릿으로 구현되어 있는 건지 뭔지, unsafe.Pointer를 어떤 형식의 포인터로도 캐스팅할 수 있습니다.


unsafe.Pointer는 uintptr의 필요충분조건입니다.


(unsafe.Pointer -> uintptr)이 성립하며, (uintptr -> unsafe.Pointer)도 참입니다.


다시 말해서 unsafe.Pointer와 uintptr는 서로 완전히 호환되는 타입입니다.


사용 예시

(* int)(unsafe.Pointer(&foo)) == &foo // true 반환 // foo는 int 타입.
(C.LPCWSTR)(unsafe.Pointer(bar)) // C.LPCWSTR 타입의 포인터 반환. // bar는 uintptr 타입.


② 기타


음.


unsafe 패키지에서 쓰는 것이라고는 보통은 type unsafe.Pointer 하나뿐이네요.


3) "syscall"


Ref.


시스템 호출 API와 관련된 것들이 모두 들어 있는 패키지입니다.


운영체제마다 구현이 다름에 주의합니다.


① syscall.Syscall(trap, narg, arg1, arg2, arg3 uintptr) (ret1, ret2, err uintptr)


syscall.Syscall()은 'calling convention'이가 __stdcall로 정의된 WINAPI 함수를 호출하기 위한 시스템 콜 함수입니다.


쉽게 말해서 Go 언어로 Win32 API 쓰고 싶을 때 syscall.Syscall()을 이용해서 호출하실 수가 있습니다.


인자

- trap: 호출하고자 하는 시스템(윈도우) API의 주소

- narg: 호출하고자 하는 시스템 API에 넘겨 주고 싶은 인자의 갯수

- arg1: 호출하고자 하는 시스템 API에 넘겨 주고 싶은 첫 번째 인자

- arg2: 호출하고자 하는 시스템 API에 넘겨 주고 싶은 두 번째 인자

- arg3: 호출하고자 하는 시스템 API에 넘겨 주고 싶은 세 번째 인자


반환

- ret1: 호출한 시스템 API의 반환으로서, 보통 성공 여부를 나타내는 오류 코드가 정수로 전달됩니다.

- ret2: 이건 도대체 뭔지 사용하는 게 맞는 건지 모르겠습니다. 저는 _로 무명으로 비워 둡니다.

- err: 수행중 어떤 오류가 발생했는지 아니면 수행이 성공했는지를 나타내는 예외 문자열 같은 것이 전달됩니다.


똑같은 방식으로 호출하되 인자만 더 많이 넘기고 싶은 경우 아래 같은 함수도 이용할 수 있습니다.

syscall.Syscall6()
syscall.Syscall9()


② type syscall.DLL; type syscall.LazyDLL;


이게 왜 필요한지 뭐가 좋은지 어떤 용도로 쓰는지 알리려면 먼저 모듈에 대해서부터 구구절절히 설명해야 합니다.


윈도우즈 프로세스를 런타임 디버거로 열어서 보면, (프로세스의 가상 메모리를 살펴보면) 하나의 프로세스는 여러 개의 런타임 모듈들로 구성된다는 것을 알 수 있습니다.


보통 그런 런타임 모듈은 프로세스라는 가상 메모리에 적재된(맵핑된) .exe나 .dll 프로그램의 인스턴스겠죠.


일반적으로 하나의 프로세스에는, .exe 모듈 하나랑, user32.dll과 kernel32.dll을 필두로 한 수많은 dll들이 맵핑되어 있습니다.


윈도우즈 세계에서는 프로세스 내부에 위치한 그런 각각의 모듈의 시작 주소를 가리켜서 그 모듈의 핸들이라고 해요.


Win32 API의 GetModuleHandle()이 반환하는 HANDLE 타입의 값이 그것입니다.


그리하여, 그 모듈의 베이스가 되는 모듈 핸들에 오프셋을 얼마간 더함으로써, 정적으로 정의된 모듈의 함수 위치를 얻어올 수 있습니다.


어떤 API는, 그 API(함수)의 주소와 같아요.


그리고 그 API를 얻기 위해서는, 첫 번째로 그 API가 위치한 모듈의 주소가 필요하고, 그 다음으로 그 모듈 안에서 API가 어디에 위치해 있는지를 알아내야 하는 것입니다.


syscall의 syscall.DLL 타입은 그런 과정의 첫 단계인 모듈의 정보를 얻기 위한 도구입니다.


syscall.DLL이 있고 syscall.LazyDLL이 있는데, 저는 용도가 똑같은 것이라고 보고 구분하지 않습니다.


보통 Lazy라는 수식어는 운영체제 연구에서 많이 쓰이는 것을 봤는데, 런타임에 꼭 필요한 순간에 뒤늦게 초기화를 하는 것들을 Lazy라고 하는 것 같습니다.


그 syscall.DLL을 구하는 함수가 역시 syscall에서 제공되고 있습니다.


func syscall.NewLazyDLL(name string) *syscall.LazyDLL


사용 예시는 다음과 같습니다.


moduleKernel32 := syscall.NewLazyDLL("kernel32.dll")
hModule := moduleKernel32.Handle() // base address of kernel32.dll at runtime


syscall.LazyDLL로는 오직 .dll 모듈만 얻어올 수 있습니다.


팁을 드리자면, 메인 프로그램이 위치한 .exe 모듈 핸들을 붙잡아오기 위해서는 다음과 같이 할 수 있습니다.


hModule, _, _ = syscall.Syscall( // (HANDLE)GetModuleHandle(NULL);
	syscall.NewLazyDLL("kernel32.dll").NewProc("GetModuleHandleW").Addr(),
	1, 0, 0, 0,
)


③ type syscall.Proc; type syscall.LazyProc;


syscall.Proc은 모듈 내부의 프로시저(API 함수)를 가리킵니다.


LazyDLL의 멤버 함수를 통해서 syscall.LazyProc을 얻어올 수 있습니다.


func (d *syscall.LazyDLL) NewProc(name string) *syscall.LazyProc


사용 예시는 다음과 같습니다.


kernel32 := syscall.NewLazyDLL("kernel32.dll")
var fnVirtualProtectEx uintptr = kernel32.NewProc("VirtualProtectEx").Addr()


syscall.Proc을 통해서 함수(프로시저)의 주소를 얻었다면, 이제 그 함수를 syscall.Syscall()의 첫 번째 인자로 넘겨서 호출할 수 있습니다.


④ 와이드 문자열 변환 함수


syscall 패키지는 윈도우 API에 관련해서 도움을 줄 수 있는 별의 별 것들을 다 지원합니다.


그리고 그 연장선에서, UTF-16 인코딩을 사용하는 (wchar *) 형식의 문자열을 Go의 string 타입과 상호 변환시켜 주는 함수 역시 syscall 패키지가 제공하고 있습니다.


func syscall.StringToUTF16(s string) []uint16
func syscall.StringToUTF16Ptr(s string) *uint16
func syscall.UTF16FromString(s string) ([]uint16, error)
func syscall.UTF16PtrFromString(s string) (*uint16, error)
func syscall.UTF16ToString(s []uint16) string


이상이 윈도우 API의 LPWSTR나 LPCWSTR을 다루기 위한 헬퍼 함수입니다.


4) "reflect"


거의 안 쓰지만, Go 형식 데이터를 일반적인 방식이 아닌 뭔가 어거지로 캐스팅할 때 쓸 수 있는 패키지입니다.


Ref.


① func reflect.ValueOf(i interface{}) reflect.Value


Go 형식 데이터를 reflect.Value로 캐스팅합니다.


이것을 통해서 보통은 읽을 수 없는 값을 읽을 수 있습니다.


예를 들면 런타임에 함수 리터럴을 값으로 읽어들이거나 어떤 구조체의 주소를 강제로 얻어올 수도 있습니다.


② func reflect.Indirect(v reflect.Value) reflect.Value


reflect.Value에 대해서 수행하는 C의 *와 같은 참조 연산입니다.


어떤 reflect.Value 형식의 값으로 주소가 들어가 있다면 쓸 수 있겠죠.


③ type reflect.Value


굉장한 구조체인데, 그냥 이 형식으로 얻어온 객체 이름 뒤에 점 찍어 보면 IDE를 통해서 지원하는 함수가 다 보일 거에요.


웃기지만 그렇게 해서 값이나 주소를 마음대로 읽고 쓸 수 있습니다.


2. API 후킹


1) gominhook 패키지 소개


API 후킹에는 "github.com/nanitefactory/gominhook" 패키지를 이용하면 좋습니다.


gominhook 패키지는 Go의 네이티브 패키지는 아니고, 제가 직접 만든 Go 언어용 윈도우 API 후킹 라이브러리입니다.


흐흐, 직접 만들었다고는 하지만 사실은 Tsuda Kageyu씨의 MinHook이라는 오픈소스 라이브러리를 퍼와서 그냥 Go에서 쓸 수 있게 인터페이스를 만든 것 뿐이에요.


그래도 나름대로 의미가 있습니다.


이승원님의 리버싱 핵심 원리에서 "리버싱의 꽃"이라고까지 소개될 만큼, API 후킹이란 것은 사실 상당히 굉장히 고급 기술이거든요.


더 인용해 보자면, MinHook의 소개문에서는 이렇게 이야기하고 있습니다.[각주:1]


As you who are interested in Windows API hooking know, there is an excellent library for it by Microsoft Research named Detours. It's really useful, but its free edition (called 'Express') doesn't support the x64 environment. Though its commercial edition (called 'Professional') supports x64, it's too expensive for me to afford. It costs around US$10,000!


상용으로 판매되는 API 후킹 라이브러리는 한 카피당 USD 1만 불의 가격으로 판매되는 엄청나게 대단한 물건이라고 합니다.


어디 보자, 한화로 1천만 원이 넘는군요. 굉장하지 않나요.


기술(技術)보다도 한 단계 수준이 더 높은 것을 기법(技法)이라고 하죠.


우리 같은 프로그래머들은 이런 라이브러리의 덕분으로 너무 편하고 너무 손쉽게 API 후킹 기법을 구현할 수 있습니다.


2) gominhook 설치


cmd.exe나 윈도우즈용 bash를 열고 다음 명령어를 실행합니다.


go get -v github.com/nanitefactory/gominhook


설치는 그게 끝입니다.


다만 배포시에 주의해야 하는데, gominhook을 쓰는 프로그램은 런타임 라이브러리인 MinHook.x64.dll에 의존성이 생깁니다.


정적 라이브러리랑 빌드하는 방법은 제가 몰라서, 한 폴더에 타겟이랑 MinHook.x64.dll을 같이 넣어 주는 식으로만 쓰고 있어요.


MinHook.x64.dll은 gominhook의 바이너리 폴더 안에 함께 들어있습니다.


또는 아래 링크를 통해서도 입수할 수 있습니다.



3) gominhook 사용 방법


가장 중요한 것부터 순서대로 적어 보겠습니다.




크게 보면 minhook이 제공하는 기능은 고작 둘뿐입니다.


① Hook: 훅을 건다.

② Unhook: 훅을 해제한다.


런타임에 자유롭게 훅을 걸고 해제한다는 것은 대단히 편리한 일이지만요.




아래는 gominhook이 제공하는 모든 상수와 모든 함수를 나열하고 있습니다.


func gominhook.Initialize() error
func gominhook.Uninitialize() error

func gominhook.CreateHook(pTarget, pDetour, ppOriginal uintptr) error
func gominhook.CreateHookAPI(strModule, strProcName string, pDetour, ppOriginal uintptr) error
func gominhook.CreateHookAPIEx(strModule, strProcName string, pDetour, ppOriginal, ppTarget uintptr) error

func gominhook.RemoveHook(pTarget uintptr) error
func gominhook.EnableHook(pTarget uintptr) error
func gominhook.DisableHook(pTarget uintptr) error

func gominhook.QueueEnableHook(pTarget uintptr) error
func gominhook.QueueDisableHook(pTarget uintptr) error
func gominhook.ApplyQueued() error

const gominhook.AllHooks = NULL
const gominhook.NULL = 0


빌려온 이름이지만 작명이 좋아서 누구라도 조금 훑어 보면 어떤 기능을 하는지 감이 올 거에요.




훅을 만들기 위해서는, gominhook.CreateHook()을 호출하기만 하면 됩니다.


func gominhook.CreateHook(pTarget, pDetour, ppOriginal uintptr) error


인자

- pTarget: 훅을 걸고자 하는 함수(API)의 주소를 담는 포인터입니다. 그 함수를 훅의 타겟이라고 부릅시다. 당연한 얘기지만 어떤 함수의 주소는 그 함수의 시작 지점의 주소입니다.

- pDetour: 타겟 함수를 덮어씌울 다른 함수입니다. 즉 타겟 함수 대신 실행되게 하고 싶은 다른 함수의 주소를 담는 포인터입니다. 역시 당연한 이야기지만 '함수를 덮어씌운다'고 하는 동작은 그 함수를 재정의; 오버라이드(Override)하는 것과 같습니다. 이 함수를 우회 함수라고 부릅시다.

- ppOriginal: 타겟 함수를 덮어씌운 뒤에, 원본 함수의 주소를 받아오는 포인터의 포인터입니다. 이중 포인터가 아니면 아마 에러 날 거에요. *func(arg)ret 타입을 uintptr로 캐스팅해서 넘겨 주세요. 인자랑 반환은 디버깅하실 때 디스어셈블리 보고 추측해서 적당히 정의해 주세요.


반환

- error: 전형적인 Go 스타일의 에러입니다. 수행이 성공하면 nil을 반환합니다. 뭔가 예상처럼 잘 되지 않았으면 그 이유를 반환합니다. 예외처리를 위해서 error.Error() 함수를 이용합니다.


용례

err := gominhook.CreateHook(
	uintptr(0x400000+0x2BC94),
	uintptr(C.OnNumberUpdate),
	uintptr(unsafe.Pointer(&fpNumberUpdate)),
)
if err != nil {
	log.Fatalln(err)
}




위에서는 훅을 만들었다고 했지 훅을 걸었다고는 안 했어요.


훅을 거는 함수는 gominhook.EnableHook()입니다.


func gominhook.EnableHook(pTarget uintptr) error


인자

- pTarget: 훅을 걸고자 하는 함수(API)의 주소입니다. 이 파라미터가 gominhook.AllHooks인 경우 만들어 놓은 모든 훅이 활성화 됩니다. 보통은 그냥 gominhook.AllHooks만 씁니다.


반환

- error: 전형적인 Go 스타일의 에러입니다. 수행이 성공하면 nil을 반환합니다. 뭔가 예상처럼 잘 되지 않았으면 그 이유를 반환합니다. 예외처리를 위해서 error.Error() 함수를 이용합니다.


용례

gominhook.EnableHook(gominhook.AllHooks)




마지막으로 minhook 라이브러리를 쓸 때 반드시 실행해야 하는 함수가 둘 있습니다.


func gominhook.Initialize() error
func gominhook.Uninitialize() error


두 함수는 그 이름이 암시하는 대로 minhook 라이브러리의 초기화와 종료를 수행합니다.


프로그램의 시작과 끝에 딱 한 번씩만 호출해 주세요.




이 정도만 알면 이 라이브러리를 바로 가져다가 응용할 수 있습니다.


4) gominhook을 이용한 API 후킹 예제


두 가지 간단한 예시가 있습니다.


첫 번째로, 제가 시험 삼아서 윈도우 10의 계산기 프로그램에 적용한 사례가 있으니 참고해 주세요.


다음으로, 아래 소스 코드는 user32.MessageBoxW를 후킹하는 프로그램을 보입니다.

package main

import (
	"fmt"
	"log"
	"syscall"
	"unsafe"

	"github.com/nanitefactory/gominhook"
)

/*
#include 

// Put C prototypes here

// Delegate type for calling original MessageBoxW.
typedef int (WINAPI *MESSAGEBOXW)(HWND, LPCWSTR, LPCWSTR, UINT);

// (!) This way you can connect/convert a go function to a c function.
int WINAPI MessageBoxWOverrideHellYeah(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType);
*/
import "C"

// Pointer for calling original MessageBoxW.
var fpMessageBoxW C.MESSAGEBOXW

// (!) This way you can connect/convert a go function to a c function.
//export MessageBoxWOverrideHellYeah
func MessageBoxWOverrideHellYeah(hWnd C.HWND, lpText C.LPCWSTR, lpCaption C.LPCWSTR, uType C.UINT) C.int {
	fmt.Println(" - MessageBoxW Override")
	foo()
	ret, _, _ := syscall.Syscall6(
		uintptr(unsafe.Pointer(fpMessageBoxW)),
		4,
		uintptr(unsafe.Pointer(hWnd)),
		uintptr(unsafe.Pointer(lpText)),
		uintptr(unsafe.Pointer(lpCaption)),
		uintptr(uint(uType)),
		0, 0,
	)
	return C.int(ret)
}

func foo() {
	fmt.Println(" - I'm so hooked now.")
}

func main() {
	// Initialize minhook
	err := gominhook.Initialize()
	if err != nil {
		log.Fatalln(err)
	}
	defer gominhook.Uninitialize()

	// Get procedure user32.MessageBoxW
	procedure := syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW")
	fmt.Println("-- not hooked yet")
	procedure.Call(
		0,
		uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Hello1"))),
		uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("World1"))),
		1,
	)
	fmt.Println(fmt.Sprintf("0x%X", procedure.Addr()), fmt.Sprintf("0x%X", &fpMessageBoxW), fmt.Sprintf("0x%X", fpMessageBoxW))
	fmt.Println()

	// Create a hook for MessageBoxW.
	err = gominhook.CreateHook(procedure.Addr(), uintptr(C.MessageBoxWOverrideHellYeah), uintptr(unsafe.Pointer(&fpMessageBoxW)))
	if err != nil {
		log.Fatalln(err)
	}

	// Enable the hook for MessageBoxW.
	err = gominhook.EnableHook(gominhook.AllHooks)
	if err != nil {
		log.Fatalln(err)
	}

	// Calling our hooked procedure user32.MessageBoxW.
	fmt.Println("-- after hook")
	procedure.Call(
		0,
		uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Hello2"))),
		uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("World2"))),
		1,
	)
	fmt.Println(fmt.Sprintf("0x%X", procedure.Addr()), fmt.Sprintf("0x%X", &fpMessageBoxW), fmt.Sprintf("0x%X", fpMessageBoxW))
	fmt.Println()

	// Disable the hook for MessageBoxW.
	err = gominhook.DisableHook(gominhook.AllHooks)
	if err != nil {
		log.Fatalln(err)
	}

	// Calling our unhooked procedure user32.MessageBoxW.
	fmt.Println("-- after unhook")
	procedure.Call(
		0,
		uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("Hello3"))),
		uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("World3"))),
		1,
	)
	fmt.Println(fmt.Sprintf("0x%X", procedure.Addr()), fmt.Sprintf("0x%X", &fpMessageBoxW), fmt.Sprintf("0x%X", fpMessageBoxW))
	fmt.Println()
}

/* This outputs...

-- not hooked yet
0x7FFE6CA4EE10 0x578180 0x0

-- after hook
 - MessageBoxW Override
 - I'm so hooked now.
0x7FFE6CA4EE10 0x578180 0x&

-- after unhook
0x7FFE6CA4EE10 0x578180 0x&

*/


5) MinHook 작동 원리


; x64 mode (assumed that the target function is at 0x140000000)

; 32bit relative JMPs of 5 bytes cover about -2GB ~ +2GB
0x140000000: E9 00000080      JMP 0xC0000005  (RIP-0x80000000)
0x140000000: E9 FFFFFF7F      JMP 0x1C0000004 (RIP+0x7FFFFFFF)

; Target function (Jump to the Relay Function)
0x140000000: E9 FBFF0700      JMP 0x140080000 (RIP+0x7FFFB)

; Relay function (Jump to the Detour Function)
0x140080000: FF25 FAFF0000    JMP [0x140090000 (RIP+0xFFFA)]
0x140090000: xxxxxxxxxxxxxxxx ; 64bit address of the Detour Function


MinHook의 API 후킹은 가장 간편하고 널리 쓰고 안정적인 방법으로 알려진 것으로서 5바이트 패치라고도 부르는 코드 패치 기법을 사용합니다.


훅을 걸고자 하는 타겟 함수의 첫 번째 다섯 바이트를 JMP 인스트럭션으로 고쳐 써서 그 함수가 실행되는 시점에 EIP가 우회 함수를 가리키도록 널뛰기를 시키는 거죠.


그런데 그렇게 쉽게 후킹하는 것도 32비트 주소 공간만 이용하는 x86의 이야기에요.


EIP가 아닌 RIP를 쓰는 64비트 시스템에서는 5바이트 짜리 JMP 인스트럭션으로 점프할 수 있는 거리가 ±2GB로 한정되어 있어서 이게 문제가 되거든요.


마이크로소프트 리서치가 64비트용 API 후킹 라이브러리만은 1만 달러씩 받고 파는 이유가 있는 거죠.


저도 이걸 직접 구현해 볼까 했었는데 좀 골치가 아프더군요.


코드 인젝션 할 때는 RAX에 값을 넣고 JMP RAX로 뛰면 되긴 해요.


근데 이렇게 런타임에 훅을 걸었다 풀었다 하면서 우회 함수 안에서 원본 함수를 호출하는 것은 어렵죠.


MinHook에서 64비트에 대응해서 쓰고 있는 방법은 기본적으로 코드 중간 중간 다리나 중계 함수를 할당해 두고 그런 지점을 경유해서 널뛰기를 두 번씩 하는 것입니다.


함수의 시작 지점에서부터 몇 개 인스트럭션에 걸쳐서 일종의 전처리를 하는 부분을 Preamble(전문)이나 Prologue(서문)이라고 부릅니다.


MinHook은 원본 함수를 통째로 디스어셈블해서 그런 함수 초반의 서문이 어디부터 어디까지인가를 찾고, 2단 점프의 다리로 쓰는 함수에 옮겨 씁니다.


그렇게 만든 함수를 트램폴린(Trampoline)이라고 부릅니다.



그리고 원본 함수를 호출하기 전에 트램폴린 함수를 경유함으로써, 우회 함수 안에서 원본 함수를 그대로 호출할 수 있는 것입니다.


그러니까 MinHook으로 API 후킹한 뒤 얻은 원본 함수는 사실은 원본 함수의 서문을 실행한 뒤 RIP를 원본 함수로 점프시키는 트램폴린 함수입니다.


3. 닫는 말


이상에서는 Go 언어로 메모리 조작과 API 후킹을 다루는 방법에 대해서 정리해 봤습니다.


이런 기법을 통해서, 프로세스의 수행시간(런타임)에 그 내장을 입맛대로 맛사지하고 주물러 보시기를 바랍니다.


프로그램의 여러가지 기능을 추가하거나 변경할 수도 있고, 아무튼 재미있으니까요.



  1. https://www.codeproject.com/Articles/44326/MinHook-The-Minimalistic-x-x-API-Hooking-Libra [본문으로]

Top