← back

Abusing xhunter1.sys —
How I Used XIGNCODE3's Own Driver Against Other Games

XIGNCODE3 installs a kernel driver that exposes commands to open handles, read memory, and write memory on any process — with no filtering on which process you target. Here's how I found it during the pandemic and used it against games protected by EAC and BattleEye.

Back during the pandemic I was playing Special Force, a Korean FPS that uses XIGNCODE3 as its anti-cheat. At some point I got curious and started poking around the driver it installs — xhunter1.sys. I found a bunch of interesting kernel commands in there and the first thing that crossed my mind was: what if I use these against other games?

So I tried it. Pointed the driver's commands at games protected by EAC, BattleEye, and others. It worked. No ban.

I kept it to myself for a while because it was genuinely useful and I didn't want it patched. But this stuff is old now, other people have started talking about similar techniques, and the specific driver version I reversed is already out of date. So here's how it worked and how I found it.

The exact command IDs and offsets I'm not going to give you — the current version of xhunter1.sys might be different, and honestly finding them yourself is part of it. I'll show you what to look for and what the commands do. The rest is on you.

Note · Version This was reversed during 2020–2021 on driver version 10.0.10011.16384 compiled 2018-03-29. The current version of XIGNCODE3 may have changed the packet format or command IDs. Reverse it yourself on whatever version you have.

Getting the driver loaded

Install any game that ships with XIGNCODE3 and launch it. Black Desert Online, Blade & Soul, ArcheAge, Special Force — plenty of options. The driver loads automatically at game start.

Once it's running you can check:

sc query xhunter1

If the game is open, that'll show it as running. The binary itself lives at C:\Windows\System32\drivers\xhunter1.sys after the game installs. The device is at \\.\xhunter1. You can test if it's up just by trying to open it:

HANDLE h = CreateFileW(L"\\\\.\\xhunter1", GENERIC_READ | GENERIC_WRITE,
                        0, nullptr, OPEN_EXISTING, 0, nullptr);

If that doesn't return INVALID_HANDLE_VALUE, the driver is running and you're in.

Figuring out the packet format

Load xhunter1.sys in IDA or Ghidra. Find DriverEntry and look for what gets assigned to MajorFunction in the DRIVER_OBJECT — that gives you the IRP handlers. The one you care about is IRP_MJ_WRITE, which is how all communication happens.

Inside the write handler, right near the top, you'll see two comparisons:

cmp dword ptr [buffer], 0x270
cmp dword ptr [buffer+4], 0x345821AB

Those are the validation checks. Every packet needs size = 0x270 and magic = 0x345821AB or the driver rejects it immediately.

Keep going. A few instructions later it reads a QWORD from [buffer+0x10] and uses it as a write destination — that's a pointer to your response buffer. The driver writes the response directly to whatever address you put there. At [buffer+0x0C] is the command ID, which gets looked up in a dispatch table. So the full request structure is:

#pragma pack(push, 1)
struct XHZ_REQUEST {
    uint32_t size;        // 0x270
    uint32_t magic;       // 0x345821AB
    uint32_t req_id;      // random, echoed back as ~req_id
    uint32_t cmd_id;      // command
    uint64_t res_ptr;     // your response buffer pointer
    uint8_t  body[0x258]; // parameters
};
#pragma pack(pop)

For the response, search the binary for 0x12121212. You'll find it being stored into a buffer — that's the response magic. The full response is 0x2FA bytes. Same header layout as the request but with magic 0x12121212 and req_id bitwise-flipped to ~original_req_id.

#pragma pack(push, 1)
struct XHZ_RESPONSE {
    uint32_t size;
    uint32_t magic;          // 0x12121212
    uint32_t req_id;         // ~your_req_id
    uint8_t  result[0x2EE];
};
#pragma pack(pop)

Finding the commands

Go through the dispatch table handlers one by one. Each handler calls specific kernel APIs, and those tell you exactly what it does. Since everything is imported from ntoskrnl.exe, just look at the import calls inside each function.

Open handle — the handler that calls both PsLookupProcessByProcessId and ObOpenObjectByPointer. Body is a PID and an access mask.

Read memory — calls ObReferenceObjectByHandle, then MmProbeAndLockPages, then MmMapLockedPagesSpecifyCache. Body is a process handle, a virtual address, and a byte count.

