DEV Community

Cover image for TinyLoad v5 — encrypted strings, opmap obfuscation, and IAT wiping
iamsopotatoe
iamsopotatoe

Posted on

TinyLoad v5 — encrypted strings, opmap obfuscation, and IAT wiping

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};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

minor individually but they add noise on top of everything else.


current usage

TinyLoad.exe --i myapp.exe --vm --c
Enter fullscreen mode Exit fullscreen mode

build from source:

g++ -o TinyLoad.exe TinyLoad.cpp -static -O2 -s
Enter fullscreen mode Exit fullscreen mode

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)