Sign in

Emulator

Exactly one run in these 83 bytes is printable long enough to register as a string, and it is m<i;k9c?Z.

00401000: 48 be 31 10 40 00 00 00 00 00 b9 22 00 00 00 eb  H.1.@......"....
00401010: 00 8a 06 34 5a 88 06 48 ff c6 83 e9 01 83 f9 00  ...4Z..H........
00401020: 74 02 eb ed 48 b8 31 10 40 00 00 00 00 00 ff d0  t...H.1.@.......
00401030: f4 99 15 0a 1f 14 18 13 14 1b 08 03 05 0f 14 0a  ................
00401040: 1b 19 11 05 0a 08 15 15 1c 05 6d 3c 69 3b 6b 39  ..........m<i;k9
00401050: 63 3f 5a                                         c?Z

The first 49 bytes are code. movabs rsi, 0x401031 loads the address of everything after them; mov ecx, 0x22 counts out 34 bytes; an eight-instruction loop reads a byte (8a 06), XORs it with 0x5a (34 5a), writes it back (88 06), advances (48 ff c6), decrements the counter (83 e9 01), tests it against zero (83 f9 00) and branches (74 02, eb ed); then movabs rax, 0x401031 / call rax transfers control into the buffer it just rewrote. Interpreting those instructions — never executing them on a CPU — produces this at 0x401031:

00401031: c3 4f 50 45 4e 42 49 4e 41 52 59 5f 55 4e 50 41  .OPENBINARY_UNPA
00401041: 43 4b 5f 50 52 4f 4f 46 5f 37 66 33 61 31 63 39  CK_PROOF_7f3a1c9
00401051: 65 00                                            e.

A leading 0xC3 — a ret, so control does return — followed by a string that was never in the file. Both halves are asserted by the same regression test: first that a raw scan cannot see the marker, then that emulation recovers it. Strings decrypted at startup, API names resolved from hashes instead of import tables, configs held as encrypted blobs — every one of these is built so that static pattern-matching reads the wrong bytes.

The model

The emulator interprets lifted IL instead of executing machine code: one path, integers only, inside a step budget, with every store landing in a per-run memory overlay rather than a process. It is reached for at sites where constant propagation already failed — a jump-table base, a syscall number, a dlopen argument — and it recovers bytes a decryptor writes at runtime.

QuestionSection
How is code that rewrites itself followed?Self-modifying code and re-lifting
How do hashed API constants become names?Hashed-API resolution
What starts a run, and when?Callers and drivers
What can a run model, and what stops it?The interpreter, Modeled library calls
How far may a run go before it is cut?Budgets
What counts as a usable result?The output gate
What has it actually recovered on real samples?Real-corpus measurements
Which analysis paths never run it at all?Coverage gates and unread outputs
How are family configs recovered?AmosStealer config extraction, Config plugins

Self-modifying code and re-lifting

Control transferred into bytes the run itself wrote is recorded as its own outcome, independent of anything the recovered bytes decode to. The interpreter watches for a jump or call whose target address lands in bytes this run itself wrote to memory, re-lifts those bytes to fresh instructions, and continues executing inside them. The re-lift byte source is constrained to be 100% memory-written bytes — never blended with on-disk or uninitialized ones — and a re-lift producing no usable instructions is rejected before execution continues into it.

BoundValueApplies to
Re-lift hops4an ordinary run
Re-lift hops16the once-per-binary entry-point run
Re-lifted bytes16 KiBacross a whole ordinary run
Minimum prefix before a hop4 bytesany hop

The raised entry-point ceiling exists because a packer nested inside a packer is the expected shape there rather than a bonus. That hop count is the primary defense against a "decrypt one instruction, jump, repeat" loop, because every hop targets a fresh address and no cycle-detection memo can catch it. The byte budget and prefix minimum bound the same loop far more loosely — 16 KiB at a 4-byte minimum prefix is 4,096 hops.

Hashed-API resolution

Hash constants are turned into names by replaying the sample's own hash arithmetic over a catalog of export names — without ever naming the algorithm and without touching a real DLL. Hash-based API resolution replaces the import table with arithmetic: rather than importing VirtualAlloc, the code walks export tables folding each name through a rolling hash and calls whichever one matches a constant. Zero imports, zero strings, one indirect call.

