Inside a Rare Kernel Anti-Cheat CTF: Small Mistakes, Huge Attack Surface
TBMKE defends two integers with a game process, a watchdog and a self-signed kernel driver. A handle taken before the driver arms, a record with no authenticator and a hash that turns itself off are enough to take both.
On this page
TBMKE v1 is a top-down wave shooter of the size you would write in an afternoon. Move with WASD, aim with the mouse, shoot the red circles before they reach you. Health starts at 100, the magazine holds 30, each wave adds three enemies, and there is no victory screen — the run ends when health reaches zero.

The cheat a player wants is just as small: never run out of health, never run
out of ammunition, and keep playing. What stands between the player and those
two integers is not small at all. Alongside TBM.exe the challenge ships a
watchdog process and a self-signed Windows kernel driver that filters handles to the
game, watches its threads and memory map, and terminates it on any violation.
The finished trainer takes both values and holds them. It runs from elevated user mode — the same context the challenge already demands of itself in order to install its driver service — against the stock driver, with no kernel code of its own and no patched build of anything. It launches the stock game, holds health and ammunition where it wants them for as long as the run lasts, and puts every modified byte back when it exits.
The trainer source and build instructions
are available in fl0sec-labs.
That result is not evidence that any single check in TBMKE is weak. The handle callback removes exactly the right access bits. The guarded records really do detect a torn or naive write. Both CRC32 implementations return the values they are supposed to return. TBMKE falls because a handful of small trust and ordering mistakes, each defensible on its own, combine into many separate ways around the checks — and only one of them has to work.
The target is TBMKE v1 on crackmes.one,
published by DeadEye, and rated 6/6 by the site. Crackmes built around a
user-mode and kernel-mode anti-cheat simulation are rare, and that rarity is a
large part of the rating. The work itself is approachable: everything below uses
ordinary Windows APIs and a debugger, and this article is marked 2/5 for the
background it assumes.
Three programs, one promise
TBM.exe draws the game and owns the state. It also does nearly all of the
user-mode defensive work: debug-port queries, hardware-breakpoint scans,
API-prologue checks, IAT validation, a live hash of its own .text, module
inspection, and validation of every gameplay value it displays.
TBMKD.sys defends the process from the outside. It strips access rights from
new handles to the game, watches thread creation and image loads, rescans the
game's memory map every fifteen seconds, verifies the files on disk, and expects
heartbeats on a deadline. Every one of those failures ends the same way.
WatchdogMain.exe is the smallest of the three and watches the file rather than
the process: it CRC32s TBM.exe on disk in a loop and keeps a named-pipe
session open with the game. When the game exits, the watchdog stops and deletes
the driver service.

