round 13 · 2026-04-17

Round 13 — 10 agents, definitive negatives

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 of CONTEXT.Rip (offset 0xF8) and CONTEXT.Rsp (offset 0x98) inside the real AV adjudicator.
  • The "trampoline emitter is in .eid" conclusion (Agent I34) is revised by R16/R17: the page-populate routine is eidolon_arena_virtualprotect_harden at RVA 0xE3BC80 in 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 (not 0xE9FDA0); the inner dispatcher at RVA 0x143D200 does not kill (R16).
  • Broker-slot RVAs 0x488CC0/0x488CC8 referenced below are an R6 typo — real RVAs are 0x1488CC0/0x1488CC8 (.text exports) and 0x1E88CC0/0x1E88CC8 (.data storage, 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:

  • CreateToolhelp32Snapshot at 0x7FF926DF28A1
  • Module32FirstW at 0x7FF926DF3465 (with dwSize = 0x438 = sizeof(MODULEENTRY32W) — dead-on ID)
  • Module32NextW at 0x7FF926DF38CB
  • CloseHandle at 0x7FF926DF4138

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:

  1. VirtualAlloc(NULL, size, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE) → heap page
  2. Write trampoline bytes (emitter still not found statically — see below)
  3. FlushInstructionCache (thunk +0x110)
  4. VirtualProtectPAGE_EXECUTE_READ (the harden pass at sub_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_SEARCH so 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 E0 template blobs in .rdata (only a C++ mov rax,0; ret epilogue)
  • 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_7FF926709510 is 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.FiberData at gs:[0x20]
    • Decrypts a per-thread block pointer via thunk +0x550
    • Schedules eidolon_cff_dispatcher_clone_megacaller_thunk on a fiber via thunk +0x040 (CreateFiber-like)
    • Writes per-thread slot via FlsSetValue (thunk +0x558)
    • Returns EXCEPTION_CONTINUE_SEARCH unless per-thread-block's [+0x48] is pre-seeded

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):

CapabilityEidolon does it?WhereKill path?
Caller-provenance ([rsp] / stack-walk)NO (I27 + I35)
Module enumeration (Toolhelp)YES at init (I28)sub_7FF926DF20B0No direct kill; feeds VM state
Thread enumerationYES at init (I28)sub_7FF92714B2E0No
Thread suspend + SetContextYES (I33)sub_7FF926A9A1C0Uses CONTEXT_FULL, not DEBUG_REGISTERS
DRx arming on game threadsNO (I33)
DRx reading (anti-debug)YESeidolon_antidbg_drread_block_*No direct
ProcessDebugPort checkYES (I29) — 1 sitesub_7FF926C0AF80No direct
ProcessInstrumentationCallbackNO (I29)
VEH installedYESsub_7FF926CEFDA0No direct from handler
UEH installedYES (I32)eidolon_warden_emit_exception_eventReturns CONTINUE_SEARCH (WER kills)
Stack-walkerNO (I27 + I35)CRT uses it; Eidolon doesn't
JIT trampoline arena (VirtualAlloc RWX → RX)YES (I31)sub_7FF9271EBBD0 + sub_7FF926C8BC80
Rolling "checksum" verificationNO (I30)Csum is a CFF predicate, not integrity
BTel telemetry HTTPSYESeidolon_btel_curl_perform_post
crashy::Report crash channelYESVia Aegis slot
WinVerifyTrust self-verifyYESSynthetic IATSilent abort on failure

Updated the analysis process defense requirements (Tier-1)

Reinforced from round 12, with R13 additions:

  1. Option-2 injector fix (NtCreateThreadEx(StartAddress=LoadLibraryA)) — unchanged
  2. Head VEH with magic tag 0xE0D1ABB0 — unchanged
  3. Head UEH with same magic tagNEW from R13 I32. Required because Eidolon's UEH writes crash telemetry before returning
  4. PEB-Ldr unlink — unchanged (round 6 I5 was wrong about "no enum"; Eidolon DOES enumerate, our unlink helps)
  5. PE-header erase — unchanged
  6. Hosts-file redirect telemetry-in.battle.net — unchanged
  7. Hook VirtualProtect (thunk +0x598)NEW R13 I31. Prevents Eidolon from making arena pages non-writable; lets us patch trampolines in situ
  8. Hook NtSetContextThreadNEW R13 I33. Blocks Eidolon from hijacking an injected payload's thread RIPs via CONTEXT_FULL writes
  9. 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 .eid decrypted code

Remaining unknowns (ranked)

  1. Where is the trampoline emitter? Confirmed NOT in .text. Must be in encrypted .eid section. Only dynamic DR0 write-trap on arena page finds it.
  2. What does the fiber-entry (0x7FF927038110) actually do? VEH schedules it — if there's a validator anywhere, it's here or in sibling sub_7FF92728E960.
  3. Who reads the callback slots at 0x488CC0/0x488CC8? Out-of-process broker (round 7 A2) — requires NtQuerySystemInformation handle scan to identify the PID.
  4. Does sub_7FF927544D30 ever flip ContextFlags to 0x10010? Currently uses 0x10000B (CONTEXT_FULL). Runtime watch recommended.
  5. What does sub_7FF926B0C0C0 @ 0x7FF926B1192A launch via CreateProcessW? Runtime capture needed.
  6. Decode remaining 180 trampolines — would need a looser fold-matcher in the emulator.