入侵后阶段:如何调整CLR到所需运行时状态以防止程序集退出

阅读量201475

|

发布时间 : 2020-09-01 15:30:29

x
译文声明

本文是翻译文章,文章原作者 mdsec,文章来源:mdsec.co.uk

原文地址:https://www.mdsec.co.uk/2020/08/massaging-your-clr-preventing-environment-exit-in-in-process-net-assemblies/

译文仅供参考,具体内容表达以及含义原文为准。

 

一、概述

在MDSec,经常需要开发定制化的入侵后(Post-exploitation)工具以满足实际需求。这一点对于红队来说也不例外,红队可能经常需要调整用于信息收集和横向移动等任务的技术,以适应目标环境。

我们接触到的大多数入侵后工具都是使用C#语言开发的,例如使用Cobalt Strike的execute-assembly功能的工具。在之前的文章中,我们曾经讨论过这个功能的一些局限性。为了解决这些问题,我们花费了一些时间来创建自己的入侵后工具,该工具允许执行.NET程序集,具有自定义的inproc-execute-assembly扩展,从而在进程中执行CLR,增强在主机上的隐蔽性。

通过使用自定义的CLR harness,我们可以将其调整到最适合当前场景的状态,比如可以禁用System.Management.AutomationTracing.PSEtwLogProviderAmsiUtils之类的功能。在对我们的CLR harness进行测试的过程中,由于一个程序集在信标进程中运行,从而导致信标停止响应。调查显示,该程序集通过调用System.Environment.Exit以错误状态退出,并终止了信标进程。在这篇文章中,我们将主要分析如何将CLR调整到所需运行时状态,以防止程序集退出。希望这篇文章能够对尝试创建类似工具的读者能够有所帮助。

 

二、研究过程

要复现问题非常简单,在C#中创建一个名为Enviroment.Exit的控制台应用程序,然后从一个简单的本地.NET托管工具调用它,就可以复现该托管应用程序过早退出的情况。

控制台应用程序中包含以下代码:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace PrematureExit
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("About to call Environment.Exit");
            Environment.Exit(0);
            Console.WriteLine("Survived exit");
        }
    }
}

运行·该应用程序后,我们可以预料到其结果:

要深入理解当前正在发生的Environment.Exit,可以使用ILSpy(.NET反编译器)并检查相关方法的代码。System.Environment类存在于mscorlib.dll文件中。取决于使用的.NET框架的版本,这个类有多个。

通过反编译CLR v4.0.30319的mscorlib.dll并浏览System命名空间,可以迅速发现指向Environment类和相关的Exit方法:

[SecuritySafeCritical]
[SecurityPermission(SecurityAction.Demand, Flags = SecurityPermissionFlag.UnmanagedCode)]
public static void Exit(int exitCode)
{
    _Exit(exitCode);
}

Exit似乎是mscorlib程序集内部的本地函数_Exit的轻量级包装器:

[DllImport("QCall", CharSet = CharSet.Unicode)]
[SecurityCritical]
[SuppressUnmanagedCodeSecurity]
internal static extern void _Exit(int exitCode);

我们无法使用ILSpy反编译这个本地方法,因此转向WinDBG,在适当配置了符号后开始进一步的研究。我们在ntdll!NtTerminateProcess上放置了一个断点,以确保可以在进程终止时立即捕获执行,从而可以检查调用栈。

在WinDBG中打开ntdll!NtTerminateProcess二进制文件,放置断点,并允许在调试器按照预期中断后继续执行:

0:000> k
 # ChildEBP RetAddr  