Each of the three covers a route the other two cannot see, which is why the architecture reads as stronger than any of its parts. A memory editor has to get past the driver. A file patch has to survive the watchdog's CRC32. An in-process write has to satisfy the game's own validators. The defeats below all work the same way: through when each check runs and the state it trusts by then, never through a check the design forgot.
Two separate handshakes
Before any of the checking runs, the three programs authenticate to one another, and they do it with two schemes that share no key.
The game and the driver open a kernel session. TBM.exe sends a handshake
IOCTL; the driver CRC32s both images on disk and, only if they match, mints a
32-bit session token and holds a MAC key of its own. After that the game proves
itself on every privileged call by presenting the token and a MAC over a rising
counter — and the driver computes those MACs itself, through a dedicated IOCTL,
so the game never has to carry the key. The driver also checks more than the
token: it reads the caller's PID from the IRP and compares it to the PID
recorded at handshake, so holding the token is not the same as being allowed to
use it.
The game and the watchdog open a second session over a named pipe, and its key is the weak one — computed from the game's process ID and creation time, both of which any process that can query the game is able to read. One of these secrets lives in the kernel and is bound to a PID; the other can be reconstructed from public facts about a running process.
What it actually checks
Nothing below is an argument that TBMKE is lightly defended; the inventory belongs before the defeat rather than after it. The challenge asks almost every question you would think to ask of a process, and the failures are not in the questions.
TBM.exe carries most of it, and most of it is competent.
Everything TBM.exe checks about itself
| the game checks | by |
|---|---|
| a debugger is attached | IsDebuggerPresent, the PEB's NtGlobalFlag, ProcessDebugPort and ProcessDebugFlags |
| hardware breakpoints | Dr0–Dr3 on every thread it owns |
| TLS callbacks | the callback array has to be empty |
| its protection entry point | follows a planted E9/EB, requires an in-image target, rejects an indirect FF 25 |
| hooked APIs | the first bytes of ReadProcessMemory, WriteProcessMemory, OpenProcess, VirtualProtect, NtOpenProcess and LdrLoadDll against a startup snapshot |
| its import table | every entry has to resolve into kernel32, ntdll, win32u, user32 or msvcrt |
| its own live code | CRC32 of mapped .text, with a restore-then-terminate path on mismatch |
| its own page protection | mapped .text must not have become writable |
| its unwind data | the first .pdata entry has to sit close to the start of .text |
| its own file | whole-file CRC32 of the image on disk |
| known tools | FNV-1a of process names, plus substrings: cheatengine, ollydbg, scyllahide, titanhide, reclass, processhacker, systeminformer, wemod, hyperdbg and more |
| tool windows | top-level window titles and classes against twenty substrings |
| loaded modules | a fingerprint over module bases and lowercased names, snapshotted then rechecked |
| where its modules came from | a DLL has to live under the Windows or application directory, or carry a valid Authenticode signature |
| injected code | committed private executable pages outside .text |
| foreign handles | one baseline enumeration of who holds a handle to it, then recurring scans measured against that baseline |
| its own identity | creation FILETIME, a hash over creation time, PID and module bytes, and a PID token recomputed twice per pass |
| a heap canary | one allocation holding tick ^ PID ^ 0xCAFEBABE |
| the driver | a session flag set when the handshake succeeds |
| the watchdog | that it authenticated within twelve seconds and has sent a heartbeat within five |
| its own workers | progress stamps, per-slot deadlines and per-worker deadlines |
| every gameplay value | twenty-five guarded lanes, plus bounds, relation and sentinel checks in the per-frame bundle |
TBMKD.sys is smaller and sees things the game cannot see about itself.
Everything TBMKD.sys checks from outside
| the driver checks | by |
|---|---|
| who opens a handle to the game | a pre-operation object callback that strips six access rights and reports a violation |
| who opens a handle to the watchdog | the same stripping, silently |
| every process that starts | FNV-1a of the base name against a twenty-one entry table, denied at creation |
| threads created in the game | a creation notify routine, fatal unless the creator is the game, Idle or System |
| images loaded into the game | FNV-1a of the module base name against a five-entry table |
| the game's memory map | a full walk under KeStackAttachProcess every fifteen seconds, flagging committed private executable regions outside the image |
| its own code | .text against a nonpaged baseline copy and its digest, every five seconds |
| who is talking to it | IoGetRequestorProcessId against the PID recorded at handshake |
| each heartbeat | an FNV-1a MAC over counter, token and session key, with a strictly increasing counter |
| heartbeat freshness | ten seconds |
| the liveness commit | a sequence derived from the session token, under the same MAC, once |
| liveness freshness | eight seconds from the handshake |
| the images on disk | CRC32 of the game and the watchdog at handshake, and again on request |
WatchdogMain.exe does four things.
Everything WatchdogMain.exe checks
| the watchdog checks | by |
|---|---|
| the game's file | CRC32 of TBM.exe on disk against a constant compiled into its own image |
| the pipe peer | a magic value, a zero sequence number and an FNV-1a MAC over a derived session key |
| that the game is listening | a signed heartbeat written every 1.5 seconds |
| that the game is alive | GetExitCodeProcess every 1.5 seconds |
Almost all of it runs on a clock, and the clocks are deliberately staggered rather than synchronised, so a check you have just watched pass is not one you can count on staying idle.
Five of those clocks belong to the driver and cannot be reached from user mode at all. Everything else is timed inside the address space it is policing: each deadline is enforced by comparing the current tick against a value the game stores in its own writable memory. The checks are real. Their evidence of having run is not.
Finally, all of it converges. Every check above terminates the game, and between them they do it through exactly three functions.
Only the middle one is out of reach. The game's verdict is arbitrated by a writable byte in the address space it is defending. The watchdog's is stranger: the most privileged action in the entire challenge — stopping and deleting the kernel driver's service — sits on its ordinary exit path, and every exit reaches it, including the one taken when a pipe write fails. The least privileged of the three processes holds the switch that removes the most privileged.
The shape of the attack surface
Seven routes reach the same result. Four run inside the game's address space, one changes the file on disk, and two need ring 0 — one that goes around the driver's enforcement and one that goes through it.
The three colours are three different claims. The trainer chain — the retained launch handle, the suppressed live hash, forged gameplay records and two narrow code patches — was built and run end to end in the VM. The on-disk CRC32-preserving patch was solved and its digest verified offline; it is a complete alternative to one step of that chain, not an extra step in it. The detection flag and the two kernel-mode routes were mapped, not built — they are here to show how much the design exposes.
The handle that predates the protection
An external trainer's first problem is normally its last one: it cannot get a usable handle to the game. The driver registers a pre-operation callback for process handles, and when the target is the protected game it does this:
info->Parameters->CreateHandleInformation.DesiredAccess &= 0xFFFFF785;The cleared bits, 0x87A, are exactly the rights a memory editor needs:
PROCESS_CREATE_THREAD, PROCESS_VM_OPERATION, PROCESS_VM_READ,
PROCESS_VM_WRITE, PROCESS_DUP_HANDLE and PROCESS_SUSPEND_RESUME. The
stripping happens inside the object manager, so issuing the syscall directly
buys nothing — NtOpenProcess returns a handle that is already weakened. The
callback also reports the attempt as a violation, which means a tool that merely
looks at the game usually kills it. The one right the mask leaves alone is
PROCESS_TERMINATE, which is an availability bug and useless here: killing the
game is the opposite of what we want.
The callback fires for handle creation and duplication. Those are the only two moments it gets. It cannot revisit a handle that already exists, and there is a handle that already exists.
CreateProcessW(game, CREATE_SUSPENDED, &pi); // full-rights hProcess, right now
apply_pre_start_edits(pi.hProcess);
ResumeThread(pi.hThread); // only now does the game start
wait_for_protection_ready(pi.hProcess); // driver installed, PID handed overCreateProcessW with CREATE_SUSPENDED hands the parent a process handle with
the rights the parent asked for, before the primary thread has executed a single
instruction. At that moment TBMKD.sys is not loaded — the game installs its
own driver, with CreateServiceW and StartServiceW, from inside its startup
path. The driver does not learn which PID it is protecting until TBM.exe opens
\Device\TBMKEv1 and completes the handshake IOCTL.

