DLL injection

DLL injection은 간단히 말해 현재 메모리에 로드되어 있는 프로세스의 코드 영역에 접근해서 특정 dll을 로드하는 코드를 실행시켜 임의의 dll을 프로세스의 주소영역에 로딩시키는 것이다.
현재 메모리에 로드되어 있는 프로세스의 코드영역에 우리가 원하는 코드를 삽입하는 방법은 2가지가 있다.
첫번째로 VirtualAllocEx() API를 이용해서 타깃 프로세스의 가상 메모리 영역을 확보해서 그 영역에 WriteProcessMemory()로 우리가 원하는 코드를 삽입하는 방법이 있다. VirtualAlloc()으로 할당한 영역에 삽입한 코드를 실행시키기 위해서 thread 형태로 새로운 실행 플로우를 만든다.
그러나 VirtualAlloc() API는 Win NT 이상에서 지원하는 함수로써 win9x/ME에서는 지원되지 않는 API다. 즉 이 방법은 윈98 및 윈미에서는 사용할 수 없다는 것이다.
두번째 방법은 ReadProcessMemory() 와 WriteProcessMemory()를 이용, 타깃 프로세스의 메모리에 직접 읽고 쓰는 방법이다. 이 API들은 윈도우즈 계열의 모든 플랫폼에서 지원하기 때문에 윈도우즈 버전에 관계없이 사용할 수 있는 장점이 있다.

그럼 현재 메모리에 로딩되어 실행중인 프로세스에 어떤 방법으로 코드를 삽입할수 있을까. 이것도 역시 윈도우즈에서 제공하는 디버깅용 API를 이용한다.

우선 타깃 프로세스를 디버깅용 API를 써서 실행즉시 suspend시킨다.

타깃 프로세스의 컨텍스트(process context: 프로그램 카운터와 레지스터등 프로세서의 상태를 말한다)와 코드를 삽입할 영역(코드섹션의 첫 페이지)을 읽어와서 다른 메모리에 백업한다.

해당영역에 임의의 dll을 로드하는 코드를 덮어쓴다.

인스트럭션 레지스터인 EIP에 실행시킬 코드의 주소를 저장시켜서 프로세스를 run시킨다. 물론 이때 dll을 로드하는 코드의 마지막 부분에는 브레이크 포인트를 set하는 코드(int 3h)가 들어가 있어야 한다.

dll을 로딩하는 코드가 실행된 후 타깃 프로세스는 INT3 명령에 의해 브레이크가 걸리게 된다. 이렇게 프로세스의 내부에 dll을 싸질러놨으니 완전범죄를 위해서 백업해 놓았던 프로세스 컨텍스트와 코드를 원래대로 되돌려놓고 EIP도 복구시켜 놓는다.

마지막으로 프로세스를 run시키면 감쪽같이 원래 실행상태로 되돌아가게 되는 것이다.

일단 이렇게 우리가 원하는 dll을 프로세스의 주소영역에 올려놓으면 dll내의 코드는 어플리케이션의 모든 메모리를 접근할 수 있고 제어할 수 있게되는 것이다.
원리 설명은 여기까지 하고 실제 코드를 살펴보자.
먼저 dll을 주입하고자 하는 타깃 프로세스를 실행시켜야 한다.

STARTUPINFO sInfo;
PROCESS_INFORMATION pInfo;

ZeroMemory((VOID*)&sInfo, sizeof(sInfo));

ret = CreateProcess(Filename, 0, 0, 0, FALSE, DEBUG_ONLY_THIS_PROCESS,
0, 0, &sInfo, &pInfo);
if(!ret) return FALSE;

타깃 프로세스를 디버그 모드로 create시킨다. fdwCreate 파라미터를 디버드 모드(DEBUG_ONLY_THIS_PROCESS)로 넘겨주고 프로세스를 실행하면 여러 디버그 이벤트가 발생하며 그때마다 타깃 프로세스는 실행을 멈추고 suspend상태가 된다.
예를 들어 디버그 모드로 프로세스를 create하자마자 CREATE_PROCESS_DEBUG_EVENT가 발생하고 이때 마지막 파리미터인 pInfo에 타깃 프로세스가 로드될 메모리 주소, 프로세스 핸들, 프로세스 id등의 정보가 채워져 return되는 것이다.
프로세스를 계속 진행시키려면 ContinueDebugEvent를 호출해서 실행제어를 타깃 프로세스에 넘겨주어야 한다. 디버그 이벤트중에서 우리가 처리할 디버그 이벤트는 EXCEPTION_DEBUG_EVENT이고 그외의 이벤트는 처리하지 않는다.
디버그 이벤트를 처리하는 코드는 다음과 같다.

