Recovering Useful Metadata from .NET NativeAOT Binaries
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 nextn
bytes into the outputZeroFill(n)
- writen
zero bytesRelPtr32Reloc
,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.