A structural pass finds the shape — a self-looping block folding name bytes through rotate/xor/add/and/shift/multiply operations, guarded by an equality test against a wanted constant whose branch leads to an indirect call traceable back to a table read. That guard-plus-table-read requirement is the firewall: a checksum loop with neither yields nothing.

Exactly one control-flow shape is recognized; four other real ones are known and yield nothing rather than a guess:

Hash-loop shapeHandled
A single self-looping block walking a NUL-terminated name one byte at a timerecognized — the only shape
A two-block loopyields nothing
A combined module-plus-function hashyields nothing
A length-prefixed name streamyields nothing
The resolve(hash) call site, where the wanted constant sits at the call rather than in a guardyields nothing

The guard-to-call trace is bounded at 3 blocks, the def-use walk behind it at 6 levels, and a candidate name at 64 bytes.

The catalog is narrower than the mechanism: one module, 103 hand-seeded kernel32 export names covering the loader and process-injection chain, not a general Windows API surface. Everything outside those 103 comes from a second, always-on source — the sample's own import, export and dynamic-symbol names plus the printable strings in its data sections.

Running the extracted arithmetic across those names builds a reverse map. For a 32-bit rotate-13 accumulator, the values that come back are:

0x91afca54 -> VirtualAlloc
0xd83d6aa1 -> WriteProcessMemory
0x72bd9cdd -> CreateRemoteThread
0xec0e4e8e -> LoadLibraryA
0x7c0dfcaa -> GetProcAddress

The first three are the process-injection chain. Four negative controls are what make the map usable:

CaseOutcome
A hash matching no catalog nameresolves to nothing
A hash colliding to two different namesresolves to neither, rather than picking one
More than 1% of the distinct names hashed landing on a value some other name also producedthe extracted arithmetic is judged mis-extracted and the entire map is discarded — a real name hash is injective in practice, so widespread collision means the recurrence was read wrong. Over a 103-name catalog this check is close to vacuous; it earns its keep as the catalog grows
The site-finder run over a real build of curl for Windows — 1,000-plus lifted functions, dense with rotate and checksum arithmeticexactly zero resolver sites, asserted. Fabricated call-graph edges on benign code would inflate every reachability-driven detector downstream, so this false-positive gate is a hard assertion, not a metric

Resolved names are spliced into the call graph as named edges tagged distinctly from real imports, alongside the original unresolved indirect edge rather than replacing it, so reachability and taint passes traverse VirtualAlloc where the bytes said 0x91afca54. This runs on PE only. The Mach-O and ELF paths hardcode an empty result.

A second PE-only pass covers the resolved pointer that is never called at the resolution site — a beacon commonly stashes it in a runtime-built dispatch table, a struct field or an array slot, and calls through that table later, often from a different function. Value-set analysis assigns each resolved name a synthetic token, propagates it through the store, and reconnects the eventual indirect call to the named API. Off unless a deep-analysis run opts in.

Callers and drivers

No probe pass runs the emulator speculatively. Each of the seven below reaches for it only at the site where constant propagation already failed to fold a value, and each falls back to its structural classifier when the emulator returns nothing. The extraction runner is the exception: it picks candidates by shape and runs them without any failed fold to trigger it.

PassWhat it asks for
Jump-table lifting (AArch64)where the dispatcher's table base is, when the compiler buried it behind arithmetic — the x86-64 lifter resolves its own jump tables by pattern alone and never calls the emulator
Syscall resolutionthe service number in the register just before a svc, so the call becomes a named callee
Dynamic-loader resolutionthe string argument to dlopen, dlsym, NSClassFromString, objc_getClass, sel_registerName — including the chained-load shape a pattern match cannot reach
Argument promotiona length or mode constant loaded from read-only data, or built by in-block arithmetic
Format-string prototypesthe format string at a variadic call, and therefore its argument shape
Behavioral signaturesresolved call-site arguments
Indirect-call fallbackthe target of a computed blr / call rax, turning a dead-end site into a call-graph edge

Four drivers start runs: two whole-binary sweeps over those probe sites, and two that run the interpreter for its own sake.