Write memory — calls KeStackAttachProcess and MmProbeAndLockPages. Body is a PID, virtual address, size, and the data to write.

Module enumeration — calls KeStackAttachProcess but no MDL functions. Walks the PEB loader list. Body is a PID.

Note down the command ID from the dispatch table entry that points to each handler. That number goes in cmd_id.

Sending packets

XHZ_RESPONSE* send(HANDLE device, uint32_t cmd,
                   const void* body, size_t body_len) {
    auto req = new XHZ_REQUEST{};
    auto res = new XHZ_RESPONSE{};

    req->size    = 0x270;
    req->magic   = 0x345821AB;
    req->req_id  = rand();
    req->cmd_id  = cmd;
    req->res_ptr = (uint64_t)res;

    if (body && body_len)
        memcpy(req->body, body, body_len);

    DWORD written = 0;
    if (!WriteFile(device, req, sizeof(XHZ_REQUEST), &written, nullptr)
        || written != sizeof(XHZ_RESPONSE)
        || res->magic != 0x12121212
        || ~res->req_id != req->req_id) {
        delete req;
        delete res;
        return nullptr;
    }

    delete req;
    return res;
}

Opening a handle to any process

The open-handle command uses ObOpenObjectByPointer from kernel mode. That bypasses the Object Manager's access checks and it skips every OB callback on the system — meaning other anti-cheats that registered handle-stripping callbacks won't fire. You get exactly the access mask you asked for, for any process.

#pragma pack(push, 1)
struct OpenHandleReq { uint32_t pid, access; };
struct OpenHandleRes { uint32_t status; HANDLE handle; };
#pragma pack(pop)

HANDLE open_process(HANDLE device, uint32_t cmd, DWORD pid, DWORD access) {
    OpenHandleReq req{ pid, access };
    auto res = send(device, cmd, &req, sizeof(req));
    if (!res) return INVALID_HANDLE_VALUE;

    auto out = (OpenHandleRes*)res->result;
    HANDLE h = out->status == 0 ? out->handle : INVALID_HANDLE_VALUE;
    delete res;
    return h;
}
HANDLE hTarget = open_process(device, OPEN_HANDLE_CMD, target_pid, PROCESS_ALL_ACCESS);

The returned handle works with any Win32 API that takes a process handle — ReadProcessMemory, WriteProcessMemory, CreateRemoteThread, all of it. Or use the driver's own read/write commands if you want to stay in kernel land.

Reading memory

The read command maps pages via MDL, so protection flags don't matter. PAGE_GUARD, PAGE_NOACCESS — irrelevant. If the page exists in physical memory, you read it.

#pragma pack(push, 1)
struct ReadReq {
    HANDLE   hProcess;
    uint32_t _pad;
    uint64_t address;
    uint32_t size;
};
struct ReadRes {
    uint32_t status, bytes_read;
    uint8_t  data[1];
};
#pragma pack(pop)

bool read_mem(HANDLE device, uint32_t cmd, HANDLE hProcess,
              uint64_t addr, void* out, uint32_t size) {
    ReadReq req{ hProcess, 0, addr, size };
    auto res = send(device, cmd, &req, sizeof(req));
    if (!res) return false;

    auto r = (ReadRes*)res->result;
    if (r->status == 0)
        memcpy(out, r->data, min(r->bytes_read, size));

    bool ok = r->status == 0;
    delete res;
    return ok;
}
float hp = 0.f;
read_mem(device, READ_CMD, hTarget, health_addr, &hp, sizeof(hp));

Writing memory

Write goes through the same MDL path. Page protection doesn't stop it, and it writes at the physical level so .text sections marked read-only are fair game too.

#pragma pack(push, 1)
struct WriteReq {
    uint32_t pid, _pad;
    uint64_t address;
    uint32_t size;
    uint8_t  data[1];
};
#pragma pack(pop)

bool write_mem(HANDLE device, uint32_t cmd, DWORD pid,
               uint64_t addr, const void* buf, uint32_t size) {
    auto total = offsetof(WriteReq, data) + size;
    auto req   = (WriteReq*)_alloca(total);
    memset(req, 0, total);
    req->pid     = pid;
    req->address = addr;
    req->size    = size;
    memcpy(req->data, buf, size);

    auto res = send(device, cmd, req, total);
    if (!res) return false;

    bool ok = *(uint32_t*)res->result == 0;
    delete res;
    return ok;
}
float godmode = 99999.f;
write_mem(device, WRITE_CMD, target_pid, health_addr, &godmode, sizeof(godmode));