00 006fec78 77aa145d ntdll!NtTerminateProcess
01 006fed50 76835902 ntdll!RtlExitUserProcess+0x6d
02 006fed64 71bd4dab KERNEL32!ExitProcessImplementation+0x12
03 006fefe4 71bd4f13 mscoreei!RuntimeDesc::ShutdownAllActiveRuntimes+0x34c
04 006feff0 6f4a36ef mscoreei!CLRRuntimeHostInternalImpl::ShutdownAllRuntimesThenExit+0x13
05 006ff028 6f4a365a clr!EEPolicy::ExitProcessViaShim+0x79
06 006ff25c 6f4dc594 clr!SafeExitProcess+0x137
07 006ff26c 6f4dc5db clr!HandleExitProcessHelper+0x63
08 006ff280 6f4efe89 clr!EEPolicy::HandleExitProcess+0x50
09 006ff290 6f91b07b clr!ForceEEShutdown+0x31
0a 006ff2c8 6ea69faf clr!SystemNative::Exit+0x4f
0b 006ff300 0097085d mscorlib_ni!System.Environment.Exit(Int32)$##6000E33+0x43
WARNING: Frame IP not in any known module. Following frames may be wrong.
0c 006ff308 6f32f066 0x97085d
0d 006ff314 6f33231a clr!CallDescrWorkerInternal+0x34
0e 006ff368 6f3385bb clr!CallDescrWorkerWithHandler+0x6b
0f 006ff3d8 6f4db08b clr!MethodDescCallSite::CallTargetWorker+0x16a
10 006ff4fc 6f4db76a clr!RunMain+0x1b3
11 006ff768 6f4db697 clr!Assembly::ExecuteMainMethod+0xf7
12 006ffc4c 6f4db818 clr!SystemDomain::ExecuteMainMethod+0x5ef
13 006ffca4 6f4db93e clr!ExecuteEXE+0x4c
14 006ffce4 6f4d7275 clr!_CorExeMainInternal+0xdc
15 006ffd20 71bcfa84 clr!_CorExeMain+0x4d
16 006ffd58 72f8e80e mscoreei!_CorExeMain+0xd6
17 006ffd68 72f94338 MSCOREE!ShellShim__CorExeMain+0x9e
18 006ffd70 76826359 MSCOREE!_CorExeMain_Exported+0x8
19 006ffd80 77ab7c24 KERNEL32!BaseThreadInitThunk+0x19
1a 006ffddc 77ab7bf4 ntdll!__RtlUserThreadStart+0x2f
1b 006ffdec 00000000 ntdll!_RtlUserThreadStart+0x1b

栈跟踪表明大量函数与CLR关闭和进程退出有关。其中,特别引起我们注意的是mscorlib_ni!System.Environment.Exit(Int32)$##6000E33clr!SystemNative::Exit函数,因为这些代码的执行非常接近我们的JIT代码(0xc)。尽管这些似乎都不是_Exit函数所期望的,但可以合理假设——修补或挂钩这两种方法以防止进程执行似乎是一种可行的方案。

因此,要防止过早终止,最简单的方法似乎是确定上述函数是否是DLL导出,可以通过相应模块中的名称来识别,并使用类似于Microsoft Detours之类的库对其进行修补或挂钩。

为了确定这一点,我们可以使用诸如CFF Explorer之类的程序检查每个模块的导出表,但有一种更简单的方法,就是取消解析符号,并再次获取栈跟踪。如果名称仍然被保留,那么就可以证明它们属于导出函数:

0:000> .sympath "c:\\null"
Symbol search path is: c:\\null
Expanded Symbol search path is: c:\\null
Error: Execute .sympath(+) command attempts to access 'c:\\null' failed: 0x2 - The system cannot find the file specified.

************* Path validation summary **************
Response                         Time (ms)     Location
Error                                          c:\\null
0:000> !reload /f
Reloading current modules
.*** WARNING: Unable to verify checksum for PrematureExit.exe
..............................

************* Symbol Loading Error Summary **************
Module name            Error
mscorlib.ni            The system cannot find the file specified
clr                    The system cannot find the file specified
...
You can troubleshoot most symbol related issues by turning on symbol loading diagnostics (!sym noisy) and repeating the command that caused symbols to be loaded.
You should also verify that your symbol search path (.sympath) is correct.
0:000> k
 # ChildEBP RetAddr  
