const RP={
ghidra:'https://ghidra-sre.org/',
unicorn:'https://www.unicorn-engine.org/',
sysv:'https://gitlab.com/x86-psABIs/x86-64-ABI',
dwarf:'https://dwarfstd.org/'};
const RPara=({children})=><p style={{fontSize:14,lineHeight:1.7,color:'var(--text-muted)',margin:'0 0 20px'}}>{children}</p>;
const RH2=({children})=><h2 style={{fontSize:20,fontWeight:700,color:'var(--text-body)',margin:'48px 0 16px'}}>{children}</h2>;
const RH3=({children})=><h3 style={{fontSize:15,fontWeight:700,color:'var(--text-body)',margin:'32px 0 12px',fontFamily:'var(--font-display)',letterSpacing:'.02em'}}>{children}</h3>;
function ArticleRE(){
return <article style={{maxWidth:720,margin:'0 auto'}}>
<div style={{fontFamily:'var(--font-display)',fontSize:11,letterSpacing:'var(--tracking-caps)',color:'var(--accent)',marginBottom:16}}>/REVERSE-ENGINEERING · 2026-09-01</div>
<h1 style={{fontSize:'var(--text-2xl)',fontWeight:700,lineHeight:1.2,margin:'0 0 12px'}}>An offset oracle: recovering optimized-away structs from a stripped binary</h1>
<div style={{fontSize:14,color:'var(--text-faint)',marginBottom:40}}>The decompiler guesses at struct boundaries. We can stop guessing — by letting the code itself confirm each field offset through micro-execution. A fully reproducible method, ~90 lines of Python.</div>

<RPara>Static decompilers infer structure from access patterns: a load at <code>[rax+0x10]</code> becomes <code>field_0x10</code>, and the type is whatever the surrounding arithmetic implies. This is a heuristic, and it fails predictably — on unions, on packed layouts, on fields the optimizer folded into a register and never spilled. The result is a decompilation that <em>type-checks in your head but is wrong on the wire.</em></RPara>
<RPara>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 <a href={RP.unicorn} target="_blank">Unicorn</a> and <a href={RP.ghidra} target="_blank">Ghidra</a> installed.</RPara>

<Callout><strong>What you need:</strong> <code>gcc</code>, Python 3.10+, <code>pip install unicorn capstone pyelftools</code>. No root, no target hardware. Runtime under a second.</Callout>

<RH2>1 · A target we know the answer to</RH2>
<RPara>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.</RPara>
<Code lang="parse.c">{`#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;
}`}</Code>
<Code lang="shell">{`$ 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`}</Code>
<RPara>In the real engagement you would carve <code>check.bin</code> and the function offset out of the firmware image instead. The rest of the method does not care where the bytes came from.</RPara>

<RH2>2 · The oracle: instrument every read</RH2>
<RPara>We map the function's code into Unicorn, point <code>rdi</code> (first SysV argument — see the <a href={RP.sysv} target="_blank">x86-64 psABI</a>) 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.</RPara>
<Code lang="oracle.py">{`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")`}</Code>
<Code lang="output">{`$ python3 oracle.py
  +0x00  4B      <- id      (u32)
  +0x04  1B      <- flags   (u8)
  +0x10  2B      <- port    (u16)`}</Code>

<Callout>The oracle reports <strong>exactly</strong> the fields the code reads — and nothing it doesn't. <code>name</code> at <code>0x08</code> never appears, because <code>check()</code> never dereferences it. That absence is signal: the decompiler invents a field there; the oracle tells you the function is blind to it.</Callout>

<RH2>3 · Turning touches into a layout</RH2>
<RPara>Three observed reads plus the SysV alignment rules give the whole struct. A field at offset <em>o</em> with width <em>w</em> implies the next field starts no earlier than <em>o+w</em>, rounded up to its own alignment. The gap between <code>0x04+1</code> and <code>0x08</code> is forced padding for the 8-byte pointer; the gap after <code>0x10+2</code> is tail padding to make <code>sizeof</code> a multiple of the largest alignment (8).</RPara>
<BlogFig caption="Fig. 1 — Reconstructed layout. Amber offsets are oracle-confirmed; grey rows are alignment-derived."><HexMap/></BlogFig>
<RPara>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 (<a href={RP.dwarf} target="_blank">stripped away</a> long ago), and without trusting the decompiler's inference.</RPara>

<RH2>4 · Why micro-execution beats static inference</RH2>
<RH3>WIDTH IS OBSERVED, NOT GUESSED</RH3>
<RPara>Static analysis infers width from the instruction (<code>movzx eax, word ptr</code> → 2 bytes). That works until the compiler uses a wider load and masks — reading 4 bytes to extract a <code>u16</code>. 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 <code>and</code>.</RPara>
<RH3>DEAD FIELDS STAY DEAD</RH3>
<RPara>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 — the difference between a day in the decompiler and a coffee break.</RPara>
<RH3>IT COMPOSES WITH COVERAGE</RH3>
<RPara>One input exercises one path. Drive the oracle with several crafted buffers — a valid packet, one with <code>flags=0</code>, 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.</RPara>

<Callout><strong>Where it stops.</strong> The oracle sees only what executes. Fields behind unreached branches stay invisible until a covering input reaches them — so this recovers the <em>accessed</em> layout, not necessarily the <em>declared</em> one. For total layout you still pair it with static xref analysis. The two disagree exactly where the interesting bugs live.</Callout>

<RH2>5 · Using it in anger</RH2>
<RPara>On real firmware the deltas from this toy are mechanical, not conceptual: relocate <code>BASE</code> to the image's load address, map the data pages the function touches (or hook <code>UC_HOOK_MEM_UNMAPPED</code> and lazily map on fault), and stub external calls by hooking their PLT stubs to set a plausible return in <code>rax</code>. The read hook and the offset-to-layout logic are unchanged. We ship this as part of every <a href="index.html#reverse-engineering">/reverse-engineering</a> engagement: when a client needs to know what a black-box parser really accepts, we make the binary tell us itself.</RPara>

<div style={{marginTop:56,borderTop:'1px solid var(--border-default)',paddingTop:24}}>
<div style={{fontFamily:'var(--font-display)',fontSize:11,letterSpacing:'var(--tracking-caps)',color:'var(--text-faint)',marginBottom:14}}>TOOLS & REFERENCES</div>
<ol style={{margin:0,paddingLeft:20,display:'flex',flexDirection:'column',gap:8,fontSize:12,color:'var(--text-muted)'}}>
<li><a href={RP.unicorn} target="_blank">Unicorn Engine</a> — lightweight multi-arch CPU emulator (the QEMU core, no devices)</li>
<li><a href={RP.ghidra} target="_blank">Ghidra</a> — NSA's open-source SRE suite; the decompiler this method double-checks</li>
<li><a href={RP.sysv} target="_blank">System V x86-64 psABI</a> — argument registers, alignment, and struct-packing rules</li>
<li><a href={RP.dwarf} target="_blank">DWARF debugging standard</a> — the type info stripping removes, which we reconstruct</li>
</ol>
<div style={{marginTop:20,fontSize:11,color:'var(--text-faint)'}}>Sample code in this post is original and released under CC0 — copy it into your own harness.</div>
</div>
</article>;
}
window.ArticleRE=ArticleRE;
