Skip to content
fl0sec
Reversing

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.

22 minestimated reading time

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 TBMKE game at wave 1. A cyan player sits in the middle of a dark grid surrounded by red enemies. The HUD shows 100 health, 30 ammunition, wave 1, and an anti-cheat active label.
Wave 1. The green AC: ACTIVE label is almost everything the player ever sees of the protection.

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.

TBMKD.syskernel driver · outside protection4 kernel callbacksprocess, thread, image load, handle9 IOCTL handlerssession, heartbeat, watchdog register3 periodic scans5 s, 3 s and 15 s cadencesone kill pathReportProtectionViolation, 11 call sitesring 0ring 3TBM.exegame and protected stateanti-debugself-integrity: .text hash, .pdata cross-checkAPI prologue and IAT validationAuthenticode verificationguarded gameplay state and its boundsdriver session, heartbeat, liveness commitwatchdog pipe serverone shared flag decides user-mode reportingWatchdogMain.exewatchdog processCRC32 of TBM.exe on disknamed pipe challenge to the gamedriver heartbeat every 1500 msservice install and delete
The three binaries by responsibility. The driver controls access from outside, the game checks itself from inside, and the watchdog checks the bytes on disk.
Kernel-debugger output as the game starts. The driver logs DriverEntry, a self-integrity baseline, successful registration of its object, process, thread and image callbacks, then Awaiting handshake. The game opens the device, both image CRC32 values match their expected constants, the handshake returns a session token bound to the game PID, two later handle opens are stripped to a reduced mask, and the watchdog PID is registered.
Driver startup in the kernel debugger. Callbacks register, both image CRC32s match (the game's is 0x688FFE38), the handshake mints a token bound to PID 5172, and later handle opens are cut to a reduced mask. The trainer's launch handle is taken before any of this exists.

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.

Game and kernel driverTBM.exeTBMKD.syshandshaketokenMAC key staysin the kernelevery gated call also checksrequestor PID == game PIDGame and watchdogTBM.exeWatchdogMain.exespawn argsMACed heartbeatpipe key = f(PID, creation time)command-line hex not in the MACboth inputs readable by any process that canquery the game
Two authentication systems that share no key. The kernel session mints a token bound to the game's PID and keeps its MAC key inside the driver; the watchdog's pipe key is derived from process attributes any onlooker can read.

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.exechecks itself, from insideanalysis statedebug APIs, PEB flags, debug port,hardware breakpoints, TLS callbacksits own codelive .text hash and restore, entry jump,API prologues, IAT targets, unwind dataits own filewhole-file CRC32, .text page protectionthe environmentprocess names, window titles, modules,signatures, private executable memoryforeign handlesone baseline, then recurring scansits own identitycreation time, PID token, identity hashand a heap canarysession livenessdriver flag, watchdog heartbeat,worker and slot deadlinesgameplay stateguarded lanes, bounds, relations,sentinels, encoded mirrorsTBMKD.syschecks the process, from ring 0handle accesssix rights stripped on createand on duplicateprocess creationname hashes, denied at creationthread creationa remote thread inside the gameimage loadsmodule name hashesthe memory mapprivate executable pages, every 15 sits own code.text against a baseline, every 5 sthe sessionhandshake, heartbeat MAC and deadline,liveness commit, disk re-verifyWatchdogMain.exechecks the bytes on diskthe game filewhole-file CRC32 against a constantthe pipe sessionmagic, sequence and MACits own heartbeatsigned and sent every 1.5 sthe game processexit code, polled every 1.5 s
The defensive inventory, grouped by what each binary is in a position to see. The game watches itself, the driver watches the process from outside, and the watchdog watches the bytes on disk.

TBM.exe carries most of it, and most of it is competent.

Everything TBM.exe checks about itself
the game checksby
a debugger is attachedIsDebuggerPresent, the PEB's NtGlobalFlag, ProcessDebugPort and ProcessDebugFlags
hardware breakpointsDr0Dr3 on every thread it owns
TLS callbacksthe callback array has to be empty
its protection entry pointfollows a planted E9/EB, requires an in-image target, rejects an indirect FF 25
hooked APIsthe first bytes of ReadProcessMemory, WriteProcessMemory, OpenProcess, VirtualProtect, NtOpenProcess and LdrLoadDll against a startup snapshot
its import tableevery entry has to resolve into kernel32, ntdll, win32u, user32 or msvcrt
its own live codeCRC32 of mapped .text, with a restore-then-terminate path on mismatch
its own page protectionmapped .text must not have become writable
its unwind datathe first .pdata entry has to sit close to the start of .text
its own filewhole-file CRC32 of the image on disk
known toolsFNV-1a of process names, plus substrings: cheatengine, ollydbg, scyllahide, titanhide, reclass, processhacker, systeminformer, wemod, hyperdbg and more
tool windowstop-level window titles and classes against twenty substrings
loaded modulesa fingerprint over module bases and lowercased names, snapshotted then rechecked
where its modules came froma DLL has to live under the Windows or application directory, or carry a valid Authenticode signature
injected codecommitted private executable pages outside .text
foreign handlesone baseline enumeration of who holds a handle to it, then recurring scans measured against that baseline
its own identitycreation FILETIME, a hash over creation time, PID and module bytes, and a PID token recomputed twice per pass
a heap canaryone allocation holding tick ^ PID ^ 0xCAFEBABE
the drivera session flag set when the handshake succeeds
the watchdogthat it authenticated within twelve seconds and has sent a heartbeat within five
its own workersprogress stamps, per-slot deadlines and per-worker deadlines
every gameplay valuetwenty-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 checksby
who opens a handle to the gamea pre-operation object callback that strips six access rights and reports a violation
who opens a handle to the watchdogthe same stripping, silently
every process that startsFNV-1a of the base name against a twenty-one entry table, denied at creation
threads created in the gamea creation notify routine, fatal unless the creator is the game, Idle or System
images loaded into the gameFNV-1a of the module base name against a five-entry table
the game's memory mapa 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 itIoGetRequestorProcessId against the PID recorded at handshake
each heartbeatan FNV-1a MAC over counter, token and session key, with a strictly increasing counter
heartbeat freshnessten seconds
the liveness commita sequence derived from the session token, under the same MAC, once
liveness freshnesseight seconds from the handshake
the images on diskCRC32 of the game and the watchdog at handshake, and again on request

WatchdogMain.exe does four things.

Everything WatchdogMain.exe checks
the watchdog checksby
the game's fileCRC32 of TBM.exe on disk against a constant compiled into its own image
the pipe peera magic value, a zero sequence number and an FNV-1a MAC over a derived session key
that the game is listeninga signed heartbeat written every 1.5 seconds
that the game is aliveGetExitCodeProcess 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.

periodwhat has to have happenedper framethe game runs its per-frame protection bundle1.5 sthe watchdog signs a heartbeat and polls the exit code3 sthe driver sweeps its deadlines3 sthe game runs its staggered periodic checks5 sthe driver rehashes its own .text5 sthe watchdog heartbeat must be newer than this8 sthe liveness commit must have reached the driver10 sa game heartbeat must have reached the driver12 sthe watchdog must have authenticated by now13 sthe per-frame bundle must have run15 sthe driver rescans the game memory map20 seach protection slot must have reported60 seach major worker must have reportedthe driver keeps this clock, in ring 0kept in user mode, in memory the attacker reaches
Every periodic check and deadline, ordered by period. The ramp is the schedule; the colour is the argument.

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.

what failswhere it is decidedwhat happenseverything TBM.exe checksanalysis, code, environment, gameplayone writable flagcompare and exchange, first writer winsthe winner tears downevery loser sleeps, forevereverything TBMKD.sys checkshandles, threads, images, memory, sessionone report functionflags accumulate, first reason is keptZwTerminateProcessexit status 0xDEADBEDEeverything the watchdog checksthe file, the pipe, the game processreturning from mainsuccess and failure share the pathstop and delete the servicethe driver goes away with itTwo of the three verdicts are decided inside something the attacker already reaches.
Three enforcement sinks. Convergence keeps a teardown path consistent, which is why it is normally good design.

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.

in the game's address spacekeep the launch handletaken before the driver knows the PIDrebuild a guarded recordall 0x78 bytes, from readable keyszero the live-text sizehash skipped, worker still reportspreset the detection flagevery user-mode detector parkshealth and ammunitionunder our controlwith the game still runningoutside the processpatch the file, keep the CRC32one code byte, four bytes of paddingfrom kernel modeattach to the address spaceno handle, so nothing to filterpatch the violation sinkone function ends all eight reasonsrewrite its baseline copythe .text reference lives in writable poolend to end in the VMproved but not part of the trainerreachable surface, not built
Routes to the same outcome. Solid green was built and run end to end; amber was demonstrated but is not part of the trainer; dashed grey is reachable surface that was mapped, not built.

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:

ProcessHandlePreOperationCallback
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.

launcher outline
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 over

CreateProcessW 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.

CreateProcessW(CREATE_SUSPENDED)the launcher holds hProcess and hThreadResumeThreadthe game begins to runCreateServiceW + StartServiceWthe game installs and starts TBMKD.sys itselfDeviceIoControl(0x222284)the driver records the game PIDthe callback arms hereand not one moment earlierhandle retainedopened before the handshake0x838 still grantedread, write and suspend all keep workingopened after the handshake0x87A stripped from the maskviolation raised, the game is terminated
The launch chain. The handle is older than the protection that would have refused it, and the callback that arms at the last step has no way to reach back and weaken it.
A Process Explorer tree showing TBMTrainer.exe, PID 8228, as the parent of TBM.exe, PID 10016.
The trainer is the game's parent process: it launched TBM.exe with CreateProcessW and kept the process handle that call returned. That handle predates the driver, so the pre-operation callback never sees it.

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.

foreign-handle baseline vs. the recurring scan
// 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:

self_text_integrity_monitor_thread
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 reached

The 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.

IDA control-flow graph of the self-integrity worker. The first block calls GetTickCount64 and stores the result into g_text_integrity_progress_tick, then compares g_text_section_base with rsi and jumps to SKIP_CRC32_CHECK when equal. The next block compares g_text_section_size with rsi and again jumps to SKIP_CRC32_CHECK, so a zero size falls through past the call to compute_live_text_crc32.
The worker stamps g_text_integrity_progress_tick before it tests the section size. With the size zeroed, every branch takes SKIP_CRC32_CHECK and compute_live_text_crc32 is never reached — while the deadline still reads a fresh stamp.

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.

012345678910111213141516171819202122232425262728290x000x3C0x78 bytes · 30 dwords · dword index shownkey XOR value (0 and 26)sentinel: 0xDEADC0DE at 8, 0xB16B00B5 at 14state = 0x01000193 * state XOR 0x811C9DC5not characterisedno MAC over any of it
One guarded record: two encoded copies, two sentinels, and a long recurrence run derived from keys that live a few hundred bytes away in the same writable image.

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:

guarded_state.hpp
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_damage at RVA 0x51F70 gets its first byte changed from 0x48 to 0xC3. Collision code still calls it, the call still returns, and health is never reduced.
  • The ammunition bound branch at RVA 0x47FB6 is 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.
IDA pseudocode of apply_guarded_player_damage. It decodes the current health from a guarded cell, and when health is positive subtracts the damage, re-encodes the damage accumulator pair, marks the player dead once health reaches zero, updates the best score, and rewrites the guarded health record.
apply_guarded_player_damage, unpatched. Overwriting its first byte with 0xC3 returns before the health minus damage path runs, so collision code still calls it and health never drops.
IDA pseudocode of the per-frame ammunition ceiling. max_ammo is clamped to 30, then the decoded ammunition value is compared against max_ammo plus 10, and exceeding it branches into the detected path.
The ammunition ceiling: the decoded value may run ten over the clamped magazine before the frame check rejects it. The second patch makes this branch unconditional, so a value the trainer has already made coherent is kept.

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:

  1. Create TBM.exe suspended and keep the returned process handle for the whole session.
  2. Resolve the image base through the child's PEB and zero the live-.text size while the primary thread is still suspended.
  3. ResumeThread, then poll the two readiness flags at RVAs 0x70B08 and 0x70B0F for up to thirty seconds, so that no gameplay state is touched while the game's own initialization is still building it.
  4. Apply the two code patches, saving the original bytes.
  5. Build and install complete guarded records inside NtSuspendProcess / NtResumeProcess windows, on request.
  6. On exit, suspend once more, restore both patch sites and the integrity size, resume, and close the launch handles.
The trainer against the stock game and driver: health and ammunition held at their set values across several waves, with every modified byte restored on exit.

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.

TBM.exe on disk · 0x71400 bytes0x51370: 0x48 -> 0xC3.reloc raw block · 0x71200 + 0x200VirtualSize 0x94never mapped, never read, still hashed0x713FC: 4 free bytescrc32(f XOR d) = crc32(f) XOR L(d)L is linear over GF(2): 32 unknown bits, 32 equations, one solve.target 0x688FFE38the value the watchdog already told us
A one-byte code edit and four solved bytes of correction, placed in file padding the loader never maps. The watchdog recomputes its original digest and is not wrong to.

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:

shared detection path
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.

as designeddetectordetectordetectordetectordetectordetectorlock cmpxchgg_bDetected @ 0x707B8terminate1 winsSleep(0x32), foreverthe rest losewith the flag preset to 1detectordetectordetectordetectordetectordetectorlock cmpxchgg_bDetected @ 0x707B8attacker writes 1 firstnever reached0 winSleep(0x32), foreverall lose
The intended election, and the case where the flag is already set. The same code path that prevents detectors from racing each other is what makes them all defer.

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.

0x01 / 1handle access0x02 / 2blacklisted process0x04 / 3thread create0x08 / 4DLL image load0x10 / 5VAD scan0x20 / 6.text integrity0x40 / 7heartbeat deadline0x80 / 8liveness deadlinebit / code8 codes, 11 call sitesReportProtectionViolation0x325CTriggerRing0Kill0x3794ZwTerminateProcess0xDEADBEDEno ladder, no threshold,no grace. first hit is fatal.
Eight reasons, eleven call sites, one sink — every violation ends at the same ZwTerminateProcess.

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.

elevated user modering 3 · the trainerkernel code executionring 0 · a driver of its ownTBMKD.syseverything a user-mode opener has to passhandle create and duplicatethread and image notificationsVAD scan every 15 sheartbeat and liveness deadlinesTBM.exe address spacethe guarded health and ammunition recordsonly what the mask leavespatch it in place:code, callbacks, globals, the sinkKeStackAttachProcessno handle is created,so no callback runs
The same target, two attackers. Everything the driver enforces sits on the path a user-mode opener must take, and on no part of the path a kernel-mode one takes.

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:

HandleReverifyDisk - the fall-through writes a match
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:

WatchdogMain.cpp - the pipe-failure exit deletes the driver
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
Was this useful?