via Claude:
I dug through the current mandiant/speakeasy master (commit 3cb6c61). Here’s how handles actually flow, end to end.
The short version
Handle resolution in speakeasy is per-handler and opt-in, not a centralized service. Each API handler decides what to write back into the logged argument list. There’s no global “symbolicate every handle in the trace” pass. The consequence is that resolution is inconsistent by design: filenames show up where a handler bothers to resolve them, and raw handle integers show up everywhere else.
How the trace is built
The logging path is WindowsEmulator.log_api → Profiler.record_api_event (windows/winemu.py:1614, profiler.py:227). The important detail: handlers receive the raw argv (integers pulled off the stack) and mutate it in place to decorate arguments. Whatever’s left in argv when the handler returns is what gets logged. Ints are formatted as hex, strings/bytes quoted. The return value is logged separately as hex(rv).
So “resolution” literally means “did this specific handler overwrite argv[i] with something human-readable.”
What each file handler does (kernel32.py)
CreateFile (:3719):
• argv[0] = target — rewrites the filename pointer to the resolved string.
• argv[1]/argv[4] rewritten to access-define and disposition names.
• The handle is the return value, logged as a raw hex int (0x80, 0x84, …). It is not named.
WriteFile (:3872) and ReadFile (:3824):
• hFile is argv[0] and is left as a raw integer. The handler resolves it internally via self.file_get(hFile) to get the File and its .path, but never writes that path back into argv.
• WriteFile decorates argv[1] with a short hex preview of the buffer, but the handle stays raw.
CloseHandle (:4030): handle is raw argv[0], never decorated.
So a typical trace looks like:
CreateFileW("C:\Users\...\evil.dat", "GENERIC_WRITE", 0x0, 0x0, "CREATE_ALWAYS", 0x80, 0x0) -> 0x80
WriteFile(0x80, 0x401000 (deadbeef...), 0x10, ...) -> 0x1
CloseHandle(0x80) -> 0x1
The path appears only on the CreateFile line. On every subsequent call the handle is just 0x80.
The side channel: file-access events
Separately, the file handlers call record_file_access_event(path, …) (api.py:223 → profiler.py:261), producing file_create / file_open / file_read / file_write events that carry the resolved path plus the data payload. This is the cleaner record of “what data hit which path.”
Two things worth knowing here:
• These events are coalesced by path — repeated writes to the same path merge into one event with accumulated size/data (profiler.py:281). The key is the path, never the handle.
• The event schema has a handle field (profiler_events.py:301,322,343,365), but I checked every call site (grep record_file_access_event) and none of the file handlers pass handle=. So in practice that field is always null. The file-event stream is path-keyed; it gives you paths and bytes but not the handle linkage.
Recycling and post-close resolution — the good news
This is where it works out in your favor:
• Handles are never recycled. File.curr_handle starts at 0x80 and only ever increments by 4 (fileman.py:96). Same monotonic pattern for KernelObject (0x220), FileMap (0x280), Console (0x340). Nothing decrements or reuses.
• File objects are never freed. FileManager.file_handles has no .pop/del anywhere. CloseHandle on a file calls emu.dec_ref(obj), but dec_ref only touches objects with a ref_cnt, and File doesn’t have one — so it’s a no-op for files (objman.py:880, winemu.py:2733). The handle→File mapping survives the close.
So yes: at the end of a run you can take any file handle — even a closed one — and resolve it. emu.file_get(0x80).path works post-close, and the path is still right.
One sharp edge: the per-class handle counters overlap numerically (a File climbing past 0x220 could collide with a kernel-object handle value). They live in separate dicts, and winemu.get_object_from_handle checks the object manager before the file manager (winemu.py:1194), so a numeric collision would resolve to the OM object, not the file. Unlikely in practice, but it’s a real ambiguity if you’re resolving purely by integer.
Resolving handles from the JSON report
Note this master branch uses a newer Pydantic event model — a single flat events list per run with a discriminator field ("api", "file_create", "file_write", …), rather than the old separate apis/file_access arrays. Given the above, your resolution recipe is:
1. Build the handle→path map from api events. Scan for file-opening APIs (CreateFileW/A, and the NT equivalents in ntoskrnl.py:2750+) and record { ret_val : args[0] }. Because handles never recycle, this map is valid for the entire run — a WriteFile(0x80,…) or CloseHandle(0x80) anywhere refers to the same path.
2. Use the file_* events for payloads. They give you path + data_ref (into the binary blob store) for what was actually read/written, coalesced by path. Use these for content, the api-event map for handle identity.
3. Skip failed opens — CreateFile returns INVALID_HANDLE_VALUE on failure, so only map successful returns.
If you want the resolution to be reliable directly from the report rather than reconstructed, the cheap upstream fix is to thread handle=hnd into the record_file_access_event calls in WriteFile/ReadFile/CloseHandle (the parameter already exists and is already serialized). That would populate the currently-always-null handle field and make the file-event stream self-resolving without needing to cross-reference the api trace.
via Claude:
I dug through the current mandiant/speakeasy master (commit 3cb6c61). Here’s how handles actually flow, end to end.
The short version
Handle resolution in speakeasy is per-handler and opt-in, not a centralized service. Each API handler decides what to write back into the logged argument list. There’s no global “symbolicate every handle in the trace” pass. The consequence is that resolution is inconsistent by design: filenames show up where a handler bothers to resolve them, and raw handle integers show up everywhere else.
How the trace is built
The logging path is WindowsEmulator.log_api → Profiler.record_api_event (windows/winemu.py:1614, profiler.py:227). The important detail: handlers receive the raw argv (integers pulled off the stack) and mutate it in place to decorate arguments. Whatever’s left in argv when the handler returns is what gets logged. Ints are formatted as hex, strings/bytes quoted. The return value is logged separately as hex(rv).
So “resolution” literally means “did this specific handler overwrite argv[i] with something human-readable.”
What each file handler does (kernel32.py)
CreateFile (:3719):
WriteFile (:3872) and ReadFile (:3824):
CloseHandle (:4030): handle is raw argv[0], never decorated.
So a typical trace looks like:
CreateFileW("C:\Users\...\evil.dat", "GENERIC_WRITE", 0x0, 0x0, "CREATE_ALWAYS", 0x80, 0x0) -> 0x80
WriteFile(0x80, 0x401000 (deadbeef...), 0x10, ...) -> 0x1
CloseHandle(0x80) -> 0x1
The path appears only on the CreateFile line. On every subsequent call the handle is just 0x80.
The side channel: file-access events
Separately, the file handlers call record_file_access_event(path, …) (api.py:223 → profiler.py:261), producing file_create / file_open / file_read / file_write events that carry the resolved path plus the data payload. This is the cleaner record of “what data hit which path.”
Two things worth knowing here:
Recycling and post-close resolution — the good news
This is where it works out in your favor:
So yes: at the end of a run you can take any file handle — even a closed one — and resolve it. emu.file_get(0x80).path works post-close, and the path is still right.
One sharp edge: the per-class handle counters overlap numerically (a File climbing past 0x220 could collide with a kernel-object handle value). They live in separate dicts, and winemu.get_object_from_handle checks the object manager before the file manager (winemu.py:1194), so a numeric collision would resolve to the OM object, not the file. Unlikely in practice, but it’s a real ambiguity if you’re resolving purely by integer.
Resolving handles from the JSON report
Note this master branch uses a newer Pydantic event model — a single flat events list per run with a discriminator field ("api", "file_create", "file_write", …), rather than the old separate apis/file_access arrays. Given the above, your resolution recipe is:
If you want the resolution to be reliable directly from the report rather than reconstructed, the cheap upstream fix is to thread handle=hnd into the record_file_access_event calls in WriteFile/ReadFile/CloseHandle (the parameter already exists and is already serialized). That would populate the currently-always-null handle field and make the file-event stream self-resolving without needing to cross-reference the api trace.