DriverSeeded fromSelection and bounds
Mach-O surface sweepthe probe sites above, in fixed input orderone shared ledger across the sweep
ELF / PE image-edge sweepthe probe sites above, in fixed input orderone shared ledger across the sweep
Extraction runnercandidate functions picked by shape alone — at least one loop, at least one load, at least one store, at least one bitwise or arithmetic operation, and no more than 512 basic blocks512 candidates per binary; follows calls up to four frames deep, over a pre-lifted closure capped at 256 functions and 8,192 basic blocks
Entry-point unpackthe real process entry point; candidate selection skipped entirelyone run per binary, no ledger; descends up to 128 frames over a closure capped at 256 functions and 8,192 basic blocks; lifts a 64 KiB window from the entry point, clamped to whatever remains in the covering segment

The shared ledger and fixed input order mean one pathological function cannot starve the rest of the binary, and the set of sites that get cut is reproducible. The 512-block ceiling on candidates is there because a giant function is not a cheap decrypt loop.

The interpreter

Lifted IL is the input — the same intermediate representation every architecture lifter emits, so the interpreter is format- and architecture-agnostic by construction.

Single path, integer only. No path forking, no symbolic execution, no floating point. Seven stop reasons exist and every run ends in one; the extraction driver keeps all seven apart rather than collapsing them, so telemetry separates "genuine self-modifying code, cut by budget" from "diverged for an unrelated reason":

OutcomeCause
Concrete resolved valuethe probe computed the value it was asked for
Clean returnthe run reached a terminating return with a value — the strongest stop reason, but not a precondition for surfacing bytes
Fall-offthe outermost frame ran off the end of a block with no terminator — a clean stop, not a failure
Budget exhaustionthe step budget ran out
Deadlinethe wall-clock deadline was crossed, on the one driver that sets one
Self-modifying-code budget exhaustedthe re-lift seam hit its hop or byte ceiling
Unresolvable loada load missed the stack slab, the overlay, the heap slab, and every segment
Divergenceany of the causes below

Divergence fires on:

Divergence causeWhy
A syscall instructionreturn conventions are not modeled, so it diverges before the syscall takes effect — the only intrinsic that ends a run
A condition test with no live concrete comparison behind it, or one whose last flag-setting operation was a compare-negative or conditional comparethose set the same flag bits by a different derivation, which is not implemented
A return with no valuea void return has no concrete answer to give
An expression tree nested deeper than 256 levelsan unbounded recursive walk was measured overflowing the stack at over 1 GiB of frames on a real corpus binary
A 128-bit binary or unary operationthe value model is 64-bit, so an XMM-width operand has no faithful reading
A floating-point constant
An unresolved branch or call
A store whose address does not fold at all
A library-call handler that declines, a self-modifying-code re-lift that declines or exhausts its budget, a call-depth overflow on entry to re-lifted code

A block that runs off its end inside a callee frame is a divergence; in the outermost frame it is the clean fall-off above, because real compiled code routinely has that shape at a loop's init-to-condition edge.

Copy-on-write memory. A store to a provably concrete address lands in a per-run overlay; a load reads the stack slab, then the overlay, then the heap slab, then the read-only image, in that order. That ordering is what lets an in-place decryptor read back the plaintext it just wrote. The heap slab is real memory, not a stub: the allocation hooks bump-allocate from a reserved arena base far above any real image.

A store whose address or value is merely unknown — rather than non-foldable — is dropped silently and the run continues, as is a store wider than 8 bytes. The overlay never holds a fabricated byte; a later load of that address misses. Honest under-recall, never a wrong byte.

No silent zeros, but not by a taint flag. A load that misses the stack, the overlay, the heap and every segment does not quietly become zero. Both production drivers permit unknown operands, so such a load evaluates to Unknown — and an Unknown can never be materialized as a byte, stored to the overlay, or used to decide a branch. The zero-fill-and-flag policy the output gate's taint check is written against exists in the code but no driver selects it, so that gate stage never fires in production.

Condition codes replayed, not guessed. A flag test is resolved by replaying the most recent concrete flag-setting operation through the full 17-code condition table. A comparison the table cannot read soundly fails closed rather than inventing an edge.