The trainer then never calls OpenProcess again. Every later
ReadProcessMemory, WriteProcessMemory, NtSuspendProcess and
NtResumeProcess goes through the launch handle, so no handle operation ever
occurs and the callback is never consulted. Suspending the child turns what
would be a race against the handshake into fixed ordering: the handle exists
before the driver, every time.
The same ordering mistake shows up a second time, one layer up, and there it does more than leave a gap. Early in startup the game enumerates every process that already holds a handle to it and records their owners as a baseline; the recurring scans then skip any handle whose owner is on that list.
// once, at startup (collect_foreign_handles_to_self):
for (h : system_handles_to_this_game)
baseline.insert(owner_pid(h)); // records OWNERS, to exempt them
// every scan afterward (scan_external_handles_to_process):
for (h : system_handles_to_this_game) {
if (owner_pid(h) in baseline) continue; // admitted owner - never flagged
report_violation(h);
}A process that already holds a writable handle when the baseline is taken is not merely missed by the driver's callback — the game itself writes that owner onto an allow-list and approves the handle for the rest of the session. The trainer's launch handle qualifies on both counts: it predates the driver, and its owner predates the baseline. The same mistake, made independently by two layers of the design, turns a gap in coverage into a standing grant of permission.
The challenge already requires an elevated context, because installing a driver service does. The trainer borrows that same context and nothing more.
Two other things in the driver look like shortcuts and are not. One IOCTL dumps
a diagnostic log with no session gate, and the log contains session tokens;
another computes the heartbeat MAC the driver expects. Together they look like
everything needed to impersonate the game. The gated handlers also compare
IoGetRequestorProcessId(Irp) against the PID recorded at handshake, so a
foreign process can read the token and still not submit a single heartbeat as
the game. The only way through the driver is from the game process, or with a
handle to it.
A hash that turns itself off
Being inside the address space is not the same as being able to change it.
TBM.exe runs a worker thread that hashes its own .text and compares the
result against a baseline, and a second routine that restores any range that
does not match. Both of them read their length from one writable global at RVA
0x70D78, written exactly once during initialization. Both of them return early
when it is zero.
The ordering inside the worker is the whole mistake:
loc_42ED7:
call GetTickCount64
xchg rax, [g_text_integrity_progress_tick] ; stamp: "the worker ran"
cmp [g_text_section_base], rsi ; rsi = 0
jz skip_crc32_check
cmp [g_text_section_size], rsi ; the size, written once, now zero
jz skip_crc32_check
call compute_live_text_crc32 ; never reachedThe worker announces that it is alive, and only then decides whether it has anything to check. A separate liveness deadline watches that stamp, so a worker that hangs or is killed is caught. A worker that runs punctually and hashes zero bytes is indistinguishable from a healthy one.
Because the trainer holds its handle before the game has executed anything, it can write that global to zero while the primary thread is still suspended. The monitor never knows another size. Four bytes, written before the code that reads them exists in a running state, and the live-image defence is a no-op that continues to report for duty.

