Recovering Useful Metadata from .NET NativeAOT Binaries

IoTSRG Team
August 27, 2025
6 min read

Recovering Useful Metadata from .NET NativeAOT Binaries (Quick Notes)

Posted: 2025-08-27 (about 7 min read)

TL;DR: NativeAOT turns .NET apps into fully native executables, which confuses IL decompilers. But key metadata such as type hierarchy, method tables, and frozen strings can be recovered by locating the ReadyToRun directory and rehydrating its dehydrated data blob. This post shows what to look for and gives starter scripts to make Ghidra or IDA friendlier.


Why NativeAOT looks native (and why that is annoying)

Traditional .NET binaries pack rich metadata and CIL that IL decompilers love. NativeAOT publishes a self-contained native EXE or DLL with the runtime bits statically linked in, so IL decompilers usually fail to extract IL. You still get heaps of code, but it looks like C or C++: large .text sections, vtable-like patterns, and no CIL stream to lean on.


Quick fingerprinting checklist

When you suspect .NET but native, look for:

  • No CIL or managed sections that typical .NET tools expect.
  • A ReadyToRun directory inside the image with the signature RTR\0.
  • A data area that acts like a hydrated section at runtime, often containing method-table-like structures and frozen objects or strings.

The ReadyToRun directory and the dehydrated data

NativeAOT borrows the ReadyToRun (R2R) layout. The binary includes an R2R directory identified by the signature RTR\0 that points to tagged sections. The most interesting tag is DEHYDRATED_DATA (tag 0xCF): a compressed instruction stream that, at startup, rehydrates into a memory buffer holding metadata-like structures. To analyze statically, simulate that rehydration.

Informal rehydration instruction set:

  • Copy(n) - copy the next n bytes into the output
  • ZeroFill(n) - write n zero bytes
  • RelPtr32Reloc, PtrReloc, InlineRelPtr32Reloc, InlinePtrReloc - materialize pointers or offsets via a fixup table

Implement these, run the stream, and you get the in-memory hydrated view the runtime would see.


Starter script: finding RTR\0 and sketching a rehydrator

Minimal scaffolding for PE or ELF. Extend as needed. This does not include full relocation or fixup handling. It gives framing you can grow.

#!/usr/bin/env python3
# quick_rtr_probe.py
# Usage: python3 quick_rtr_probe.py nativeaot_binary

import sys, struct

RTR_SIG = b'RTR\x00'

def find_all(buf, needle):
    i = 0
    while True:
        j = buf.find(needle, i)
        if j == -1: break
        yield j
        i = j + 1

def parse_rtr_header(buf, off):
    # Very simplified. Align to your target's actual header structure.
    # Expect: Signature (4), Major(2), Minor(2), Flags(4), NumSections(2), EntrySize(1), EntryType(1)
    hdr = struct.unpack_from("<IHHIHBb", buf, off)
    sig, maj, minu, flags, nsec, esize, etype = hdr
    return {
        "sig": sig, "maj": maj, "min": minu, "flags": flags,
        "num_sections": nsec, "entry_size": esize, "entry_type": etype
    }

def main():
    if len(sys.argv) != 2:
        print("Usage: quick_rtr_probe.py <binary>")
        return
    data = open(sys.argv[1], "rb").read()
    hits = list(find_all(data, RTR_SIG))
    if not hits:
        print("No RTR\0 signature found.")
        return
    print(f"Found RTR signature at offsets: {[hex(h) for h in hits]}")
    # Parse the first one as a demo
    hdr = parse_rtr_header(data, hits[0])
    print("RTR header (rough):", hdr)
    # TODO:
    # 1) Enumerate sections (need real entry layout).
    # 2) Locate DEHYDRATED_DATA section.
    # 3) Implement rehydration VM:
    #    - iterate opcodes
    #    - maintain output buffer
    #    - apply fixups (absolute and relative)
    # 4) Dump hydrated blob to a file for downstream scanning.
    # 5) Optional: emit Ghidra-friendly CSV of pointers and regions.

if __name__ == "__main__":
    main()

Hunting method tables in the hydrated blob

Once you rehydrate, search for structures that look like .NET method tables: a flag field, base size, a RelatedType pointer (often the base class), counts for vtable slots and interfaces, followed by arrays of function pointers and interface method table pointers. The exact layout varies across versions, but the theme is stable enough to pattern match heuristically.

Practical heuristic to find System.Object:

  • Flags indicate a class, not an interface or valuetype.
  • Exactly three nonzero vtable slots: ToString, Equals(object), GetHashCode.
  • RelatedType is null (no base class).
  • Many other types have RelatedType fields that point to it.

With System.Object pinned, iterate all pointers in the hydrated region:

  • If a pointer lands inside a plausible method table with a RelatedType you already know, add it to the graph.
  • Harvest interface lists at the tail of each method table to expand coverage.
  • Maintain an exclusion list for false positives such as nonsensical slot counts or flags.

Frozen objects and strings

Hydrated data often contains frozen instances serialized at publish time such as static readonly fields and interned strings. After you identify method tables for System.String and related types, scan for consistent object headers and pointer graphs to annotate string pools and common constants. This provides helpful naming and context when lifting to Ghidra.


Making Ghidra friendlier

  • Auto-label: write a short Ghidra or Jython script that ingests your hydrated dump and discovered method tables, then sets symbols or types on vtable entries and object arrays.
  • Datatype archives: define a MethodTable_t with flag, size, and slot fields so the listing becomes readable.
  • Bookmarks: mark the hydrated region, the ReadyToRun directory, and fixup tables to navigate faster.

Limits and gotchas

  • Version drift: flags and encodings can evolve across .NET versions, so keep heuristics loose.
  • Platforms: NativeAOT outputs differ across OS and architecture and may inline or trim aggressively.
  • Symbols: if the build stripped native symbols, expect more guesswork. If not, leverage PDB or dSYM where available.

Appendix: tiny Ghidra labeling stub

Feed it a CSV you generate during rehydration such as addr,label.

# ghidra_script.py (run inside Ghidra's Script Manager)
#@category NativeAOT
#@keybinding
#@menupath Tools.NativeAOT.Apply Labels
from ghidra.program.model.symbol import SourceType
from ghidra.util.task import ConsoleTaskMonitor

csv = askFile("CSV with addr,label", "Load")
monitor = ConsoleTaskMonitor()
fm = currentProgram.getFunctionManager()
sym = currentProgram.getSymbolTable()

for line in open(csv.absolutePath, "r"):
    line=line.strip()
    if not line or line.startswith("#"): continue
    addr_s,label = line.split(",",1)
    a = toAddr(int(addr_s,16))
    sym.createLabel(a, label, currentProgram.getGlobalNamespace(), SourceType.USER_DEFINED)
print("Done.")

Closing

NativeAOT is not the end of .NET reversing. It is a different landscape. With a quick signature check, a small rehydration VM, and some method table graphing, you can restore a surprising amount of structure and make native looking blobs feel familiar again.