Enhance README with comprehensive project details

Expanded README with detailed sections on motivation, usage, and technical explanations.
This commit is contained in:
MochaByte
2026-05-10 20:22:25 +02:00
committed by GitHub
parent 19a5e162a0
commit 0960e27f1f
+495 -1
View File
@@ -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<Op>` 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<Opcode>` | Fixed-size operation record |
| `vmkit::Handler<Op>` | Per-opcode behavior, you specialize this |
| `vmkit::OpcodeList<Ops...>` | Pack of opcodes the VM should dispatch |
| `vmkit::Vm<Opcode, Ctx, Cfg, OpcodeList>` | 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<MyOp::Alloc> {
static void execute(MyContext& ctx, const vmkit::Op<MyOp>& 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<std::uint8_t> blob,
std::uint32_t seed) noexcept {
// your in-place XOR / chacha / aes routine here
}
};
// 5. Run.
vmkit::Vm<MyOp, MyContext, MyCfg, vmkit::OpcodeList<MyOp::Alloc, MyOp::Write, MyOp::Exec>> 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<Op>::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<MyOp::NewThing> {
static void execute(MyContext& ctx, const vmkit::Op<MyOp>& 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<MyOp, MyContext, MyCfg, vmkit::OpcodeList</* existing... */, MyOp::NewThing>> 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<Dispatcher, 256> 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<Ops...>`, sets
`dispatch_table[Op] = &dispatch_to<Op>`.
3. `dispatch_to<Op>` is a `static_assert`-guarded thunk that calls
`Handler<Op>::execute(ctx, op)`.
Step 2 is a fold expression over the parameter pack, built entirely at
compile time:
```cpp
((t[static_cast<std::size_t>(Ops)] = &dispatch_to<Ops>), ...);
```
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<Op>` is a primary template that's deliberately undefined. When you
write `template<> struct vmkit::Handler<MyOp::Alloc> { ... }`, you're
filling in one slot of a compile-time registry.
The kicker is in `dispatch_to<Op_>`:
```cpp
static_assert(HasHandler<Op_, Ctx, OpType>, "vmkit: missing Handler<Op> specialization for a listed opcode");
Handler<Op_>::execute(ctx, op);
```
`HasHandler` is a concept that probes for `Handler<Op>::execute(ctx, op)`.
If you list an opcode in `OpcodeList<...>` without specializing `Handler<>`
for it, `dispatch_to<Op>` 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<std::uint8_t>(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<std::uint8_t>`, 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<MyOp::Alloc>`
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 `<array>`, `<cstddef>`, `<cstdint>`, and `<span>`.
All header-only, no allocators, freestanding-compatible.
## Reinterpret-cast on the bytecode
```cpp
const auto* ops = reinterpret_cast<const OpType*>(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 <typename Opcode, std::size_t U32 = 8, std::size_t U64 = 4>
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<Opcode>` 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
```