前言
为了避免有的读者不了解基础知识,造成后续理解上的困难和偏差,一般我都会在文章开头将相关的内容给出。如果你已经掌握这一部分,可以跳过阅读。
共享DLL位置固定
在Windows
中,并不是所有的DLL
文件都需要人为加载,有少数几个DLL
文件,由于包含了操作系统的核心组件或广泛使用的API
,所以每个进程都会自动载入。
也正是因为这个原因,这些DLL
文件在不同的进程空间中,有相同的内存地址。常见有:
1 Kernel32.dll 用户态核心DLL,提供大量用户使用的API
2 Ntdll.dll 内核态核心DLL,提供切换到内核调用的API
3 User32.dll 用于界面、消息处理API
4 Gdi32.dll 图形设备API
5 Advapi32.dll 系统管理与安全API
6 Shell32.dll 文件与文件夹、快捷方式等API
相对调用指令的计算方式
当写好的C代码
还原成汇编代码时,会发现函数调用实际是call funcAddress
的形式。在 x86-64
架构下,call
指令的调用为相对调用。也就是说,funcAddress
是目标函数相对于当前call
指令的下一条指令的相对偏移地址。有点绕,接下来我做具体的解释:
例如我们用WinDbg
打开一个PE
文件,你会发现call text.321640
指令对应的机器码是 E8 9B050000
(E8
是 call
指令的机器码)。而x86
架构用的排序方式是小端排序,所以实际执行指令为call 0000059B
。而这个 9B050000
(也就是059B
)是怎么来的呢?
如果你有PE
文件分析的基础,你知道 test.321640
表示的就是存放在321640
这个位置的函数
当我们用 321640 - 3210A0
,得到05A0
那为什么是05A0
?为什么不是059B
?因为我们的 call 0000059B
这条指令本身还占用了5
个字节。
这就是上文我们说的 "funcAddress
即目标函数相对于当前call
指令的下一条指令的相对偏移地址"。这里我用一幅图给你解释:
所以,我们计算的321640 - 3210A0
还需要减去call
整体指令的长度,也就是5
个字节,于是完整的计算过程应该是:321640-3210A0-5
= 59B
由此,我们也得到了call
指令偏移地址的计算方式:
目标函数地址 - call指令地址 - 5
实现原理
现在我们来思考这样一种情况:
我们已知每个进程都会载入如kernel32.dll
这样的DLL
文件,尽管进程不同,但DLL
在内存中的位置是相同的。也就是说,各个导出函数(也就是API
)在不同进程中的位置也是相同的。所以我们只要在当前进程中找到目标API
的位置,也就找到了目标进程中该API
的位置。
那么,如果我们在某个经常使用的API
开头处,插入一条 call shellcodeAddress
这样的指令,使得只要调用了该API
,就会转去执行shellcode
。在执行完shellcode
后,我们再恢复正常的执行流,使API
依然能正常执行,只是中途触发了shellcode
。
这里用一幅图来表示就很清楚了:
上面的过程只需要换个名词你可能就理解了,其实就是EDR
常用来监控恶意行为的"inline hook
"。只不过在此处被我们用来绕过EDR
如果是实际利用中,我们需要还需要解决一个问题,就是如何在插入inline hook
的情况下,保证API
执行正常,避免严重干扰到目标进程的正常运行。(BeaconEye
作者)的实现方式主要有以下步骤:
1 计算API与shellcode位置的相对偏移量X,所以插入call指令时,偏移值为X-5
2 取出API开头8个字节的内存,放入buf
3 将call X-5写入API开头位置,覆盖掉原先的指令,使得只要触发API就会跳转去执行shellcode
4 在shellcode执行完毕后,将保存在buf中的原API开头内容还原,重新覆盖前8个字节,恢复成未hook的API
5 在shellcode结尾处使用jmp CloseHandlerAddress指令,跳转到API的开头,重新进入API的执行流程,保证程序能够正常运行
6 释放shellcode内存,一切似乎未发生过
代码实现
这一部分我们只分析最关键的代码,简单的诸如获取句柄、分配内存之类的则一笔带过。
我们上面已经说过,只要在当前进程中获取到kernel32.dll
内某个APIv
(如CloseHandle
)的内存位置,就等于获取到了目标进程中该API
的内存位置。想要拿到API
的位置,首先应该定位到DLL
的基址,可以通过如下代码实现:
private static IntPtr GetModuleHandle(string dll)
{
using var self = Process.GetCurrentProcess(); // 获取当前进程句柄
foreach (ProcessModule module in self.Modules) // 遍历当前进程内已加载的DLL
{
if (!module.ModuleName.Equals(dll, StringComparison.OrdinalIgnoreCase)) // 比较是否是目标DLL
continue;
return module.BaseAddress; // 返回目标DLL基址
}
return IntPtr.Zero;
}
在获取到目标DLL
后,只需要通过Windows API
即可获取API
内存地址:
var exportFuncAddrerss = GetProcAddress(DllModule, APIName);
如果你是用C++
实现,只需把 var
换成 LPVOID
类型即可。
在拿到API
地址后,就可以按一般注入方式那样,在目标进程中申请一块存放shellcode
的内存地址,这里不再赘述。
关键在于如何让目标进程触发这段shellcode
,我们看具体实现。先保存API
开头8
个字节的内容:
var originalBytes = Marshal.ReadInt64(exportAddress);
这行代码会读取 exportFuncAddress
位置处的8
字节的内容,exportFuncAddress
就是我们之前定位到的保存在 originalBytes
中,这一步主要是为了shellcode
执行完毕后,能够还原API
的正常内存。
之后,再直接覆盖API
开头的内容,也就是exportFuncAddress
开头的内容:写入 call shellLoader
,这个shellLoader
就是存放shellcodeLoader
的相对偏移地址,具体如何计算,已经通过上文做出了解释,具体到代码的话,就是下面这样:
var relAddr = (int)(loaderAddress - ((ulong)exportAddress + 5));
这个relAddr
会与 call
指令组合成一个字节数组,然后直接写入到API
的开头位置:
var callOpCode = new byte[] { 0xe8, 0, 0, 0, 0 }; // e8 即 call 的机器码, 此处完成了call xxx 指令的编写
using var ms = new MemoryStream(callOpCode);
using var br = new BinaryWriter(ms);
br.Seek(1, SeekOrigin.Begin);
br.Write(relAddr);
status = WriteVirtualMemory( // 将 call shellcodeLoad + shellcode 写入到API位置
hProcess, // 也就是正式下 hook
exportAddress,
callOpCode,
out var bytesWritten);
至此,目标进程(假设是Notepad.exe
)的CloseHandle API
的开头就不再是原先关闭句柄的代码,而是改成了 call shellLoader
,也就变成了跳转到shellcodeLoader
的功能。
为什么是shellcodeLoader
,而不是shellcode
?因为我们在触发shellcode
的情况下,还要保证API
能够正常运转。我们看下面最关键的这段汇编代码,也就是上面提到的shellcodeLoader
的代码内容:
首先是 pop rax
sub rax,0x5
push rax
,这三行代码让我想了半天也没想明白到底啥意思,直到灵光一闪(太菜了)。我们先思考一下这个问题:在执行这段shellcodeLoader
之前,我们执行的是什么代码?是 call shellcodeLoader
,基于x86-64
调用协定,当A函数在调用B函数
时,会将返回位置压入堆栈,也就是将A函数
执行 call BFuncAddr
的下一条指令的位置压入栈顶,以便于B函数
执行完毕后,接着执行A函数
也就是说,在执行shellcodeLoader
这部分代码时,栈顶存放的是 call shellcodeLoader
的下一条指令的位置。我们在foreword
中已经讲解过,call
指令的机器码是E8
,再加上相对偏移地址是32位
,4
个字节,一共是5
个字节。
所以 pop rax
sub rax,0x5
push rax
这三行代码的作用就是将函数的返回位置修正为API
开头的位置。Shellcode
执行完毕后,会跳转到API
的开头重新执行一遍。
至于下面的 push rax、rcx、r8、r9、r10、r11
很好理解,都是为了保证易失寄存器不被干扰。然后是 movabs rcx,0x1122334455667788
,这里是需要理解的一个关键点。
0x1122334455667788
如果用机器码的格式来看,应该是 88 77 66 55 44 33 22 11
,一共8
个字节。其实这8
个字节仅起到一个占位的作用。还记得我们之前保存的API
开头的那8
个字节吗?也就是被放入了originalBytes
字节数组中的那些。只需要通过如下代码,就能将 88 77 66 55 44 33 22 11
替换成originalBytes
:
using var writer = new BinaryWriter(new MemoryStream(ShellcodeLoader));
//Write the original 8 bytes that were in the original export prior to hooking
writer.Seek(0x12, SeekOrigin.Begin);
writer.Write(originalBytes);
writer.Flush();
所以,API
开头的内容被存放进了ShellcodeLoader
中。
再通过 mov QWORD PTR [rax],rcx
,把API
的原有内容又覆写到 PTR [rax]
,而在上面,因为执行了 pop rax
sub rax,0x5
push rax
,所以实际上就将API
的原有内容,又还原到了API
的开头,覆盖掉了我们之前写入的 call ShellcodeLoader
接着,执行 sub rsp,0x40
call shellcode
add rsp 0x40
指令,跳转去执行shellcode
那么问题来了,我们从始至终都没有计算shellcode
的相对偏移位置,这个 call shellcode
怎么来的呢?但如果我们仔细看一下机器码,会发现是 E8 11 00 00 00
也就是 call 11
,即跳转去执行距离当前偏移位置为17(0x11 = 17)
的指令
为什么是0x11
?我们数一下后面的机器码就知道了:下图红框中一共17
个字节的机器码。
换句话说,作者通过将真正的shellcode
与这段shellcodeLoader
拼接,组合成了完整的shellcode
。用一张执行流程图来辅助说明:
由于Shellcode
就紧跟在ShellcodeLoader
后面,所以我们完全可以数机器码的字节个数得出偏移位置。
在Shellcode
执行完毕后,程序回到ShellcodeLoader
中继续执行 pop r11
等指令,将保存在堆栈中的参数值,还原到易失寄存器。
最后,利用 jmp rax
,在shellcode
执行完毕后,跳回到API
开头,执行未被hook
过的API
在文章的末尾,总结一下完整的执行流程:
1 拼接ShellcodeLoader与Shellcode(比如CS给你生成的shellcode);
2 获取当前进程的API位置;
3 拿到目标进程句柄,申请一块API附近的内存,用来存ShellcodeLoader和Shellcode;
4 计算ShellcodeLoader与API的偏移量,构造 call shellcodeLoader 指令,写入API开头,覆盖掉原内容(相当于放置hook);
5 目标进程触发API;
6 跳转执行ShellcodeLoader;
7 修正返回位置(使完整的shellcode执行完毕后,能够返回到API的开头处,等于重新调用干净的API);
8 压入各个易失寄存器,避免执行shellcode过程中,污染原先的参数;
9 将原先保存在originalBytes中的API开头的8字节内容,重新写回API开头,等下次执行API时,API就能恢复正常功能;
10 跳转执行Shellcode;
11 恢复易失寄存器中的参数内容
12 通过 jmp 指令,跳转执行干净的API
效果
这一实现思路,没有在代码注入的过程中新建任何进程,或者干扰其他进程的运行状态(如suspend
之类),减去了很大一块的敏感行为。在未公开之前,甚至连Crowd Strike
都能过。
在文章发布日,测试了如下EDR
:
卡巴斯基✅
微步✅
360(实体机版本) 待测试
防御
从我自己比较浅薄的理解来看,想要防御这种无线程注入方式,EDR
可以监控特定内存范围的写入行为,如果在ntdll.dll
或者kernel32.dll
等DLL
文件的加载范围内,发生了 call
指令或者 jmp
的写入,就应当告警或者拦截。
参考文献
《Needles without the Thread》ppt
https://github.com/CCob/ThreadlessInject
https://pretalx.com/bsides-cymru-2023-2022/talk/BNC8W3/
本文作者:观沧海
关注公众号后台回复 0001
领取域渗透思维导图,0002
领取VMware 17永久激活码,0003
获取SGK地址,0004
获取在线ChatGPT地址,0005
获取 Windows10渗透集成环境,0006
获取 CobaltStrike 4.8破解版
加我微信好友,邀请你进交流群
往期推荐
备用号,欢迎关注
原文始发于微信公众号(刨洞技术交流):Threadless Inject - 一种新颖的进程注入技术
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论