首页
社区
课程
招聘
[翻译]DLL注入之SetThreadContext
2020-9-18 22:13 21891

[翻译]DLL注入之SetThreadContext

2020-9-18 22:13
21891

现在的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_OPERATIONPROCESS_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就会调用起来。我们可以再这里下一个断点。

1
bp 04A00000

接下来我们可以让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
 # ChildEBP RetAddr 
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世界

收藏
点赞3
打赏
分享
最新回复 (12)
雪    币: 67
活跃值: (629)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
kinglyu 2020-9-18 22:45
2
0
配合内存加载效果更好
雪    币: 11223
活跃值: (4794)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
hhkqqs 1 2020-9-19 09:56
3
0
缺陷是后续清理内存的时机无法直接得知,除非代码里自行call NtSetEvent。如果把ETHREAD的FreezeCount和SuspendApc.Inserted分别设为1和0,这方法照样抓瞎
雪    币: 11223
活跃值: (4794)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
hhkqqs 1 2020-9-19 11:05
4
2
“创建一个新的线程可以被其他很多方法检测到,比如说ETW event”
从Win10 1507到1803,NtSetContextThread必定被ETW记录(WOW64除外),1809到2004,Usermode的NtSetContextThread、PsSetContextThread、PspWow64SetContextThread都会被ETW记录(包括x64和WOW64)。这老外想当然地认为SetThreadContext很隐蔽,其实和CreateThread半斤八两
雪    币: 22
活跃值: (423)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
靴子 2020-9-19 20:46
5
0
雪    币: 1055
活跃值: (412)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
provence 2020-9-22 22:11
6
0
曾哥,永远的神!
雪    币: 302
活跃值: (246)
能力值: ( LV4,RANK:45 )
在线值:
发帖
回帖
粉丝
一二三六 2020-9-29 17:56
7
0
hhkqqs “创建一个新的线程可以被其他很多方法检测到,比如说ETW event” 从Win10 1507到1803,NtSetContextThread必定被ETW记录(WOW64除外),1809到2004, ...
你好 你说的这个ETW event有R3的API接口吗
雪    币: 11223
活跃值: (4794)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
hhkqqs 1 2020-9-29 20:13
8
0
一二三六 你好 你说的这个ETW event有R3的API接口吗
论坛自行搜索StartTrace EnableTrace OpenTrace…
雪    币: 302
活跃值: (246)
能力值: ( LV4,RANK:45 )
在线值:
发帖
回帖
粉丝
一二三六 2020-10-11 16:57
9
0
hhkqqs 论坛自行搜索StartTrace EnableTrace OpenTrace…
我看了下ETW Flags  发现除了EVENT_TRACE_FLAG_CSWITCH跟线程切换相关  并没有发现哪个Flags能记录SetContextThread啊
雪    币: 11223
活跃值: (4794)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
hhkqqs 1 2020-10-11 19:02
10
0
一二三六 我看了下ETW Flags 发现除了EVENT_TRACE_FLAG_CSWITCH跟线程切换相关 并没有发现哪个Flags能记录SetContextThread啊
我指的是系统本身etw也会记录setcontextthread,win10每个版本NtSetContextThread都被内嵌了一句EtwWrite,虽然没记录太多信息,但也实打实地记录了这个API的调用
雪    币: 11223
活跃值: (4794)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
hhkqqs 1 2020-10-13 15:46
11
0
一二三六 我看了下ETW Flags 发现除了EVENT_TRACE_FLAG_CSWITCH跟线程切换相关 并没有发现哪个Flags能记录SetContextThread啊
应用层的ETW阉割了太多东西,你在ntos跟一下KiSystemServiceCopyEnd的一个跳转分支PerfInfoLogSysCallEntry就知道了,InfinityHook的灵感就是从这里来的,就算不Hook也可以记录系统调用
雪    币: 302
活跃值: (246)
能力值: ( LV4,RANK:45 )
在线值:
发帖
回帖
粉丝
一二三六 2020-10-16 15:15
12
0
hhkqqs 应用层的ETW阉割了太多东西,你在ntos跟一下KiSystemServiceCopyEnd的一个跳转分支PerfInfoLogSysCallEntry就知道了,InfinityHook的灵感就是从这 ...
enen...好的  谢谢  我这就去看看
雪    币: 89
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
sanqiu 2022-11-30 02:14
13
0
游客
登录 | 注册 方可回帖
返回