Threadless Inject - 一种新颖的进程注入技术

admin 2023年5月31日12:37:20评论92 views字数 6292阅读20分58秒阅读模式

前言

为了避免有的读者不了解基础知识,造成后续理解上的困难和偏差,一般我都会在文章开头将相关的内容给出。如果你已经掌握这一部分,可以跳过阅读。

共享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)是怎么来的呢?

Threadless Inject - 一种新颖的进程注入技术

如果你有PE文件分析的基础,你知道 test.321640 表示的就是存放在321640这个位置的函数

Threadless Inject - 一种新颖的进程注入技术

当我们用 321640 - 3210A0,得到05A0

Threadless Inject - 一种新颖的进程注入技术

那为什么是05A0?为什么不是059B?因为我们的 call 0000059B 这条指令本身还占用了5个字节。

这就是上文我们说的 "funcAddress 即目标函数相对于当前call指令的下一条指令的相对偏移地址"。这里我用一幅图给你解释:

Threadless Inject - 一种新颖的进程注入技术

所以,我们计算的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

这里用一幅图来表示就很清楚了:

Threadless Inject - 一种新颖的进程注入技术

上面的过程只需要换个名词你可能就理解了,其实就是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

Threadless Inject - 一种新颖的进程注入技术

4 在shellcode执行完毕后,将保存在buf中的原API开头内容还原,重新覆盖前8个字节,恢复成未hook的API
5 在shellcode结尾处使用jmp CloseHandlerAddress指令,跳转到API的开头,重新进入API的执行流程,保证程序能够正常运行
6 释放shellcode内存,一切似乎未发生过

Threadless Inject - 一种新颖的进程注入技术

代码实现

这一部分我们只分析最关键的代码,简单的诸如获取句柄、分配内存之类的则一笔带过。

我们上面已经说过,只要在当前进程中获取到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的代码内容:

Threadless Inject - 一种新颖的进程注入技术

首先是 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个字节的机器码。

Threadless Inject - 一种新颖的进程注入技术

换句话说,作者通过将真正的shellcode与这段shellcodeLoader拼接,组合成了完整的shellcode。用一张执行流程图来辅助说明:

Threadless Inject - 一种新颖的进程注入技术

由于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(实体机版本) 待测试

Threadless Inject - 一种新颖的进程注入技术

防御

从我自己比较浅薄的理解来看,想要防御这种无线程注入方式,EDR可以监控特定内存范围的写入行为,如果在ntdll.dll或者kernel32.dllDLL文件的加载范围内,发生了 call 指令或者 jmp 的写入,就应当告警或者拦截。

参考文献

《Needles without the Thread》ppthttps://github.com/CCob/ThreadlessInjecthttps://pretalx.com/bsides-cymru-2023-2022/talk/BNC8W3/
本文作者:观沧海


Threadless Inject - 一种新颖的进程注入技术

关注公众号后台回复 0001 领取域渗透思维导图,0002 领取VMware 17永久激活码,0003 获取SGK地址,0004 获取在线ChatGPT地址,0005 获取 Windows10渗透集成环境0006 获取 CobaltStrike 4.8破解版


加我微信好友,邀请你进交流群


Threadless Inject - 一种新颖的进程注入技术




往期推荐



iVMS-8700综合安防管理平台代码审计

记一次从linux打进域控

记一次从外网到拿下域控

红队标准手册

某国zf的一次渗透

关于GOIP设备的勘验和服务器渗透实战

可能要被封号了!

备用号,欢迎关注


Threadless Inject - 一种新颖的进程注入技术

原文始发于微信公众号(刨洞技术交流):Threadless Inject - 一种新颖的进程注入技术

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年5月31日12:37:20
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Threadless Inject - 一种新颖的进程注入技术https://cn-sec.com/archives/1759676.html

发表评论

匿名网友 填写信息