The stamp is written before the size is read. Swap those two instructions and the trainer's zeroing surfaces as a stalled worker instead of a silent pass — the order is the entire bug.
Rebuilding a guarded record
The gameplay values are not stored as integers. Health, ammunition, score, wave
and kills each occupy a 0x78-byte record: thirty dwords holding two separately
keyed encodings of the value, two fixed sentinels, and a long run of words
generated from the same inputs.
Writing a new value into the first dword and walking away fails immediately, which is the record doing its job. A validator decodes both copies and requires them to agree, checks the sentinels, regenerates the recurrence from the decoded value and compares every word it covers. One changed dword contradicts the rest of the record.
The construction is small enough to state completely:
using GuardedRecord = std::array<uint32_t, 30>;
inline GuardedRecord make_guarded_record(uint32_t first_key,
uint32_t second_key,
uint32_t value) {
GuardedRecord record{};
record[0] = first_key ^ value;
record[8] = 0xDEADC0DE;
record[14] = 0xB16B00B5;
record[26] = second_key ^ value;
uint32_t state = first_key ^ second_key ^ value;
for (size_t index = 1; index < record.size(); ++index) {
if (index == 8 || index == 14 || index == 26) continue;
state = 0x01000193u * state ^ 0x811C9DC5u;
record[index] = state;
}
return record;
}Both keys are plain readable globals in the same image, reached through pointers a few hundred bytes from the record itself. The recurrence is FNV-shaped and keyless. There is no MAC, no signature, and nothing else in the record that a process with read access cannot reproduce.
So the record proves structure and says nothing about authorship. It can tell that a value was not assembled by a validator's rules; it cannot tell whether the code that followed those rules was the game's. Running the same constructor against the same keys produces a record that passes every check, because it is correct.
The safe write unit is therefore the whole 0x78 bytes, never a field. The
trainer builds the buffer locally, calls NtSuspendProcess, replaces the record
in one write, and resumes — so no game thread can ever observe a half-written
recurrence. The retained launch handle carries PROCESS_SUSPEND_RESUME, so even
this costs no new handle.
Coherent is not the same as permitted
A correctly built record is still checked against the game's own idea of a sensible value. Health above 110 and ammunition above 40 trip per-frame bounds. I confirmed this the direct way: a perfectly formed record containing 200 ammunition was accepted by the structural validator, the HUD showed 200 for about a second, and then the gameplay thread parked.
Inside the tolerated band, coherent rewrites alone are enough. Godmode and ammunition that never falls need the bound itself out of the way, which is two patches:
apply_guarded_player_damageat RVA0x51F70gets its first byte changed from0x48to0xC3. Collision code still calls it, the call still returns, and health is never reduced.- The ammunition bound branch at RVA
0x47FB6is rewritten from a conditional jump into an unconditional one past the rejection path, so a value the trainer has already made coherent is not thrown out for being large.


