
DLL PEB 모듈 구조 조작을 이용한 코드 실행/인젝션 기법
로드된 모듈의 EntryPoint를 런타임에 수정하여 은밀하게 코드를 실행하는 기법입니다.
Windows 프로세스는 런타임에 다양한 모듈을 로드합니다. 각 모듈은 DllMain() 함수를 정의하며, 이 함수는 프로세스 또는 스레드 생성/소멸 시 (네 가지 가능한 시나리오) 호출됩니다.
프로세스 수명 동안 이러한 함수를 올바르게 호출하기 위해 Windows 로더 함수(ntdll!Ldrp*)는 각 모듈의 주요 매개변수(EntryPoint 필드 포함)가 포함된 항목 목록을 참조합니다.
DLL의 EntryPoint를 덮어쓰면 코드 실행이 우리가 선택한 위치로 리디렉션되도록 보장할 수 있습니다.
이는 코드 실행 기본 요소(primitive)와 API 프록싱(proxy) 모두에 사용될 수 있습니다. 예를 들어 합법적인 Windows 함수에 의해 호출되므로 의심스럽지 않은 호출 스택으로 특정 API를 실행하는 데 사용할 수 있습니다.
또한 공격자가 대상 프로세스의 메모리를 읽고 쓸 수 있는 경우 원격 프로세스에서 실행을 트리거하는 데 사용할 수 있습니다. Threadless Injection과 유사하게, 실행과 관련된 기존 API(CreateRemoteThread, QueueUserAPC)를 호출하지 않고도 프로세스에서 코드를 실행할 수 있는 기능을 제공합니다.
Windows 프로세스 내 모듈 로딩/언로딩은 많은 문제, 잠재적인 불안정성, 경쟁 조건, 충돌을 야기하는 복잡한 주제입니다. 예를 들어 DllMain() 함수의 일부로 코드를 실행하는 것과 관련된 잘 알려진 장애물은 로더 락(Loader Lock)이 걸려 있고, 완전히 설정되지 않았거나 종료 중인 스레드에서 실행된다는 점입니다.
따라서 가능한 것과 불가능한 것을 적절히 문서화하려고 노력했습니다. 예를 들어, 대부분의 일반적인 API 호출은 수행할 수 있지만, 완전한 기능의 비콘(beacon)을 실행하려면 wininet.dll 또는 winhttp.dll에서 사용되는 함수로 인한 교착 상태를 피하기 위해 별도의 프로세스에서 실행해야 하는 특정 요구 사항이 있습니다.
각 프로세스는 런타임에 _LDR_DATA_TABLE_ENTRY 구조체 목록을 유지합니다. 이러한 구조체에는 DLL의 EntryPoint(덮어쓸 대상), 이름, 특정 해시, 타임스탬프, 다양한 플래그 등 DLL과 관련된 많은 세부 정보가 포함됩니다. 이러한 구조체 중 일부는 문서화되어 있고 일부는 그렇지 않습니다.
이러한 구조체는 WinDbg 명령을 통해 시각화할 수 있습니다:
dt nt!_LDR_DATA_TABLE_ENTRY 0xdeadbeef
0:006> dt nt!_LDR_DATA_TABLE_ENTRY 0x18d1c4838c0
ntdll!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485de0 - 0x0000018d`1c4832b0 ]
+0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485df0 - 0x0000018d`1c4832c0 ]
+0x020 InInitializationOrderLinks : _LIST_ENTRY [ 0x0000018d`1c4832d0 - 0x0000018d`1c482c80 ]
+0x030 DllBase : 0x00007ffe`e87e0000 Void
+0x038 EntryPoint : 0x00007ffe`e8838d00 Void
+0x040 SizeOfImage : 0x2fe000
+0x048 FullDllName : _UNICODE_STRING "C:\WINDOWS\System32\KERNELBASE.dll"
+0x058 BaseDllName : _UNICODE_STRING "KERNELBASE.dll"
+0x068 FlagGroup : [4] "???"
+0x068 Flags : 0x8a2cc
+0x068 PackagedBinary : 0y0
+0x068 MarkedForRemoval : 0y0
+0x068 ImageDll : 0y1
(...)
+0x0e0 MappingInfoIndexNode : _RTL_BALANCED_NODE
+0x0f8 OriginalBase : 0x00007ffe`e87e0000
+0x100 LoadTime : _LARGE_INTEGER 0x01db5f86`2fa735fc
+0x108 BaseNameHashValue : 0x235bec4
+0x10c LoadReason : 0 ( LoadReasonStaticDependency )
+0x110 ImplicitPathOptions : 0x4000
+0x114 ReferenceCount : 1
+0x118 DependentLoadFlags : 0x800
+0x11c SigningLevel : 0 ''
구조체의 주소는 프로세스 PEB의 PEB_LDR_DATA 구조체에서 참조하는 이중 연결 구조체를 탐색하여 얻을 수 있습니다.
dt nt!_PEB_LDR_DATA 0xb4b4c3c3
DontCallForThreads 플래그에 주목하세요. 이름에서 알 수 있듯이 이 플래그가 설정되면 OS는 해당 모듈의 DllMain()을 스레드 이벤트(즉, DLL_THREAD_ATTACH 또는 DLL_THREAD_DETACH)에 대해 호출하지 않습니다.
DLL을 생성할 때 OS 로더 함수와 함께 작동하려면 다음 템플릿을 따라야 합니다:
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // DLL 모듈에 대한 핸들
DWORD fdwReason, // 함수 호출 이유
LPVOID lpvReserved ) // 예약됨
{
// 호출 이유에 따라 작업을 수행합니다.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// 각 새 프로세스에 대해 한 번 초기화합니다.
// DLL 로드 실패를 위해 FALSE를 반환합니다.
break;
case DLL_THREAD_ATTACH:
// 스레드별 초기화를 수행합니다.
break;
case DLL_THREAD_DETACH:
// 스레드별 정리를 수행합니다.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // 프로세스 종료 시나리오인 경우 정리하지 않습니다.
}
// 필요한 정리를 수행합니다.
break;
}
return TRUE; // 성공적인 DLL_PROCESS_ATTACH입니다.
}
위에서 설명한 대로 이 기법은 DLL의 EntryPoint를 임시로 덮어써서 실행을 리디렉션합니다. 실행 리디렉션 이상의 제어권은 없으므로, 실행하려는 항목, 인수, 반환 값을 얻는 방법을 처리하기 위해 측면에서 몇 가지 준비가 필요합니다.
이 작업은 힙에 DATA_T 구조체를 정의하여 다양한 단계에서 액세스 가능하도록 유지함으로써 수행됩니다.
해당 구조체는 다음과 같이 정의됩니다:
typedef struct _DATA_T {
// LDR 구조체 조작
ULONG_PTR runner; // 실행할 악성 진입점
ULONG_PTR bakOriginalBase; // 덮어쓴 OriginalBase의 백업
ULONG_PTR bakEntryPoint; // 덮어쓴 EntryPoint의 백업
HANDLE event; // Runner가 실행되었음을 알리는 이벤트
// 함수 호출
ULONG_PTR ret; // 반환 값
DWORD createThread; // 이 API 호출을 새 스레드에서 실행 (wininet/winhttp에 필요)
ULONG_PTR function; // 호출할 Windows API
DWORD dwArgs; // 인수 개수
ULONG_PTR args[MAX_ARGS]; // 인수 배열
} DATA_T, * PDATA_T;
API 실행을 설정하려면 이러한 필드를 준비해야 합니다. ret 값은 실행 후 반환 값을 수집합니다. event는 동기화에 사용되며 실행이 완료되었음을 알립니다. 다른 모든 필드는 호출할 API(function), 인수(dwArgs 및 args[]), 실행이 리디렉션되는 Runner() 함수의 주소, 덮어쓴 원래 DLL 항목의 백업(bakOriginalBase 및 bakEntryPoint)을 정의하는 입력입니다.
createThread 필드는 DllMain() 설정에서 제대로 실행되지 않는 복잡한 API 함수(많은 wininet 및 winhttp 라이브러리 포함)의 경우 1로 설정해야 합니다.
다음은 PoC에서 볼 수 있는 MessageBoxA() 호출 설정 예시입니다:
pDataT->dwArgs = 4;
pDataT->runner = (ULONG_PTR)Runner;
pDataT->function = (ULONG_PTR)MessageBoxA;
pDataT->args[0] = (ULONG_PTR)0;
pDataT->args[1] = (ULONG_PTR)"Hello";
pDataT->args[2] = (ULONG_PTR)"LDRSHUFFLE";
pDataT->args[3] = (ULONG_PTR)MB_OKCANCEL;
pDataT->event = CreateEventA(NULL, FALSE, FALSE, "ExecEvt");
UpdateLdr() 함수는 대상 모듈의 _LDR_DATA_TABLE_ENTRY에서 올바른 수정을 수행합니다.
RestoreLdr()는 나중에 Runner()에 의해 호출되어 이러한 변경 사항을 복원합니다.
이 함수들은 기본적으로 PEB를 찾고 모듈 구조체를 탐색하여 올바른 DLL과 해당 필드를 식별합니다. 헤더 파일에서 Batsec의 DarkLoadLibrary에서 사용된 정의를 재사용하고 있으며, 독자들은 이 프로젝트와 관련 MDSec 블로그 게시물을 확인하여 Windows의 모듈 로딩 내부에 대한 훌륭한 작업을 참조할 것을 권장합니다.
참고: 이 PoC는 희생 DLL(SACRIFICIAL_DLL_NAME)을 로드하고 이 DLL에 대해 이러한 변경을 수행합니다. 그러나 이미 로드된 DLL을 수정하는 것도 완전히 가능합니다. 실제로 이것이 교차 프로세스 인젝션에 사용되는 접근 방식입니다. 안정성을 위해 ntdll 또는 kernel32와 같은 중요한 DLL을 건드리지 않는 것이 좋습니다. 이러한 DLL은 보안 솔루션에서 더 많이 검사되는 경향이 있습니다.
스레드 생성 또는 소멸 시 실행은 Runner()로 리디렉션되며, 이 함수는 모듈에 대한 가짜 DllMain() 역할을 합니다. 이 함수는 다음을 수행합니다:
PDATA_T 구조체)의 위치를 찾습니다.RestoreLdr()를 원래 상태로 복원합니다.DllMain() 호출을 수행합니다(기본적으로 정상 DLL 호출을 프록시).이 시점에서 "정상적인" OS 실행이 수행되었습니다. 그런 다음 페이로드를 계속 실행합니다:
DATA_T 구조체에 저장된 값과 인수에 따라 악성 API 호출을 실행합니다. 이 API가 새 스레드에서 실행되도록 표시된 경우(createThread = 1) 이 호출은 새 스레드에서 수행됩니다.pDataT->event)를 신호로 보내 메인 코드가 호출이 수행되었음을 알 수 있도록 합니다.Windows가 가짜 EntryPoint(Runner() 함수 주소)를 호출할 때 호출 스택은 다음과 같습니다:

