rea-reverse

.NET Assembly Static Analysis with REA: Metadata, CIL, Members

Written for REA 6.4.0Updated 10 min read
On this page

Short version: REA, the open-source project by morluto, reads .NET PE/CLI assemblies with its own TypeScript parser. It never loads or executes the assembly, and it needs no .NET SDK, ILSpy or disassembler. You run rea inspect-managed-artifact first to learn what the file is, then rea inspect-managed-members to read types, signatures and CIL bodies. When you have two builds, rea compare-managed-members compares them. NativeAOT output has no CIL bodies, so it takes a separate native route through Ghidra.

This guide is for developers who already use REA (or are about to) and want to look inside a .NET library they depend on. You might be auditing a dependency upgrade, checking what a NuGet package actually ships, or preparing context for an AI agent before it reads decompiled C#. Our example target is Newtonsoft.Json, an MIT-licensed library whose source is public, so you can check every finding against the original code.

You will learn:

  • how REA classifies a managed artifact (runtime family, implementation, architecture)
  • which metadata facts it returns, and why tokens are “build-local”
  • how to read CIL bodies, call edges and field accesses for selected types
  • how to compare two versions of the same assembly without trusting names alone
  • where managed analysis stops and why NativeAOT is a different job

Prerequisites

  1. REA installed. Follow Getting started with REA and MCP, or install the CLI globally:

    npm install --global rea-agents
    rea --help

    You can also prefix any command with npx -y rea-agents@latest instead of installing.

  2. jq for slicing JSON output. Real assemblies produce large results, and you rarely want to read all of it at once.

  3. A target assembly. We download two releases of Newtonsoft.Json from nuget.org. A .nupkg file is a ZIP archive, so unzip is enough:

    mkdir -p ~/rea-dotnet && cd ~/rea-dotnet
    curl -L -o nj-13.0.1.nupkg https://www.nuget.org/api/v2/package/Newtonsoft.Json/13.0.1
    curl -L -o nj-13.0.3.nupkg https://www.nuget.org/api/v2/package/Newtonsoft.Json/13.0.3
    unzip -q nj-13.0.1.nupkg -d nj-13.0.1
    unzip -q nj-13.0.3.nupkg -d nj-13.0.3
    ls nj-13.0.3/lib/

    We use the lib/netstandard2.0/Newtonsoft.Json.dll file from each package, so both sides of the later comparison target the same API surface.

No .NET runtime is needed. The managed-code docs state that the npm package contains no .NET runtime, SDK or ILSpy, and that setup never installs them (managed-code analysis).

Static inspection does not run the target. If you later move on to runtime capture of a .NET application, do it only on software and systems you are authorized to analyze, and read the project’s DISCLAIMER.md first.

Step 1: Identify the artifact with inspect-managed-artifact

Always identify the file before you read its members. A .dll or .exe extension tells you nothing reliable. The file could be a plain native PE, a ReadyToRun assembly, a C++/CLI mixed-mode image, or a NativeAOT executable. REA’s CLI guide states this directly: choose the workflow from the artifact’s contents, not its extension.

DLL=~/rea-dotnet/nj-13.0.3/lib/netstandard2.0/Newtonsoft.Json.dll
rea inspect-managed-artifact "$DLL" --json > artifact.json

The CLI prints a complete Evidence record. The analysis result is under normalized_result. Start with the classification block:

jq '.normalized_result.classification | {status, runtime_family, implementation, managed_architecture}' artifact.json
jq '.normalized_result | {target_frameworks, assembly: .assembly.name, version: .assembly.version, mvid: .module.mvid}' artifact.json

What the fields mean, according to REA’s schema:

Field Possible values Why it matters
classification.status managed, not-managed, malformed If this isn’t managed, stop. Member inspection is the wrong tool.
runtime_family dotnet-framework, modern-dotnet, unity-mono, mixed-clr-native, unknown Tells you which runtime the build is tied to, when markers prove it.
implementation cil, cil-and-ready-to-run, cpp-cli-mixed, metadata-only, … Says whether method bodies are plain CIL or also have precompiled native code.
managed_architecture anycpu, anycpu-prefer-32, x86, x86_64, arm, arm64, unknown Matters when you later match P/Invoke declarations to native libraries.
metadata.status complete, partial, malformed, absent A partial status means a missing row is not proof of absence.

For our netstandard2.0 build, expect runtime_family to be unknown. That isn’t an error. The managed-code guide explains that a .NET Standard target only declares API compatibility, so REA keeps the target in target_frameworks (for example .NETStandard,Version=v2.0). It reports a runtime family only when independent markers prove one, such as a ReadyToRun signature.

Each classification.evidence entry carries a code, a detail and a file_offset. You can therefore point at the exact bytes that support a classification instead of quoting a summary.

Tip for agents: in MCP, the docs recommend calling inspect_managed_artifact with {"path": "...", "detail": "summary"} first. You get a compact overview, and the full result stays retained in the session. You can then page references, resources or attributes through inspect_analysis_view using normalized_result.parent_evidence_id. This keeps a large assembly from flooding the agent’s context.

