First full runtime probe of the live loader. Discovered 9 major findings: no in-DLL caller-provenance, no CFG enforcement, no bot-DLL scan, MD5 driver at sub_7FF9264E3480, 4 kill paths catalogued.
Later-round corrections (R16/R17):
- The "4 kill paths" count is obsolete — R17 enumerated ≥11 kill-capable callsites.
- The VEH inner (RVA
0x143D200) does not kill; it only dispatches. Kills route through the UEH chain.- The Aegis/Warden broker-via-RPM hypothesis is dead. R15 verified none of the candidate PIDs target D2R; R16 found the two callback slots are FF-filled (dormant). D2R loads no other executables, so no broker exists.
- The broker-slot RVAs mentioned below (
0x488CC0/0x488CC8) are an early-round typo. Real RVAs are0x1488CC0/0x1488CC8(.textexports) and0x1E88CC0/0x1E88CC8(.datastorage, currently dormant).
Round 6 findings — live Lua probes + 5 IDA agents
Date: 2026-04-17
Scope: Parallel runs of 5 specialized IDA agents against the canonical IDB + 2 live Lua probes against the running D2R PTR 3.2 process.
Input state: d2r_loader.dll base 0x7FF922070000 (live), D2R.exe base 0x7FF7B3810000, game running with analysis payload injected (PID 44820).
Headline results
| # | Question | Before round 6 | After round 6 |
|---|---|---|---|
| 1 | Where is the PTR 3.2 caller-provenance check? | In d2r_loader.dll (inline) | NOT in d2r_loader.dll (Agent I1, high confidence) |
| 2 | Is CFG/XFG the mechanism? | Primary suspect after I1 | No. Both binaries have GuardFlags = 0x100 only (CF_INSTRUMENTED metadata); function table empty; dispatch stub is FF E0 no-op (Lua CFG probe) |
| 3 | Does Eidolon scan for bot DLLs? | Open | No (Agent I5, high confidence). No EnumProcessModules, Module32*, LdrEnum*, bot-name strings, or hash-table scans anywhere |
| 4 | How many synchronous kill paths exist? | Unknown | Only 4 in-DLL paths (Agent I3). Eidolon deliberately does NOT import RaiseFailFastException, NtTerminateProcess, IsDebuggerPresent, CheckRemoteDebuggerPresent |
| 5 | What's the megadispatcher's actual purpose? | Unknown "405 KB CFF function" | Re-entrant CFF helper with 6 call sites from a "megacaller" at 0x7FF9277703C0 (Agent I2). Real work delegated to ~13 helper sub-functions, most important being sub_7FF9264E3480 (shared with IAT resolver) |
| 6 | How is the IDD encrypted? | Unknown | Simple 8-byte rolling XOR with baked key 0xFFFF834A942B7856 (Agent I4). Not PEB.Ldr-bound — the PEB.Ldr fold happens in per-trampoline encoding only |
| 7 | How does the resolver bootstrap before the synthetic IAT exists? | Unknown | Classic FNV-1a over PEB.Ldr module names + export-table walk (Agent I4). FNV-1a offset basis at 0x7FF926AC02DC, prime at 0x7FF926AC01BB. Cached ntdll base at 0x7FF927C3CDC0, kernel32 at 0x7FF927C3CDC8 |
| 8 | What was the "resolved-pointer table" really? | "16-byte rows of {name_RVA, trampoline_ptr}" (round-5 claim) | WRONG. Variable-length records: DLL-marker rows (marker_high = 0x01DAE188) interleaved with function rows. Field at q1 is loader-image RVA, not a heap pointer. Actual heap trampolines live elsewhere |
Agent reports
Agent I1 — Caller-provenance check hunt (disconfirmed in d2r_loader.dll)
All four hypothesized mechanisms ruled out with high confidence:
[rsp]return-address range check — one degeneratemov rax, [rsp]pattern in the whole DLL, inside obfuscation noise at0x7FF926A8C70C. Zero real sites.NtQueryInformationThread(ThreadQuerySetWin32StartAddress=9)— symbol absent from the synthetic IAT name table (onlyNtCloseandRtlFreeHeapfrom ntdll are imported!). Absent from strings.RtlLookupFunctionEntry/RtlVirtualUnwind— absent from IAT, strings, and byte patterns.- DR0..DR3 hardware breakpoints + VEH — zero
mov drN, reginstructions; VEH handler at0x7FF92728D200does NOT filterSTATUS_BREAKPOINT(0x80000003) orSTATUS_SINGLE_STEP(0x80000004).
Most informative findings along the way:
eidolon_tls_callback_0at0x7FF9270AC100fires onDLL_THREAD_ATTACH/DLL_THREAD_DETACH— i.e., onNtCreateThreadExbut NEVER on thread-hijack. This seeds the per-thread state atgs:[0x20](TEB.NtTib.FiberData).eidolon_per_thread_get_fiberdataat0x7FF92666A840is the canonical per-thread state getter. Main-thread's FiberData IS populated at process-attach, so simply running on the main thread isn't the problem.- Follow-up CFG probe (below) further ruled out CFG/XFG as the mechanism.
Implication: the PTR 3.2 hijack-injection regression is caused by something outside d2r_loader.dll. Candidates (ranked):
- Warden broker-side validation (separate process, reads
0x7FF927CD8CC0/0x7FF927CD8CC8slots and observes process state viaReadProcessMemory). - NTDLL / kernel32 changes in the Windows build the user is running —
LdrpLoadDllInternalhas historically been the site of provenance checks when they exist. - Environmental / state mismatch unrelated to security — e.g. hijacked thread lacks fresh TLS scratch that LoadLibrary needs, SEH chain confused by forged stack frame.
The only way to know is to run the diagnostic shellcode described in the plan file (record rax, GetLastError(), [rsp] to RWX slots). Everything upstream of that is hypothesis.
Implication for the fix: Option-2 (spawn helper thread) remains the right choice — it sidesteps whatever the actual mechanism is. Option-1 (ret-gadget forgery) is not doomed by Eidolon itself but is also not sufficient.
CFG/XFG probe follow-up (written + run as probe_r6_cfg.lua)
Parsed IMAGE_LOAD_CONFIG_DIRECTORY64 at correct offsets (+0x70 GuardCFCheckFunctionPointer, +0x78 DispatchFP, +0x80 FunctionTable, +0x88 FunctionCount, +0x90 GuardFlags, +0x118/+0x120/+0x128 XFG fields):
| Field | D2R.exe | d2r_loader.dll |
|---|---|---|
GuardFlags | 0x100 (CF_INSTRUMENTED only) | 0x100 (CF_INSTRUMENTED only) |
GuardCFFunctionCount | 0 | 0 |
GuardCFFunctionTable | 0x0 | 0x0 |
GuardCFCheckFunctionPointer | 0x7FF7B4DE8250 | 0x7FF923E1D3E0 |
GuardCFDispatchFunctionPointer | 0x7FF7B4DE8260 | 0x7FF923E1D3F0 |
| Dispatch stub target | 0x7FF7B4DB5A90 = FF E0 CC CC ... (jmp rax + padding) | 0x7FF923DBC3A0 = FF E0 CC CC ... (same) |
CFG is not enforced in either binary. The FF E0 (jmp rax) dispatch stub is the standard "NO_ENFORCEMENT" variant Windows emits when the OS/loader decides CFG should not be active. CF_INSTRUMENTED alone only means the compiler emitted CFG-aware call sites; without a function table, there's nothing to validate against.
Even the .gxfg section (new in PTR 3.2) is dormant — it's present in d2r_loader.dll at 0x7FF923F70000 (0x2830 bytes) containing XFG type-signature entries, but the XFG dispatch pointers in the load config all point at no-op stubs too.
Conclusion: CFG/XFG is not the cause of the hijack failure.
Agent I2 — CFF megadispatcher mapping
The "405 KB CFF function" at 0x7FF9264624A0:
| Property | Value |
|---|---|
| Size | 0x62F87 bytes |
| Instructions | 104,211 |
| Basic blocks | 25,730 |
ret instructions | ZERO — exits via tail-jmp |
| Dispatch mechanism | Balanced binary-search tree over 32-bit state, NOT a jump table |
| State storage | Stack slot [rsp+var_B00+4], DWORD |
| Initial state computation | LEA/XOR/SUB mix chain from global dword_7FF927C71A4C |
| Caller | eidolon_cff_dispatcher_clone_megacaller at 0x7FF9277703C0 |
| Number of distinct call sites | 6 (from megacaller) + 1 data-ref from 0x7FF927D32EE0 |
| Junk / anti-RE | 50+ rdtsc in the first 40K instructions, stack-watermark probes (cmp esp, imm), call $+5 push-and-fall, call-to-middle-of-instruction |
Key structural insights:
- The megadispatcher is a re-entrant shared helper, not a one-shot setup routine. Each of the 6 call sites passes a different
r8operation tag (saved at[rsp+var_810]on entry). - Actual semantic work happens in ~13 helper sub-functions in
0x7FF9264C0000..0x7FF9264F0000. The megadispatcher is the glue. sub_7FF9264E3480is called by BOTH the IAT resolver AND the megadispatcher → strongest candidate for "IAT resolver kernel" (renaming candidate:eidolon_helper_iat_resolver_kernel).sub_7FF9264D2250(0x59F1 bytes, 1244 BBs) is the most likely home for the trampoline-row writer.- The lone non-CFF-internal external call from inside the megadispatcher is at
0x7FF9264655C3→0x7FF97AB3C5C8(previously-resolved trampoline target).
Agent I3 — Silent-abort catalog
Only 4 Eidolon-side paths can synchronously kill the process:
| Path | Address | Trigger | Defense |
|---|---|---|---|
| A VEH fatal classifier | sub_7FF9267C6B70 → TerminateProcess via eidolon_telem_veh_prev_handler_router at 0x7FF926860A05 | Exception the VEH classifies as fatal | Install the analysis process hooks AFTER Warden VEH; use direct syscalls / ROP to dodge |
| B Fingerprint fail-closed | sub_7FF926C47950 (8 callers) | Mutated state-key check fails (e.g. during flow-info serialize) | Don't write to Eidolon's obfuscator state words (dword_7FF927C92FAC..FD4) |
| C VM static-init integrity | sub_7FF926DE7590 (1 caller: eidolon_vm_static_init_k_byte_off60) | Early init-time integrity check | Fires only during init; post-init injection is clear |
| D Panic vtable slot | sub_7FF9268F1140 via data-only ref at 0x7FF927D34F98 | Any subsystem writes the slot and invokes it | Replace the slot with a no-op stub — neuters the cleanest kill-switch |
Eidolon deliberately does NOT import any of: RaiseFailFastException, NtTerminateProcess, NtRaiseException, NtRaiseHardError, RtlReportCriticalFailure, IsDebuggerPresent, CheckRemoteDebuggerPresent, DebugBreak, _exit, quick_exit, abort.
Notable non-terminators: cert validator eidolon_telem_cert_validator_blizzard (0x7FF926F8A5A0), IAT resolver (0x7FF926042AF0), CFF megadispatcher (0x7FF9264624A0). Pin-fail is a Standard.Alert event, not a kill.
Agent I4 — IAT resolver deep dive (0x7FF926042AF0)
The entry is itself OLLVM-CFF (same template as megadispatcher — entry computes 32-bit state via LEA/XOR chain then binary-search dispatches). Confirmed by the Lua disasm dump.
IDD (encrypted Import Descriptor Data):
- Location:
loader+0x22B1000(live0x7FF924321000),~22records, stride0x28bytes each, 5 × 8-byte XOR slots per record - Encryption: 8-byte rolling XOR with baked key
0xFFFF834A942B7856(bytes56 78 2B 94 4A 83 FF FFLE) - Key is NOT derived from PEB.Ldr — it's a stored constant
- The PEB.Ldr fold is baked into per-trampoline encoding (stage 2), not into IDD decrypt (stage 1)
Bootstrap path (before synthetic IAT exists):
- FNV-1a hash over module names in
PEB.Ldr.InLoadOrderModuleList→ matches hard-coded hashes → cachesntdll/kernel32bases - FNV offset basis literal at
0x7FF926AC02DC(mov [stk], 811C9DC5h) - FNV prime at
0x7FF926AC01BB(imul eax, r15d, 1000193h) - Cached ntdll base global:
0x7FF927C3CDC0 - Cached kernel32 base global:
0x7FF927C3CDC8 - Walker is at
eidolon_antidbg_peb_ldr_walker_b(0x7FF926AC86F0, 15-case switch) — misnamed (it's actually the IAT resolver bootstrap, not anti-debug)
Trampoline emission: inline inside CFF states, no discrete emitter function. Each trampoline qword is built by XORing the intended target with gs:[60h]→[+0x18] at build time so the runtime fold subtracts it out.
Agent I5 — Bot-DLL enumeration hunt (verdict: no)
High-confidence finding: Eidolon does not enumerate loaded modules to scan against a bot-DLL blocklist.
Searched and not found:
EnumProcessModules*,Module32*W,CreateToolhelp32Snapshot,LdrEnumerate*,LdrFindEntryForAddress,LdrLockLoaderLock— 0 hits anywhereGetModuleBaseName*W,GetModuleFileName*W,GetMappedFileName*W— 0 hitsRtlImageNtHeader,RtlImageDirectoryEntryToData— 0 hits- Bot/cheat name strings (
cheat,bot,inject,hack,x64dbg,cheatengine,processhacker,ollydbg,windbg) — 0 hits - Case-compare helpers (
wcsicmp,_wcsnicmp,RtlEqualUnicodeString,RtlPrefixString) — 0 hits - FNV-1a hashing-loop targeting module names — only present for IAT bootstrap, not for blocklisting
- Path-vetting strings (
system32,programfiles,windir) — 0 hits
Eidolon's PEB.Ldr walks exist only for: IAT resolver bootstrap (finding which module exports a hashed symbol), and CFF-VM key-mixing (folding PEB bits into state entropy). Neither reads BaseDllName.Buffer.
Misleading antidbg_* prefixes: the walkers _a/_b/_c/_d (at 0x7FF9262C9FB0, 0x7FF926AC86F0, 0x7FF927983F70, 0x7FF92683ACD0) are mis-named — they're CFF state-mixers, not anti-debug. Recommend rename to eidolon_cff_vm_peb_keymix_{a,b,c,d} and eidolon_iat_resolver_peb_ldr_walk_for_dynapi for walker_b.
Implication for the analysis process: PEB-unlink + PE-header erase are belt-and-suspenders against Eidolon itself, but keep them for Warden (which historically does scan modules).
Live-probe corrections to earlier rounds
Round-5 claim: ".pdata is wiped on disk and reconstructed at runtime"
Probably wrong. Runtime probe of d2r_loader.dll at 0x7FF923F50000 shows .pdata present with virt_size = 0x10C50, raw_size = 0x10E00. Earlier rounds may have been conflating the .pdata strip with a separate feature, or looking at the wrong dump. Needs a fresh look at the disk image of d2r_loader.dll to confirm whether the on-disk section is zeroed.
Round-5 claim: "Resolved-pointer table rows are 16 bytes"
Wrong for the second time. Actual structure is variable-length records with DLL-marker rows (q0.high = 0x01DAE188) interleaved with function-entry rows. The q1 field is loader-image-RVA-shaped, not a heap pointer. Earlier agents mis-interpreted consecutive qwords as "rows." The actual runtime trampolines must be resolved by following these RVAs through another indirection (probably the per-trampoline lookup indexes into the arena heap buffer, whose address isn't stored in this table at all — it's stored in a cached global).
Round-5 claim: "Trampoline arena is at heap address 0x000002XX_XXXXXXXX"
Partially wrong. The value 0x2049C330000 we kept seeing in round 3–5 was the trampoline for the D2R_loader!Ordinal_1 IAT slot in D2R.exe, not "the arena". Each trampoline is its own small heap allocation; there may not be a single contiguous "arena" at all. Needs further investigation.
Round-4 claim: "Aegis/Warden slots change per-session"
Wrong. Probed live: values 0xC181000C8D41C829 (Aegis) and 0xC801D129B3802195 (Warden) are identical to previous sessions. They're baked constants set at init, never mutated after (at least 0 mutations in ~10 ms spin-probe window). Per Agent A2 (round 5): these slots are host-side tokens consumed by the out-of-process Warden/Aegis brokers via ReadProcessMemory.
Phantom list additions
| Phantom | Disconfirmed by | Truth |
|---|---|---|
"Caller-provenance check in d2r_loader.dll" | Agent I1 + CFG probe | Not in this DLL. Real cause is either Warden broker-side (out-of-process) or NTDLL/Windows-side |
| "CFG/XFG enforcement causes the hijack failure" | Lua CFG probe | Both D2R.exe and d2r_loader.dll have GuardFlags = 0x100 only, function table empty, dispatch stub is no-op FF E0. Not enforced |
| "Eidolon scans module list for bot DLLs" | Agent I5 | Does not — 0 hits for every relevant API, string, and pattern |
| "Resolved-pointer table has 16-byte rows" (round 5 correction) | Live probe at 16-byte stride | Actual layout is variable-length DLL-marker + function-entry records |
"Trampoline arena at 0x2049C330000 is the single contiguous arena" | Live probe | That's one trampoline's target, not an arena |
| "Eidolon has a bytecode VM" (round 4 correction now fully verified) | Agents I2 + I4 | No VM. IAT resolver and megadispatcher are both OLLVM-CFF. Period |
| "IDD encryption is PEB.Ldr-keyed" | Agent I4 | Baked constant 0xFFFF834A942B7856. PEB.Ldr is folded into per-trampoline encoding only |
New findings (not phantoms, actual new intelligence)
- PTR 3.2 added a
.gxfgsection tod2r_loader.dll(2 new sections vs. live build). Not enforced, but its presence suggests Blizzard may be preparing XFG enforcement for a future build. - The IDD XOR key is
0xFFFF834A942B7856. Offline decryption of the IDD blob is now possible. - FNV-1a is confirmed as the import-hash algorithm at
0x7FF926AC01BB/0x7FF926AC02DC. Hash tables can be precomputed for blocklisting / intercepting specific imports. - Panic vtable slot at
0x7FF927D34F98is the single clean kill-switch (Path D in Agent I3's catalog). Neutering this disables one of the 4 synchronous-abort paths. - Main caller into the CFF megadispatcher is
0x7FF9277703C0("megacaller"). 27 KB, also CFF-flattened. Each of its 6 call sites to the megadispatcher probably sets a different operation tag. - No
ntdllimport resolved exceptNtClose+RtlFreeHeap. Eidolon relies on the bootstrap FNV walker to reach ntdll for everything else — confirms the self-contained resolver design. - TLS callback
eidolon_tls_callback_0(0x7FF9270AC100) only fires onNtCreateThreadEx, never on thread-hijack. Per-thread state (FiberData atgs:[0x20]) is what it seeds. Hijacked main thread already has its FiberData set from the process-attach pass.
Recommended doc-v3 patches (for master eidolon_analysis.md)
- Strike all "CFG/XFG is the most likely cause" references — replaced by "mechanism unknown; likely Warden broker or NTDLL-side; confirm via diagnostic shellcode".
- Add the
0xFFFF834A942B7856IDD XOR key + FNV-1a bootstrap hashes to the "Synthetic IAT" section. - Correct the resolved-pointer table layout — third correction (it's NOT 16-byte rows; it's variable-length records).
- Replace the old "Aegis/Warden slot encoded values" narrative with "stable baked constants consumed out-of-process by Warden/Aegis brokers".
- Add the 4-path silent-abort catalog to "Anti-tamper machinery" — and add the panic-slot-neutering recommendation to "Defense implications".
- Update the megadispatcher subsection with Agent I2's findings (25K BBs, binary-search tree, 6 call sites, helper delegation).
- Update the phantom list with round-6 debunks (CFG-as-mechanism, 16-byte rows, single arena).
Next investigation steps (ranked)
- Run the diagnostic shellcode (record
rax,GetLastError(),[rsp]to RWX slots). This is now the only way to find the real provenance check — all in-DLL mechanisms are ruled out. - Build the Option-2 injector fix (
NtCreateThreadEx(StartAddress=LoadLibraryA)) regardless of what the diagnostic shows — it sidesteps the unknown mechanism. - Offline-decrypt the IDD using the now-known XOR key to get the full encrypted import table in plaintext.
- Inspect
sub_7FF9264E3480(the IAT resolver kernel shared with the megadispatcher) — probable location of the real semantic IAT-resolution work. - Inspect the megacaller at
0x7FF9277703C0— its 6 call sites into the megadispatcher each pass a different op-tag; recover the tags to understand what operations the megadispatcher actually performs. - Inspect the Warden broker process (separate from d2r_loader.dll). Requires expanding d2r-toolbox MCP to enumerate / read the Warden process.
- Neuter the panic vtable slot at
0x7FF927D34F98as a the analysis process defense hardening.
Updated renaming candidates (not yet applied)
From Agent I2:
0x7FF9264645D4→eidolon_cff_megadispatcher_dispatch_root0x7FF927C71A4C→eidolon_g_eidolon_cff_megadispatcher_seed_dword0x7FF9277703C0→eidolon_cff_megacaller_six_sites0x7FF9264E3480→eidolon_helper_iat_resolver_kernel(shared with IAT resolver; highest-value rename)0x7FF9264D2250→eidolon_helper_resolve_or_emit_giant_a
From Agent I3:
0x7FF9267C6B70→eidolon_terminate_self_kernel320x7FF926C47950→eidolon_dispatch_kill_on_fingerprint_fail0x7FF926DE7590→eidolon_vm_init_kill_dispatcher0x7FF9268F1140→eidolon_panic_callback_exitprocess0x7FF92634C720→eidolon_panic_compute_exit_code0x7FF927D34F98→g_eidolon_panic_vtable_slot
From Agent I4:
0x7FF926AC86F0→eidolon_resolver_bootstrap_walk_peb_ldr(currently misnamedeidolon_antidbg_peb_ldr_walker_b)0x7FF927C3CDC0→eidolon_g_eidolon_ntdll_base_cached0x7FF927C3CDC8→eidolon_g_eidolon_kernel32_base_cached0x7FF927C5FDEC→eidolon_g_eidolon_iat_resolver_cff_seed- (data at
loader+0x22B1000) →eidolon_idd_blob_encrypted - (constant
0xFFFF834A942B7856) →eidolon_EIDOLON_IDD_XOR_KEY
From Agent I5 (rename corrections):
0x7FF9262C9FB0,0x7FF927983F70,0x7FF92683ACD0→eidolon_cff_vm_peb_keymix_{a,c,d}(currently mis-prefixedantidbg_*)0x7FF9277C9260→eidolon_peb_get_self_image_base_helper