pwn.dog

Detouring Yourself Into a Ban: Reverse-Engineering AMD Anti-Lag+

contents

A symbol-level teardown of the Adrenalin 23.10.1 feature that got Counter-Strike 2 players VAC-banned - what it hooked, how it hooked it, and the single byte that turned it off.


In October 2023, AMD shipped Anti-Lag+ in Adrenalin 23.10.1. Within days, Counter-Strike 2 players on Radeon cards started getting VAC-banned. Valve’s statement was one sentence:

“AMD’s latest driver has made their “Anti-Lag/+” feature available for CS2, which is implemented by detouring engine dll functions. If you are an AMD customer and play CS2, DO NOT ENABLE ANTI-LAG/+; any tampering with CS code will result in a VAC ban. Once AMD ships an update we can do the work of identifying affected users and reversing their ban. @AMD

AMD pulled the feature in 23.10.2 a week later. There was never a public, symbol-level explanation of what was detoured or how. This post is that teardown - done entirely statically, from the two shipping installers, with 7-Zip and Ghidra. Nothing was executed; no hook was built or modified. The point is to understand why a signed, legitimate vendor feature was structurally indistinguishable from a cheat.

TL;DR:

  • Anti-Lag+ is internally called Delag / DELAG_NEXT. It lives in the Direct3D usermode drivers that load into the game process - amdxc64.dll (DX11/12) and atidxx64.dll (DX9/legacy) - not the kernel miniport.
  • It installs an inline trampoline detour via Microsoft Detours, but it doesn’t hook a D3D export. It walks the game’s own call stack, finds a call instruction inside the game’s code, and patches that.
  • The injected payload is a QueryPerformanceCounter-paced Sleep/spin on the game’s render thread - a Reflex-style latency reducer.
  • 23.10.2 didn’t remove the code. It flipped a whitelist value from '1' to '0' in the driver’s application-profile database and deleted CS2’s profile entry. The hook engine is still sitting in the DLL, dormant.

Acquiring the patient

Two installers, both with valid AMD Authenticode signatures (so the third-party mirror of 23.10.1 is genuine):

VersionDateAnti-Lag+Role
23.10.1Oct 11 2023activethe build that shipped the detour
23.10.2Oct 19 2023disabledthe hotfix - our diff partner

The Adrenalin installers are PE self-extractors with a ~1.2 GB overlay; 7-Zip opens them directly (576 files). No need to run anything:

7z x .\AMD-23.10.1.exe -o.\23101\ -ir!amdxc64.dll -ir!atidxx64.dll -ir!amdihk64.dll

The naïve approach - hash everything and diff - is useless here: the entire display driver was rebuilt between the two releases (build folder B396516B396804), so ~130 binaries differ by build stamp alone. You have to triage by function, not by hash.


Finding “Delag”

A string sweep of the usermode D3D drivers lights up immediately. amdxc64.dll is full of a debug-config system that registers globals by name for a C:\DELAG\DELAG.INI override file - and those names are a feature spec in themselves:

&DelagGlobals::g_uiREQUIRED_STACK_DEPTH_TO_HOOK
&DelagGlobals::g_bFOLLOW_ALL_JUMPS_WHEN_HOOKING
&DelagHooks::g_bATTEMPT_DELAYED_DETOURING_FIRST
&DelagGlobals::g_uiGAME_IS_WHITELISTED_FOR_DELAG_NEXT
&DelagGlobals::g_uiDELAG_WHITELISTING_TARGET_RELEASE
DELAG_NEXT: ADVANCED_INTERCEPT: Captured call stack with %i frames...
DELAG_NEXT: ADVANCED_INTERCEPT: Installing the advanced intercept at stack depth %i...
DELAG_NEXT: ADVANCED_INTERCEPT: MyDetourAttach(): failed with code %i !!!!!!!!
AntiLag+ latency meter is only available for the DX12 version of this game.