Step 2: Read the metadata inventory

The same artifact result holds the assembly-level metadata you usually need first:

# Referenced assemblies (AssemblyRef rows)
jq -r '.normalized_result.references[] | "\(.name) \(.version)"' artifact.json

# Embedded or linked resources
jq -r '.normalized_result.resources[] | "\(.name) embedded=\(.embedded) len=\(.data_length)"' artifact.json

# Custom attributes with a decoded string argument
jq -r '.normalized_result.attributes[] | select(.decoded_fixed_string != null) | "\(.type_name): \(.decoded_fixed_string)"' artifact.json

Why look at these before the code:

  • References show the dependency surface. A JSON library that suddenly references a networking assembly in a new release is worth a closer look.
  • Resources show embedded data that never appears as code.
  • Attributes such as TargetFrameworkAttribute, InternalsVisibleToAttribute or assembly version attributes often explain build decisions.

Every row carries a metadata token and a row_offset. Tokens are build-local. REA’s docs say this repeatedly: a token such as 0x06001234 only means something together with the artifact’s SHA-256 and the module MVID. Write down normalized_result.artifact.sha256 and normalized_result.module.mvid next to any token you put in a note or bug report.

Step 3: Inspect members and CIL for selected types

inspect-managed-members decodes types, fields, methods, signatures, CIL bodies, call edges and field accesses. Running it on the whole assembly works, but for a library the size of Newtonsoft.Json the output is large. Start with the type list:

rea inspect-managed-members "$DLL" --json > members-all.json
jq -r '.normalized_result.types[].full_name' members-all.json | grep '^Newtonsoft.Json\.' | head -40

Then narrow down with --type. The value must be an exact full_name from that list. Repeat --type to select several types:

rea inspect-managed-members "$DLL" \
  --type Newtonsoft.Json.JsonConvert \
  --type Newtonsoft.Json.JsonTextReader \
  --json > members.json

jq '.normalized_result.selection' members.json

Check selection.unmatched_type_names first. A typo or a guessed namespace shows up there; REA doesn’t silently ignore it. Nested types are a known caveat: they currently report bare names, so a bare name can select several nested types at once.

Selection has another use. The docs describe a 64 MiB representation budget for metadata and CIL decoding. If a whole-assembly run reports resource-limit with partial coverage, selecting types lets REA decode the parts you care about within budget. Member references stay an assembly-wide inventory, so the output can still be big.

Now read a method body:

jq '.normalized_result.methods[]
    | select(.declaring_type == "Newtonsoft.Json.JsonConvert" and .name == "SerializeObject")
    | {token, sig: .signature.parameter_types, ret: .signature.return_type,
       il_size: .body.il_size, status: .body.status, ops: .body.opcode_counts}' members.json

Useful fields on each method:

  • body.status: present, partial, absent or malformed. Abstract and extern methods have no body. That’s normal.
  • body.anchors: decoded instructions with il_offset, opcode, operand_kind and operand.
  • body.exception_regions: try/catch/finally layout.
  • body.il_sha256: digest of the exact raw CIL bytes.
  • body.normalized_il_sha256: digest of the decoded [opcode, operand_kind, operand] tuple list. The docs call this a reproducible fingerprint, not a semantic identity. It doesn’t resolve tokens across builds.

Two relationship arrays let you follow the code without a decompiler:

# Who calls what inside the selected types
jq -r '.normalized_result.call_edges[] | "\(.caller) -[\(.opcode)]-> \(.target_name)"' members.json | head -30

# Which methods read or write which fields
jq -r '.normalized_result.field_accesses[] | "\(.method) \(.opcode) \(.field_name)"' members.json | head -30

This is the level an AI agent should start from. Call edges and field accesses are observed facts tied to tokens and offsets. Decompiled C# is a reconstruction. If you want C#, REA can import output from a decompiler you supply (import-managed-reconstruction). The import is recorded as reconstruction inference against the exact static member Evidence, not as a replacement for it.

Native boundaries (P/Invoke)

If an assembly calls native code, list its declarations:

rea inspect-managed-native-boundaries "$DLL" --json > native.json

The results include P/Invoke imports (import_name, import_scope_name, char_set, call_convention) and native implementation markers. Each import carries verification: "managed-declaration-only". That label is accurate: a declaration proves the assembly declares a call, not that it ever runs. A pure managed library like Newtonsoft.Json may have few or none. The command is more interesting on assemblies that wrap a native library. To check declarations against real export data from native analysis, use verify-managed-native-boundaries.

Step 4: Compare two builds with compare-managed-members

Now the version question: what changed in the netstandard2.0 build between 13.0.1 and 13.0.3?

OLD=~/rea-dotnet/nj-13.0.1/lib/netstandard2.0/Newtonsoft.Json.dll
NEW=~/rea-dotnet/nj-13.0.3/lib/netstandard2.0/Newtonsoft.Json.dll
rea compare-managed-members "$OLD" "$NEW" --json > compare.json

jq '.normalized_result | {summary, matching, coverage}' compare.json