Host safety. Integer and memory operations are modeled; every other intrinsic — ARC, pointer authentication, Objective-C dispatch, atomics, NEON, memory barriers — is unmodelled but not fatal: its effect is not performed and its destination register is set to Unknown rather than left holding a stale value. Resolved control flow is confined to the image by call-resolver map membership, and re-lifted control flow by the requirement that the target bytes be 100% overlay-written. A standalone jump-target allowlist predicate exists but has no production caller. Every mid-run re-lift invocation — a decoder called on attacker-chosen bytes — runs inside a panic catch. The four files that touch attacker-supplied bytes most directly — the extraction runner, the output gate, the re-lift seam, and the library-call handlers — are asserted to reach no host I/O by a test that greps their own source text for std::fs, std::net, std::process, libc::, and unsafe. That is a source-scan over four files, not a proof over the whole interpreter.

Modeled library calls

Forty-one exact symbol names are recognized; every other call falls through to interprocedural descent or divergence. Matching is string equality with no demangling and no prefix rule. Thirty-one of the names do real work:

GroupCountSymbols
Memory6memcpy, memmove, memset, bzero, memchr, memcmp
Allocation6malloc, calloc, realloc, free, HeapAlloc, VirtualAlloc
Strings6strlen, strcpy, strncpy, strcat, strcmp, getenv
Encoding4base64 and base32, each under two spellings
Cipher2RC4, under two spellings
Loader7LoadLibrary in four spellings, GetProcAddress, dlopen, dlsym

A test pins the exactness: my_memcpy, memcpyPadded, MEMCPY, and Memcpy all fall through unrecognized.

A handler's arguments are read from registers, and their freshness is not checked. At most four integer arguments are taken from the caller's own ABI parameter registers, stopping at the first register the caller never wrote. Lifted call operations carry no argument expressions, so nothing short of a full liveness analysis distinguishes a register this call site just populated from a stale-but-concrete value an unrelated earlier operation left there. A four-argument handler such as RC4 invoked at a call site that sets up only two can therefore bind a wrong key length or data length and produce a plausible-wrong decrypt; two regression tests pin that shape. The only backstop is downstream: the output gate still rejects a decrypt that does not clear it.

On stack-argument ABIs the hooks are inert. An x86-32 stdcall or cdecl caller passes everything on the stack, so the register argument pool is empty and every handler diverges rather than reading the wrong location. Honest, but it leaves the whole hook table unavailable on 32-bit Windows samples, where packer stubs are common.

The loader hooks can also collect evidence, on one path. LoadLibrary records a sentinel handle against the module name the program passed it; a later GetProcAddress on a known handle records module!symbol. An unresolvable module or unreadable name string contributes nothing rather than a guess. Capture is opt-in: only the entry-point unpack driver turns it on, and what it captures feeds the entry-point dump. The extraction runner collects no names at all.

The remaining ten names are the anti-analysis set, which exists so a packer stub's self-defense does not end the run:

CallModeled as
IsDebuggerPresent0 — not being debugged
CheckRemoteDebuggerPresentwrites FALSE to the out-param, returns success
GetTickCount / GetTickCount64 / timeGetTimea fixed 0x1000 sentinel
QueryPerformanceCounterwrites the same fixed 0x1000 sentinel
Sleep / SleepExno-op, no elapsed time
IsProcessorFeaturePresent0 for every feature ID, so a caller takes the portable fallback path
NtQueryInformationProcessdeclined — a hard stop, never a fall-through

The sample's anti-debug questions get the answers an undebugged machine would give, and its sleeps are free. The tick sentinel is constant, so a stub timing itself across two calls measures zero elapsed milliseconds and takes whichever branch that implies. NtQueryInformationProcess is the deliberate hole: its behavior depends on an information class with too many shapes to model without risking a confident wrong answer, so a caller branching on it diverges exactly as an unhooked call would.

Budgets

How a run stopped does not decide whether its bytes surface. A decoder loop can finish decrypting a whole buffer and then hit the step budget, the deadline, or an unrelated divergence two blocks later without ever reaching its own return; requiring a return dropped every one of those however complete the written bytes were. A run that budget-cut, deadline-cut or diverged after writing a genuine buffer is scanned exactly like one that returned; the byte-level output gate decides. The risk that leaves — a run cut mid-buffer — is caught by the gate's minimum length and diversity floors: a regression test pins a budget cut after one readable byte producing nothing.