DELAG_NEXT = “Delag Next” = Anti-Lag**+**. MyDetourAttach, “Captured call stack”, “Installing the advanced intercept at stack depth” - this is a hooking engine that decides where to hook by walking a call stack. And shipping right alongside it: detoured.dll, the Microsoft Detours marker DLL.

The same 64 Delag* strings appear, byte-identical, in atidxx64.dll (the DX9/legacy UMD). The feature was statically linked into both Direct3D usermode drivers.


The targeting: hooking the game by walking its stack

Here is the heart of it - amdxc64.dll @ 0x1805abcc0, the “ADVANCED_INTERCEPT” installer, lightly cleaned up from Ghidra’s output:

undefined1 Delag_InstallAdvancedIntercept(uint depth)   // depth = g_uiREQUIRED_STACK_DEPTH_TO_HOOK
{
    void  *stack[32];
    RtlCaptureStackBackTrace(0, 0x20, stack, 0);          // capture the GAME's call stack
    uint  frames = ...;
    if (frames <= depth) return 0;                        // "iDepth >= uiNumStackFrames -> abort"

    byte *retaddr   = stack[depth];                       // a return address INSIDE the game
    int   call_size = GetCallInstructionSize(retaddr);    // length-decode the preceding call
    if (call_size == 0)
        call_size = retaddr - ScanBackwardForCall(retaddr); // GetPreviousInstruction fallback

    byte *target = retaddr - call_size;                   // the exact CALL site in the game's .text

    if (call_size >= 5)  return Delag_DetourGameCallSite(target);   // 5-byte E9 detour
    else                 return AdvancedDirectWithStepping(target); // relocate to make room
}

This is the whole problem in twelve lines. Anti-Lag+ runs as the D3D usermode driver, so it is already executing on the game’s render thread. It calls RtlCaptureStackBackTrace, picks the return address depth frames up - an address inside the game’s own engine code - and back-computes the call instruction that produced it. That call, inside the game’s .text, is what gets patched.

It is not hooking Present, not hooking a D3D export, not hooking anything AMD owns. It is reaching up the stack into the game and rewriting the game’s own instructions.

The attach is textbook Microsoft Detours (amdxc64.dll @ 0x1805aac10):

uint MyDetourAttach(void *target, void *hookFn, void **trampoline)
{
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());
    *trampoline = target;
    DetourAttach(trampoline, hookFn);
    uint err = DetourTransactionCommit();
    if (err == 0) GetModuleHandleExA(..., target, ...);   // pin the patched (game) module
    else          printf("...MyDetourAttach(): failed with code %i", err);
    return (err == 0);
}

What does the actual byte surgery? amdxc64 carries its own statically-linked copy of Microsoft Detours, and DetourTransactionCommit writes the patch in place. Detours’ internals are a slog to read, so here is the same operation in the clear, from a standalone sibling engine that ships in the very same package - amdihk64.dll (“AMD inline-hook”), which gets its own teardown in Part 2. Its commit routine (@ 0x1800014dc) is the technique laid bare - and it’s exactly what Detours does, too:

// for each pending hook, with all other threads suspended:
target[0]      = 0xE9;                       // JMP rel32 over the game's prologue...
*(int*)&target[1] = (trampoline+0x30) - (target+5);   // ...to the trampoline
// trampoline+0x30: FF 25 <rip-rel32>  =  JMP qword [rip+disp]   (absolute jump onward)
// slack filled with 0xCC

// then walk every suspended thread's CONTEXT and rewrite RIP if it was caught
// mid-patch (original <-> trampoline), so nothing resumes inside a half-written instruction
VirtualProtect(target, len, oldProt, &tmp);
FlushInstructionCache(GetCurrentProcess(), target, len);
ResumeThread(...);

In that commit: an E9 over a victim prologue, an FF 25 absolute jump in the trampoline, VirtualProtect + FlushInstructionCache, and even thread-RIP fixups so it can hook hot code safely. That’s the technique - and it’s what amdxc64’s own Detours commit does to CS2’s call site too. If you showed this routine to an anti-cheat engineer with the symbols stripped, they would call it a cheat. There is nothing to distinguish it.


