/REVERSE-ENGINEERING · 2026-07-29
An offset oracle: recovering optimized-away structs from a stripped binary
The decompiler guesses at struct boundaries. Micro-execution lets the code confirm each field offset. A fully reproducible method, ~90 lines of Python.
Static decompilers infer structure from access patterns: a load at [rax+0x10] becomes field_0x10, and the type is whatever the surrounding arithmetic implies. This is a heuristic, and it fails predictably on unions, packed layouts, and fields the optimizer folded into a register and never spilled. The result is a decompilation that type-checks in your head but is wrong on the wire.
This post describes a technique we use on client firmware when we need a struct layout we can trust: treat the target function as an oracle. Feed it a buffer we control, watch which bytes it reads and in what widths, and let the observed memory accesses, not the decompiler's inference, define the layout. Everything below reproduces on a Linux x86-64 box with Unicorn and Ghidra installed.
What you need: gcc, Python 3.10+, pip install unicorn capstone pyelftools. No root, no target hardware. Runtime under a second.
1 · A target we know the answer to
To prove the method works, we start from ground truth: a struct whose real layout we can diff against. Compile with optimization so the compiler is free to pack, pad, and elide.
#include <stdint.h>
#include <string.h>
struct session { // real layout, -O2, SysV x86-64:
uint32_t id; // 0x00
uint8_t flags; // 0x04 (+3 bytes pad)
char *name; // 0x08
uint16_t port; // 0x10 (+2 bytes tail pad)
}; // sizeof == 0x18
// returns 1 if the packet is a valid admin session
int check(const uint8_t *pkt) {
struct session s;
memcpy(&s, pkt, sizeof s);
if (s.id != 0xC0FFEE) return 0;
if (!(s.flags & 0x01)) return 0; // bit0 = admin
if (s.port < 1024) return 0;
return 1;
}
$ gcc -O2 -fno-stack-protector -c parse.c -o parse.o
$ objcopy -O binary --only-section=.text parse.o check.bin
$ nm parse.o | grep check # offset of check() in .text
0000000000000000 T check
In the real engagement you would carve check.bin and the function offset out of the firmware image instead. The rest of the method does not care where the bytes came from.
2 · The oracle: instrument every read
We map the function's code into Unicorn, point rdi (first SysV argument; see the x86-64 psABI) at a scratch buffer we've filled with a recognizable pattern, and install a memory-read hook. Every read inside the argument region is a field touch; its address gives the offset, its size gives the field width.
from unicorn import *
from unicorn.x86_const import *
CODE = open("check.bin","rb").read()
BASE = 0x1000 # where we map .text
ARG = 0x200000 # scratch buffer for the struct
STACK = 0x300000
mu = Uc(UC_ARCH_X86, UC_MODE_64)
for a,sz in [(BASE,0x1000),(ARG,0x1000),(STACK,0x10000)]:
mu.mem_map(a, sz)
mu.mem_write(BASE, CODE)
# fill the arg region with a per-byte marker so a wide read
# of N bytes still lands entirely inside the region
mu.mem_write(ARG, bytes((i & 0xff) for i in range(0x100)))
mu.reg_write(UC_X86_REG_RDI, ARG)
mu.reg_write(UC_X86_REG_RSP, STACK + 0x8000)
touches = [] # (offset, size)
def on_read(uc, access, addr, size, value, ud):
if ARG <= addr < ARG + 0x100:
touches.append((addr - ARG, size))
mu.hook_add(UC_HOOK_MEM_READ, on_read)
# run until the function returns (ret pops our sentinel)
mu.mem_write(STACK + 0x8000, (0xdead0000).to_bytes(8,"little"))
try:
mu.emu_start(BASE, 0xdead0000, count=2000)
except UcError:
pass
for off,size in sorted(set(touches)):
print(f" +0x{off:02x} {size}B")
$ python3 oracle.py
+0x00 4B <- id (u32)
+0x04 1B <- flags (u8)
+0x10 2B <- port (u16)
The oracle reports exactly the fields the code reads, and nothing it doesn't. name at 0x08 never appears, because check() never dereferences it. That absence is signal: the decompiler invents a field there; the oracle tells you the function is blind to it.
3 · Turning touches into a layout
Three observed reads plus the SysV alignment rules give the whole struct. A field at offset o with width w implies the next field starts no earlier than o+w, rounded up to its own alignment. The gap between 0x04+1 and 0x08 is forced padding for the 8-byte pointer; the gap after 0x10+2 is tail padding to make sizeof a multiple of the largest alignment (8).
This matches the C declaration byte-for-byte, including the field the decompiler would have mistyped. We recovered it without a single symbol, without DWARF (stripped away long ago), and without trusting the decompiler's inference.
4 · Why micro-execution beats static inference
WIDTH IS OBSERVED, NOT GUESSED
Static analysis infers width from the instruction (movzx eax, word ptr → 2 bytes). That works until the compiler uses a wider load and masks, reading 4 bytes to extract a u16. The oracle records the true memory transaction, so a masked wide load is visible as a 4-byte touch you can then split by watching the subsequent and.
DEAD FIELDS STAY DEAD
Fields the function never reads produce no touches. On a 200-field config struct where one handler reads six, the oracle hands you exactly those six and their offsets. That is a day in the decompiler versus a coffee break.
IT COMPOSES WITH COVERAGE
One input exercises one path. Drive the oracle with several crafted buffers (a valid packet, one with flags=0, one with a low port) and union the touch sets. Each path reveals the fields its branch reads; the union converges on the full accessed layout. This is the same move a fuzzer makes, aimed at structure recovery instead of crashes.
Where it stops. The oracle sees only what executes. Fields behind unreached branches stay invisible until a covering input reaches them. This recovers the accessed layout, not necessarily the declared one. For total layout you still pair it with static xref analysis. The two disagree exactly where the interesting bugs live.
5 · Using it in anger
On real firmware the deltas from this toy are mechanical, not conceptual: relocate BASE to the image's load address, map the data pages the function touches (or hook UC_HOOK_MEM_UNMAPPED and lazily map on fault), and stub external calls by hooking their PLT stubs to set a plausible return in rax. The read hook and the offset-to-layout logic are unchanged. We ship this as part of every /reverse-engineering engagement: when a client needs to know what a black-box parser really accepts, we make the binary tell us itself.
Tools & references
- Unicorn Engine — lightweight multi-arch CPU emulator (the QEMU core, no devices)
- Ghidra — NSA's open-source SRE suite; the decompiler this method double-checks
- System V x86-64 psABI — argument registers, alignment, and struct-packing rules
- DWARF debugging standard — the type info stripping removes, which we reconstruct
Sample code in this post is original and released under CC0. Copy it into your own harness.