TinyLoad v5 is out. if you haven't seen the project before — it's a PE packer for Windows. you give it an .exe, it compresses and VM-encrypts it into a self-extracting stub that runs the original entirely in RAM. one .cpp file, no dependencies, MIT. repo here.
v4 added opaque predicates, anti-debug, and section scrambling. v5 is about hardening the stub itself — the code that actually runs when the packed exe launches. three main additions: encrypted API strings, content-derived opmap obfuscation, and IAT wiping post-load.
quick recap
when you run a TinyLoad packed exe, the stub spins up a custom 32-opcode VM interpreter, executes a decryption bytecode program against the payload, then manually maps the original PE into memory and runs it. every packed file gets a randomly shuffled opcode table so the bytecode looks different every time.
v5 makes the stub itself harder to analyse statically.
1. encrypted API strings
in v4 the DLL and function names the stub needs — kernel32.dll, GetModuleHandleA, VirtualAlloc etc — were sitting as plaintext strings in the binary. any static analysis tool would immediately see what APIs the stub calls just from strings view.
v5 XOR-encrypts all of them:
static const BYTE _ed_k32[] = {0x3A,0x37,0x21,0x3A,0x30,0x3A,0x64,0x6A,0x77,0x3E,0x37,0x30};
static const BYTE _ed_gmha[] = {0x70,0x5D,0x4D,0x77,0x54,0x58,0x48,0x52,0x5A,0x08,0x20,0x2C,0x27,0x28,0x20,0x07};
static const BYTE _ed_gpa[] = {0x06,0x27,0x37,0x14,0x37,0x29,0x24,0x09,0x2D,0x2E,0x39,0x29,0x3E,0x3D};
each one decrypts at runtime with a rolling XOR:
static char* sdec2(char* buf, const BYTE* enc, size_t n, uint8_t k) {
for (size_t i = 0; i < n; i++) buf[i] = enc[i] ^ (uint8_t)(k + i);
buf[n] = 0; return buf;
}
no readable strings in the binary anymore. strings view on the packed output is clean.
2. opmap obfuscation via FNV hash
this one directly addresses feedback from the r/ReverseEngineering community who pointed out that the opmap decode table was sitting plaintext right behind the stub — effectively a silver platter for static analysis.
v5 derives a per-file XOR mask for the opmap using FNV-1a hash, seeded from the file's own content:
static void xorOpmap(BYTE* opmap, const Tail& t, const BYTE* vmCode, const BYTE* pay) {
uint32_t h = 0x811C9DC5u;
auto feed = [&](uint8_t b) { h ^= b; h *= 0x01000193u; };
for (int i = 0; i < 4; i++) {
feed((uint8_t)(t.origSz >> (i * 8)));
feed((uint8_t)(t.packSz >> (i * 8)));
feed((uint8_t)(t.vmCodeSz >> (i * 8)));
}
DWORD vmLim = t.vmCodeSz < 32 ? t.vmCodeSz : 32;
if (vmCode) for (DWORD i = 0; i < vmLim; i++) feed(vmCode[i]);
DWORD payLim = t.packSz < 32 ? t.packSz : 32;
if (pay) for (DWORD i = 0; i < payLim; i++) feed(pay[i]);
for (int i = 0; i < NUM_OPS; i++) {
feed((uint8_t)i);
opmap[i] ^= (uint8_t)((h >> 24) ^ (h >> 16) ^ (h >> 8) ^ h);
}
}
the hash feeds on origSz, packSz, vmCodeSz, and the first 32 bytes of both the VM bytecode and the payload. the resulting mask is different for every single packed file — you can't extract the opmap from one sample and apply it to another. the stub runs xorOpmap at unpack time to recover the real table before passing it to the VM.
3. IAT wiping post-load
after the stub manually maps the packed PE into memory and resolves all its imports, v5 zeroes out the import structures:
// kill recovery
imp->OriginalFirstThunk = 0;
imp->Name = 0;
imp++;
// ...
nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress = 0;
nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size = 0;
once imports are resolved the IAT entries aren't needed anymore. zeroing them means if someone dumps the process from memory after load, the import directory is gone — no DLL names, no function names, no reconstruction path from the dump alone.
4. dead code and junk VM instructions
v5 also adds dead code functions in the stub (__attribute__((used)) to force the compiler to keep them despite -O2) and junk NOP and self-mov instructions scattered through the VM bytecode to inflate and confuse disassembly:
eOp(bc,enc,NOP_I); // junk
eOp(bc,enc,MOV_I); eR(bc,6); eR(bc,6); // junk — mov r6, r6
minor individually but they add noise on top of everything else.
current usage
TinyLoad.exe --i myapp.exe --vm --c
build from source:
g++ -o TinyLoad.exe TinyLoad.cpp -static -O2 -s
or grab the binary from releases.
if you run into files it doesn't pack correctly, open an issue. and if you find it useful, a star helps a lot ❤️
repo: github.com/iamsopotatoe-coder/TinyLoad
blog + changelogs: iamsopotatoe-coder.github.io/TinyLoad/#blog
Top comments (0)