DEBUG_EVENT dEvent;
BOOL bFirstBreak, bSecondBreak;

bFirstBreak = FALSE;
bSecondBreak = FALSE;

while(1)
{
if( !(ret = WaitForDebugEvent(&dEvent, INFINITE)) ) // 이벤트 대기
return -1;

continueStatus = DBG_EXCEPTIION_NOT_HANDLED;

if(CREATE_PROCESS_DEBUG_EVENT == dEvent.dwDebugEventCode) // 디버드 모드로 create하자마자 발생
{
BaseOfImage = dEvent.u.CreateProcessInfo.lpBaseOfImage; // 프로세스의 base주소를 얻어올 수 있다.
}
else if(EXCEPTION_DEBUG_EVENT == dEvent.dwDebugEventCode) // 디버그 이벤트 발생
{
if(EXCEPTION_BREAKPOINT == dEvent.u.Exception.ExceptionRecord.ExceptionCode)
{ //브레이크포인트에 걸렸을 때…
if(FALSE == bFirstBreak)
{ //첫번째 브포. 원하는 dll을 주입한다
InjectDLL( pInfo.hProcess, pInfo.hThread, BaseOfImage,
DllName);

bFirstBreak = TRUE;
}else if(FALSE == bSecondBreak)
{ //두번째 브포. 코드페이지를 원상태로 되돌려놓는다
RestoreOriginalCodePage( PHandle, THandle, 0);
bSecondBreak = TRUE;
}
continueStatus = DBG_CONTINUE;
}
}

if(EXIT_PROCESS_DEBUG_EVENT == dEvent.dwDebugEventCode)
break;

// 실행제어를 debugged process에 넘긴다.
ContinueDebugEvent(dEvent.dwProcessId, dEvent.dwThreadId, continueStatus);
}

일단 디버깅 당하는 프로세스가 메모리에 로드되면 EXCEPTION_BREAKPOINT가 발생한다. 첫번째 브포에서 프로세스의 첫번째 코드 페이지와 프로세스 context를 백업한 후, dll을 로딩하는 코드를 삽입해서 프로세스를 실행시키고 두번째 오는 브포에서 코드 페이지를 원래데로 되돌려놓는 작업을 하면 되는 것이다.
프로세스 컨텍스트와 코드 페이지를 읽어오는 코드는 다음과 같다.

// Global Variables
PVOID pFirstCodePage;
CONTEXT OriginalContext;