One step is one lifted IL operation, not one machine instruction. Three tiers exist:

TierStepsUsed by
Default probe4,096the analysis passes that ask for one value
Extraction262,144a candidate decrypt loop
Entry-point unpack64,000,000one run per binary, seeded at the real entry point

Above those sit ledgers, debited by what each run actually executed. A ledger is per sweep, not per binary, and one binary carries several independently:

LedgerStepsAmounts to
Whole-binary probe sweep4,000,000one for the Mach-O surfaces sweep and one for the ELF/PE image-edge sweep
Extraction runner4,000,000one per format's extraction driver — roughly 15 fully-budgeted candidates before every remaining site returns empty
Argument promotion2,000,000one each for ELF and PE — on the order of 500 full-budget probes, since its probes are single-shot rather than table sweeps

Totalled across the sweeps a single binary may see, the real ceiling is nearer 10 million steps than 4 million. The entry-point unpack driver carries no ledger at all; its 64-million-step budget and 8-second deadline are its only bounds.

Only the extraction runner clamps a run to the ledger. There, each run's budget is lowered to the remaining allowance before it starts, making that bound a hard integer ceiling, and the ledger is additionally debited a re-lift cost proxy — the summed basic-block count of the functions about to be lifted — before each candidate, so compute is backpressured too, not just executed steps. The probe consumers only check whether the ledger is already exhausted and debit afterwards, so a probe sweep can overshoot its ledger by up to one full 4,096-step run.

Memory is bounded by how many bytes one run may write. Those caps count written bytes, not bytes of process memory, and the gap between the two was measured rather than asserted:

Run typeWrite capPeak resident overlay
Extraction65,536 bytesroughly a megabyte
Entry-point unpack4,194,304 bytesabout 86 MB, re-verified in-process by a regression test

The overlay is a byte-keyed map, one node per written byte, costing 21.5 resident bytes per entry — dense and scattered write patterns measured identically, since the map never compresses contiguous keys. The entry-point dump the unpack driver produces is capped at 8 MiB, truncated on read-back.

Every one of these values is a compile-time constant — no flag, environment variable or config file moves any of them. Two runtime switches exist, both on/off rather than tuning: a command-line flag silences every emulator consumer process-wide, all four drivers and all seven probe passes at once, and an environment variable gates the value-set analysis the runtime function-pointer-table pass depends on, off unless set.

The property that makes this auditable is the absence of a clock. Step counters are integers and processing order is fixed — block, then operation, then ascending index — so the same binary is cut at the same site on every host, every run. One exception is documented and deliberate: the entry-point unpack driver, and only that driver, sets an 8-second wall-clock deadline, on the reasoning that a 64-million-step budget is not proof against a per-operation cost outlier. It trades reproducibility for a backstop, explicitly.

The output gate

Recovered bytes are surfaced only after clearing a byte-level quality bar. The gate suppresses; it never fabricates or alters a byte. Rejections are evaluated first, then the accept criteria in order:

StageRuleResult
Uninitialized-memory flagthe run read uninitialized memoryrejected regardless of content — but never fires in production, since no driver selects the zero-fill policy that sets the flag
Minimum lengthfewer than 4 bytesrejected — too short to carry statistical signal, and the floor that catches a run cut mid-buffer
Distinct byte valuesfewer than 3rejected before any accept criterion runs
Structure predicatea caller-supplied predicate matchesaccepted, high confidence
Entropy dropat least 1.0 bit/byte versus the source ciphertextaccepted, medium confidence
Printable ASCIIat least 80%accepted, medium confidence

The distinct-value floor exists because a degenerate result is the best-looking failure: a constant run like [0x41; N] has entropy 0.0, which is the largest possible entropy drop from a high-entropy source, and a printable ratio of 1.0. Index desync, key collision, or a constant pad all produce exactly that shape.

Regions are capped at 4,096 bytes and 16 per run, enumerated in ascending address order so the output is identical across hosts.

The self-modifying-code outcome bypasses this gate entirely. "Control transferred into bytes this run itself wrote" is structural provenance, judged on the transfer rather than on whether the target bytes read as text — the unpacked payload in the opening example is machine code, not a string.

