round 12 · 2026-04-17

Round 12 — 63 thunks decoded + round-6 partial reversal

Emulator fix unlocked 63/248 thunks. Recovered live PEB.Ldr. Initially thought caller-provenance was back in play — Round 13 reversed this.

Round 12 findings — Real IAT decoded, MAJOR round-6 reversal

Date: 2026-04-17 Scope: Live probe decoded 63 of 248 runtime IAT thunks to specific Win32 API names. Four IDA agents (I23-I26) delivered several major corrections. The most important finding overturns multiple round-6 conclusions.


Headline: ROUND 6 MAJOR REVERSAL — Eidolon DOES have stack-walking capability

Rounds 6 and earlier concluded that d2r_loader.dll has no caller-provenance check and does not scan modules. Those conclusions were based on what's now understood as Eidolon's decoy plaintext name table at 0x22B1376 — a canary list holding only one API per DLL for static-analysis tools to see. Round 12's live decoder walked the REAL 248-entry thunk table at byte_7FF927BFE190 and found the stack-walking trio PLUS module-enumeration APIs:

APIs that contradict round-6 conclusions

APIThunk offsetRound 6 saidRound 12 reality
RtlLookupFunctionEntry+0x630"Not in synthetic IAT — zero hits" (Agent I1)Present, live-decoded target 0x7FFA3AA8E010
RtlCaptureContext+0x628Not searched explicitlyPresent, target 0x7FFA3ABA0BE0
RtlRestoreContext+0x640Not searchedPresent, target 0x7FFA3AACCD90
Module32FirstW+0x3D0"Not in IAT — 0 hits" (Agent I5)Present, target 0x7FFA39393570
Module32NextW+0x3D8"Not in IAT — 0 hits" (Agent I5)Present, target 0x7FFA393844E0
Thread32First+0x530Present, target 0x7FFA3936DAC0
SuspendThread+0x500Previously known (round 11)Confirmed
SetThreadContext+0x4C0Not explicitly searchedPresent, target 0x7FFA393A5100
ZwQueryInformationProcess+0x610Not explicitly searchedPresent, target 0x7FFA3ABE1E10
SetUnhandledExceptionFilter+0x4D0Only CRT usage assumedAlso in runtime IAT

Implication for the hijack-injection regression

This reopens the caller-provenance check hypothesis for the PTR 3.2 hijack-injector regression. With RtlLookupFunctionEntry + RtlCaptureContext in the real IAT, Eidolon has everything it needs to:

  1. Inside the VEH handler sub_7FF926CEFDA0 — capture context at exception time
  2. Walk the stack via RtlLookupFunctionEntry to find each return address's module
  3. Verify every frame's return address lies inside a loaded module's .text
  4. Reject the LoadLibrary call if the chain is grounded in an RWX page (our hijack shellcode)

The validator was hidden from round 6 Agent I1 because I1 searched the plaintext decoy table, not the encrypted IDD + runtime thunk table that holds the real imports.

Round 12 Tier-1 recommendation update: Option-2 injector fix is even more clearly the right path. Our hijack shellcode's [rsp] will not survive ANY stack-walk validator; spawning a helper thread with StartAddress = LoadLibraryA sidesteps the check entirely.


Complete round-12 live decoding results

Decoded thunks (63 / 248 = 25.4%)

Emulator walked 128-byte trampoline bodies, executed arithmetic chain + PEB.Ldr fold (with live recovered PEB.Ldr = 0x00007FFA3AC528E0), and matched final targets against live exports of kernelbase/ntdll/kernel32 (4,389 total exports).

Kernelbase (49): CreateEventW, CreateFiber, CreateProcessW, CreateThread, DeactivateActCtx, FlsAlloc, FlushInstructionCache, GetCommandLineW, GetComputerNameW, GetCurrentDirectoryW, GetCurrentProcessId, GetFinalPathNameByHandleW, GetModuleFileNameW, GetModuleHandleA, GetModuleHandleExW, GetModuleHandleW, GetOEMCP, GetProcessId, GetStartupInfoW, GetVersionExW, GlobalMemoryStatusEx, IsThreadAFiber, MapViewOfFile, Module32FirstW, Module32NextW, QueryPerformanceFrequency, ReadFile, ResetEvent, SetEndOfFile, SetEvent, SetFilePointer, SetFilePointerEx, SetLastError, SetStdHandle, SetThreadContext, SetUnhandledExceptionFilter, Sleep, SleepEx, SuspendThread, SystemTimeToTzSpecificLocalTime, Thread32First, TlsGetValue, VerifyVersionInfoW, VirtualAlloc, VirtualFree, VirtualProtect, WaitForMultipleObjects