The first argument is the baseline and the second is the candidate. Each path is inspected in turn, under the same 128 MiB per-file admission limit as single inspection.

How REA pairs members, and why it matters:

  1. Exact CIL plus signature is tried first.
  2. If that fails, it pairs on the exact declared type, method name and raw signature together. A name alone never forms a match.
  3. Then structural method shape: normalized signatures, the decoded-CIL fingerprint, constants and bounded context.
  4. Duplicates stay ambiguous. REA doesn’t break ties by token order.

So summary gives you unchanged, changed, added, removed and unknown counts, and matching counts how many pairs each tier produced (each item’s match.basis names its tier). List the changed methods and the dimension that changed:

jq -r '.normalized_result.methods[]
       | select(.status == "changed")
       | "\(.left.declaring_type)::\(.left.name)  \(.dimensions | join(","))"' compare.json | head -40

Dimensions include signature, cil, opcode-shape, call-shape, field-shape, exception-shape, availability, body-coverage and metadata. The metadata dimension catches flag changes such as accessibility. Identical CIL doesn’t mean unchanged method metadata.

Read unknown honestly. The docs explain that an unmatched member is unknown rather than added/removed when its signature wasn’t decoded, or when an unpaired same-name member on the other side has an undecoded signature. When coverage is partial, one-sided absence is not proof of removal.

Finally, never carry a token across builds. compare-managed-members remaps tokens through observed structure. If your notes for 13.0.1 say 0x06000ABC, look up the matched item in compare.json to find its token in 13.0.3.

Because Newtonsoft.Json is open source, you can verify your reading: open the GitHub diff between the two release tags and check that the methods REA marks changed line up with real source edits. The upstream release notes list new APIs only in 13.0.2 (for example UnixDateTimeConverter.AllowPreEpoch and JsonCloneSettings); 13.0.3 lists bug fixes only. Expect some added items, but treat REA’s result, not the notes, as the record of what the netstandard2.0 build actually contains.

Step 5: Recognize NativeAOT and switch routes

NativeAOT publishing (dotnet publish -p:PublishAot=true) compiles the application to a native image. The ordinary CIL method bodies are gone. The managed-code guide states the consequence plainly: a CIL decompiler can’t reconstruct those bodies as C#, and inspect_managed_artifact doesn’t infer a NativeAOT identity from an ordinary native PE. If you point the managed tools at a NativeAOT binary, expect not-managed. Don’t force it.

The NativeAOT image can be PE, ELF or Mach-O, and can even be a shared library. Identify it from its bytes, not its file name. Then use the native route:

rea inspect-native-load-image ./NativeAotFixture --provider ghidra --json
rea inspect-native-data-type ./NativeAotFixture --address 0xMETHODTABLE_ADDRESS --provider ghidra --json
rea function ./NativeAotFixture 0xADDRESS --provider ghidra --json

These commands come from REA’s NativeAOT guide. Replace the placeholder addresses with values from your own results. Know the boundaries before you start:

  • The built-in, read-only parser handles x64 PE32+ with .NET 8 RTR 9.1 metadata. Other RTR versions and architectures are reported as unsupported or partial.
  • For an x64 PE, inspect-native-load-image returns observations.metadata_recovery with the RTR header, MethodTable addresses and frozen strings. The enclosing load-image status can read unsupported. That means independent PE load-image verification wasn’t done. It does not mean the parser failed.
  • Recovered names are generated, not original. Original class/member names and custom field layouts remain unknown, and the pseudocode is recovered native logic, not original C#.
  • An optional headless Ghidra adapter (a locally built JAR supplied through REA_GHIDRA_NATIVEAOT_JAR) adds database annotations. REA does not install it for you.

To practise, use a NativeAOT build of your own: a small console app published for win-x64 on .NET 8, copied to your analysis host. That keeps you within the “your own binary” rule and matches the parser’s supported layout. Ghidra setup is covered in REA + Ghidra for native binaries.

Troubleshooting

classification.status is not-managed for a file I know is .NET. It may be a single-file host bundle, which REA doesn’t unpack, or a NativeAOT image. Extract the embedded assembly with a tool you trust, then inspect it. Or switch to the native route.

Unity game assemblies. A Mono-backed Unity Assembly-CSharp.dll is ordinary PE/CLI and may classify as unity-mono. IL2CPP builds are different: REA does not decode IL2CPP metadata.

resource-limit with partial coverage. You hit the 64 MiB decoding budget. Rerun inspect-managed-members with --type for the types you need. Inputs above 128 MiB are rejected at admission.

selection.unmatched_type_names isn’t empty. The name must match full_name exactly, including namespace and case. Copy it from a full run.

The comparison reports many unknown items. Check coverage.left_status and coverage.right_status. Partial metadata on either side turns one-sided members into unknown. That is deliberate.

runtime_family is unknown. For netstandard libraries this is expected. The target framework is in target_frameworks.

Output is huge in the terminal. --json is compact when piped. Always redirect to a file and query with jq. The default terminal format is TOON, which agents read easily but scripts don’t.

Next steps