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.
| Question | Section |
|---|---|
| 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.
| Bound | Value | Applies to |
|---|---|---|
| Re-lift hops | 4 | an ordinary run |
| Re-lift hops | 16 | the once-per-binary entry-point run |
| Re-lifted bytes | 16 KiB | across a whole ordinary run |
| Minimum prefix before a hop | 4 bytes | any 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 shape | Handled |
|---|---|
| A single self-looping block walking a NUL-terminated name one byte at a time | recognized — the only shape |
| A two-block loop | yields nothing |
| A combined module-plus-function hash | yields nothing |
| A length-prefixed name stream | yields nothing |
The resolve(hash) call site, where the wanted constant sits at the call rather than in a guard | yields 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:
| Case | Outcome |
|---|---|
| A hash matching no catalog name | resolves to nothing |
| A hash colliding to two different names | resolves to neither, rather than picking one |
| More than 1% of the distinct names hashed landing on a value some other name also produced | the 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 arithmetic | exactly 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.
| Pass | What 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 resolution | the service number in the register just before a svc, so the call becomes a named callee |
| Dynamic-loader resolution | the string argument to dlopen, dlsym, NSClassFromString, objc_getClass, sel_registerName — including the chained-load shape a pattern match cannot reach |
| Argument promotion | a length or mode constant loaded from read-only data, or built by in-block arithmetic |
| Format-string prototypes | the format string at a variadic call, and therefore its argument shape |
| Behavioral signatures | resolved call-site arguments |
| Indirect-call fallback | the 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.
| Driver | Seeded from | Selection and bounds |
|---|---|---|
| Mach-O surface sweep | the probe sites above, in fixed input order | one shared ledger across the sweep |
| ELF / PE image-edge sweep | the probe sites above, in fixed input order | one shared ledger across the sweep |
| Extraction runner | candidate 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 blocks | 512 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 unpack | the real process entry point; candidate selection skipped entirely | one 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":
| Outcome | Cause |
|---|---|
| Concrete resolved value | the probe computed the value it was asked for |
| Clean return | the run reached a terminating return with a value — the strongest stop reason, but not a precondition for surfacing bytes |
| Fall-off | the outermost frame ran off the end of a block with no terminator — a clean stop, not a failure |
| Budget exhaustion | the step budget ran out |
| Deadline | the wall-clock deadline was crossed, on the one driver that sets one |
| Self-modifying-code budget exhausted | the re-lift seam hit its hop or byte ceiling |
| Unresolvable load | a load missed the stack slab, the overlay, the heap slab, and every segment |
| Divergence | any of the causes below |
Divergence fires on:
| Divergence cause | Why |
|---|---|
| A syscall instruction | return 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 compare | those set the same flag bits by a different derivation, which is not implemented |
A return with no value | a void return has no concrete answer to give |
| An expression tree nested deeper than 256 levels | an 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 operation | the 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:
| Group | Count | Symbols |
|---|---|---|
| Memory | 6 | memcpy, memmove, memset, bzero, memchr, memcmp |
| Allocation | 6 | malloc, calloc, realloc, free, HeapAlloc, VirtualAlloc |
| Strings | 6 | strlen, strcpy, strncpy, strcat, strcmp, getenv |
| Encoding | 4 | base64 and base32, each under two spellings |
| Cipher | 2 | RC4, under two spellings |
| Loader | 7 | LoadLibrary 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:
| Call | Modeled as |
|---|---|
IsDebuggerPresent | 0 — not being debugged |
CheckRemoteDebuggerPresent | writes FALSE to the out-param, returns success |
GetTickCount / GetTickCount64 / timeGetTime | a fixed 0x1000 sentinel |
QueryPerformanceCounter | writes the same fixed 0x1000 sentinel |
Sleep / SleepEx | no-op, no elapsed time |
IsProcessorFeaturePresent | 0 for every feature ID, so a caller takes the portable fallback path |
NtQueryInformationProcess | declined — 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:
| Tier | Steps | Used by |
|---|---|---|
| Default probe | 4,096 | the analysis passes that ask for one value |
| Extraction | 262,144 | a candidate decrypt loop |
| Entry-point unpack | 64,000,000 | one 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:
| Ledger | Steps | Amounts to |
|---|---|---|
| Whole-binary probe sweep | 4,000,000 | one for the Mach-O surfaces sweep and one for the ELF/PE image-edge sweep |
| Extraction runner | 4,000,000 | one per format's extraction driver — roughly 15 fully-budgeted candidates before every remaining site returns empty |
| Argument promotion | 2,000,000 | one 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 type | Write cap | Peak resident overlay |
|---|---|---|
| Extraction | 65,536 bytes | roughly a megabyte |
| Entry-point unpack | 4,194,304 bytes | about 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:
| Stage | Rule | Result |
|---|---|---|
| Uninitialized-memory flag | the run read uninitialized memory | rejected regardless of content — but never fires in production, since no driver selects the zero-fill policy that sets the flag |
| Minimum length | fewer than 4 bytes | rejected — too short to carry statistical signal, and the floor that catches a run cut mid-buffer |
| Distinct byte values | fewer than 3 | rejected before any accept criterion runs |
| Structure predicate | a caller-supplied predicate matches | accepted, high confidence |
| Entropy drop | at least 1.0 bit/byte versus the source ciphertext | accepted, medium confidence |
| Printable ASCII | at 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.
| Sweep | Scope | Result | Cause |
|---|---|---|---|
| Extraction driver, July 2026 | 103 real corpus samples, 86 of which completed analysis | 0 of 86 produced any recovered data | the 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 driver | 5 real UPX-packed samples, debug and release alike | 0 of 5 | the run diverges at operation 31, at a branch whose truth depends on a value loaded from a stack-relative address |
| Benign precision controls | curl, jq, ripgrep, and a C++ fixture across ELF, Mach-O, and PE | empty results, as intended | an 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.
| Path | Extraction runner |
|---|---|
| Standard ingest scan | does not run — the fact set excludes the feature matrix it is gated on |
| Direct callers of the analyzer | runs |
| The CLI's full mode | runs |
| The ML retrain path | runs |
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.
- Hash-resolved import facts — the list is populated and serialized but consumed nowhere; the value it delivers arrives entirely through the named call-graph edges, which are consumed.
- The entry-point unpack dump, including the strings recovered from it — stored and read by nothing. The marker in the opening example is recovered into the dump, not into the string-matching surface a rule would search.
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:
| Blocker | Measured cost | Bound it exceeds |
|---|---|---|
| Script literal size | roughly 63,700 bytes of hex text | an order of magnitude past the 4,096-byte cap on what a recovered region may carry |
xorshift64 decode | about 127,000 rounds at three-plus IL operations each, for one obfuscator generation | the 262,144-step extraction budget |
C++ std::string append per plaintext byte, in another generation | not measured | the 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.
| Plugin | Validation | Family field | Effect on findings |
|---|---|---|---|
| Delimited record | all four fields must independently validate; abstains on a three-field or five-field split, or on any single field failing its shape check | names the layout it recognized, not an attributed real-world family, because no in-the-wild corpus backs a family claim for that shape | joined 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 fallback | every 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 last | asserts no family at all — empty by construction | none |
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.
- Not a sandbox. Nothing runs on a CPU. No detonation, no network, no observable syscall side effects.
- Not whole-binary emulation. Runs are scoped to a function slice, seeded for a purpose, and bounded. The question answered is "what does this slice compute", not "what does this program do".
- Not a verdict source. Output carries provenance: which function, which site, how many steps executed, which gate criterion passed, what confidence. "This function wrote these bytes at this address, in this many steps, clearing the printability criterion at medium confidence" is an engine fact; "that is a stealer config" is a separate, detection-side interpretation, and scoring lives further downstream still.
Related briefs
- Unpacking — wrapper obfuscation is handled before any of this: a packed sample is statically unpacked first, and the emulator works on the string encryption and hash resolution that survive inside the payload.
- Engine — the bounded-emulation stage's place in the wider pipeline.
- Malware — the scored-indicator ledger where recovered configs and resolved API names land.