From 0960e27f1f1db6368260fce97b73f34920f1ecd0 Mon Sep 17 00:00:00 2001 From: MochaByte <115954804+mochabyte0x@users.noreply.github.com> Date: Sun, 10 May 2026 20:22:25 +0200 Subject: [PATCH] Enhance README with comprehensive project details Expanded README with detailed sections on motivation, usage, and technical explanations. --- README.md | 496 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 495 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1885a3b..ef20492 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,496 @@ # vmkit -A header-only, freestanding C++20 template for IR-bytecode VM loaders + +A header-only, freestanding C++20 template for IR-bytecode VM loaders. The +whole point is to spin up a new loader without rewriting the same dispatch / +decode / decrypt plumbing every single time. + +## TOC + +- [vmkit](#vmkit) + * [Motivation](#motivation) + * [What this is](#what-this-is) + * [What it deliberately leaves to you](#what-it-deliberately-leaves-to-you) + * [Layout](#layout) + * [Quickstart](#quickstart) + * [Building the example](#building-the-example) + * [How the round-trip works](#how-the-round-trip-works) + * [Configuration flags](#configuration-flags) + * [Adding a new opcode](#adding-a-new-opcode) + * [Compile flags for production loaders](#compile-flags-for-production-loaders) +- [Technical explanation](#technical-explanation) + * [The dispatch table](#the-dispatch-table) + * [Why specialization beats a switch](#why-specialization-beats-a-switch) + * [Opcode randomization](#opcode-randomization) + * [Bytecode encryption at rest](#bytecode-encryption-at-rest) + * [Per-operation context encryption](#per-operation-context-encryption) + * [Why everything is `if constexpr`](#why-everything-is-if-constexpr) + * [Why C++20 specifically](#why-c20-specifically) + * [Reinterpret-cast on the bytecode](#reinterpret-cast-on-the-bytecode) + * [Op layout and alignment](#op-layout-and-alignment) + * [Constraints worth knowing](#constraints-worth-knowing) + * [Going beyond the template](#going-beyond-the-template) + +## Motivation + +Two pieces of prior art kicked this whole thing off. + +The first was [_RISCy Business_](https://secret.club/2023/12/24/riscy-business.html) +on secret.club. It's about embedding a full RISC-V interpreter to execute +LLVM-retargeted Windows code as RISC-V bytecode. Pretty neat. The second +was the **Firebeam VM** in Havoc Pro, which applies that same VM-as-loader +idea inside a production C2. + +Most of the value here isn't in inventing some clever ISA: it's in having +a small, embeddable, hardenable execution layer between your bytecode and +the host. So I wanted to come at it from a loader-first angle and trade ISA +fidelity for simplicity. RISC-V gives you a real toolchain, but at the cost +of carrying an interpreter and a CRT shim. + +For a loader the bytecode rarely needs to do more than _allocate, write, +decrypt, jump_. A custom IR with fixed-size ops covers that surface in a +fraction of the code, and the obfuscation primitives that actually matter +(opcode randomization, bytecode encryption at rest, per-op state +encryption) port across cleanly. They're properties of the dispatch loop, +not the instruction set. + +So this template is the dispatch / decode / decrypt skeleton I wished I had +on hand when starting fresh: in "modern" C++, freestanding-friendly, with +everything Windows-specific left as a clearly marked extension point. Drop +in a `Handler<>` specialization per opcode and you've got a working loader. + +## What this is + +A single header (`vm_loader.hpp`) that gives you: + +- A fixed-size IR `Op` record and a typed `Vm<...>` dispatcher +- Compile-time per-opcode validation through `Handler` template specialization +- A 256-entry `constexpr` jump table built at compile time (zero runtime cost on dispatch) +- Three opt-in obfuscation hooks that cost _nothing_ when disabled: + - opcode randomization (per-build randomized bytecode opcodes) + - bytecode XOR encryption at rest + - per-operation context encryption +- A `consteval` Jenkins-OAAT API hash, in case you want it for dynamic resolution + +The runnable example is a real shellcode loader, not a MessageBox stand-in. +`example_builder.cpp` reads `example/payload.bin` (raw shellcode), XOR-encrypts +both the IR bytecode and the payload, and emits `example/embedded.h` with two +encrypted blobs inside. `example_loader.cpp` `#include`s that header and runs +the classic 5-op pipeline: `AllocRegion` → `WritePayload` (encrypted bytes +into the region) → `DecryptRegion` (XOR in place) → `ProtectRX` → `ExecRegion` +(cast to fn ptr and jump). Same shape a production loader uses; the only +thing you have to bring is the payload. + +## What it deliberately leaves to you + +- Memory primitives (`VirtualAlloc` / `NtAllocateVirtualMemory`) +- Execution methods (fibers, threadpool, indirect syscalls, …) +- Anti-analysis checks +- Syscall resolution and API hash tables +- Payload encryption + +The template covers the VM core. The rest is wired in through `Handler<>` +bodies on your side. + +## Layout + +``` +template/ + vm_loader.hpp the entire VM + Makefile builds the example loader and builder + compile_flags.txt clangd config (C++20, freestanding-friendly) + example/ + example_loader.cpp Windows loader that runs encrypted shellcode via the VM + example_builder.cpp matching builder that consumes payload.bin + emits embedded.h + payload.bin raw shellcode you drop in (NOT committed) + embedded.h generated by the builder, consumed by the loader +``` + +Four moving parts inside `vm_loader.hpp`: + +| Type | Role | +| ------------------------------------------- | --------------------------------------------------- | +| `vmkit::Op` | Fixed-size operation record | +| `vmkit::Handler` | Per-opcode behavior, you specialize this | +| `vmkit::OpcodeList` | Pack of opcodes the VM should dispatch | +| `vmkit::Vm` | The dispatcher | + +## Quickstart + +```cpp +#include "vm_loader.hpp" + +// 1. Define your opcodes (must fit in uint8_t). +enum class MyOp : std::uint8_t { Alloc = 0, Write = 1, Exec = 2 }; + +// 2. Define your loader's mutable state. +struct MyContext { void* regions[8]; }; + +// 3. Specialize Handler<> for each opcode. +template <> struct vmkit::Handler { + static void execute(MyContext& ctx, const vmkit::Op& op) noexcept { + ctx.regions[op.u32[0]] = my_virtual_alloc(op.u64[0]); + } +}; +// ... Write, Exec ... + +// 4. Pick a config (or roll your own by inheriting from DefaultConfig). +struct MyCfg : vmkit::DefaultConfig { + static constexpr bool bytecode_xor_encrypted = true; + static void decrypt_bytecode(std::span blob, + std::uint32_t seed) noexcept { + // your in-place XOR / chacha / aes routine here + } +}; + +// 5. Run. +vmkit::Vm> vm; +vm.execute(blob_span, ctx, seed); +``` + +## Building the example + +You need to drop a raw shellcode binary at `example/payload.bin` first, +otherwise `make` will refuse with `No rule to make target 'example/payload.bin'`. +Anything that's a valid x64 entry point works. Then: + +```sh +make # build everything (builder -> embedded.h -> loader) +make run-loader # build + run the loader (executes the shellcode) +make run-builder # build + run the builder on its own (prints to stdout) +make clean +``` + +The build chain is: builder compiles first, then runs against `payload.bin` +to generate `example/embedded.h`, then the loader compiles against it. Swap +out `payload.bin` and re-run `make` and the whole thing rebuilds with the +new payload baked in. + +`make CXX=g++` or `make CXXFLAGS="-std=c++20 -O3"` if you want to override +the defaults. + +## How the round-trip works + +A walk through what actually happens between `make` and the shellcode running: + +1. **Builder side.** `example_builder.cpp` reads `example/payload.bin` (raw + shellcode), builds a 5-op `OpT program[]` whose `(size, src_off)` fields + reference the payload, encodes each opcode through a forward map (real + opcode → randomized byte), and XOR-encrypts both the bytecode and the + payload with a 32-byte key derived from a fixed seed (`0xC0FFEE`). It + prints a self-contained C++ header with `#pragma once` and four `inline + constexpr` symbols: `g_ir_blob`, `g_ir_seed`, `g_ir_payload`, and + `g_ir_payload_size`. +2. **Make.** The Makefile redirects the builder's stdout into + `example/embedded.h`. If `payload.bin` is missing, Make stops cold; if + it changes, embedded.h regenerates and the loader rebuilds. +3. **Loader side.** `example_loader.cpp` `#include`s `embedded.h`, copies + `g_ir_blob` into a stack-local mutable buffer (since `execute()` + decrypts in place), and hands it off to `vm.execute(blob, ctx, g_ir_seed)`. +4. **Execute.** The VM: + - calls `LoaderConfig::decrypt_bytecode` once (XOR the bytecode with the + derived key), + - reads each `Op`, looks up its randomized opcode byte in + `LoaderConfig::opcode_reverse_map`, + - dispatches through the `constexpr` 256-entry table to the matching + `Handler::execute`. +5. **The pipeline runs.** Five ops: `AllocRegion(PAGE_READWRITE)`, + `WritePayload` (copies still-encrypted bytes from `g_ir_payload`), + `DecryptRegion` (XOR in place using the same derived key), + `ProtectRX` (`VirtualProtect` to `PAGE_EXECUTE_READ`), `ExecRegion` + (cast to fn ptr and jump). The shellcode starts running at the end of + step 5. + +If the builder's forward map and the loader's reverse map drift, or the +two `derive_key` implementations disagree on a single byte, the whole +thing falls apart immediately: either the bytecode dispatches into +`unknown_op`, or the decrypted shellcode is garbage and the `ExecRegion` +jump dies. That's the contract this example is testing for you ^^. + +## Configuration flags + +`vmkit::DefaultConfig` exposes three flags. Override the ones you want, +leave the rest alone: + +| Flag | Effect when `true` | +| --------------------------------- | ---------------------------------------------------------------- | +| `opcode_randomization` | Decode each opcode through `opcode_reverse_map[...]` | +| `bytecode_xor_encrypted` | Calls `Cfg::decrypt_bytecode(blob, seed)` once before dispatch | +| `per_op_context_encryption` | Wraps each op with `decrypt_context` / `encrypt_context` | + +When a flag is `false`, the corresponding hook is **never instantiated**. +No overhead, no symbols, no dead code in the binary. Pretty nice ^^. + +## Adding a new opcode + +```cpp +enum class MyOp : std::uint8_t { /* existing... */, NewThing = 7 }; + +template <> struct vmkit::Handler { + static void execute(MyContext& ctx, const vmkit::Op& op) noexcept { + // your logic + } +}; + +// Then add it to the OpcodeList. Forget this and your opcode silently no-ops at runtime. +// Forget the Handler specialization and the build dies with a static_assert. +vmkit::Vm> vm; +``` + +## Compile flags for production loaders + +The header itself is plain C++20. For an actual loader the typical flag set +looks like: + +``` +-std=c++20 -O2 -ffreestanding -fno-exceptions -fno-rtti -nostdlib++ +``` + +On MSVC: `/std:c++20 /EHs-c- /GR- /kernel` (or hand-tune; `/kernel` implies +no-exceptions + no-RTTI anyway). + +--- + +# Technical explanation + +This part is for anyone who wants to understand _how_ it works, not just +how to use it. Feel free to skip if you only need the API. + +## The dispatch table + +The classic IR interpreter pattern is one giant `switch` on the opcode +byte. That works, sure, but every new opcode means editing the switch, +and a missing `case` is a silent runtime no-op (which is exactly when you +don't want to find out, btw). + +`vmkit` flips that. The `Vm<>` class holds a single static member: + +```cpp +static constexpr std::array dispatch_table = build_table(); +``` + +`build_table()` is a `constexpr` function that: + +1. Initializes all 256 slots to `&unknown_op` (a no-op). +2. For every opcode `Op` listed in `OpcodeList`, sets + `dispatch_table[Op] = &dispatch_to`. +3. `dispatch_to` is a `static_assert`-guarded thunk that calls + `Handler::execute(ctx, op)`. + +Step 2 is a fold expression over the parameter pack, built entirely at +compile time: + +```cpp +((t[static_cast(Ops)] = &dispatch_to), ...); +``` + +The result: dispatch is a single indirect call through a table the compiler +already knows about. Modern compilers will frequently devirtualize and +inline it. There's no runtime registration step, no virtual table, no hash +lookup. + +## Why specialization beats a switch + +`Handler` is a primary template that's deliberately undefined. When you +write `template<> struct vmkit::Handler { ... }`, you're +filling in one slot of a compile-time registry. + +The kicker is in `dispatch_to`: + +```cpp +static_assert(HasHandler, "vmkit: missing Handler specialization for a listed opcode"); +Handler::execute(ctx, op); +``` + +`HasHandler` is a concept that probes for `Handler::execute(ctx, op)`. +If you list an opcode in `OpcodeList<...>` without specializing `Handler<>` +for it, `dispatch_to` fails to instantiate and the build dies with a +clear message. With a `switch`, that exact same mistake compiles cleanly +and silently no-ops at runtime. Which, again, is _not_ when you want to +find out about it. + +## Opcode randomization + +By default, the bytecode opcode byte _is_ the real opcode. With +`opcode_randomization = true`, the byte stored in the bytecode is a +randomized encoding instead, and the runtime maps it back through a +256-byte reverse table: + +```cpp +std::uint8_t raw = static_cast(op.opcode); +if constexpr (Cfg::opcode_randomization) { + raw = Cfg::opcode_reverse_map[raw]; +} +dispatch_table[raw](ctx, op); +``` + +There are two halves to this: + +- **Builder side** (forward map): real opcode → randomized byte. Each build + picks a fresh permutation seeded from a config value, so identical IR + programs produce different bytecode across builds. +- **Runtime side** (reverse map): randomized byte → real opcode. Embedded + as a `constexpr std::array` in the binary. + +The two maps are inverses of each other. The example demonstrates a tiny +hand-rolled permutation; a real builder would produce a random shuffle +keyed off the build seed. + +What this buys you: static signatures based on opcode byte sequences become +useless, since every build has a different alphabet. What it doesn't buy +you: protection against execution-trace analysis or symbolic execution. + +## Bytecode encryption at rest + +`bytecode_xor_encrypted = true` makes `execute()` call +`Cfg::decrypt_bytecode(blob, seed)` once before the dispatch loop starts. +The header _intentionally_ doesn't ship a crypto routine (that'd be one +more recognizable signature), so you wire in whatever transform matches +your builder. + +The `xor_codec::apply` helper in the header is provided for convenience: a +minimal in-place XOR with a key span. For real loaders you'll probably +want at least a seed-derived key, ideally a stream cipher (sry, no +shortcuts here). + +`blob` is `std::span`, mutable on purpose, since the +decryption happens in place. If you keep an encrypted copy elsewhere, copy +before calling `execute()`. + +## Per-operation context encryption + +`per_op_context_encryption = true` adds two calls around each opcode: + +```cpp +for (i = 0; i < count; ++i) { + if (i > 0) Cfg::decrypt_context(ctx, i); + /* dispatch */ + Cfg::encrypt_context(ctx, i + 1); +} +if (count > 0) Cfg::decrypt_context(ctx, count); +``` + +The pattern is: the loader's mutable state (region pointers, syscall +table, exec method, …) lives _encrypted_ in memory between operations. A +memory dump captured between two ops shows ciphertext, not pointers. Each +op transitions through plaintext for the duration of `Handler<>::execute`, +and gets re-encrypted right after. + +The op index gets fed into the encryption hook, so the key can rotate per +op and make static dump analysis even harder. + +This is mostly useful against passive memory forensics, btw. Anything +actively hooking your handlers will see plaintext. + +## Why everything is `if constexpr` + +Each obfuscation feature is gated on a `static constexpr bool`: + +```cpp +if constexpr (Cfg::bytecode_xor_encrypted) { + Cfg::decrypt_bytecode(blob, seed); +} +``` + +This is _not_ a runtime branch. If the flag is `false`, the entire branch +gets discarded at compile time. `decrypt_bytecode` is never even +instantiated and contributes zero bytes to the binary. A loader that uses +no obfuscation features compiles down to literally the dispatch loop, +nothing else. + +Compare with `#ifdef`: this gives you the same dead-code elimination, but +with template-argument granularity. You can have two `Vm<>` instances in +the same binary running different configs, which is something `#ifdef` +just can't do. + +## Why C++20 specifically + +Three features the design genuinely needs: + +1. **`auto` non-type template parameters.** Lets `Handler` + work regardless of whether the underlying enum is `int` or `uint8_t`. + Pre-C++17 this would need a template-template wrapper or a macro, neither + of which is fun to read. +2. **Concepts.** `HasHandler` is the cleanest way to say "this opcode has + a valid handler" and produce a diagnostic that's actually readable. +3. **`constexpr std::array` member init via lambda.** The + `opcode_reverse_map` in the example is built by an immediately-invoked + `constexpr` lambda. C++17 could do it via a helper function; C++20 just + lets you write it inline. + +The header pulls in only ``, ``, ``, and ``. +All header-only, no allocators, freestanding-compatible. + +## Reinterpret-cast on the bytecode + +```cpp +const auto* ops = reinterpret_cast(blob.data()); +``` + +Strict-aliasing pedants will recoil. The real-world story tho: + +- The bytecode _is_ an array of `OpType`, just delivered as bytes. +- `OpType` is trivially copyable and standard-layout. +- `std::uint8_t` is allowed to alias other types in practice on every + compiler that ships a Windows loader. + +Real loaders get built with `-fno-strict-aliasing` anyway. If you're +targeting something where this genuinely matters, use `std::memcpy` into a +stack-local `OpType` per iteration and the optimizer will collapse it. + +## Op layout and alignment + +```cpp +template +struct alignas(8) Op { Opcode opcode; std::uint32_t u32[U32]; std::uint64_t u64[U64]; }; +``` + +- `alignas(8)` guarantees the bytecode array is 8-byte aligned, so `u64[]` + reads are aligned without per-op shuffling. +- The slot counts are template parameters, not hardcoded. Bump them up if + your handlers need more operands per op; bump them down if the defaults + are wasteful for your use case. +- All ops in a single `Vm<>` instance are the same size. If you need + variable-length operands (large strings, payload chunks…), put them in a + side table and reference them by offset. `WritePayload` in the example + shows the pattern. + +--- + +## Constraints worth knowing + +- The opcode enum's underlying type must fit in a byte (0..255). Dispatch + table is exactly 256 entries; anything wider doesn't fit. +- `Op` is fixed-size. Variable-length operands go in side tables + referenced by offset. +- `execute()` takes a _mutable_ `std::span` because in-place bytecode + decryption rewrites it. Copy first if you want to keep the encrypted form + around. +- Unknown opcodes silently no-op. That's by design. Easy to drop in junk + opcodes for control-flow obfuscation. Replace `unknown_op` in + `vm_loader.hpp` with a trap if you'd rather hard-fail. + +## Going beyond the template + +The template covers the VM core. Production loaders typically build out: + +- Real Win32 / NTAPI hooks for alloc / protect / exec +- Indirect syscalls (Hell's gate variants and friends) +- API hashing tables and dynamic resolution +- Anti-analysis checks +- Callstack spoofing +- Multiple execution methods +- Payload encryption +- Transport obfuscation +- Section-entropy padding and PE checksum patching + +All of those slot in either as `Handler<>` bodies, as overrides on a +custom `Cfg`, or as builder-side preprocessing. None of them require +touching the VM core in `vm_loader.hpp`. As always, feel free to bend the +template to your use case ^^. + +# Credits + +``` +Secret Club: https://secret.club/2023/12/24/riscy-business.html +Infinity Curve: https://infinitycurve.org/products/havoc-professional +```