Both are edits to .text, and both would be caught by the live hash — which is
why the four-byte write to the size global has to happen first, before the game
runs at all.
The trainer, end to end
Nothing in the finished tool is complicated once the pieces are in the right order:
- Create
TBM.exesuspended and keep the returned process handle for the whole session. - Resolve the image base through the child's PEB and zero the live-
.textsize while the primary thread is still suspended. ResumeThread, then poll the two readiness flags at RVAs0x70B08and0x70B0Ffor up to thirty seconds, so that no gameplay state is touched while the game's own initialization is still building it.- Apply the two code patches, saving the original bytes.
- Build and install complete guarded records inside
NtSuspendProcess/NtResumeProcesswindows, on request. - On exit, suspend once more, restore both patch sites and the integrity size, resume, and close the launch handles.
The game keeps running. The watchdog keeps hashing a file that has not changed.
The driver keeps receiving heartbeats from a client it has no reason to doubt,
because the client really is TBM.exe and really does know the session token.
The driver's fifteen-second VAD scan sees a memory map that nobody allocated
into. No thread was created, no module was injected, and no handle was opened.
The same edit, made on disk
The damage patch has an entirely different delivery route, one that never touches the running process at all.
WatchdogMain.exe CRC32s TBM.exe on disk in a loop and compares against a
constant, 0x688FFE38, compiled into its own image. Changing one code byte
changes that digest, which is exactly what a file integrity check is for.
CRC32 is linear over GF(2). The difference between the digest of the patched file and the digest of the original is a function of the changed bits and their positions, and that function is additive — so a second change somewhere else in the file can be chosen to cancel the first. Pick 32 free bits, measure what each one does to the digest on its own, and the 32 measurements form a 32-by-32 binary matrix. Gaussian elimination over GF(2) picks the combination whose delta is the delta of the code patch.
The four free bytes need somewhere to live that is hashed but never executed or
read. The raw tail of .reloc is ideal: the section's raw size is rounded up to
the file alignment while its virtual size is far smaller, so the tail is present
in the file — and therefore in the CRC32 — and lies past the end of what the
loader maps. The one-byte damage patch goes at file offset 0x51370; the
correction goes at 0x713FC.
The watchdog is not tricked about the digest; the digest genuinely matches. The mistake was covering bytes that have no runtime meaning, which lets a real code change be cancelled out by padding.
This variant was solved and its digest verified offline. It replaces one step of the trainer chain rather than adding to it, and the patched image was not run.
One flag, every detector
The user-mode detectors are numerous and they all defer to one global at RVA
0x707B8, which has 174 cross-references across 63 functions. Every one of them
does the same thing:
if (InterlockedCompareExchange(&g_bDetected, 1, 0) == 0) {
DetectedRoutine(); // the winner tears everything down
} else {
for (;;) Sleep(50); // every loser parks here, forever
}The election is reasonable in itself: whichever detector fires first owns the teardown, and the others must not race it while the service is being removed and the process is exiting. What the pattern assumes is that the only writer of the flag is a detector. It is a writable global in the address space it protects, so if it already holds one when the first genuine detection happens, that detector takes the loser path and sleeps — and so does every detector after it.
The trainer does perform this write, before the resume, along with the integrity size. The demonstrated result does not depend on it: the handle predates the callback, the records are coherent, and the live hash reads nothing, so there is nothing for the detectors to find. I keep the write in the trainer regardless — it is the shortest way to show sixty-three functions collapsing onto a single failure mode. It does nothing about the driver, whose deadlines and termination path do not consult a user-mode global.
What ring 0 changes
Every violation the driver finds — a filtered handle access, a blacklisted
process, a created thread, a loaded image, a changed memory map, a failed
self-integrity check, a missed heartbeat, a missed liveness commit — converges
on one reporting function, which calls one termination routine, which calls
ZwTerminateProcess with status 0xDEADBEDE.
That funnel is good engineering from a defensive point of view and it is also a
single point of failure. It means a kernel-mode attacker does not need to defeat
eight mechanisms; one ret at the sink ends all of them at once. The driver
does check its own .text against a baseline, but the baseline is a copy held
in nonpaged pool with its expected hash stored beside it, both writable by any
code that can write kernel memory — so the integrity check can be made to agree
with whatever the code has become.
The other asymmetry here is not about patching the driver at all.
KeStackAttachProcess attaches the current thread to another process's address
space. No handle is created, so nothing reaches the object callback; no thread
is created, so no notify routine fires; no section is mapped, so the VAD scan
finds nothing. Every enforcement mechanism in TBMKD.sys is positioned on the
user-mode path to the game's memory, and a kernel-mode attacker simply does not
use that path.
This is the structural limit of the design rather than a bug in it, and it is why the routes in this section are drawn as surface. None of them was built. A driver that filters handles is defending against tools that need handles, and it is effective against those.
The rest of the surface
Everything so far was a route — something you can run end to end to reach the two integers. The rest of what a careful read turns up is not usually a route on its own: each item removes a check, widens a window, or hands over something the design meant to keep to itself. What makes them worth a section is that they are not unrelated slips. They fall into three shapes, and each shape recurs across binaries that share no code.
Checks keyed on state the attacker owns
The live-hash size global was one instance of a general habit: a real check that reads its precondition from memory the checked thing can write.
The worker deadlines are that mistake generalised. Each worker stamps a progress clock, and a separate thread fails any worker whose clock goes stale — except that a clock reading zero counts as "hasn't started yet" and is skipped. The clocks are plain writable globals. Write zero and the worker is exempt forever; write a fresh tick and its deadline is satisfied without the worker having done anything.
The three enable bytes that switch the guarded machinery on during startup are never checked again. Nothing requires them to stay on. Clear them afterward and the twenty-five guarded lanes, the late per-frame bundle and the encoded invariant checks all stop running. That alone is not a full route — the heartbeat thread keeps its own bounds and relation checks, and the live-code, module and session checks are untouched — but three byte writes remove a large part of the in-process validation. A second, mirrored pair of bytes gates the remaining bounds and relation checks; one of the two turns out to be the pause latch, so it cannot stay set in a run that has to keep playing, but the other has no such constraint.
The Authenticode decision on a DLL outside the trusted directories is cached by lowercase path, not by content or signature. Verify a path once and whatever later occupies that path is admitted.
The driver caches the game's image range — base and size — in two unsynchronised stores, so a scan can catch a valid base beside a zero size and measure nothing.
Failure paths that report success
The watchdog's CRC32 of TBM.exe is skipped entirely when the constant it
compares against is zero, and that constant is a dword in the watchdog's own
data section — the whole file check is a four-byte write from being absent. The
same binary ships a --dump-hash mode that CRC32s any path you give it, so the
replacement constant for a modified image is produced by the tool that was meant
to reject it.
The driver will re-verify the game on disk on request, but three of the handler's paths report a match without hashing anything:
if (GameCrc32 == 0) { out->match = 1; return STATUS_SUCCESS; } // dev mode
if (!GetProcessImagePathByPid(&path)) { out->match = 1; return STATUS_SUCCESS; } // path unresolved
crc = TBMKEFileCrc32(path); // 64 KiB reads; returns 0 on failure
if (crc == 0) { out->match = 1; return STATUS_SUCCESS; } // unreadable file
out->match = (crc == GameCrc32); // the only branch that can write 0
// it reports only - never calls ReportProtectionViolation.So a 1 means "matched, or could not be checked," and anything that makes the
file unreadable to the driver turns the check into a pass. The verdict also
lands in the output buffer rather than a violation, so it binds only as far as
the caller chooses to let it.
The foreign-handle baseline latches itself complete even when its own setup or allocation failed, and never retries. A baseline that collected nothing is recorded exactly like one that collected everything.
The watchdog's own guarantees are hollow too. After the handshake it never
reads the pipe again — it writes a signed heartbeat every 1.5 seconds and draws
no conclusion from what happens to it, so a peer that accepts the writes and
ignores them looks exactly like a healthy game. Both writes check only the
boolean from WriteFile, never the byte count, so a short write counts as
delivered. The liveness poll ignores the return of GetExitCodeProcess over a
variable preset to STILL_ACTIVE, so a failed query reads as a living game.
The strangest part is the exit. Every non-handshake exit tears the driver down, including the one taken when a heartbeat write fails:
if (!WriteFile(pipe, &beat, sizeof(beat), &n, nullptr)) {
// "[WD] Pipe write failed. Game probably terminated."
break; // leaves the heartbeat loop...
}
// ...and every path out of the loop falls into teardown:
CloseHandle(pipe);
DeleteTBMKEv1Service(); // ControlService(STOP) + DeleteService("TBMKEv1")
return 0;Break the game's pipe server and the watchdog stops and deletes the kernel
driver on its way out. The handshake-failure path does the opposite: it returns
without deleting anything, so a watchdog that never connects leaves the driver
loaded and unattended. Two failure modes, opposite outcomes, and both back to
front. The driver's mask also leaves PROCESS_TERMINATE alone on
both processes, so the watchdog can simply be terminated by anything running as
the same user — a dead game rather than a route, but the same missing guard.
Checks that cannot fire
The DLL blacklist holds five constants — 0xAA11BB22, 0xCC33DD44,
0xEE55FF66, 0x11223344, 0x55667788 — hand-written magic numbers rather
than digests of anything real. The callback runs on every image load into the
game, hashes the base name, compares it against those five, and can never match:
one of the driver's eight violation reasons is unreachable code. Fifteen of the
twenty-one process-name hashes show the same tell — sequential bytes where a
real digest would be uniform — leaving only a handful of live entries, and the
list is applied to every process on the machine rather than to descendants of
the game, so what it reliably achieves is blocking unrelated tools system-wide
while the game runs.
The thread and image notify routines return void. They are notifications, not
filters: the thread already exists and the image is already mapped when the
routine runs, and its only power is to start a kill. Injected code has run
before the process dies.
The watchdog's pipe MAC is real but keyed on nothing secret — the game's PID and creation time, both readable by anything that can query the game, as the handshake section covered. A check over a key the attacker can recompute cannot reject a forged peer.
The .pdata bound is the smallest of these. It makes no comparison at all when
the exception directory is empty, when there is no .text section, or when that
section's virtual address is zero, and the one comparison it does make is
one-sided.
What the mistakes have in common
None of the mistakes in this article is an error of cryptography or of control flow. Each one is a decision about what to trust, made once, in a place where it looked safe.
The shape they share is a check whose precondition is weaker than the check
itself. The handle callback is exact about the rights it strips, but it only
runs when a handle is created, and by then the trainer is already holding one.
The live hash is a real CRC32 of .text, but it reads the length to hash from a
writable global that the trainer sets to zero before the game runs. In both, the
check is sound and the thing it assumes on the way in is not — and that
assumption is invisible from inside the check that depends on it. The same
reading fits the rest: the watchdog's deadlines, the worker clocks, the cached
Authenticode path, the foreign-handle baseline. Each trusts something it never
verifies.
Fix any one of them and the others still lead to the same two integers.
View the PoC on GitHub