Ntdll (11): RtlExitUserThread, RtlReleaseSRWLockShared, RtlTryAcquireSRWLockExclusive, RtlRunOnceInitialize, TpStartAsyncIoOperation, ZwContinue, ZwQueryInformationProcess, RtlCaptureContext, RtlLookupFunctionEntry, RtlRestoreContext, VerSetConditionMask

Kernel32 (6): CryptDestroyKey, CryptEncrypt, GetUserNameW, RegCloseKey, RegOpenKeyExW (plus one kernel32 target at 0x7FFA390A2900 unresolved)

Remaining 180 thunks failed decoding — their decode chains contain either:

  • Multiple PEB.Ldr folds (unusual; requires chained XOR)
  • Intermediate XOR with immediates from memory
  • Opcodes my emulator doesn't yet recognize (some use 2-byte operand forms)
  • Truncation past the 128-byte window

Notable APIs decoded

SetUnhandledExceptionFilter at +0x4D0: Eidolon registers a UEH in addition to its VEH. The UEH is a fallback path when exceptions propagate past the VEH. an injected payload's defense should also install a head UEH if our own VEH defense is partially bypassed.

TlsGetValue at +0x550: Eidolon reads its TLS slot in-process. This is where the per-thread FiberData at gs:[0x20] is accessed. Previously known; now confirmed via IAT.

TpStartAsyncIoOperation at +0x4F8: Eidolon uses the Windows thread pool. May coordinate async telemetry sends or periodic integrity checks.

IsThreadAFiber at +0x380: Eidolon checks whether it's running on a fiber. Unusual for anti-tamper; could be part of anti-debug detection (some debuggers convert threads to fibers for stepping).


Agent I23 — sub_7FF92604CE40 is NOT the trampoline emitter

Round 11 I19's heuristic was wrong. The "160 C6 byte-writes" that seeded round 11's emitter-candidate selection were CFF byte-aliasing artifacts, not real mov byte [mem], imm8 instructions. Deep analysis:

  • Actual immediate writes inside the function: only 2 × 0x48, 1 × 0xE0, 1 × 0x35, 1 × 0x05 — nowhere near what a polymorphic emitter would need
  • Function receives a small stack buffer (&var_78, ~120 bytes), not a heap arena pointer
  • Returns a bool — emitters return pointers
  • Calls eidolon_btel_http_request_construct — BTel telemetry helper, not an emitter
  • Consumes dword_7FF927C5FF38/FF88, NOT the claimed dword_7FF927C93348

Round 11's "per-API RNG counter" hypothesis also falls: Agent I25 proved dword_7FF927C93348 and dword_7FF927CA4168 have zero write sites each — they are build-time embedded CFF seeds that produce constant outputs via MBA-folding. NOT advancing counters.

Where IS the emitter? Most likely inline inside eidolon_iat_resolver_main at 0x7FF926042AF0 itself, using memcpy+patch of a .rdata template. Only dynamic tracing (write-watch on the arena) will pin it statically.


Agent I24 — Outer wrapper iterates PER-DLL, not per-API

Corrects round-11 I20's finding. eidolon_iat_resolver_outer_wrapper @ 0x7FF927571DD0 (14,855 bytes, 806 BBs) has this canonical loop at 0x7FF927575000:

rbx = container.begin;
do {
    rcx = rbx; edx = 0x48; r8d = 9; r9d = 0x2B;
    call eidolon_iat_resolver_main;  // 0x7FF927575047
    rbx += 0x48;   // advance 72 bytes per DLL descriptor
} while (rbx != container.end);

