.NET Assembly Static Analysis with REA: Metadata, CIL, Members
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 targets are Newtonsoft.Json and its small companion package Newtonsoft.Json.Bson, both MIT-licensed libraries by James Newton-King 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
- 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
-
REA installed. Follow Getting started with REA and MCP, or install the CLI globally:
npm install --global rea-agents rea --helpYou can also prefix any command with
npx -y rea-agents@latestinstead of installing. -
jqfor slicing JSON output. Real assemblies produce large results, and you rarely want to read all of it at once. -
Target assemblies. We download one release of Newtonsoft.Json and two releases of Newtonsoft.Json.Bson from nuget.org. A
.nupkgfile is a ZIP archive, sounzipis enough:mkdir -p ~/rea-dotnet && cd ~/rea-dotnet curl -L -o nj-13.0.3.nupkg https://www.nuget.org/api/v2/package/Newtonsoft.Json/13.0.3 curl -L -o bson-1.0.1.nupkg https://www.nuget.org/api/v2/package/Newtonsoft.Json.Bson/1.0.1 curl -L -o bson-1.0.2.nupkg https://www.nuget.org/api/v2/package/Newtonsoft.Json.Bson/1.0.2 unzip -q nj-13.0.3.nupkg -d nj-13.0.3 unzip -q bson-1.0.1.nupkg -d bson-1.0.1 unzip -q bson-1.0.2.nupkg -d bson-1.0.2 ls nj-13.0.3/lib/ bson-1.0.1/lib/Steps 1 and 2 use
nj-13.0.3/lib/netstandard2.0/Newtonsoft.Json.dll. Steps 3 and 4 uselib/netstandard1.3/Newtonsoft.Json.Bson.dllfrom each Bson package, so both sides of the comparison target the same API surface (1.0.1 has no netstandard2.0 build). Why two packages: Bson is small enough that you can read its member and comparison output end to end, which is hard with the 18 MB member dump of the full Newtonsoft.Json.
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, REA 6.7.0 reports runtime_family as unknown, implementation as cil and managed_architecture as anycpu. The classification.evidence list says why: its target-framework-observed entry reads “An assembly-level TargetFrameworkAttribute names .NET Standard, an API specification that does not identify a runtime family.” That is accurate, since a .NET Standard library can load on .NET Framework as well as on modern .NET. The exact target stays in target_frameworks (here .NETStandard,Version=v2.0). REA 6.4.0 reported modern-dotnet for the same file, so scripts that branch on runtime_family behave differently after the upgrade.
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, all three managed tools take an optional absolute path (omit it to reuse the active managed target). In the 6.7.0 tool schemas, inspect_managed_artifact and inspect_managed_members also take detail: "summary" returns identity, coverage and per-collection counts plus a linked inspect_analysis_view that pages the full rows (view kind managed or managed-members). inspect_managed_members also takes type_names, the MCP form of --type in Step 3. inspect_managed_native_boundaries still takes only path. 6.4.0 had none of these. I read them from the 6.7.0 schemas and did not exercise them over MCP; on the CLI, --json to a file plus jq works as shown here.
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,InternalsVisibleToAttributeor 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
inspect-managed-members decodes types, fields, methods, signatures, CIL bodies, call edges and field accesses for the whole assembly. In 6.7.0 you can add --type <full type name> (repeatable) to decode only the members of the types you name; 6.4.0 had no such option.
Newtonsoft.Json now fits. REA keeps a 64 MiB budget for the in-memory and JSON representation of decoded metadata. 6.4.0 stopped at that budget on the 13.0.3 netstandard2.0 build (about 690 KB on disk): coverage unavailable, one resource-limit issue, empty types and methods. 6.7.0 still has the 64 MiB constant in its dist/, but the same file now decodes completely:
rea inspect-managed-members "$DLL" --json > members-nj.json
jq '.normalized_result | {coverage, types: (.types | length), methods: (.methods | length)}' members-nj.json
{
"coverage": { "state": "complete", "issues": [] },
"types": 496,
"methods": 4185
}
That run took 1.6 s and wrote an 18.8 MB JSON file. If you only care about one class, select it:
rea inspect-managed-members "$DLL" --type Newtonsoft.Json.JsonTextReader --json > members-jtr.json
jq '.normalized_result | {selection, coverage, types: (.types | length), methods: (.methods | length)}' members-jtr.json
This returns 1 type and 134 methods in a 1.9 MB file, and selection.unmatched_type_names lists any name that matched nothing. Check that list: when I misspelled the name as JsonTextReadr, REA returned 0 types and 0 methods with coverage still complete and no error. member_refs and exported_types stay assembly-wide: the JsonTextReader run still carried all 2,068 member references.
The rest of this step reads Newtonsoft.Json.Bson, the assembly Step 4 compares:
BSON=~/rea-dotnet/bson-1.0.2/lib/netstandard1.3/Newtonsoft.Json.Bson.dll
rea inspect-managed-members "$BSON" --json > members.json
jq '.normalized_result.coverage' members.json
jq -r '.normalized_result.types[].full_name' members.json | head -20
Always look at coverage before reading the arrays. A partial state means some facts were not decoded, so a missing member is not proof that it is absent.
Now read a method body:
jq '.normalized_result.methods[]
| select(.declaring_type == "Newtonsoft.Json.Bson.BsonDataReader" and .name == "Read")
| {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,absentormalformed. Abstract and extern methods have no body. That’s normal.body.anchors: decoded instructions withil_offset,opcode,operand_kindandoperand.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 assembly
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 netstandard1.3 build of Newtonsoft.Json.Bson between 1.0.1 and 1.0.2?
OLD=~/rea-dotnet/bson-1.0.1/lib/netstandard1.3/Newtonsoft.Json.Bson.dll
NEW=~/rea-dotnet/bson-1.0.2/lib/netstandard1.3/Newtonsoft.Json.Bson.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 and 64 MiB representation budget as single inspection. In 6.4.0 that ruled out comparing two Newtonsoft.Json releases (both sides came back unavailable). 6.7.0 compares 13.0.2 with 13.0.3 in 1.7 s with complete coverage on both sides: 5,187 unchanged, 1,194 changed, nothing unknown. We stay with Bson because its result is small enough to walk through.
For Bson 1.0.1 against 1.0.2, 6.7.0 returned:
"summary": { "unchanged": 494, "changed": 326, "added": 0, "removed": 2, "unknown": 5 },
"coverage": { "status": "complete-within-inputs", "left_status": "complete", "right_status": "complete" }
How REA pairs members, and why it matters:
- Exact CIL plus signature is tried first.
- If that fails, it pairs on the exact declared type, method name and raw signature together. A name alone never forms a match.
- Then structural method shape: normalized signatures, the decoded-CIL fingerprint, constants and bounded context.
- 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 1.0.1 say 0x06000ABC, look up the matched item in compare.json to find its token in 1.0.2.
Because Newtonsoft.Json.Bson is open source, you can verify your reading: open the GitHub repository, compare the two release tags, and check that the methods REA marks changed line up with real source edits. Of the 326 changed methods, 282 differ only in the cil dimension, so check them against the source diff before calling them edits. The two removed items are compiler-generated async state-machine fields (<ReadElementAsync>d__2::<>7__wrap1 and <DoReadAsBytesAsync>d__32::<t>5__2), and the five unknown items are fields whose signature matched several candidates on each side, which REA reports as ambiguous rather than guessing a remap. With 6.4.0 the same pair had partial coverage on both sides and 341 unknown, 223 added and 224 removed items, so older reports of this comparison overstate the churn. Treat REA’s result as the record of what the shipped builds contain, and the source diff as the explanation.
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-imagereturnsobservations.metadata_recoverywith the RTR header, MethodTable addresses and frozen strings. The enclosing load-image status can readunsupported. 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 unavailable coverage. The assembly’s decoded metadata exceeded the 64 MiB representation budget. Rerun with --type for the types you need (MCP: type_names); 6.4.0 had no such option. Inputs above 128 MiB are rejected at admission.
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. REA found no marker that ties the build to a runtime. Read classification.evidence and target_frameworks to see what it did observe. A plain .NET Standard library lands here in 6.7.0; 6.4.0 reported it as modern-dotnet.
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
- Keep these results for later and compare them as Evidence bundles: Evidence, snapshots and version comparison in REA.
- For NativeAOT and other native targets, set up a provider: REA + Ghidra for native binaries.
- New to REA’s agent setup: Getting started with REA and MCP.
- How REA’s evidence-first approach differs from other reverse-engineering MCP servers: REA vs GhidraMCP vs IDA Pro MCP.
- Primary reference: docs/managed-code-analysis.md and docs/cli.md.