Real-corpus measurements

The mechanism is proven on hand-built and hand-assembled fixtures; its last real-corpus measurement was zero.

SweepScopeResultCause
Extraction driver, July 2026103 real corpus samples, 86 of which completed analysis0 of 86 produced any recovered datathe driver seeded a function's literal constants and nothing else, so a real prologue's sub sp, sp, #N diverged on operation 1, before the run ever reached the loop the candidate was selected for
Entry-point unpack driver5 real UPX-packed samples, debug and release alike0 of 5the run diverges at operation 31, at a branch whose truth depends on a value loaded from a stack-relative address
Benign precision controlscurl, jq, ripgrep, and a C++ fixture across ELF, Mach-O, and PEempty results, as intendedan unpacked binary's entry stub runs its prologue and returns or diverges without ever reaching the self-modifying-code trigger

Across five instrumented samples, 100% of candidates diverged, 512 of 512 on the largest — 67 at one operation executed, 441 at two.

The UPX failure has a different and deeper cause than the extraction one. Seeding binds the stack-pointer register but never the stack contents — no kernel-supplied argc/argv/envp frame exists at that synthetic address — so the load is honestly unknown, an unknown value can structurally never decide a branch, and the run stops 31 operations in, nowhere near UPX's decompression loop. The idiom that kills it (while (*p) { p += 4; count++ }) is ubiquitous in C-runtime startup code before main.

A concrete stack pointer and frame pointer are now seeded, which addresses the first failure. Neither sweep has been re-measured since.

Coverage gates and unread outputs

Extraction does not run on a normal scan. It is gated on the per-function feature matrix, which the standard ingest fact set excludes as ML-only.

PathExtraction runner
Standard ingest scandoes not run — the fact set excludes the feature matrix it is gated on
Direct callers of the analyzerruns
The CLI's full moderuns
The ML retrain pathruns

Entry-point unpack is different — it is deliberately not gated on function discovery, so it does run everywhere.

Two outputs currently have no downstream reader.

AmosStealer config extraction

The one family config extractor that works in production is not emulator output. AmosStealer configs are recovered by a static byte replay of the sample's own obfuscation arithmetic over its constant tables. Three blockers pushed it off the emulator, two of them measured:

BlockerMeasured costBound it exceeds
Script literal sizeroughly 63,700 bytes of hex textan order of magnitude past the 4,096-byte cap on what a recovered region may carry
xorshift64 decodeabout 127,000 rounds at three-plus IL operations each, for one obfuscator generationthe 262,144-step extraction budget
C++ std::string append per plaintext byte, in another generationnot measuredthe hook table models no push_back, reserve, or resize

That last generation is replayed instead by a separate, deliberately narrow straight-line AArch64 interpreter living in the detection crate, which handles integer register operations, loads, stores, and the loop-closing compare and diverges on everything else. Four obfuscator generations are known; three are implemented.

Config plugins

Two plugins interpret recovered bytes as structured configuration, and only one of them can name anything. The structured plugin parses a four-field |-delimited record — host:port|mutex|campaign_id|ALGO:HEXKEY.

PluginValidationFamily fieldEffect on findings
Delimited recordall four fields must independently validate; abstains on a three-field or five-field split, or on any single field failing its shape checknames the layout it recognized, not an attributed real-world family, because no in-the-wild corpus backs a family claim for that shapejoined by function address to the decryptor-loop finding that produced its bytes, it promotes that finding from suggestive to proven and floors its tier at Likely Malicious
Generic strings fallbackevery printable token must match a URL, a bare host:port pair, or a bare IP literal; tokens under 4 bytes are skipped, and a dotted-numeric host must also validate as a real, remote-dialable IPv4 address — out-of-range quads, loopback and unspecified rejected. Abstains on zero typed matches; runs lastasserts no family at all — empty by constructionnone

The family field, not byte quality, is what the promotion reads. Byte quality was the rejected alternative, on the argument that a benign, structurally tight transform loop can clear a quality bar but cannot produce a parseable four-field C2 record. That argument has not been measured — no false-positive rate for the delimited-record shape exists on any corpus.

Scope and non-goals

Nothing here executes on a CPU, covers a whole binary, or produces a verdict.