Implications:

  • Per-DLL stride = 0x48 bytes (not per-API)
  • Wrapper makes 39 calls to resolver_main (one per DLL), not 248
  • Therefore eidolon_iat_resolver_main is a per-DLL resolver, not per-API — it loops the APIs internally
  • The "single-API resolver" label from round 11 was wrong

Arena allocation mechanism: CRT operator new[] (via sub_7FF92624B7E0 at 0x7FF927572C32), NOT VirtualAlloc through the synthetic IAT. This explains why the live arena at 0x2049BCF0000 is heap-shaped — it came from the process heap through new[], not from VirtualAlloc.

Wrapper does NOT touch:

  • byte_7FF927BFE190 (thunk table) — compaction happens in sub_7FF926709510 elsewhere
  • 0x7FF928101A70 (runtime IID)
  • qword_7FF927CD9D40 (scratch output)
  • dword_7FF927C21FE0 (rolling checksum)
  • IDD at 0x7FF928101000
  • PEB.Ldr (gs:[0x60])

The wrapper's sole caller is eidolon_btel_post_dispatch_thunk_third_channel @ 0x7FF926CC30D0.


Agent I25 — Per-API "RNG counters" are static MBA seeds

dword_7FF927C93348 and dword_7FF927CA4168 are NOT counters. Each has exactly ONE xref in the IDB (a read), ZERO writes. Current stored values:

  • dword_7FF927C93348 = 0xC33D52D5 → MBA-folded to CFF state 0x45E93A3B (constant per run)
  • dword_7FF927CA4168 = 0xB4395223 → MBA-folded to initial state 0x0769C227

The 0x7FF927CA4070..4170 region is a 40-entry CFF edge table, not a counter array. Each entry is a build-time constant that rewrites the state machine's cookie on specific dispatch edges.

Real state advancer: dword_7FF927C21FE0 (rolling resolve checksum) — 8 write sites inside eidolon_iat_resolver_main:

  • 0x7FF9260446E8, 0x7FF926045147, 0x7FF926046C45, 0x7FF92604CB00, and 4 others

A small fold helper sub_7FF926E8B9A0 (xor eax, [7FF927C21FE0]; retn) reads the checksum — likely used for integrity verification downstream.

For an analyst defense: the "don't touch" list grows:

  • dword_7FF927C21FE0 (rolling checksum) — corrupting this guarantees detection if integrity-verified downstream
  • 0x7FF927CA4070..4170 (CFF edge table) — any modification breaks the state machine
  • dword_7FF927C5FF38, dword_7FF927C5FF88 (primary CFF seeds for telemetry subsystems)

Agent I26 — Runtime "IID" table has 39 CANARY entries, NOT 248 imports