Getting module bases

The module enum command attaches to the process and walks its PEB.Ldr — the same linked list Windows uses to track loaded DLLs. Gives you name, base, and size for everything loaded.

#pragma pack(push, 1)
struct EnumModReq { uint32_t pid, max; };
struct ModEntry {
    uint64_t base;
    uint32_t size, _pad;
    wchar_t  name[260];
};
struct EnumModRes {
    uint32_t status, count;
    ModEntry entries[1];
};
#pragma pack(pop)

uint64_t get_module(HANDLE device, uint32_t cmd,
                    DWORD pid, const wchar_t* name) {
    EnumModReq req{ pid, 128 };
    auto res = send(device, cmd, &req, sizeof(req));
    if (!res) return 0;

    auto r = (EnumModRes*)res->result;
    uint64_t base = 0;
    for (uint32_t i = 0; i < r->count && !base; i++)
        if (_wcsicmp(r->entries[i].name, name) == 0)
            base = r->entries[i].base;

    delete res;
    return base;
}
auto base  = get_module(device, ENUM_MOD_CMD, pid, L"game.exe");
auto unity = get_module(device, ENUM_MOD_CMD, pid, L"UnityPlayer.dll");

Full example

int main() {
    srand((unsigned)time(nullptr));

    HANDLE device = CreateFileW(L"\\\\.\\xhunter1", GENERIC_READ | GENERIC_WRITE,
                                0, nullptr, OPEN_EXISTING, 0, nullptr);
    if (device == INVALID_HANDLE_VALUE) {
        puts("[-] launch a game that uses xigncode3 first");
        return 1;
    }

    DWORD pid = find_pid(L"your_target_game.exe");

    HANDLE hProc = open_process(device, OPEN_HANDLE_CMD, pid, PROCESS_ALL_ACCESS);
    if (hProc == INVALID_HANDLE_VALUE) { puts("[-] failed"); return 1; }

    uint64_t base = get_module(device, ENUM_MOD_CMD, pid, L"your_target_game.exe");
    printf("[+] base: %llx\n", base);

    float val = 0;
    read_mem(device, READ_CMD, hProc, base + YOUR_OFFSET, &val, sizeof(val));
    printf("[+] %.1f\n", val);

    float patched = 99999.f;
    write_mem(device, WRITE_CMD, pid, base + YOUR_OFFSET, &patched, sizeof(patched));
    puts("[+] done");

    CloseHandle(hProc);
    CloseHandle(device);
}

Fill in the _CMD constants with the command IDs you found while reversing. The offsets are on you — use Cheat Engine or IDA on the target game to find them.

Why it works on other games

The driver doesn't care what PID you pass in. No allowlist, no checks. PsLookupProcessByProcessId takes whatever you give it and the handle comes back from kernel mode with ObOpenObjectByPointer, which skips all OB callbacks system-wide. EAC and BattleEye both register callbacks to strip dangerous access rights from handles opened on their game processes. Those callbacks don't fire when the call comes from kernel mode. You get whatever you asked for.

So if Game A uses XIGNCODE3 and you have it loaded, its driver will happily open PROCESS_ALL_ACCESS into Game B sitting next to it. The anti-cheat becomes the cheat.

"The anti-cheat protecting Game A becomes a tool for cheating in Game B."

Quick reference

Instead of exact command IDs — find them yourself by looking for these API patterns in the dispatch table handlers:

  • PsLookupProcessByProcessId + ObOpenObjectByPointer → open any process handle
  • PsLookupProcessThreadByCid + ObOpenObjectByPointer → open by PID + TID
  • ObReferenceObjectByHandle + MmProbeAndLockPages → read process memory
  • KeStackAttachProcess + MmProbeAndLockPages → write process memory
  • KeStackAttachProcess, no MDL → enumerate modules (PEB walk)
  • ZwQueryInformationProcess with class from packet → generic process info
  • ZwQueryInformationProcess(class=27) → dump handle table
/ / /

Reversed during 2020–2021. Driver version 10.0.10011.16384, compiled 2018-03-29.