WARNING: Stack unwind information not available. Following frames may be wrong.
00 006fed50 76835903 ntdll!NtTerminateProcess
01 006fed64 71bd4dab KERNEL32!ExitProcess+0x13
02 006fefe4 71bd4f13 mscoreei!ND_WU1+0x75b
03 006feff0 6f4a36ef mscoreei!ND_WU1+0x8c3
04 006ff028 6f4a365a clr!ClrCreateManagedInstance+0xb06f
05 006ff25c 6f4dc594 clr!ClrCreateManagedInstance+0xafda
06 006ff26c 6f4dc5db clr!CorExeMain+0x5344
07 006ff2c8 6ea69faf clr!CorExeMain+0x538b
08 006ff300 0097085d mscorlib_ni+0xb59faf
09 006ff308 6f32f066 0x97085d
0a 006ff314 6f33231a clr+0xf066
0b 006ff368 6f3385bb clr!LogHelp_TerminateOnAssert+0x93a
0c 006ff3d8 6f4db08b clr!LogHelp_TerminateOnAssert+0x6bdb
0d 006ff4fc 6f4db76a clr!CorExeMain+0x3e3b
0e 006ff768 6f4db697 clr!CorExeMain+0x451a
0f 006ffc4c 6f4db818 clr!CorExeMain+0x4447
10 006ffca4 6f4db93e clr!CorExeMain+0x45c8
11 006ffce4 6f4d7275 clr!CorExeMain+0x46ee
12 006ffd20 71bcfa84 clr!CorExeMain+0x25
13 006ffd58 72f8e80e mscoreei!CorExeMain+0x64
14 006ffd68 72f94338 MSCOREE!DllUnregisterServer+0x14e
15 006ffd80 77ab7c24 MSCOREE!CorExeMain+0x8
16 006ffddc 77ab7bf4 ntdll!RtlGetAppContainerNamedObjectPath+0xe4
17 006ffdec 00000000 ntdll!RtlGetAppContainerNamedObjectPath+0xb4

调用栈中,似乎完全没有在引用的程序代码和最终调用kernel32!ExitProcess之间的名称,这表明没有导出任何我们感兴趣的函数。

我们的备选方法是对ntdll!NtTerminateProcess进行挂钩,但经过进一步分析后,很快发现这并非一个合适的选择,因为当NtTerminateProcess函数被调用时,不可逆地导致.NET CLR关闭,并且从NtTerminateProcess返回的结果导致CLR陷入死锁。其他方法(例如终止关联线程,或引发异常)也能导致类似的结果。

由于缺乏容易的挂钩点,我们可以选择一种替代方法,一种可以在CLR环境中而不是CLR环境外执行的方法,这样就可以得到一种更轻松的方式,使用.NET反射来定位与Environment.Exit相关的方法。

我们对PrematureExit程序进行了以下修改,通过使用反射来定位Exit方法,并获得指向依赖本地代码方法的指针:

static void Main(string[] args)
{
    var methods = new List<MethodInfo>(typeof(Environment).GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic));
    var exitMethod = methods.Find((MethodInfo mi) => mi.Name == "Exit");

    Console.WriteLine("exitMethod = {0}, param count = {1}", exitMethod.Name, exitMethod.GetParameters().Length);

                RuntimeHelpers.PrepareMethod(exitMethod.MethodHandle);
    var exitMethodPtr = exitMethod.MethodHandle.GetFunctionPointer();

    Console.WriteLine("exitMethodPtr = 0x{0}", exitMethodPtr.ToString("x"));

    Console.ReadKey();

    Console.WriteLine("About to call Environment.Exit");
    Environment.Exit(0);
    Console.WriteLine("Survived exit");
}

在上面的代码中,使用RuntimeHelpers.PrepareMethod函数来确保依赖的方法是JIT的(如果需要),对exitMethod.MethodHandle.GetFunctionPointer的调用将检索指向被伪装代码的指针。

观察到以下输出:

然后使用WinDBG,检查exitMethodPtr在指定地址处的代码:

0:005> u 0x6ea69f6c
mscorlib_ni!System.Environment.Exit(Int32)$##6000E33:
6ea69f6c 55              push    ebp
6ea69f6d 8bec            mov     ebp,esp
6ea69f6f 57              push    edi
6ea69f70 56              push    esi
6ea69f71 53              push    ebx
6ea69f72 83ec20          sub     esp,20h
6ea69f75 33d2            xor     edx,edx
6ea69f77 8955f0          mov     dword ptr [ebp-10h],edx