The payload: what it actually did to the game

When the detour fires, the game’s call lands in AMD’s callback (@ 0x1805aa4b8) instead. It RtlCaptureContexts the game’s registers into a TLS buffer, restores the originals the trampoline saved, runs the payload, and RtlRestoreContexts back into the game. The payload (@ 0x1805a0f10) is a frame pacer:

void Delag_FramePacingPayload(void)
{
    EnterCriticalSection(&g_delagLock);
    uint64 now = NowMicroseconds();              // QueryPerformanceCounter * 1e6 / QPF

    // 16-entry ring buffer of recent frame timestamps
    push_frame_time(now);

    // last few inter-frame deltas -> stabilized frame time, with hysteresis:
    bool act = (stable_frametime <= target*4/3) && (now - last_action < 100_000 /*us*/);
    if (latest_interval < target*3/4) act = false;

    if (act) {
        uint64 deadline = now + per_frame_delay * multiplier;
        while (WaitUntilDeadline(deadline) != 0)  // STALL the game's own thread until deadline
            ;
    }
    LeaveCriticalSection(&g_delagLock);
}

WaitUntilDeadline is a hybrid wait: Sleep(1) while >~10 ms remain, Sleep(0) (yield) for 1–10 ms, and a busy-spin for the final sub-millisecond. The net effect is to delay the start of the game’s CPU frame work so that input sampling and simulation happen as late as possible relative to GPU present - collapsing the render-ahead queue and lowering click-to-photon latency. Same goal as NVIDIA Reflex and the original driver-side Anti-Lag.

The idea is completely benign. The delivery - patching the game’s .text and stalling its thread from a foreign module - is what VAC sees.


The gate, and a one-byte off-switch

Whether the detour installs at all is decided by Delag_CheckWhitelistAndEnable (@ 0x1805952a0):

release = profile_query("g_uiDELAG_WHITELISTING_TARGET_RELEASE");  // driver rollout ceiling
game_wl = profile_query("g_uiGAME_IS_WHITELISTED_FOR_DELAG_NEXT"); // per-game whitelist value
if (game_wl != 0 && game_wl <= release) {
    CreateThread(DelayedDetourInstaller, ...);   // <-- spawns the worker that hooks the game
    g_delagActive = 1;
    return 1;
}
return 0;

Both values come from AMD’s application-profile database, atiapfxx.blb - the same system that stores per-game settings like Dlg_LimitFPS, Bst_WListed (Boost whitelist), Ris_BListed (Image Sharpening), and FreMux12WListed (Frame Generation). A game gets Anti-Lag+ if, and only if, its profile is whitelisted at a release ≤ the driver’s ceiling.

Which means you can disable the whole feature without touching a single instruction. And that is exactly what 23.10.2 did.

Diffing atiapfxx.blb between the two drivers (it’s the only feature-relevant file whose content, not just build stamp, changed: 500,744 → 503,128 bytes), the whitelist records look like this:

00043 1B0  44 6C 67 4E 78 74 5F 57 4C 69 73 74 65 64 00 00   DlgNxt_WListed..
00043 1C0  02 00 00 00 [31] 00 00 00 5F 7C 00 00 ...           value = 0x31 = '1'   (23.10.1)
                        ^^^^
00043 698  44 6C 67 4E 78 74 5F 57 4C 69 73 74 65 64 00 00   DlgNxt_WListed..
00043 6A8  02 00 00 00 [30] 00 00 00 FA 7C 00 00 ...           value = 0x30 = '0'   (23.10.2)

