Decant lets an unmodified Windows memory-editing tool run under Wine while its memory accesses are redirected to a separate Windows VM. The tool sees local process memory; each read and write is serviced by reading the guest VM's physical RAM from outside via memflow.
┌────────────────────────────────────────────────────────────────┐
│ Windows guest VM (QEMU/KVM) │
│ target.exe, the game/process being inspected │
│ (runs unmodified) │
└───────────────▲────────────────────────────────────────────────┘
│ physical RAM read out-of-band
│ (hypervisor memory introspection)
│
┌───────────────┴──────────── memflow connector (QEMU/KVM) ──────┐
│ HOST (Linux), where the hypervisor runs │
│ │
│ ┌──────────────────────────────┐ │
│ │ decant-daemon "the cellar" │ reads/writes guest memory │
│ │ MemoryBackend dispatch │ via MemflowBackend │
│ └──────────────▲───────────────┘ │
│ │ localhost TCP, length-prefixed bincode │
│ │ (decant-protocol Request/Response) │
│ ┌──────────────┴───────────────┐ │
│ │ Wine process │ │
│ │ target tool (unmodified) │ │
│ │ + decant-interpose.dll │ "the carafe" │
│ │ intercepts Win32/NT │ │
│ │ memory exports, marshals │ │
│ │ them to the cellar │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
- The guest: the Windows VM and its
target.exe. The interposed tool API reads and writes its memory from outside. Explicit guest injection is a separate domain where the daemon maps DLL bytes and invokes the payload through a configured guest execution method (section 3). - The cellar (
decant-daemon): a host-side TCP server owning the activeMemoryBackendand dispatchingdecant-protocolrequests to it;--backend mock(default, no VM) ormemflow(VM). - MemflowBackend (
decant-memflow): reads guest physical RAM through a QEMU/KVM connector, resolving it into virtual-memory reads, process/module enumeration, and export tables. AMemoryBackendimplementor. - LeechCore device (
decant-leechcore-device): an optional raw physical-memory client used by MemProcFS consumers such as Orpheus. It batches LeechCore scatter requests over the daemon protocol; MemProcFS performs its own Windows analysis on the returned RAM. - The carafe (
decant-interpose): the DLL loaded into the tool under Wine. It intercepts the Win32/NT memory and introspection exports, marshals each to the cellar, maintains a synthetic handle table, synthesizes process/module snapshots from daemon data, and forwards everything else to the Wine builtin.
Every Win32/NT memory-introspection call a tool can make (ReadProcessMemory,
WriteProcessMemory, NtReadVirtualMemory, VirtualQueryEx,
CreateToolhelp32Snapshot, Module32First/Next, EnumProcessModules,
GetModuleHandle, GetProcAddress, …) collapses onto the
MemoryBackend trait:
fn list_processes(&self) -> Result<Vec<ProcessInfo>>;
fn process_by_pid(&self, pid: Pid) -> Result<ProcessInfo>;
fn process_by_name(&self, name: &str) -> Result<ProcessInfo>;
fn module_list(&self, pid: Pid) -> Result<Vec<ModuleInfo>>;
fn module_by_name(&self, pid: Pid, name: &str) -> Result<ModuleInfo>;
fn module_exports(&self, pid: Pid, module: &str) -> Result<Vec<(String, u64)>>;
fn read(&self, pid: Pid, addr: u64, len: usize) -> Result<Vec<u8>>;
fn write(&self, pid: Pid, addr: u64, data: &[u8]) -> Result<usize>;
fn memory_map(&self, pid: Pid) -> Result<Vec<MemRegion>>;The Request/Response wire enums mirror these
one-to-one. Translate the nine once and every Win32 API above them is handled; an exotic
toolhelp/psapi combination still bottoms out in read, query, or enumerate. Anything requiring
arbitrary tool-initiated guest execution does not fit this interface and is not simulated
(section 3). Guest DLL mapping uses the separate guest injection request type.
memflow reads the VM's physical RAM from outside the guest, where the hypervisor exposes it. Two consequences:
-
memflow runs where the hypervisor runs. The QEMU/KVM connector reads the QEMU process's mapping of guest RAM, so the daemon lives on the host beside the VM while the carafe lives in the Wine-hosted tool. They are separate processes bridged only by TCP; that split is why a daemon exists.
-
Tool API unsupported operations. The Wine-hosted tool sees a synthetic process handle. Through that handle, Decant exposes guest read/write, scan, module/export lookup, and pointer resolution. It does not translate arbitrary tool-initiated process-control calls into guest execution:
VirtualAllocEx,CreateRemoteThread, DLL injection, and direct guest function calls returnProtoError::Unsupported { op }(BackendError::Unsupportedon the backend side) and incrementDiagnostics::unsupported_ops, never a false success. Explicit no-guest-software DLL injection is a separatedecant-cli guest-injectpath with its own method/capability contract; it is not exposed through the synthetic handle. The implemented guest method maps DLL bytes and invokes the payload through an IAT-hook call in the selected target.
The synthetic process handle services the full handle tail. OpenProcess mints it; then
ReadProcessMemory/WriteProcessMemory, CloseHandle/NtClose, DuplicateHandle,
WaitForSingleObject/WaitForSingleObjectEx/NtWaitForSingleObject,
GetHandleInformation/SetHandleInformation, GetProcessId, GetExitCodeProcess,
GetPriorityClass, GetProcessTimes, IsWow64Process, QueryFullProcessImageName,
GetProcessImageFileName, the NtQueryInformationProcess basic/wow64/image classes, and
VirtualQueryEx/NtQueryVirtualMemory all resolve against it.
NtQueryInformationProcess(ProcessBasicInformation) returns the pid with a PEB base of 0
on the synthetic-handle path; the guest injection domain reads the real PEB through
memflow's EPROCESS walk, so guest PEB-walking features are serviced by the guest domain.
On a synthetic handle, the execution and process-control exports
(VirtualAllocEx/VirtualFreeEx, NtAllocateVirtualMemory/NtFreeVirtualMemory,
CreateRemoteThread/CreateRemoteThreadEx, NtCreateThreadEx, TerminateProcess,
NtSuspendProcess, NtResumeProcess) return their documented failure sentinel (null or
STATUS_NOT_SUPPORTED), report the refusal to the daemon, and write to the tool's stderr.
SetWindowsHookEx and QueueUserAPC are forwarded, not intercepted: neither carries a
guest process handle (an event hook targets the local Wine session, an APC a thread
handle Decant never mints), so neither is expressible against the guest. Intercepting
them would only break the tool's local use.
The carafe is injected into the Wine-hosted tool, which is host-side process manipulation. Guest DLL injection is only performed by the explicit guest injection domain, where the daemon accepts a DLL byte image and applies the guest mapper/execution policy.
MemoryBackend is the single seam all memory access flows through, so the stack above it
runs against a mock guest with no VM. That is
MockBackend, built by MockGuest:
let guest = MockGuest::new()
.process("target.exe", 1234)
.module("target.exe", 0x1400000000, 0x80000)
.export("add", 0x1000)
.region(0x1400000000, /* r,w,x */ true, true, false)
.u32_at(0x1400000010, 0xdeadbeef)
.bytes_at(0x1400000020, &[1, 2, 3, 4])
.done()
.build();
let backend = MockBackend::new(guest);The mock implements every method deterministically and round-trips writes (a write
then a read of the same range returns the new bytes), so the read-back
write-verification strategy works without a VM. This keeps development VM-free:
decant-analysis(AOB scanner, pointer-chain resolver) runs entirely against aMockGuest.decant-daemondispatch is tested with the server on aMockBackend.decant-cliand the carafe's marshaling run end-to-end against the mock behind the daemon.
Only MemflowBackend needs a VM; it swaps in behind the same trait.
Mixed-target Cargo workspace. Host crates are default-members; the Windows-gnu crates
are members built only with --target x86_64-pc-windows-gnu.
| Crate | Target | Role |
|---|---|---|
crates/decant-protocol |
host + win-gnu | Wire contract + shared domain types; write_msg/read_msg framing |
crates/decant-backend |
host | MemoryBackend trait + MockBackend/MockGuest |
crates/decant-memflow |
host | MemflowBackend |
crates/decant-analysis |
host | AOB scanner + pointer-chain resolver |
crates/decant-client |
host + win-gnu | shared RPC Client over decant-protocol |
crates/decant-daemon |
host | "the cellar", TCP server + dispatch |
crates/decant-cli |
host | user CLI |
crates/decant-vmi |
host | library facade re-exporting backends, scanner/resolver, client |
crates/decant-wine-harness |
host | launches exes under Wine for cargo test |
crates/decant-interpose |
win-gnu (cdylib) | "the carafe" interposer DLL |
testbins/hello-dll |
win-gnu (cdylib) | minimal PE32+ DLL exporting add |
testbins/dll-smoke |
win-gnu (exe) | loads hello-dll, proves the toolchain under Wine |
testbins/guest-target |
win-gnu | sample target for VM tests |
testbins/sample-tool |
win-gnu | stand-in memory tool for harness tests |
testbins/decant-launcher |
win-gnu | suspended-create injection harness |
xtask |
host | build/test orchestration |
The domain types (Pid, ProcessInfo, ModuleInfo, MemRegion) live once in
decant-protocol; the MemoryBackend trait re-uses them directly (decant-backend
re-exports them), so the trait's return types are the wire types. A domain-type change
recompiles both ends at once, with no From/Into marshaling and no drift between
backend and wire. decant-protocol stays light (serde + bincode), compiling
unchanged for the daemon (x86_64-unknown-linux-gnu) and the carafe DLL
(x86_64-pc-windows-gnu). Backend-internal errors (BackendError, a thiserror enum)
stay separate from the wire ProtoError (a plain serde enum), bridged by a single
From at the daemon edge.
Carafe and cellar exchange the primitives over localhost TCP carrying
length-prefixed bincode: a little-endian u32 byte count then a bincode
Request/Response, via write_msg/read_msg over any Read/Write. Wine's Winsock
maps onto host TCP, and the framing tests over an in-memory Cursor. The reader caps
each message at MAX_MSG_LEN (64 MiB), so a corrupt prefix errors rather than
over-allocating, a truncated stream gives UnexpectedEof, and back-to-back messages do
not bleed. bincode is compact and schema-coupled; both ends build from the same
workspace, so cross-version wire stability is not needed. The daemon binds loopback only.
A handful of crates compile only for x86_64-pc-windows-gnu (the interposer cdylib
and the Windows testbins that run under Wine or in the guest); the rest are host code.
members lists all crates, default-members lists host crates only, so a bare
cargo build/test touches the host set and needs no mingw toolchain. The Windows
crates build explicitly with cargo build -p <crate> --target x86_64-pc-windows-gnu
(via xtask), sharing one Cargo.lock and target/. decant-protocol and
decant-client build for both worlds, linking the same wire contract and RPC client into
the daemon and the DLL.
Everything targets x86_64 (guest, Wine prefix, DLL, testbins); there is no i686
target. This gives one calling convention for every intercepted and forwarded export
and undecorated export names (add, not _add@8), and avoids a second WoW64 memory
layout. 32-bit-only tools are not supported.
MemflowBackend (crates/decant-memflow/src/backend.rs) implements MemoryBackend over
a memflow connector:
MemoryBackend |
memflow call |
|---|---|
list_processes |
os.process_info_list() → {Pid(i.pid), i.name.to_string()} |
process_by_pid / _name |
os.process_info_by_pid(u32) / process_info_by_name(&str) |
module_list |
proc.module_list() → {name, base.to_umem(), size} |
module_by_name |
proc.module_by_name(&str) |
module_exports |
proc.module_export_list(&minfo) → (name, base + offset) (RVA→VA) |
read |
proc.read_raw(Address::from(addr), len) |
write |
proc.write_raw(Address::from(addr), data) |
memory_map |
proc.mapped_mem_vec(-1) → CTup3<Address, umem, PageType>; w = PageType::WRITEABLE, x = !PageType::NOEXEC |
read_raw/write_raw return a PartialResult; a paged-out guest page yields a partial
error, surfaced as a hard ReadFailed/WriteFailed rather than silently-truncated bytes.
memory_map permission flags are coarse (page-table derived, not full Win32 PAGE_*).
Pid is u32.
The connector and OS layer are runtime plugins, not linked. Inventory::scan()
discovers the qemu/kvm connector .so and the win32 .os plugin;
inventory.builder().connector(<name>).args(<ConnectorArgs>).os("win32").build() yields
an OsInstanceArcBox<'static>. The only dependency is
memflow = { version = "0.2", features = ["plugins"], optional = true }, no compile-time
memflow-win32. So decant-memflow compiles with no VM, and connect() succeeds only on
the host where the plugins are installed.
Operational facts for running against a guest:
- Two connectors read the same guest. The
qemuconnector (default) reads the qemu process directly through ptrace; it needsCAP_SYS_PTRACEon the daemon (or root), no kernel module, and takes the VM name as its arg (DECANT_CONNECTOR_ARGS=<name>, or empty to auto-detect a single VM). Thekvmconnector reads through thememflow.kokernel module for lower overhead; it needs root and takes the qemu process PID as its arg. Both pass the arg as memflow's default (unnamed) arg; apid=named arg failsError(Connector, ArgValidation). - These arg shapes are not interchangeable. For qemu, run with a VM name:
DECANT_CONNECTOR_ARGS=<vm-name> decant-daemon --backend memflow --connector qemu. For kvm, run with the qemu PID:DECANT_CONNECTOR_ARGS=$(pgrep -f 'guest=<vm-name>') sudo -E decant-daemon --backend memflow --connector kvm. If qemu finds the qemu process and memory map but memflow-win32 then fails withunable to find dtb, the daemon exits before binding its TCP port. That is a connector and Windows-OS-layer startup failure, not a target-process failure. KVM may still work on the same guest, or the operator can provide memflow-win32 hints throughDECANT_OS_ARGS=':arch=x64,dtb=<hex-dtb-without-0x>,kernel_hint=<hex-va-without-0x>'. - The plugin ABI is the integer
MEMFLOW_PLUGIN_VERSION(=1), not the crate version, so amemflow0.2.4 core loads 0.2.1 plugins. MEMFLOW_PLUGIN_PATHmust point at the directory holding thelibmemflow_{qemu,kvm,win32}.soplugins. The daemon resolves the backend before binding the socket, so a connector failure exits with a message instead of a partial server.- Writes should target stable memory (zero padding); a hot heap slot can be reclaimed or rewritten by the guest between operations.
memflow handles take &mut self and are not Sync, while MemoryBackend is &self +
Send + Sync, so the OS handle sits behind a Mutex. The backend caches the resolved
process per pid (an owned os.clone().into_process_by_pid, refreshed on pid change)
rather than re-resolving every read and rebuilding the address translation; the daemon
sets TCP_NODELAY on accepted connections. Together these keep a multi-region scan
interactive. If a write fails after guest-side allocation, the backend clears that cached
process view and retries; large writes fall back to page-sized chunks so newly materialized
pages are addressed through fresh translation state.
Install the plugins on the VM host (x86_64 Linux):
curl --proto '=https' --tlsv1.2 -sSf https://sh.memflow.io | sh # memflowup
memflowup install memflow-qemu memflow-win32 # (+ memflow-kvm for KVM)The QEMU connector reads the qemu process via procfs and needs CAP_SYS_PTRACE
(sudo setcap 'CAP_SYS_PTRACE=ep' <daemon>, or run as root); KVM needs the memflow.ko
module (DKMS) plus a memflow group/udev rule.
The carafe must load into an unmodified tool under Wine and take over the relevant memory exports, binding only to public exports and the PE format (section 11).
Delivery: launcher-driven remote-thread injection. testbins/decant-launcher does
CreateProcessW(target, CREATE_SUSPENDED) → VirtualAllocEx+WriteProcessMemory (the
DLL path) → CreateRemoteThread at kernel32!LoadLibraryA → wait → ResumeThread. The
carafe's DllMain (DLL_PROCESS_ATTACH) self-installs its hooks, so the target stays
unmodified. This is the wine-env/run.sh <tool> entry point.
Interception: Import Address Table (IAT) patching. The carafe walks a loaded module's
PE import directory (DOS header → NT headers → data-directory entry 1 →
IMAGE_IMPORT_DESCRIPTOR array → INT/IAT thunk pairs) and, for each import matching a
target name (e.g. kernel32.dll!ReadProcessMemory), overwrites the 8-byte IAT slot with a
pointer to the carafe's replacement, guarded by VirtualProtect(PAGE_READWRITE) and
restored afterward. It patches the main exe via GetModuleHandleW(NULL) and every other
loaded module via psapi!EnumProcessModules. Only the named slots are redirected; every
other import still points at the Wine builtin, so unimplemented exports forward with no
proxy DLL or export table to maintain. No code bytes are touched, only a pointer table the
loader already built.
Runtime resolution. IAT patching only catches exports a tool resolved at load time.
Tools that resolve the memory APIs at runtime through GetProcAddress, or enumerate
processes through NtQuerySystemInformation (Cheat Engine among them), would bypass the
patched slots. The carafe widens the surface, still binding only to public exports:
GetProcAddressredirector.GetProcAddressis patched like any other export. The hook returns the carafe's replacement for any name it interposes and forwards every other name (and all ordinal lookups) to the originalGetProcAddress. The export-name set the IAT installer patches and the set the redirector recognizes come from one macro list (interpose_exports!), so they cannot drift.NtQuerySystemInformationsynthesis. ForSystemProcessInformation, the carafe builds theSYSTEM_PROCESS_INFORMATIONlist from the daemon's process list, writing only the documented, x64-stable field subset (NextEntryOffset,UniqueProcessId,ImageName) and honoring the two-callSTATUS_INFO_LENGTH_MISMATCHsize negotiation. Other classes forward.- Alternate paths.
NtOpenProcess,NtGetNextProcess,Toolhelp32ReadProcessMemory, and theNtQueryInformationProcessimage classes are served the same way.
NtQueryInformationProcess(ProcessBasicInformation) returns the requested
PROCESS_BASIC_INFORMATION with the pid filled in. The synthetic-handle path returns a
PEB base of 0 because the daemon's default mock-shaped response does not need it; the
guest injection domain reads the real PEB through memflow's EPROCESS walk
(crates/decant-inject/src/guest.rs::read_peb), so guest PEB-walking features are
serviced by the guest injection domain, not by the synthetic handle.
Region walk. A scanner queries VirtualQueryEx/NtQueryVirtualMemory upward from a
low address. The hooks return committed regions from the daemon's memory map and span the
gaps as MEM_FREE, so the walk advances past them instead of stalling at address 0. Each
region reports State, Type, and Protect derived from the guest page tables and
module list: a region overlapping a loaded module reports MEM_IMAGE, others
MEM_PRIVATE. MEM_MAPPED is not distinguished, reserved uncommitted memory is not
enumerated, and copy-on-write and guard sub-flags are not reported, so a default scan over
all types is unaffected while a Type- or Protect-filtered scan may differ from native.
The map is cached per pid for the walk to avoid a round trip per query. A scan then reads
each region through the marshaled ReadProcessMemory in one request per caller read,
passing the requested size through rather than paging slot by slot.
Protection changes. A tool that edits a page it sees as read-only typically calls
VirtualProtectEx (or NtProtectVirtualMemory) to grant write access, writes, then
restores the prior protection. The carafe services both for a synthetic handle: it returns
success and reports the page's current protection from the region map as the prior value,
without changing anything. No change is needed because the write is marshaled to the daemon
and applied by memflow to guest physical memory, which translates the address through the
page tables and writes the underlying physical page directly; the guest's virtual page
protection does not gate it. So a write to a read-only page lands whether or not the
protection call succeeds. For a real handle both forward to the original export.
Alternatives that do not apply on Wine. AppInit_DLLs does not load the DLL:
kernelbase!LoadAppInitDlls is a no-op stub on Wine (its body is test [dbg_flag],8 /
ret), and nothing invokes it during process init. A WINEDLLOVERRIDES proxy must
re-export the entire shadowed surface and only works for an incidental import
(DXVK/ReShade style), not the early/KnownDLL-class kernel32/ntdll loads a memory tool
depends on. Inline-hooking the Nt* prologues would tie the carafe to a specific Wine
build (section 11). Remote-thread injection plus IAT patching is the one mechanism that
interposes an unmodified tool on stock Wine using public exports and the PE format only.
Decant is usable three ways: embed a backend in-process (as memflow is used), connect a
Client to a running daemon, or drive the CLI. decant-client holds Client (lazy
connect, reconnect-once, typed methods); depending only on decant-protocol and
thiserror, it builds for host and windows-gnu and is shared by the CLI, the interposer's
rpc module, and library users. The decant crate re-exports the backend trait,
MockBackend/MockGuest, the scanner and resolver, MemflowBackend (behind the
memflow feature), and Client behind a prelude. The CLI adds --json.
The carafe binds to one boundary: the public Win32/NT export ABI, the named functions
a Windows DLL exports (kernel32, ntdll, psapi, …) with their documented signatures.
Every Windows program depends on it, so Wine keeps it stable across releases. The carafe
intercepts a handful of memory and introspection exports (ReadProcessMemory,
WriteProcessMemory, NtReadVirtualMemory, VirtualQueryEx, toolhelp/psapi enumeration,
GetModuleHandle/GetProcAddress, …) and forwards the rest to the Wine builtin, so a new
Wine version needs no recompile. The x86_64-only target reinforces this with one calling
convention and undecorated names.
The carafe prefers the public Win32/NT export ABI (the named functions a Windows DLL
exports, kernel32, ntdll, psapi, ... with their documented signatures) for every
interception. Every Windows program depends on it, so Wine keeps it stable across releases.
The carafe intercepts a handful of memory and introspection exports (ReadProcessMemory,
WriteProcessMemory, NtReadVirtualMemory, VirtualQueryEx, toolhelp/psapi enumeration,
GetModuleHandle/GetProcAddress, …) and forwards the rest to the Wine builtin, so a new
Wine version needs no recompile. The x86_64-only primary target reinforces this with one
calling convention and undecorated names.
The following Wine internals are bound to only when no public-export equivalent exists for a capability the project needs, and each such binding is gated on a version-detection shim:
__wine_unix_call/ the unixlib (PE↔Unix) boundary. Wine's private path for a builtin's PE side to call its.soUnix side; bound only behind a syscall-resolution path that scansntdllfor the syscall number table.- The wineserver IPC protocol. Bound only when the public-export process/module enumeration is insufficient; the project otherwise sources facts from memflow.
- Internal cross-DLL import paths. Bound only to reach a builtin's non-exported helper when a public export cannot cover the operation.
- Syscall-dispatch thunks / the internal syscall table. Bound when raw-syscall
interception is required; the syscall number is resolved by pattern-scanning
ntdllrather than assuming a fixed Wine layout.
The shipped interposition (IAT patching plus the GetProcAddress redirector) works
unchanged across Wine versions. Inline-hooking the Nt* prologues is added behind a
version-detection shim that scans the prologue bytes per Wine build, so per-version
revalidation is automatic. Inline hooks are bound only when an IAT patch cannot express
the capability (e.g. a non-imported Nt* routine called via a function pointer).
A call by name is covered whether resolved at load time (IAT patch) or at runtime through
GetProcAddress (section 9). A raw syscall, with the syscall number in a register
and syscall/int 2e executed directly, never goes through a named export; the project
intercepts these by resolving the syscall number from ntdll's stub bytes and installing
an inline hook at the syscall instruction. Coverage of raw syscalls is therefore
automatic, not best-effort.
Section 9 fixes one delivery mechanism into the tool. Injection is now a capability behind a single trait, so a user can pick a shipped method by config or supply their own against a stable boundary. The harness and its verification contract are the durable interface; injectors are implementations of that interface.
An Injector performs only the load: inject(&InjectionRequest) -> Result<LoadInfo, InjectError>. The harness owns spawning the target suspended and resuming it; an injector
never resumes. The request carries both a carafe_path, for loader-based methods that hand a
path to LoadLibrary, and the raw carafe_image bytes, for manual-map methods that
reimplement the loader, so one request type serves both method classes. Every method reports a
Portability tier: PublicExportsOnly (the standard method, upholding the section 11
guarantee), LoaderInternals (manual map), or PrologueBytes (thread hijack or inline). The
tier is logged at startup, and any method below PublicExportsOnly prints a notice that the
run opts out of the cross-version portability guarantee.
Load is confirmed by the carafe signaling a named sync primitive, the ReadyToken, from
DllMain after its hooks are installed, never by enumerating the target's module list. A
manual-mapped image is deliberately absent from the loader module list, so external
enumeration confirms LoadLibrary and silently fails for manual map. The harness creates the
token before injection, passes its name in, waits with a timeout, and resumes the main thread
only on success; timeout or signal failure kills the target and exits non-zero. This makes
verification identical for LoadLibrary, manual map, thread hijack, or APC: if the carafe is
alive and hooking, it reports. Waiting on the token stays in the harness, so a plugin cannot
report "hooks installed" on its own.
Pre-made injectors are selected by config: [injection] method = "standard" | "manual-map" | "thread-hijack" | "plugin" | "external" with timeout_ms. The standard method is the current
LoadLibraryA remote-thread path. The manual-map method consumes carafe_image, maps a PE32+
image into the suspended tool process, applies base relocations, resolves imports, runs TLS
callbacks, protects sections, and calls the DLL entry point. Because that image is not registered
with the Wine loader, the ready-token signal is the only success criterion.
The thread-hijack method rewrites the suspended main thread's instruction pointer to a small
loader stub. The harness resumes the thread into that stub, waits for the carafe's ready signal,
then sets a release event; only then does the stub restore the saved register state and jump to
the original instruction pointer. This method reports PrologueBytes portability because it
lands on injected code bytes, even though it still uses public exports for the loader calls.
Bring-your-own is a PluginInjector that loads a user cdylib exporting decant_inject against a
versioned #[repr(C)] ABI (DECANT_INJECT_ABI); the host resolves the export, checks the ABI
version, marshals the request, and maps the result back. The plugin returns once the carafe's
DllMain is reached; the harness still owns the ready-token wait.
Injection primitives act on a Windows process handle inside a wineserver prefix, so a
bring-your-own injector cannot be an arbitrary Linux program; it must run PE-side, in the same
prefix, to share the handle namespace. This stays agnostic in the way that matters: any
toolchain targeting Windows, or any existing Windows injector, qualifies. PluginInjector
error messages state this constraint directly.
A fourth method, external, hands the load to a user-configured command that runs out of
process. The harness spawns the command and writes one length-prefixed frame to its stdin:
the target PID, the carafe path, the carafe image bytes, and the ready-token name. The command
reopens the target itself with OpenProcess(pid) and performs the load, using the path for a
loader-based load or the bytes for a manual map. A PID crosses the process boundary where a
handle would not, since a handle is valid only inside the harness that created it. The command
runs PE-side in the same prefix for the handle-namespace reason above, reports PrologueBytes
portability because its mechanism is opaque to the harness, and is verified by the same
ready-token wait: the harness resumes only after the carafe signals, whatever the command did.
The shipped decant-external-standard reads this frame and delegates to the standard load.
Configuration selects the method and carries the two boundaries' parameters. The launcher reads
a TOML file named by DECANT_CONFIG; an absent file is the standard defaults, a malformed one
is an error.
[injection]
method = "standard" # standard | manual-map | thread-hijack | plugin | external
timeout_ms = 5000 # ready-token wait before the harness kills the target
plugin_path = "my_injector.dll" # required for method = "plugin"
external_command = ["my_inject.exe", "--flag"] # required for method = "external"No-guest-software DLL mapping uses the same config model with domain = "guest". The guest
target can be a concrete pid, or a process name plus process_pattern; the optional pattern
is a generic hex signature with ?/?? wildcards and is resolved by the daemon immediately
before injection.
[injection]
domain = "guest"
method = "manual-map"
timeout_ms = 10000
[guest]
process = "target.exe" # or: pid = 1234
process_pattern = "44 45 ?? 41"
stage_pattern = "44 45 43 41 4E 54 3A 3A 53 54 41 47 45 30 30"
result_pattern = "44 45 43 41 4E 54 3A 3A 52 45 53 55 4C 54 30 34"
payload_path = "payload.dll"
allocation = "virtual-alloc"
dependency_policy = "require-loaded"
tls = "callbacks-only"
final_protections = "section"
loader_metadata = "reject-unsupported" # reject-unsupported | best-effort | allow-unsupported
call_stack = "native" # native | registered-unwind
permission_transitions = "standard" # standard | write-through-final
thread_starts = "existing-thread" # existing-thread | require-module-backed
image_backing = "private" # private | sec-image
vad_spoof = "off" # off | vad-image-map
hook_module = "kernel32.dll"
hook_function = "Sleep"
[guest.execution]
method = "iat-hook"
timeout_ms = 10000The memflow guest injection backend handles PE32+ x64 images, DIR64 relocations, ordinary and
delay imports, ordinal imports, API-set fallbacks, forwarded exports for modules already loaded
in the target, loader-driven dependency loads through the target's LoadLibraryA and
GetProcAddress, and section-derived final page protections. With the default
final_protections = "section" path, it allocates RW memory, writes the image, applies
PE-derived permissions, then calls TLS callbacks and DllMain; rwx remains an explicit
compatibility mode. loader_metadata = "best-effort" registers x64 unwind metadata through guest
RtlAddFunctionTable and seeds the load-config security cookie when the mapped image exposes the
default cookie slot. It represents loader-private state with Decant-owned metadata rather than
handing ownership to LdrLoadDll. When loader_entries = "synthesized", Decant allocates a
static TLS slot via TlsAlloc, patches the index into the image buffer, copies the TLS template,
writes per-thread template copies for existing direct TLS slots, and threads an optional
TlsSetValue call through the DllMain thunk so remote-thread, thread-hijack, and apc enter
the payload with the slot installed on the executing thread. For payloads with load-config metadata,
loader_metadata = "best-effort" and loader_entries = "synthesized" also request
a CFG valid-call-target mark for the entry point and exported function RVAs via
SetProcessValidCallTargets. Broader CFG/load-config metadata is still not synthesized.
call_stack = "registered-unwind" registers x64 unwind metadata for the IAT-hook stub and uses a
single framed stack allocation so stack walking can unwind through the stub; it does not, by
itself, spoof caller frames or shape the stack to impersonate another call path.
permission_transitions = "write-through-final" allocates with final-ish image permissions,
materializes demand-zero pages by read-touch when possible, writes through memflow, and skips
final protection calls whose target protection already matches the allocation protection. It
checks critical writes by reading them back; the allocation/write/protect sequence is still
observable. thread_starts = "require-module-backed" keeps the
IAT-hook path on an existing target thread and verifies that the IAT slot, original import target,
and staging cave are inside loaded module ranges; it verifies module-backed hook plumbing but does
not inspect payload entrypoints or helper calls. For remote-thread, require-module-backed
requires a payload-image executable code cave for the ThreadProc thunk and refuses to fall back to
a temporary thread start.
image_backing = "sec-image" stages the payload as a guest temp file and maps it through
CreateFileMappingW(SEC_IMAGE) + MapViewOfFile(FILE_MAP_COPY), so the
executable region starts as a real kernel-created image-file section rather than private
committed memory; Decant then applies relocations, imports, delay imports, the load-config
security cookie, TLS callbacks, and DllMain on top of that view. The section object and
image-file VAD backing are produced by the NT memory manager through public guest exports, not
forged. Pages Decant patches
(imports, security cookie, IAT) become copy-on-write private pages, while unpatched pages remain
file-backed, so the section object is real but the modified view is not identical to the on-disk
image. sec-image requires
allocation = "virtual-alloc" for helper buffers and final_protections = "section", because an
image-file-backed mapping uses PE-derived page protections rather than a single RWX region. The
default iat-hook execution path snapshots the configured IAT slot plus stage/result bytes,
patches them, lets one target thread run the requested function, reads the return value, and
restores on success, timeout, or error. The result block is temporary call scratch for this path,
not a payload success marker. The guest stub does not write the IAT slot from inside the target;
the host transaction owns restoration because it can write through page protections via memflow.
For VirtualAlloc mappings, Decant then executes a second IAT-hook call that writes one byte per
allocated page, materializing demand-zero pages before the host writes the mapped image. This path
is import-triggered: execution occurs when the target calls the configured import. The
remote-thread execution path still uses the IAT-hook trampoline to call public exports, but
creates the guest thread by calling CreateThread from inside the target process. The new thread
starts at a ThreadProc thunk, and that thunk calls
DllMain(hinst, DLL_PROCESS_ATTACH, reserved) with the proper x64 calling convention. When the
payload image has a large enough executable code cave, the thunk is placed there so the recorded
thread start is inside the mapped image. thread_starts = "require-module-backed" makes the
payload-image placement mandatory.
The thunk writes DllMain's return value and a completion marker into scratch memory, which the
host polls through the backend; it does not need a second target import call to wait for the new
thread. Remote-thread launch helper calls use native stack handling even when
stack_shaping = "spoofed" is selected.
thread-hijack enumerates guest threads through EPROCESS/ETHREAD walking, opens the selected
thread, suspends it through guest SuspendThread, captures and rewrites its x64 context, and
resumes it at a DllMain thunk. The thunk installs optional TLS, calls DllMain, writes the
completion marker, and jumps back to the original RIP. apc queues the same DllMain thunk through
QueueUserAPC; completion is observed through the thunk scratch block once the target thread
enters an alertable wait.
Operators should provide stage_pattern or stage_base for executable stub bytes
plus result_pattern or result_base for a writable scratch slot; without them, auto-selection
is limited to memory that passes those separate permission checks. The strict
loader_metadata = "reject-unsupported"
default fails payloads that require metadata outside the selected policy; best-effort registers
the public runtime metadata Decant can safely express, including static TLS slot allocation when
loader_entries = "synthesized", and allow-unsupported skips the guards only for payloads that
do not rely on the corresponding loader registration. The schema parses thread-hijack, apc,
package/session selectors, and static TLS registration. Support for each is tracked by
GuestCapabilities (section 13), and the memflow backend advertises the x64 guest paths it can
execute.
The mapper reports, but does not hide or synthesize away, manual-map artifacts. Successful
GuestLoadInfo.notes include artifact audit: entries covering private or SEC_IMAGE-backed image
memory, loader/module metadata state, PE metadata handled by Decant
instead of LdrLoadDll, section-derived versus explicit-RWX permissions, loader metadata status
for TLS/unwind/load-config records, call-stack policy, permission-transition policy,
thread-start policy, image-backing policy, and the selected execution path. When requested,
Decant can synthesize partial, transient PEB loader-list entries. For private mappings,
vad_spoof = "vad-image-map" mutates the matched existing VAD type; it does not create a
section object. With image_backing = "sec-image" the section object and image-file VAD backing are real
kernel-created state produced through public guest exports, not forged. stack_shaping = "spoofed" is limited to writing a synthetic return address for selected helper/payload calls; it
does not synthesize a full caller chain or normalize arbitrary stacks. Decant does not hide all
allocation/write/protect observability. It does not synthesize or rewrite kernel thread-start
metadata beyond what thread_starts = "require-module-backed" validates.
vad_spoof = "vad-image-map" walks EPROCESS/VadRoot through memflow's out-of-band physical
access and rewrites the matched MMVAD's u.VadType to VadImageMap so the region reports
image-file backing; the offset probe covers both the Win10 19041+ (eproc_vad_root=2008)
and 18362 (1624) layouts. The supported way to obtain a real image-file section object
is image_backing = "sec-image", which asks the guest memory manager to create that state.
The tracked guest fixture lives in testbins/guest-inject-fixture/native/. xtask guest-inject-fixture builds a CRT-free target EXE with separate executable stub bytes and a
writable result block, plus a payload DLL that contains a real DIR64 relocation. The xtask fails
if that relocation disappears, because fallback-base manual mapping must exercise relocation
application. scripts/decant-test.sh guest-fixture builds the same artifacts, starts or reuses a
memflow daemon, locates the target by its fixture marker, injects the payload bytes, and checks
the fixture payload's marker update as a test assertion. Normal guest injection does not add a
marker or depend on the target reporting success.
Fate-style UWP support maps to access and selection policy rather than a distinct mapper:
loader-based PE-side methods must grant the AppContainer SID (for example S-1-15-2-1) read and
execute access to a path-loaded DLL before LoadLibraryW can open it. Private guest byte
manual-map does not require a guest-visible DLL path; image_backing = "sec-image" stages a
temporary guest file to obtain a real image section, and loader-style methods use guest-visible
paths as well.
The cdylib boundary is a versioned C ABI. A plugin exports decant_inject_abi() -> u32
returning DECANT_INJECT_ABI and decant_inject(*mut DecantInjectRequest) -> i32 (0 on
success); the host resolves both, rejects a version mismatch or a missing export with a clear
error, marshals the request, and reads back out_remote_base. The ABI constant is bumped on any
layout change to DecantInjectRequest. The external boundary is the stdin frame above, so a
bring-your-own external command needs no linkage against Decant, only agreement on the frame.
Both boundaries share the same PE-side constraint: an injector acts on a process handle inside a
wineserver prefix, so it must run as a Windows image in that prefix, not as an arbitrary Linux
program. Any toolchain targeting Windows qualifies.
The Windows-only decant_inject::sdk module exposes the public-export primitives used by the
shipped methods: remote allocation, read, write, protection changes, remote thread start and wait,
remote LoadLibraryA, remote GetProcAddress, and a small remote-call helper. Plugin authors can
compose APC or thread-control methods against this surface without binding to Wine internals.
The single source of truth for what a GuestMemoryBackend can express is
GuestCapabilities (crates/decant-inject/src/guest.rs). Each field names a discrete
capability (thread enumeration, TEB read, get/set context, suspend/resume, hardware
breakpoints, APC, queue APC, create thread, static TLS, forwarded exports, and so on).
GuestManualMapInjector consults missing_manual_map and refuses to start a run whose
backend cannot express the requested policy; GuestInjectError::Unsupported carries the
specific kernel-structure offset or IAT-hook sequence that has not landed for the active
backend. The memflow backend advertises the x64 guest methods it can execute, including
remote-thread, thread-hijack, and apc; external-agent remains a different integration
surface rather than a memflow guest execution method.
The parity program tracks BlackBone's surface item by item against this struct; the goal is that every BlackBone capability has a decant analogue or an explicit reason it is replaced by a memflow-side equivalent (kernel-mode operations, raw syscalls, inline covered by a separate sub-target). The structure-offset probes (EPROCESS VadRoot, EPROCESS ThreadListHead, ETHREAD TrapFrame, KTRAP_FRAME DR0-DR7) cover the Win10 19041+ and 18362 layouts and are extended as new build offsets are confirmed.