现在的DLL注入技术有很多种,每种方法都有他的优点和缺点。
最简单的一种方式是通过使用CreateRemoteThread函数在目标进程创建一个线程,然后指定线程的入口函数为LoadLibrary。原因在于LoadLibrary和线程的起始函数从二进制的角度来看,他们有着相同的原型。(都接受一个指针)
这个方法比较简单明了。创建一个新的线程可以被其他很多方法检测到,比如说ETW event。如果存在一个驱动使用PsSetCreateThreadNotifyRoutine hook 了线程的创建,那么自然在创建远程线程时就会被检测到。
一个更隐秘的技术是使用已经存在的线程去完成这件事情。一种方法是通过QueueUserApc 向目标进程的线程插入APC对象。(异步过程调用的函数地址指定为LoadLibrary) 这种方法潜在的问题是目标线程必须进入 Alertable 状态,我们的函数才会被调用。不幸的是,我们没有办法保证一个线程将来会把自己置入 Alertable 状态。虽然我们可以尽可能的把目标进程的所有线程都插入APC,但是在某些情况下,都会注入失败。一个经典的例子就是cmd.exe, 据我所知,他就是一个单线程的进程,且从不会进入Alertable状态。
这篇博客讲述的是关于另外一种方法使得目标进程调用LoadLibrary。通过修改现有线程的上下文达到不创建线程,只改变现有线程的执行流来加载DLL的目的。
接下来让我们看看如何在x86和x64系统上实现这种方法。
首先,我们需要锁定目标进程的一个线程。技术原理上来说,选择目标进程的任意线程都是可以的。但是一个在等待的线程就不适合作为候选人,除非他马上准备去运行,所以最好选择一个正在运行的线程或者即将运行的线程,使得我们的DLL尽可能被及时加载。
一旦我们锁定目标进程和线程后,我们需要以适当的权限去打开它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | / /
/ / 打开进程句柄
/ /
auto hProcess = ::OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE,FALSE,pid);
if (!hProcess)
return Error( "Failed to open process handle" );
/ /
/ / 打开线程局部
/ /
auto hThread = ::OpenThread(THREAD_SET_CONTEXT|THREAD_SUSPEND_RESUME|
THREAD_GET_CONTEXT,FALSE,tid);
if (!hThread)
return Error( "Failed to open thread handle" );
|
对于目标进程,我们需要 PROCESS_VM_OPERATION
和 PROCESS_VM_WRITE
权限用于写入代码。对于线程,是因为我们需要能够改变线程上下文和挂起线程。
注入自身需要一系列的步骤。我们需要在目标进程申请一块可执行的内存,然后把我们的代码放进去。
1 2 3 4 | const auto page_size = 1 << 12 ;
auto buffer = static_cast<char * >(::VirtualAllocEx(hProcess,nullptr,page_size,
MEM_COMMIT|MEM_RESERVE,PAGE_EXECUTE_READWRITE));
|
这里我们申请了一页 RWX的内存。实际上我们并不需要那么多内存,但是Windows的内存管理在分配虚拟内存时最小是按照页来分配的,所以我们显示的申请一个完整的页面。
我们需要放置什么代码到目标进程呢?很明显,我们是想调用LoadLibrary
,但是远不止于此。我们需要调用LoadLibrary后需要恢复执行原来线程离开时的上下文。因此我们先挂起线程,捕获他的执行上下文。
1 2 3 4 5 6 7 | if (::SuspendThread(hThread) = = - 1 )
return false;
CONTEXT context;
context.ContextFlags = CONTEXT_FULL;
if (!::GetThreadContext(hThread,&context))
return false;
|
接下来,我们需要拷贝一些代码到目标进程。这段代码必须是用汇编写出来的,而且必须和目标进程的位数一样。(总不能拿x86汇编写入64bit进程跑吧)对于x86,我们可以使用下面裸函数和内联汇编的写法:
1 2 3 4 5 6 7 8 9 10 | void __declspec(naked) InjectedFunction(){
__asm{
pushad
push 11111111h ; DLL路径参数
mov eax, 22222222h ; LoadLibrary 函数地址
call eax
popad
push 33333333h ; 代码返回地址
}
}
|
函数修饰为 __declspec(naked)属性表示告诉编译器不要给函数添加序言和结尾代码,我只要自己写的实际代码。代码中奇怪的数字仅仅是占位而已,当我们拷贝代码到目标进程时,需要进行修正。
在Demo源码中,我将上面的函数的机器码打包成了一个字节数组:
1 2 3 4 5 6 7 8 9 | BYTE code[] = {
0x60 ,
0x68 , 0x11 , 0x11 , 0x11 , 0x11 ,
0xb8 , 0x22 , 0x22 , 0x22 , 0x22 ,
0xff , 0xd0 ,
0x61 ,
0x68 , 0x33 , 0x33 , 0x33 , 0x33 ,
0xc3
};
|
上面的字节对应上面的汇编指令。现在我们修正其中的哑值。
1 2 3 4 5 6 7 8 9 | auto loadLibraryAddress = ::GetProcAddress(::GetModuleHandle(L "Kernel32.dll" ),
"LoadLibraryA" );
/ / 设置DLL路径
* reinterpret_cast<PVOID * >(code + 2 ) = static_cast<void * >( buffer + page_size / 2 );
/ / 设置 LoadLibraryA 函数地址
* reinterpret_cast<PVOID * >(code + 7 ) = static_cast<void * >(loadLibraryAddress);
/ / 跳转地址 (返回原来的代码)
* reinterpret_cast<unsigned * >(code + 0xf ) = context.Eip;
|
首先,我们获取到LoadLibraryA
的函数地址,因为这个函数将用来加载我们的DLL到目标进程。LoadLibraryW
也可行,但是ASCII版本的会使我们的工作简单些。DLL的路径地址被随意地设置在buffer里2KB的位置处。
接下来,我们将修改好的代码和DLL路径写入目标进程
1 2 3 4 5 6 7 8 9 10 11 12 | / /
/ / 拷贝机器码到 buffer 里
/ /
if (!::WriteProcessMemory(hProcess, buffer ,code,sizeof(code),nullptr))
return false;
/ /
/ / 拷贝dll路径到 buffer 里
/ /
if (!::WriteProcessMemory(hProcess, buffer + page_size / 2 ,dllPath,::strlen(dllPath) + 1 ,nullptr))
return false;
|
最后一件事情就是设置新的指令指针到拷贝的代码处,然后恢复线程执行。
1 2 3 4 5 6 | context.Eip = reinterpret_cast<DWORD>( buffer );
if (!::SetThreadContext(hThread,&context))
return false;
::ResumeThread(hThread);
|
这就是32bit版本的注入方式。
这种情况下的调试是不容易的,因为我们需要附加到目标进程,并且跟踪我们写的代码。下面的例子我运行了一个32bit的notepad.exe,在\Windows\SysWow64 目录下(64-bit系统上)。demo项目的命令行允许我设置目标进程id和DLL路径注入。在调用SetThreadContext
之前,我已经通过Visual Studio设置好了命令行参数以及断点。控制台程序显示了代码应该拷贝到的目标进程虚拟地址:
现在我们可以运行WinDbg附加到Notepad.exe ,查看控制台显示的虚拟地址的反汇编。
1 2 3 4 5 6 7 8 | 0 : 005 > u 04A00000
04a00000 60 pushad
04a00001 680008a004 push 4A00800h
04a00006 b8805a3b76 mov eax,offset KERNEL32!LoadLibraryAStub ( 763b5a80 )
04a0000b ffd0 call eax
04a0000d 61 popad
04a0000e 685c29e476 push offset win32u!NtUserGetMessage + 0xc ( 76e4295c )
04a00013 c3 ret
|
我们可以清晰地看到我们修改过后的代码,当恢复线程执行时,LoadLibraryA
就会调用起来。我们可以再这里下一个断点。
接下来我们可以让notepad运行起来,然后再运行起注入进程。果不其然,我们命中了这个断点。这是断点和调用栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | Breakpoint 0 hit
eax = 00000001 ebx = 01030000 ecx = 00000000 edx = 00000000 esi = 0093fbe4 edi = 01030000
eip = 04a00000 esp = 0093fba0 ebp = 0093fbb8 iopl = 0 nv up ei pl nz ac pe nc
cs = 0023 ss = 002b ds = 002b es = 002b fs = 0053 gs = 002b efl = 00000216
04a00000 60 pushad
0 : 000 > k
WARNING: Frame IP not in any known module. Following frames may be wrong.
00 0093fb9c 7570fecc 0x4a00000
01 0093fbb8 01037219 USER32!GetMessageW + 0x2c
02 0093fc38 0104b75c notepad!WinMain + 0x18e
03 0093fccc 763b8744 notepad!__mainCRTStartup + 0x142
04 0093fce0 7711582d KERNEL32!BaseThreadInitThunk + 0x24
05 0093fd28 771157fd ntdll!__RtlUserThreadStart + 0x2f
06 0093fd38 00000000 ntdll!_RtlUserThreadStart + 0x1b
|
如果我们有需要的话,可以进行单步调试。现在让notepad跑起来,会发现将会加载我们的DLL。我们可以在DllMain里做我们想做的事情。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | BOOL WINAPI DllMain(HMODULE hModule, DWORD reason, PVOID reserved) {
switch (reason) {
case DLL_PROCESS_ATTACH:
/ / 这里仅仅证明运行了我们的DLL
OutputDebugString(_T( "InjectedDll DllMain executes!\n" ));
break ;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break ;
}
return TRUE;
}
|
64bit的代码我写的十分简易,不能保证在任何情况下都能奏效,需要一些更多的测试。
下面是代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | BYTE code[] = {
/ / sub rsp, 28h
0x48 , 0x83 , 0xec , 0x28 ,
/ / mov [rsp + 18 ], rax
0x48 , 0x89 , 0x44 , 0x24 , 0x18 ,
/ / mov [rsp + 10h ], rcx
0x48 , 0x89 , 0x4c , 0x24 , 0x10 ,
/ / mov rcx, 11111111111111111h
0x48 , 0xb9 , 0x11 , 0x11 , 0x11 , 0x11 , 0x11 , 0x11 , 0x11 , 0x11 ,
/ / mov rax, 22222222222222222h
0x48 , 0xb8 , 0x22 , 0x22 , 0x22 , 0x22 , 0x22 , 0x22 , 0x22 , 0x22 ,
/ / call rax
0xff , 0xd0 ,
/ / mov rcx, [rsp + 10h ]
0x48 , 0x8b , 0x4c , 0x24 , 0x10 ,
/ / mov rax, [rsp + 18h ]
0x48 , 0x8b , 0x44 , 0x24 , 0x18 ,
/ / add rsp, 28h
0x48 , 0x83 , 0xc4 , 0x28 ,
/ / mov r11, 333333333333333333h
0x49 , 0xbb , 0x33 , 0x33 , 0x33 , 0x33 , 0x33 , 0x33 , 0x33 , 0x33 ,
/ / jmp r11
0x41 , 0xff , 0xe3
};
|
这里我不会再进行讨论细节。但是这里的代码看起来和x86版本不同,原因在于x64的代码调用约定不是x86 的 __stdcall。比如说,x64上对于四个整数的参数将会被传入RCX,RDX,R8和R9 而不是在栈上。在我们的例子中,RCX足以使得LoadLibraryA
正常工作。
修改哑值的地方自然要使用不同的偏移值
1 2 3 4 5 6 | / / 设置DLL路径
* reinterpret_cast<PVOID * >(code + 0x10 ) = static_cast<void * >( buffer + page_size / 2 );
/ / 设置 LoadLibraryA 地址
* reinterpret_cast<PVOID * >(code + 0x1a ) = static_cast<void * >(loadLibraryAddress);
/ / 跳转地址 (返回原始地址)
* reinterpret_cast<unsigned long long >(code + 0x34 )(code + 0x34 ) = context.Rip;
|
到这里,你应该基本掌握了如果通过目标进程现有线程进行DLL注入的方法。这个方法相对于创建线程要难于检测。一种可能的方式是定位可执行页面与已知模块的地址进行比较。但是检测都存在一个窗口期,这里可以考虑注入进程在注入完成后(通过事件对象)来释放用于注入分配的内存,概率性规避。
完整的项目代码在Github上。
[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界