DlgNxt_WListed - “Delag-Next Whitelisted” - is the per-game Anti-Lag+ enable flag. The diff:

  • 23.10.1: 18 game records, every one set to '1'.
  • 23.10.2: 29 game records, every one set to '0'.
  • CS2’s profile (cs2.exe, CounterStrike2, CounterStrike2Profile) is present only in 23.10.1 - it was deleted outright in the hotfix; cs2.exe was even removed from the driver’s master application-name table (while csgo.exe stayed).
  • 11 mostly-DX12, mostly-single-player titles (Starfield, Hogwarts Legacy, Spider-Man, Returnal, …) were added - staged for the eventual re-enable, but all parked at '0'.

So the famous “fix” was: flip every whitelist value from '1' to '0', and delete CS2’s profile. The detour engine, the stack-walker, the Detours integration, the QPC pacer - all of it is still sitting in 23.10.2’s amdxc64.dll, byte-for-byte (the gate function is at the same address with identical logic). It just never gets switched on.

(One honest caveat: DlgNxt_WListed as a literal string appears only in the .blb - not in any driver binary, including the 106 MB kernel module - so the name→global wiring is resolved through the profile descriptor system, not a hardcoded strcmp. The identification rests on the matching name, the proven sibling mechanism for Bst_WListed/Dlg_BListed, and the perfectly-correlated 1→0 flip. We couldn’t xref a string that isn’t there.)


Why it was indistinguishable from a cheat

Anti-cheats verify the integrity of the game’s loaded modules: the bytes of cs2.exe and its engine DLLs in memory should match what was loaded from disk. Anti-Lag+:

  1. runs as the D3D usermode driver inside the game process (legitimately loaded),
  2. walks the game’s call stack to find a function in the game’s own code, and
  3. overwrites that code’s prologue with a jump into an AMD DLL (a Detours patch - the byte-level shape is shown in Part 2), then diverts the game’s thread into that DLL where it hijacks/restores CPU context and calls Sleep.

Steps 2–3 are operationally identical to an aimbot installing a hook. There is no signature that distinguishes “vendor latency optimization” from “cheat” at the memory-integrity level - both are unauthorized inline detours of the game image. VAC flagged the modified pages, and it was right to; the legitimacy of the patcher is simply invisible to an integrity check. That is the entire incident.


Epilogue: Anti-Lag 2

The redesign, Anti-Lag 2, fixes the root cause by inverting the direction of control. Instead of the driver reaching into the game, the game reaches out to the driver through an integrated SDK - the developer inserts AMD’s calls at the right points in their own frame loop, exactly like NVIDIA Reflex. Valve integrated Anti-Lag 2 into Counter-Strike 2 itself in 2024. Same end goal (pace the frame to cut input latency), but nothing patches protected memory, so there is nothing for VAC to flag.

The lesson is a clean one: the technique wasn’t malicious, but a detour is a detour. If your latency optimization is indistinguishable from a cheat at the byte level, the only safe place to put it is inside the game, with the game’s consent.


Function map

ModuleAddressRole
amdxc64.dll0x1805952a0Delag_CheckWhitelistAndEnable - whitelist/release gate
amdxc64.dll0x1805abcc0Delag_InstallAdvancedIntercept - stack-walk → game call site
amdxc64.dll0x1805ab4e0Delag_DetourGameCallSite - direct E9 detour
amdxc64.dll0x1805aac10MyDetourAttach - Microsoft Detours transaction
amdxc64.dll0x1805aa4b8hook callback - context shim
amdxc64.dll0x1805a0f10Delag_FramePacingPayload - QPC-paced wait
amdxc64.dll0x1805a4c60Delag_WaitUntilDeadline - Sleep/spin
amdihk64.dll0x1800014dctrampoline commit in the sibling engine - E9 + trampoline + thread-RIP fixups (representative; amdxc64’s embedded Detours does the equivalent for Delag)

Engine present identically in amdxc64.dll (DX11/12) and atidxx64.dll (DX9/legacy). All analysis static: 7-Zip extraction, Ghidra via MCP. No code was executed; no hook built or modified. Defensive/educational reverse engineering.