Corrects round-9 I11's assumption. The table at 0x7FF928101A70 is NOT the full import descriptor array. It holds 39 records, each carrying exactly ONE canary API per DLL (20-byte IID stride, 16-byte OFT pool per entry = 1 API name + null terminator).

  • Sentinel FT = 0x01DAE188 on every one of 39 valid records (0 on the header/terminator)
  • Record 0 is a pseudo-header with anomalous TimeDateStamp = 0x022B17FB
  • Record 40 (all-zero) is the terminator
  • 26 unique DLLs; 13 entries are duplicates (different canary APIs per DLL)
  • 4 entries use ordinal-only imports (WS2_32 ord #23/#116, OLEAUT32 #7, COMCTL32 #345)

The 39 canary APIs:

SetupDiGetDeviceInterfaceDetailW, ImmGetConversionStatus, GetAdaptersInfo,
GetDiskFreeSpaceW, ShellExecuteExA, CertCloseStore, GetUserNameExW,
WSAAsyncSelect (ord 23), PathCanonicalizeW, timeGetDevCaps,
XInputSetState, CoCreateFreeThreadedMarshaler, WinVerifyTrust,
GetWindowInfo, SelectObject, QueryServiceConfigW, RtlFreeHeap,
acmStreamPrepareHeader, HidD_GetSerialNumberString, SysAllocString (ord 7),
WinHttpCrackUrl, RoGetActivationFactory, WindowsCreateStringReference,
SetRestrictedErrorInfo, RoOriginateLanguageException,
AcquireSRWLockExclusive, NtClose, CryptAcquireContextA, GetAdaptersInfo (dup),
EnumThreadWindows, CommandLineToArgvW, GetFileVersionInfoA, SfcIsFileProtected,
BCryptCloseAlgorithmProvider, common_controls_init (ord 345), WSAStartup (ord 116),
CoTaskMemFree, CertAddCertificateContextToStore, InitSecurityInterfaceA

This is the DECOY table. It exists to make Eidolon's imports look "normal" to CFF Explorer, PE-Bear, IDA auto-import recovery. The REAL Win32 surface is the 248-entry thunk table at byte_7FF927BFE190, which Round 12's emulator is now decoding.

No thunk-offset correspondence: slot offsets in the 39-canary table (at 16-byte stride) do NOT map to slot offsets in the 248-thunk table. The two are independent structures serving different purposes.


Round-6 findings that now need revision

Given that the real Win32 surface includes stack-walking and module-enumeration APIs, multiple round-6 conclusions need re-examination:

Round 6 findingRevised status
"No in-DLL caller-provenance check" (I1)Reopened — the APIs are available; the check may exist, just hidden behind CFF obfuscation. Agent I1 searched the decoy name table, not the real IAT
"No bot-DLL enumeration" (I5)Partial reversalModule32FirstW/NextW ARE imported. Whether they're used for bot scanning specifically is still open
"Resolver uses NtAllocateVirtualMemory"Wrong — CRT operator new[] per Agent I24
"248 imports in runtime IID"Wrong — 39 canary IIDs + 248 polymorphic thunks (separate structures) per Agent I26
"RtlLookupFunctionEntry absent from IAT" (I1)Wrong+0x630 thunk decodes to this API

Crypto / state globals reference (round 12 update)

AddressRoleMutable at runtime?
dword_7FF927C21FE0Rolling resolve checksumYES — 8 writes in resolver
dword_7FF927C93348IAT-sibling CFF seed (constant 0xC33D52D5)NO — build-time only
dword_7FF927CA4168BTel CFF seed entry (constant 0xB4395223)NO
0x7FF927CA4070..4170BTel CFF edge table (40 DWORDs)NO
dword_7FF927C5FF38Primary CFF seed ANO
dword_7FF927C5FF88Primary CFF seed BNO
dword_7FF927C5FDECIAT resolver CFF entry seedNO
dword_7FF927C71A4CMegadispatcher dispatch cookie (0x2EB7282F)Likely NO (constant per run)
qword_7FF927CD9D40Static output scratch for resolverYES — written during init
qword_7FF927C2AF70..AFD0Per-API encrypted seed slots (9)NO

Next investigation targets (round 13 if continued)

  1. Find the exact VEH → RtlLookupFunctionEntry → stack-walk call graph. Confirm whether Eidolon actually uses RtlLookupFunctionEntry for caller-provenance validation, or just for its own SEH propagation. Dynamic trace via breakpoint on the +0x630 thunk.

  2. Decode remaining 180 thunks. Extend the emulator to handle:

    • Chained PEB.Ldr folds (some trampolines fold twice)
    • Register-to-register xor operations (48 31 C0 etc.)
    • Memory-indirect reads
  3. Identify the REAL trampoline emitter. Round 12 agents ruled out sub_7FF92604CE40. The emitter is likely inline in the resolver body doing memcpy from a .rdata template. Needs dynamic write-watch on the arena.

  4. Find where the checksum dword_7FF927C21FE0 is verified. If Eidolon checks the final checksum downstream, corrupting any import-resolution step will trigger detection. Search for xrefs to sub_7FF926E8B9A0 (the fold helper).

  5. Scan for Module32FirstW callsites. Now that we know it's imported, find what function calls it — this tells us whether Eidolon actively scans modules (reopening round 6 I5's verdict).

  6. Scan for RtlLookupFunctionEntry callsites. Most important — this API is the smoking gun for stack-walking. If it's called from sub_7FF926CEFDA0 (VEH) or eidolon_veh_handler_uses_fiberdata, we've found the caller-provenance check.