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
| API | Thunk offset | Round 6 said | Round 12 reality |
|---|---|---|---|
RtlLookupFunctionEntry | +0x630 | "Not in synthetic IAT — zero hits" (Agent I1) | Present, live-decoded target 0x7FFA3AA8E010 |
RtlCaptureContext | +0x628 | Not searched explicitly | Present, target 0x7FFA3ABA0BE0 |
RtlRestoreContext | +0x640 | Not searched | Present, 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 | +0x530 | — | Present, target 0x7FFA3936DAC0 |
SuspendThread | +0x500 | Previously known (round 11) | Confirmed |
SetThreadContext | +0x4C0 | Not explicitly searched | Present, target 0x7FFA393A5100 |
ZwQueryInformationProcess | +0x610 | Not explicitly searched | Present, target 0x7FFA3ABE1E10 |
SetUnhandledExceptionFilter | +0x4D0 | Only CRT usage assumed | Also 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:
- Inside the VEH handler
sub_7FF926CEFDA0— capture context at exception time - Walk the stack via
RtlLookupFunctionEntryto find each return address's module - Verify every frame's return address lies inside a loaded module's
.text - 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 claimeddword_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_mainis 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 insub_7FF926709510elsewhere0x7FF928101A70(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 state0x45E93A3B(constant per run)dword_7FF927CA4168 = 0xB4395223→ MBA-folded to initial state0x0769C227
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 downstream0x7FF927CA4070..4170(CFF edge table) — any modification breaks the state machinedword_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 = 0x01DAE188on 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 finding | Revised 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 reversal — Module32FirstW/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)
| Address | Role | Mutable at runtime? |
|---|---|---|
dword_7FF927C21FE0 | Rolling resolve checksum | YES — 8 writes in resolver |
dword_7FF927C93348 | IAT-sibling CFF seed (constant 0xC33D52D5) | NO — build-time only |
dword_7FF927CA4168 | BTel CFF seed entry (constant 0xB4395223) | NO |
0x7FF927CA4070..4170 | BTel CFF edge table (40 DWORDs) | NO |
dword_7FF927C5FF38 | Primary CFF seed A | NO |
dword_7FF927C5FF88 | Primary CFF seed B | NO |
dword_7FF927C5FDEC | IAT resolver CFF entry seed | NO |
dword_7FF927C71A4C | Megadispatcher dispatch cookie (0x2EB7282F) | Likely NO (constant per run) |
qword_7FF927CD9D40 | Static output scratch for resolver | YES — written during init |
qword_7FF927C2AF70..AFD0 | Per-API encrypted seed slots (9) | NO |
Next investigation targets (round 13 if continued)
-
Find the exact VEH → RtlLookupFunctionEntry → stack-walk call graph. Confirm whether Eidolon actually uses
RtlLookupFunctionEntryfor caller-provenance validation, or just for its own SEH propagation. Dynamic trace via breakpoint on the+0x630thunk. -
Decode remaining 180 thunks. Extend the emulator to handle:
- Chained PEB.Ldr folds (some trampolines fold twice)
- Register-to-register
xoroperations (48 31 C0etc.) - Memory-indirect reads
-
Identify the REAL trampoline emitter. Round 12 agents ruled out
sub_7FF92604CE40. The emitter is likely inline in the resolver body doingmemcpyfrom a.rdatatemplate. Needs dynamic write-watch on the arena. -
Find where the checksum
dword_7FF927C21FE0is verified. If Eidolon checks the final checksum downstream, corrupting any import-resolution step will trigger detection. Search for xrefs tosub_7FF926E8B9A0(the fold helper). -
Scan for
Module32FirstWcallsites. 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). -
Scan for
RtlLookupFunctionEntrycallsites. Most important — this API is the smoking gun for stack-walking. If it's called fromsub_7FF926CEFDA0(VEH) oreidolon_veh_handler_uses_fiberdata, we've found the caller-provenance check.