제공된 PoC에는 MessageBoxA()를 호출하는 예제가 포함되어 있습니다.
또한 wininet을 사용한 HTTP 다운로드 데모도 포함되어 있습니다. 해당 코드를 활성화하려면 HTTP 변수를 정의하세요.

위에서 설명한 원칙은 프로세스 메모리 공간에서 읽고 쓰는 것으로 요약되며, 미래의 임의 시점에 코드 실행을 유발합니다.
약간의 조정을 통해 이러한 읽기/쓰기 작업을 원격 프로세스에 적용하여 해당 DLL의 EntryPoint를 덮어쓸 수 있습니다.
전제 조건은 프로세스 메모리 공간에서 읽고 쓸 수 있는 능력입니다. 예:
OpenProcess(PROCESS_VM_READ, FALSE, dwPid) 및 OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, PID)
PoC에는 LdrInject라는 추가 프로젝트가 있으며, 이러한 단계를 수행하는 방법을 보여줍니다. 간단히 말해 다음을 수행합니다:
ReadPEB()에서 대상 프로세스의 _LDR_DATA_TABLE_ENTRY 목록을 탐색하여 덮어쓰기에 적합한 DLL을 식별합니다. Windows가 스레드 생성 시 해당 DLL의 EntryPoint를 호출하도록 하려면 DontCallForThreads == 0인 DLL이어야 합니다. 또한 목록의 첫 번째 DLL(ntdll.dll, kernel32.dll 등)은 보안 제품에서 더 많이 검사되는 경향이 있으므로 선택하지 않습니다.
해당 DLL의 세부 정보는 PEBINJ_DATA 데이터 구조체에 저장됩니다.
셸코드(여기서는 비콘)는 InjectShellcodeToRemoteProcess()를 사용하여 원격 프로세스 공간에 기록됩니다.
두 번의 WriteProcessMemory() 호출이 DLL의 EntryPoint를 덮어쓰고 OriginalBase에 백업하여 나중에 복원할 수 있도록 합니다.
이 시점에서 다음 DLL_THREAD_ATTACH 또는 DLL_THREAD_DETACH 이벤트가 발생하면 셸코드가 호출됩니다. 이는 비콘 실행 컨텍스트에서 특정 제한 사항과 주의 사항이 있으며, 다음 섹션에서 자세히 설명합니다.
이 기법은 매우 특정한 상황에서 셸코드를 실행합니다. 로더 락이 활성화되어 있고(OS가 DLL을 로드/언로드하는 중이라고 인식), 스레드가 생성되거나 소멸되고 있으며, 일반적으로 스레드 동기화 문제, 교착 상태 등이 발생할 가능성이 있습니다.
테스트 중 두 가지 문제가 관찰되었습니다:
일반적인 Cobalt Strike 비콘을 실행하면 wininet.dll 또는 winhttp.dll의 API를 사용할 때 교착 상태가 발생합니다.
스레드 소멸 시 실행하면 소멸 중인 스레드에서 실행되므로 안정성 문제가 발생합니다.
안정성을 높이려면 다음을 수행해야 합니다:
비콘이 새 스레드에서 실행되도록 합니다. 따라서 UDRL은 일반적인 Cobalt Strike 리플렉티브 DLL 진입점을 호출하기 전에 CreateThread를 수행합니다.
소멸 중인 스레드가 아닌 생성 중인 스레드에서만 실행합니다. 이를 위해 OS가 EntryPoint를 호출할 때 호출된 이유가 fdwReason == DLL_THREAD_ATTACH인지 확인합니다:
winApi.CreateThread(NULL, 4096, (LPTHREAD_START_ROUTINE)&runner, (LPVOID)&ct_data, 0, &dwThId);
일반적인
((DLLMAIN)entryPoint)((HINSTANCE)loaderStart, 4, NULL);
대신 사용합니다.
ULONG_PTR __cdecl ReflectiveLoader(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
// 스레드 생성 이벤트에 대해서만 실행
if (fdwReason != DLL_THREAD_ATTACH) {
return TRUE;
}
...
}
이 두 가지 추가 단계는 UDRL 데모에 포함되어 있습니다.
VirtualAlloc
VirtualProtect
CreateThread
Sleep
MessageBoxA
InternetOpenW (createThread = 1로 실행해야 함)
InternetOpenUrlA (createThread = 1로 실행해야 함)