0x01 Execute-Assembly 原理
在《Cobalt Strike 原理分析》一文中,介绍了内存加载程序集(Assembly)的主要有四步:
- 1> 加载CLR环境
- 2> 获取程序域
- 3> 装载程序集
- 4> 执行程序集
在odzhan的Shellcode: Loading .NET Assemblies From Memory所描述的那样,.Net Framework随着版本的更新,使用了不同的接口,.Net Framework V1.0 采用的是ICorRuntimeHost接口
,支持v1.0.3705, v1.1.4322, v2.0.50727和v4.0.30319。到了.Net Framework v2.0,采用ICLRRuntimeHost接口
,支持v2.0.50727和v4.0.30319。然后到了.Net Framework v4.0,则使用了ICLRMetaHost接口
,但是可能不再兼容4.0以下的.Net Framework。所以使用ICLRMetaHost接口
并不是一个非常合适的接口。
我们可以使用多个函数进行接口的实例化,最常见的可能属CoCreateInstance
或者CLRCreateInstance
。
剩下的关于获取程序域
,装载程序集
,以及执行程序集
在Execute-Assembly实现都有具体实现。完整代码如下。
0x02 Execute-Assembly检测思路
根据上述的Execute-Assembly的实现原理,可以预测到Execute-Assembly主要有3个检测点。第一个检测点是加载CLR环境,第二个检测点是加载程序集,第三个检测点在于执行入口点的地方。在我看来,第一第二个检测点是比较好实现的。
0x2.1 ETW使用前置知识
根据XPN在他的博文Hiding your .NET - ETW一文中指出利用ETW(Event Trace for Windows)检测CLR的加载。而ProcessHacker或者ProcessExplorer这两款工具都能从进程角度查看进程是否加载了CLR环境。
使用logman query providers
命令查看所有的提供者。如图,执行结果的第一项是提供者名称,第二项是提供者对应的GUID。
也可以通过设置指定得provider name
或者GUID
来获取具体的提供者的详细信息。即使用logman query providers <provider name>
或者logman query providers <GUID>
。
通过执行logman query providers ".NET Common Language Runtime"
语句返回的结果如下。除了具有第一部分提供程序的名称和GUID之外,第二部分是一些关键字的信息,也就是筛选事件的标志。通过设置这些标志来筛选我们所需要的事件。第三部分是安全级别,而第四部分对应的是事件对应的进程ID和进程路径。
XPN在他的博文Hiding your .NET - ETW中,也给出了验证测试代码,代码的功能简而言之就是通过ETW实时的
捕获.NET Common Language Runtime
提供者的AssemblyDCStart_V1
事件。但是这个验证代码有一个缺陷就是,只有当Assembly Loader进程退出后才能捕获对应的AssemblyDCStart_V1
事件。但是,这对我来说是致命的。所以我尝试使用krabsetw库来实现。
|
|
0x2.2 krabsetw安装与使用
krabsetw是微软开发的一个C++库,其主要目的在于简化ETW的交互。krabsetw目前只支持x64的操作系统,而且编译环境最好是VS2017及以上。
本文也并不使用推荐的NuGet安装krabsetw。而是使用vcpkg进行包管理。具体的关于NuGet的使用可以参考这篇文章。
当编译完成vcpkg.exe
之后,使用.\vcpkg.exe list
查看已经安装的开源库,然后使用.\vcpkg.exe install krabsetw:x64-windows
安装krabsetw库。并且一定要将项目的预处理器设置为UNICODE
。至于NDEBUG
和TYPEASSERT
任选其一进行设置。这是krabsetw项目所规定的。具体参见项目说明:https://github.com/microsoft/krabsetw/blob/master/krabs/README.md
使用krabsetw捕获CLR加载事件代码如下,具体的使用例子可以参考krabsetw例子说明。值得注意的是这个设置的关键字我设置的是MonitoringKeyword
是可以实时监控的。而不是设置LoaderKeyword
。
|
|
0x2.3 加载程序集
第二个检测点位于加载程序集之后。在memcpy处打一个断点。
并在memcpy函数执行之后的目的地址下一个执行断点,并执行。这一步是为了定位需要加载的程序集在Assembly Loader进程中的位置。因为Assembly内存加载,程序集必然在进程的内存空间中。只是需要定位在哪里?且那块内存的内存属性和类型。
可以看到程序集保存在内存类型为MEM_COMMIT
和MEM_PRIVATE
以及保护类型为PAGE_READWRITE
的内存块
整个扫描逻辑就很简单了,只需要调用VirtualQueryEx获取内存信息,只需要选择内存类型为MEM_COMMIT
和MEM_PRIVATE
以及保护类型为PAGE_READWRITE
的内存块。然后扫描PE头信息即可。
0x03 绕过上述检测
绕过上述检测的最简单的思路就是Patch ETW。而我想的是使用BOF进行Bypass ETW 以及Assembly加载。值得庆幸得是CobaltStrike官方以及有大佬已经做了这一部分的研究。
0x3.1 脚本学习
在官方的文档Beacon Object Files中,详细描写了怎么使用CNA和BOF。根据文档提供的例子。
使用local
定义了本地变量。
使用barch
函数获取进程架构,以此后续拼接读取BOF时使用。参数$1表示的是当前会话的ID。Alias的参数有3个。
- $0 是我们起的别名和传输的参数
- $1 是当前会话的 ID
- $2-3-4….第二个参数及以后,就是我们 是我们传递的参数,他们由空格隔开,我们举一个例子:12figure out the arch of this sessionbarch = barch($1);
然后通过readb
读取BOF文件(.obj)
然后再将参数打包。参数1 $1
表示会话ID,第二个参数是传入参数的类型,参数类型如下。从第三个参数就是传入的参数。
|
|
最后调用beacon_inline_execute
,其实就是执行inline_execute命令。第三个参数是入口点函数。
需要参考Sleep语言的说明http://sleep.dashnine.org/manual/index.html
|
|
0x3.2 BOF编写
BOF主要需要实现两个点,第一实现ByPass ETW,第二需要实现Assembly加载。先看官方给的例子。首先使用BeaconDataParse
解析参数,然后调用BeaconDataExtract
和BeaconDataInt
依次获取string类型和int类型。
|
|
其中Bypass ETW原理很简单,只需要Patch EtwEventWrite
或者EtwEventWriteFull
函数,而Assembly Load就是上面所描述的四个步骤即可。