BOOL InjectDLL(HANDLE hProcess, HANDLE hThread, VOID* hModuleBase, char *DllName)
{
FARPROC LoadLibProc;
CONTEXT MyContext;

// DLL을 삽입하기 위해선 LoadLibraryA의 주소를 알아와야 한다.
LoadLibProc = GetProcAddress(GetModuleHandle(“KERNEL32.dll”), “LoadLibraryA”);
if (!LoadLibProc ) return FALSE;

OriginalContext.ContextFlags = CONTEXT_FULL; //CONTEXT_CONTROL;
if(!GetThreadContext( hThread, &OriginalContext)) // process context를 가져온다.
return FALSE;

// debugged process의 첫번째 코드 페이지의 주소를 알아와야 한다
pFirstCodePage = GetFirstCodePage(hProcess, hModuleBase);
if (pFirstCodePage) return FALSE;

// 첫번째 코드 페이지를 읽어와서 OriginalCodePage에 저장한다.
ret = ReadProcessMemory(hProcess, pFirstCodePage, OriginalCodePage, PAGE_SIZE, &dwRead);
if (!ret || PAGE_SIZE != dwRead)
return FALSE;

참고로 프로세스의 컨텍스트를 가져오는 부분에서 GetThreadContext()가 아닌 GetProcessContext()를 써야하는 것 아닌가 의아해할 사람도 있겠지만 context는 thread의 영역임을 알아두자.
첫번째 코드 페이지(4096바이트)의 주소를 가져오는 GetFirstCodePage()에 대한 설명은 뒤에서 하겠다. 이제 타깃 프로세스의 첫번째 코드 페이지에 dll을 로딩하는 코드를 삽입해야 한다.
방법은 다음과 같다.

char NewCodePage[PAGE_SIZE] =
{
0xB8, 00, 00, 00, 00, // mov EAX, 0h | Pointer to LoadLibraryA() (DWORD)
0xBB, 00, 00, 00, 00, // mov EBX, 0h | DLLName to inject (DWORD)
0x53, // push EBX
0xFF, 0xD0, // call EAX
0x5b, // pop EBX
0xcc // INT 3h
};
int nob=15;
char *DLLName;
DWORD *EBX, *EAX;

DLLName = (char*)((DWORD)NewCodePage + nob);
EAX = (DWORD*)( CodePage + 1);
EBX = (DWORD*) ( NewCodePage + 6);

strcpy( DLLName, DllName ); // DllName: 삽입하고자 하는 dll의 full path
*EAX = (DWORD)LoadLibProc;
*EBX = (DWORD)pFirstCodePage + nob;

// 코드를 첫번째 코드 페이지에 삽입한다.
ret = WriteProcessMmory(hProcess, pFirstCodePage, &NewCodePage, PAGE_SIZE, &dwRead);
if (!ret || PAGE_SIZE != dwRead)
return FALSE;

LoadLibraryA()를 호출하는 opcode를 만들어서 타깃 프로세스의 첫번재 코드 페이지에 덮어쓰고 있다. 여기서 dll을 로드하는 opcode를 만드는 방법은 위와 같이 해도 되고 아니면 아래와 같은 방법을 쓰기도 한다.

#pragma pack(1)
typedef struct
{
BYTE push;
DWORD operand_push;
BYTE call;
DWORD operand_call;
BYTE int3;
char dllName[1];
} MY_CODE, *PMY_CODE;

BYTE NewCodePage[PAGE_SIZE];
PMY_CODE pNewCode;

pNewCode->push = 0x68; // opcode
pNewCode->operand_push = (DWORD)pFirstCodePage + offsetof(MY_CODE, dllName);
pNewCode->call = 0xE8; // opcode
pNewCode->operand_call = (DWORD)LoadLibProc – (DWORD)pFirstCodePage
– offsetof(MY_CODE, call) – 5;
pNewCode->int3 = 0xCC;

lstrcpy(pNewCode->dllName, DllName); // Copy DLL name

// 코드를 첫번째 코드 페이지에 삽입한다.
ret = WriteProcessMmory(hProcess, pFirstCodePage, &NewCodePage, PAGE_SIZE, &dwRead);
if (!ret || PAGE_SIZE != dwRead)
return FALSE;

여기서는 LoadLibrary의 offset주소를 계산해서 call 인스트럭션의 오퍼랜드를 만들었다. 파라미터로 dll명의 offset을 넘겨주고 있으므로 LoadLibrary에 의해 dll이 로드될 것이다. 어떤 방법을 쓰든 결과는 똑같다.
두 방법 모두 LoadLibrary 의 호출이 끝난 후 브레이크 인스트럭션인 INT3(0xcc)를 삽입했으므로 dll이 로드되고 나서 브레이크가 걸리게 된다. 그럼 위에서 설명한 데로 두 번째 브레이크 이벤트가 발생해서 코드 페이지를 원래데로 되돌리는 RestoreOriginalCodePage()가 불려지게 된다.
자, dll코드를 삽입시켰으니 이 코드를 실행시키도록 context를 변경시켜야 한다.

MyContext= OriginalContext;
MyContext.Eip = (DWORD)pFirstCodePage; // 프로그램 카운터 레지스터 변경
ret = SetThreadContext(hThread, &MyContext); // 프로세스 컨텍스트 셋팅
if(!ret) return FALSE;

변경된 첫번째 코드 페이지를 실행시키도록 인스트럭션 포인트를 설정했다. 이렇게 해서 프로세스를 run시키면 dll을 프로세스의 주소영역에 로딩하는 opcode가 실행된다.
위에서 설명을 빠뜨렸던 프로세스의 첫번째 코드 페이지를 가져오는 코드는 다음과 같다.

PVOID GetFirstCodePage(HANDLE hProcess, PVOID pProcessBase);
{
DWORD baseOfCode;
DWORD peHdrOffset;
DWORD dwRead;

// Read in the offset of the PE header within the debuggee
if ( !ReadProcessMemory(hProcess, // e_lfanew 변수값을 가져온다.
(PBYTE)pProcessBase + 0x3C,
&peHdrOffset,
sizeof(peHdrOffset),
&dwRead) )
return FALSE;

// Read in the IMAGE_NT_HEADERS.OptionalHeader.BaseOfCode field
if ( !ReadProcessMemory(hProcess, // BaseOfCode 변수값을 가져온다
(PBYTE)pProcessBase + peHdrOffset
+ 4 + IMAGE_SIZEOF_FILE_HEADER
+ offsetof(IMAGE_OPTIONAL_HEADER, BaseOfCode),
&baseOfCode, sizeof(baseOfCode),
&dwRead) )
return FALSE;

return (PVOID) ((DWORD)pProcessBase + baseOfCode); // 코드의 주소값
}

PE 포맷에서 offset 0x3C은 dos stuff부분인 IMAGE_DOS_HEADER의 e_lfanew의 위치를 가리킨다. dos stuff은 윈도우 어플리케이션이 도스 모드에서 실행될때 도스 모드에서는 실행될 수 없음을 나타내는 오류 메시지를 나타내는 역활을 한다. e_lfanew는 실제 PE 포맷의 상대주소를 저장하고 있는 변수이다. 즉, PE 포맷에서 IMAGE_NT_HEADERS의 주소를 가지고 있다.
PE 헤더의 주소를 가져왔으면 IMAGE_OPTIONAL_HEADER에서 코드 영역의 처음 주소가 저장된 BaseOfCode 변수값을 읽어올 수 있다. IMAGE_FILE_NT_HEADER의 시작 주소를 얻어왔으면 구조체 내의 Signature변수 4바이트와 IMAGE_SIZEOF_FILE_HEADER 구조체 다음에 위치하는 IMAGE_OPTIONAL_HEADER32 구조체 속에 BaseOfCode 변수가 들어 있다. BaseOfCode 변수의 위치는 하드코드로 계산할 수도 있지만 여기서는 offsetof라는 함수를 이용했다. offsetof(A, B)는 A구조체에서 B 구성원의 byte offset주소를 리턴하는 함수이다.
참고로 같은 구조체에 있는 AddressOfEntryPoint를 코드 페이지의 시작 주소로 혼돈하지 않도록 한다. AddressOfEntryPoint는 코드 영역의 처음 주소가 아니라 Main 함수의 위치이다. 우리가 처음 프로그램 코드를 시작하는 Main이나 WinMain은 유저 어플리케이션의 시작 포인트지 프로세스의 startup 코드가 아니다.
BaseOfCode는 offset주소이므로 process base주소에서 offset을 더하면 실제 코드 페이지의 절대 주소가 나온다.

다음은 원래 첫번째 페이지의 코드를 원상태로 되돌려 놓는 함수이다.

DWORD RestoreOriginalCodePage( HANDLE hProcess, HANDLE hThread, DWORD *outSize )
{
BOOL ret;
DWORD written;
CONTEXT Context;

if(outSize) *outSize = PAGE_SIZE; //Just for user’s info

ret = WriteProcessMemory( hProcess, pFirstCodePage, OriginalCodePage, PAGE_SIZE, &written );

if(!ret || (dwWritten != PAGE_SIZE) )
return -1;

Context.ContextFlags = CONTEXT_FULL;
// GetThreadContext( hThread, &Context);

ret = SetThreadContext( hThread, (CONST CONTEXT*)&OriginalContext);
if(!ret)
return -1;

return 0;
}

첫번째 코드 페이지의 내용이 저장된 OriginalCodePage를 WriteProcessMemory로 쓰고 프로세스 컨텍스트를 원상태로 복귀시키고 있다.

 

>>> 출처 : www.WInApi.co.kr -> 고스트 님이 작성하신 글입니다. <<<

Author: yyjksw