Reinstated round-6 no-caller-provenance verdict (CRT-only consumers of stack-walk APIs). Rolling checksum is CFF opaque predicate, not integrity. Thread manipulation uses CONTEXT_FULL, not DRx. Trampoline emitter definitively in .eid.
Later-round corrections (R16/R17):
- The "no caller-provenance / no stack-walk" conclusion reinstated here (Agent I27) is reversed by R17: Eidolon DOES perform a stack-walk legitimacy check — not via
RtlLookupFunctionEntry(the R13 observation is correct about those callsites being CRT-only), but inline via direct reads ofCONTEXT.Rip(offset0xF8) andCONTEXT.Rsp(offset0x98) inside the real AV adjudicator.- The "trampoline emitter is in
.eid" conclusion (Agent I34) is revised by R16/R17: the page-populate routine iseidolon_arena_virtualprotect_hardenat RVA0xE3BC80in plain.text, iterating a 32-byte descriptor table built at runtime. Static passes missed it due to CFF obfuscation.- The real OS-registered VEH is at RVA
0x222B40(not0xE9FDA0); the inner dispatcher at RVA0x143D200does not kill (R16).- Broker-slot RVAs
0x488CC0/0x488CC8referenced below are an R6 typo — real RVAs are0x1488CC0/0x1488CC8(.textexports) and0x1E88CC0/0x1E88CC8(.datastorage, dormant). The broker-via-RPM hypothesis is dead (R15/R16).
Round 13 findings — 10 agents + definitive negative results
Date: 2026-04-17 Scope: 10 parallel IDA agents targeting every remaining open question + extended live emulator. Headline: Round 12's "round-6 reversal" was itself wrong. Round 6 was right. Plus major new findings on the real trampoline emitter location and thread-manipulation.
Top-line corrections
1. Round 12 WAS WRONG — Round 6 is right after all (Agent I27)
Round 12 claimed stack-walking APIs in the runtime IAT prove Eidolon does caller-provenance. Agent I27 ran every callsite of RtlLookupFunctionEntry, RtlCaptureContext, RtlRestoreContext: 100% of them live inside statically-linked MSVC CRT boilerplate — __scrt_fastfail, capture_current_context, capture_previous_context, __FrameHandler3::GetEstablisherFrame, __acrt_call_reportfault. Eidolon itself calls none of these APIs. Round 6 Agent I1's original "no caller-provenance check in d2r_loader.dll" conclusion stands.
The presence of these APIs in the IAT merely reflects that MSVC's CRT (linked into every C++ binary) uses them. Having an import doesn't imply the application uses it.
Implication: The PTR 3.2 hijack-injection regression is NOT caused by a stack-walker in d2r_loader.dll. The cause is still: Warden broker process doing out-of-band inspection (most likely), NTDLL/Windows-internal check, or environmental/TLS state. Option-2 (spawn-helper-thread) injector fix remains the correct defense.
2. Rolling checksum dword_7FF927C21FE0 is NOT a defense (Agent I30)
It's a CFF opaque predicate. The 24 "consumers" are actually using eax = 0x88944AC9 XOR csum as a pseudo-random loop upper bound inside their own CFF dispatchers. The small helper sub_7FF926E8B9A0 just returns this fold. No code compares against a constant, no kill-path. Safe to corrupt — at worst you get non-deterministic early loop exits or extended iterations, never a panic.
3. Module enumeration IS happening (partial round-6 reversal — Agent I28)
Round 6 Agent I5 was wrong: Eidolon DOES enumerate modules at DLL static init via sub_7FF926DF20B0 (2KB+ CFF function in eidolon_vm_static_init_g). The full sequence confirmed:
CreateToolhelp32Snapshotat0x7FF926DF28A1Module32FirstWat0x7FF926DF3465(withdwSize = 0x438 = sizeof(MODULEENTRY32W)— dead-on ID)Module32NextWat0x7FF926DF38CBCloseHandleat0x7FF926DF4138
The szModule field (at stack offset +0x210, 256 WCHARs) is loaded into var_49C+4 and passed to subsequent callees. But: no reachability from this module walker to TerminateProcess (sub_7FF9267C6B70) or ExitProcess (panic stub sub_7FF9268F1140). It's a passive VM state feeder, not an active kill-switch. No plaintext blocklist of DLL-name hashes found statically.
Thread enumeration also confirmed: sub_7FF92714B2E0 walks Thread32First/Next with PID filter via GetCurrentProcessId. But the thread walker is DECOUPLED from the SuspendThread/SetThreadContext path — it doesn't arm anything on enumerated threads.
4. Thread manipulation uses CONTEXT_FULL, NOT CONTEXT_DEBUG_REGISTERS (Agent I33)
Eidolon does NOT arm hardware breakpoints on threads. The SuspendThread/SetThreadContext pair at sub_7FF926A9A1C0 + sub_7FF926629CF0 uses ContextFlags = 0x10000B (CONTEXT_FULL) — confirmed by mov dword ptr [rax], 0x10000B at 0x7FF92662A1EB. Binary-wide search for 0x10010 (CONTEXT_DEBUG_REGISTERS) returned zero matches in anti-tamper code.
The purpose of thread manipulation is one of: thread-RIP hijacking, stack poisoning to defeat call-chain reconstruction, or integrity snapshotting of game-thread RIPs. It's NOT DRx-based debugging.
Defense implication: Our analysis process doesn't need to periodically clear DRx. Only defense: hook NtSetContextThread and block writes to an injected payload's own thread handles.
5. Real trampoline arena IS VirtualAlloc-backed (Agent I31 corrects round 11 I24)
Round 11 I24 claimed CRT operator new[]. Wrong. The actual JIT arena allocator is at sub_7FF9271EBBD0 @ 0x7FF9271EBC9C calling VirtualAlloc with lpAddress=NULL → returns heap-range addresses like the observed 0x2049BCF0000. After alloc it writes a self-referential pointer table (base+0x8, +0x40, +0x740..+0x980+) — a free-list bucket layout.
Full JIT hygiene pattern confirmed:
VirtualAlloc(NULL, size, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)→ heap page- Write trampoline bytes (emitter still not found statically — see below)
FlushInstructionCache(thunk+0x110)VirtualProtect→PAGE_EXECUTE_READ(the harden pass atsub_7FF926C8BC80 @ 0x7FF926C8CA98)
Defense opportunity: hook VirtualProtect (or override the thunk table entry at +0x598) to veto the RW-removal. This keeps Eidolon's trampolines writable so An in-process observer can patch them. Second defense: hook FlushInstructionCache to create a stale-cache window.
6. UEH dispatcher found (Agent I32)
Eidolon registers its UEH at 0x7FF9273F4523 in eidolon_antidbg_veh_install_self. The registered UEH is eidolon_warden_emit_exception_event @ 0x7FF926072B40 — the same function that handles VEH chain-walk (shared telemetry backend). The UEH returns EXCEPTION_CONTINUE_SEARCH → WER/kernel does the actual kill.
Previous UEH saved at qword_7FF927CDE840 for chain-call purposes.
the analysis process defense (NEW Tier-1 requirement):
- Install head UEH with magic exception code
0xE0D1ABB0(customer-bit-set sentinel) - Our UEH matches tagged exceptions, returns
EXCEPTION_EXECUTE_HANDLER(kills silently without telemetry) - Non-tagged exceptions: return
EXCEPTION_CONTINUE_SEARCHso Eidolon's normal path is preserved
7. Trampoline emitter is in .eid section, NOT .text (Agent I34, definitive negative)
Three rounds of static analysis now agree: the trampoline byte emitter does NOT exist in d2r_loader.dll's visible .text. Exhaustive scan confirmed:
- Zero
48 B8 ... FF E0template blobs in.rdata(only a C++mov rax,0; retepilogue) - Zero
rep movsq(2 matches are both false-positives in CFF junk) - Zero byte-store sequences writing
0x48; 0xB8(or any 2-adjacent trampoline opcodes) sub_7FF926709510is a CONSUMER of the thunk table, not a writer (round 10 mislabeled it)
The emitter is runtime-decrypted shellcode in the .eid section (loader+0x1F50000..+0x22D0000, 3.5 MB of encrypted payload). Eidolon decrypts a generator from .eid into RWX memory, runs it to build trampolines, and possibly erases it.
Only a dynamic write-watch on the arena pages will find it. Recommended: set a DR0 write-trap on 0x2049BCF0000 + 0 before the arena-allocator fires, catch the first mov byte [arena], 0x48 instruction, and dump the containing page.
8. VEH inner dispatcher does NOT validate stack provenance (Agent I35, confirms I27)
Full decompilation of sub_7FF92728D200 (VEH inner, 5,979 bytes, 359 BBs):
- Filters 5 noisy exception codes to
EXCEPTION_CONTINUE_SEARCH - Heavy path (incl. STATUS_BREAKPOINT/SINGLE_STEP/ACCESS_VIOLATION) does:
- Reads
TEB.FiberDataatgs:[0x20] - Decrypts a per-thread block pointer via thunk
+0x550 - Schedules
eidolon_cff_dispatcher_clone_megacaller_thunkon a fiber via thunk+0x040(CreateFiber-like) - Writes per-thread slot via
FlsSetValue(thunk+0x558) - Returns
EXCEPTION_CONTINUE_SEARCHunless per-thread-block's[+0x48]is pre-seeded
- Reads
NOT called by the dispatcher: RtlCaptureContext, RtlLookupFunctionEntry, RtlRestoreContext, ZwQueryInformationProcess, rolling checksum, panic stub. It's a demultiplexer, not a validator.
Any actual validator lives DOWNSTREAM in the fiber entry (0x7FF927038110) or in a sibling CFF sub_7FF92728E960 (2KB CFF function, worth next-round target).
9. Anti-debug ZwQueryInformationProcess is ONE callsite (Agent I29)
Exactly ONE callsite at sub_7FF926C0AF80 @ 0x7FF926C0C983 with runtime-computed InfoClass. The check is the canonical ProcessDebugPort (7) / ProcessDebugObjectHandle (30) pattern:
if (NTSTATUS_SUCCESS && v109[0] != 0) goto debugger_detected_path;The "detected" path writes state into a separate PVA cluster dword_7FF927C3EE94..EEAC (NOT the rolling checksum). No direct kill path.
Bonus finding — ZwQueryInformationThread at thunk +0x620 (NOT +0x610) called once from eidolon_antidbg_drread_block_d for hardware-breakpoint detection. Classic DR-read-block pattern.
ProcessInstrumentationCallback is NOT set. Eidolon does not use Win11's instrumentation callback — Our analysis process doesn't need to emulate one.
Defense: hook the single ZwQIP callsite by overwriting thunk +0x610 to point at a shim that zeros the buffer for classes 7/30. Surgically effective (only 1 caller).
Extended emulator results (probe_r13_extended.lua)
Added support for: 0x48 0x0D (or), 0x48 0x25 (and), 0x48 0x81 F0/C0/E8 (alt xor/add/sub encoding), 0x48 0x83 ... (imm8 variants), 0x48 0xF7 D0/D8 (not/neg), 0x90 (nop). Result: 64 thunks decoded (vs 63 in round 12 with 128-byte window). One new: UnmapViewOfFile at +0x578.
The remaining 180 trampolines fail at various positions with 0x50 as the first unrecognized byte — this is the push rax of a modified PEB.Ldr fold that doesn't match the exact 22-byte pattern. Variants likely insert additional arithmetic between the mov rax, gs:[60h] and mov rax, [rax+18h] or between the xor [rsp], rax and pop rax steps.
To crack the remaining 180, we'd need a more flexible fold matcher that accepts any arithmetic op between the fold's 4 anchor points. The 64 currently-decoded thunks are a representative sample — they're the APIs Eidolon chose to give "easy" polymorphic variants to. The remaining 180 are just harder-chain versions of equally-normal imports.
Fold-count distribution for decoded thunks
- 0 folds: 19 trampolines (no PEB.Ldr, pure arithmetic)
- 1 fold: 20 trampolines
- 2 folds: 25 trampolines
- 3 folds: 3 trampolines
- 4 folds: 1 trampoline
Confirms Eidolon randomizes fold count per-API to 0-4.
Critical new defensive picture
Updated "Eidolon actually does" catalog (consolidating across rounds):
| Capability | Eidolon does it? | Where | Kill path? |
|---|---|---|---|
Caller-provenance ([rsp] / stack-walk) | NO (I27 + I35) | — | — |
| Module enumeration (Toolhelp) | YES at init (I28) | sub_7FF926DF20B0 | No direct kill; feeds VM state |
| Thread enumeration | YES at init (I28) | sub_7FF92714B2E0 | No |
| Thread suspend + SetContext | YES (I33) | sub_7FF926A9A1C0 | Uses CONTEXT_FULL, not DEBUG_REGISTERS |
| DRx arming on game threads | NO (I33) | — | — |
| DRx reading (anti-debug) | YES | eidolon_antidbg_drread_block_* | No direct |
| ProcessDebugPort check | YES (I29) — 1 site | sub_7FF926C0AF80 | No direct |
| ProcessInstrumentationCallback | NO (I29) | — | — |
| VEH installed | YES | sub_7FF926CEFDA0 | No direct from handler |
| UEH installed | YES (I32) | eidolon_warden_emit_exception_event | Returns CONTINUE_SEARCH (WER kills) |
| Stack-walker | NO (I27 + I35) | CRT uses it; Eidolon doesn't | — |
| JIT trampoline arena (VirtualAlloc RWX → RX) | YES (I31) | sub_7FF9271EBBD0 + sub_7FF926C8BC80 | — |
| Rolling "checksum" verification | NO (I30) | — | Csum is a CFF predicate, not integrity |
| BTel telemetry HTTPS | YES | eidolon_btel_curl_perform_post | — |
| crashy::Report crash channel | YES | Via Aegis slot | — |
WinVerifyTrust self-verify | YES | Synthetic IAT | Silent abort on failure |
Updated the analysis process defense requirements (Tier-1)
Reinforced from round 12, with R13 additions:
- Option-2 injector fix (
NtCreateThreadEx(StartAddress=LoadLibraryA)) — unchanged - Head VEH with magic tag
0xE0D1ABB0— unchanged - Head UEH with same magic tag ← NEW from R13 I32. Required because Eidolon's UEH writes crash telemetry before returning
- PEB-Ldr unlink — unchanged (round 6 I5 was wrong about "no enum"; Eidolon DOES enumerate, our unlink helps)
- PE-header erase — unchanged
- Hosts-file redirect
telemetry-in.battle.net— unchanged - Hook
VirtualProtect(thunk+0x598) ← NEW R13 I31. Prevents Eidolon from making arena pages non-writable; lets us patch trampolines in situ - Hook
NtSetContextThread← NEW R13 I33. Blocks Eidolon from hijacking an injected payload's thread RIPs via CONTEXT_FULL writes - Overwrite thunk
+0x610(ZwQueryInformationProcess) ← NEW R13 I29. Single-point spoof of debugger-attached detection
What we explicitly do NOT need to do
- Clear DRx periodically (I33: not armed by Eidolon)
- Register ProcessInstrumentationCallback (I29: Eidolon doesn't use one)
- Worry about the rolling checksum (I30: not a defense)
- Forge stack frames for hijack injector (I27 + I35: no stack-walker)
- Install a bytecode VM interpreter (no Eidolon VM exists)
New "don't touch" additions (Tier-2 hygiene)
qword_7FF927CDE8A0(Eidolon worker thread handle storage)qword_7FF927CDE840(saved old UEH — used for chain call)dword_7FF927C3EE94 / EE98 / EE9C / EEA8 / EEAC(PVA anti-debug state cluster — separate from rolling checksum)dword_7FF927C5FF38,dword_7FF927C5FF88(primary CFF seeds)
"Safe to touch" confirmations (new in R13)
dword_7FF927C21FE0(rolling checksum) — CFF predicate only, not a defense (I30)- Thunk
+0x598(VirtualProtect) — can be overridden to neutralize JIT arena hardening (I31) - Thunk
+0x610(ZwQueryInformationProcess) — can be overridden to always return "no debugger" (I29) - Panic stub at
sub_7FF9268F1140— ZERO callers (I34 + I35 both confirm) — either orphan or reached only via.eiddecrypted code
Remaining unknowns (ranked)
- Where is the trampoline emitter? Confirmed NOT in
.text. Must be in encrypted.eidsection. Only dynamic DR0 write-trap on arena page finds it. - What does the fiber-entry (
0x7FF927038110) actually do? VEH schedules it — if there's a validator anywhere, it's here or in siblingsub_7FF92728E960. - Who reads the callback slots at
0x488CC0/0x488CC8? Out-of-process broker (round 7 A2) — requires NtQuerySystemInformation handle scan to identify the PID. - Does
sub_7FF927544D30ever flip ContextFlags to0x10010? Currently uses0x10000B(CONTEXT_FULL). Runtime watch recommended. - What does
sub_7FF926B0C0C0 @ 0x7FF926B1192Alaunch viaCreateProcessW? Runtime capture needed. - Decode remaining 180 trampolines — would need a looser fold-matcher in the emulator.