这个方法立即被识别为System.Environment.Exit方法的实现,该方法在之前产生的栈跟踪中观察到。这似乎是为防止应用程序而进行修补的一种理想选择。

实施的修补如下所示:

static void Main(string[] args)
{
    var methods = new List<MethodInfo>(typeof(Environment).GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic));
    var exitMethod = methods.Find((MethodInfo mi) => mi.Name == "Exit");

    Console.WriteLine("exitMethod = {0}, param count = {1}", exitMethod.Name, exitMethod.GetParameters().Length);

RuntimeHelpers.PrepareMethod(exitMethod.MethodHandle);
    var exitMethodPtr = exitMethod.MethodHandle.GetFunctionPointer();

    Console.WriteLine("exitMethodPtr = 0x{0}", exitMethodPtr.ToString("x"));

    Console.ReadKey();

    unsafe
    {
        IntPtr target = exitMethod.MethodHandle.GetFunctionPointer();

        MEMORY_BASIC_INFORMATION mbi;

        if (VirtualQueryEx((IntPtr)(-1), target, out mbi, (uint)Marshal.SizeOf(typeof(MEMORY_BASIC_INFORMATION))) != 0)
        {
            if (mbi.Protect == AllocationProtectEnum.PAGE_EXECUTE_READ)
            {
                // seems to be executable code
                uint flOldProtect;

                if (VirtualProtectEx((IntPtr)(-1), (IntPtr)target, (IntPtr)1, (uint)AllocationProtectEnum.PAGE_EXECUTE_READWRITE, out flOldProtect))
                {
                    *(byte*)target = 0xc3; // ret

                    VirtualProtectEx((IntPtr)(-1), (IntPtr)target, (IntPtr)1, flOldProtect, out flOldProtect);
                }
            }
        }
    }

    Console.WriteLine("About to call Environment.Exit");
    Environment.Exit(0);
    Console.WriteLine("Survived exit");
}

上面的代码首先确认获得的函数指针是否位于只读的可执行区域(例如DLL)内,如果是,则将这段代码进行修补,从而实现ret (0xc3),而不再是继续退出进程。

修补后的效果如下所示:

调用Environment.Exit时,该进程不再终止。我们还针对.NET框架的2.0-3.5版本编译和测试了同一个程序集,以确保所需的行为可以得以保留,同时我们还在x86和x64进行了测试。

为了解决从进程中CLR执行时会终止Cobalt Strike信标的这个问题,我们在加载并执行要运行的应用程序的程序集之前,预先加载并执行了新创建的PreventExit程序集。

这样,就具有了提前修补Environment.Exit的效果,以确保随后无法通过这个方法来终止信标。

在尝试终止后再允许程序集继续执行,很可能会导致程序集遇到未处理的错误情况。并引发异常,但是这个异常是由CLR处理,通常会导致用于执行程序集的.NET反射API(_MethodInfo::Invoke_*)返回COM错误HRESULT,从而避免发生崩溃,或出现不稳定的情况。

 

三、总结

修补Environment.Exit方法的过程非常简单,这样就可以防止程序集意外终止宿主进程。在不同的.NET框架版本之间,这种方法似乎都是有效的,并且能够有助于降低在进程中执行.NET入侵后工具的风险。

 

四、参考文章

[1] https://reverseengineering.stackexchange.com/questions/20997/c-changing-method-body-in-runtime
[2] https://github.com/icsharpcode/ILSpy

本文翻译自mdsec.co.uk 原文链接。如若转载请注明出处。
分享到:微信
+10赞
收藏
P!chu
分享到:微信

发表评论

内容需知
  • 投稿须知
  • 转载须知
  • 官网QQ群8:819797106
  • 官网QQ群3:830462644(已满)
  • 官网QQ群2:814450983(已满)
  • 官网QQ群1:702511263(已满)
合作单位
  • 安全客
  • 安全客
Copyright © 北京奇虎科技有限公司 360网络攻防实验室 安全客 All Rights Reserved 京ICP备08010314号-66