用 REA 静态分析 .NET 程序集:元数据、CIL 与成员对比
本页目录
结论先行: REA(morluto 的开源项目)用自带的 TypeScript 解析器读取 .NET 的 PE/CLI 程序集。它不加载、不执行目标代码,也不需要 .NET SDK、ILSpy 或反汇编器。基本流程分三步:先用 rea inspect-managed-artifact 确认文件是什么,再用 rea inspect-managed-members 读取类型、签名和 CIL 方法体;手上有两个版本时,用 rea compare-managed-members 做对比。NativeAOT 产物没有 CIL 方法体,必须改走 Ghidra 原生分析路线。
这篇文章适合已经在用 REA、想弄清某个 .NET 依赖库内部情况的开发者。典型场景有三类:评估依赖升级、核实 NuGet 包里到底打包了什么、在让 AI agent 读反编译 C# 之前先给它一份可靠的事实底稿。示例目标是 Newtonsoft.Json。它采用 MIT 许可、源码公开,所以每一条结论都可以拿原始代码核对。
读完你会掌握:
- REA 怎样给托管产物分类(运行时家族、实现形式、架构)
- 它返回哪些元数据,以及为什么 token 是“构建内局部”的
- 怎样只针对选定类型读取 CIL、调用边和字段访问
- 怎样在不轻信名字的前提下对比同一程序集的两个版本
- 托管分析的能力边界在哪里,NativeAOT 为什么要换一条路
前置条件
-
安装 REA。 参考 安装 REA 并接入 Claude Code / Cursor / Codex,或者全局安装 CLI:
npm install --global rea-agents rea --help不想全局安装的话,在每条命令前加
npx -y rea-agents@latest也可以。 -
jq,用来切分 JSON 输出。真实程序集的结果很大,一般不会整份读。 -
目标程序集。 从 nuget.org 下载 Newtonsoft.Json 的两个版本。
.nupkg本质是 ZIP,用unzip解开即可: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/两个包都取
lib/netstandard2.0/Newtonsoft.Json.dll,这样后面对比时两边面向的是同一套 API。
整个过程不需要 .NET 运行时。官方文档写明:npm 包里不含 .NET runtime、SDK 和 ILSpy,安装流程也不会替你装这些(见 managed-code analysis)。
静态检查不会运行目标程序。如果后面要对 .NET 应用做运行时采集,只能针对你有权分析的软件和系统,并先阅读项目的 DISCLAIMER.md。
第 1 步:用 inspect-managed-artifact 识别产物
读成员之前,先确认文件类型。.dll 或 .exe 扩展名并不可靠:这个文件可能是普通原生 PE,也可能是 ReadyToRun 程序集、C++/CLI 混合模式映像或者 NativeAOT 可执行文件。REA 的 CLI 文档明确要求:根据文件内容选择工作流,不要根据扩展名。
DLL=~/rea-dotnet/nj-13.0.3/lib/netstandard2.0/Newtonsoft.Json.dll
rea inspect-managed-artifact "$DLL" --json > artifact.json
CLI 输出的是一条完整的 Evidence 记录,分析结果在 normalized_result 下面。先看分类:
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
各字段含义(依据 REA 的 schema):
| 字段 | 可能的值 | 为什么重要 |
|---|---|---|
classification.status |
managed、not-managed、malformed |
不是 managed 就该停下,成员检查不适用于这个文件 |
runtime_family |
dotnet-framework、modern-dotnet、unity-mono、mixed-clr-native、unknown |
只有存在可证明的标记时,才说明它绑定哪个运行时 |
implementation |
cil、cil-and-ready-to-run、cpp-cli-mixed、metadata-only 等 |
区分方法体是纯 CIL,还是另带预编译的原生代码 |
managed_architecture |
anycpu、anycpu-prefer-32、x86、x86_64、arm、arm64、unknown |
之后把 P/Invoke 声明和原生库对上号时要用到 |
metadata.status |
complete、partial、malformed、absent |
为 partial 时,查不到某一行不能当作“不存在” |
对 netstandard2.0 版本来说,runtime_family 是 unknown,这是正常结果,不是出错。文档解释过:.NET Standard 只声明 API 兼容性,所以 REA 把它记在 target_frameworks 里(例如 .NETStandard,Version=v2.0)。只有当 ReadyToRun 签名这类独立标记能证明运行时家族时,才会给出具体值。
classification.evidence 的每一项都带 code、detail 和 file_offset。下结论时可以直接指向支撑它的字节位置,不用转述摘要。
给 agent 用的建议: 走 MCP 时,文档建议第一次调用 inspect_managed_artifact 传 {"path": "...", "detail": "summary"}。这样先拿到一份精简概览,完整结果留在会话里,再用 normalized_result.parent_evidence_id 通过 inspect_analysis_view 分页读取 references、resources 或 attributes,大程序集就不会一次塞满 agent 的上下文。
第 2 步:读元数据清单
同一份 artifact 结果里,已经包含最先需要的程序集级元数据:
# 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
先看这些、再看代码的理由:
- 引用反映依赖面。一个 JSON 库在新版本里突然引用了网络相关的程序集,就值得细查。
- 资源会暴露不以代码形式出现的内嵌数据。
- 特性(如
TargetFrameworkAttribute、InternalsVisibleToAttribute、版本相关特性)常常能解释构建时的决定。
每一行都带元数据 token 和 row_offset。token 是构建内局部的,这一点文档反复强调:像 0x06001234 这样的 token,只有和产物的 SHA-256、模块 MVID 放在一起才有意义。在笔记或 issue 里引用 token 时,把 normalized_result.artifact.sha256 和 normalized_result.module.mvid 一起写上。
第 3 步:针对选定类型读取成员和 CIL
inspect-managed-members 会解码类型、字段、方法、签名、CIL 方法体、调用边和字段访问。对整个程序集直接跑也行,但 Newtonsoft.Json 这种规模的库输出会很大。先拿类型列表:
rea inspect-managed-members "$DLL" --json > members-all.json
jq -r '.normalized_result.types[].full_name' members-all.json | grep '^Newtonsoft.Json\.' | head -40
然后用 --type 缩小范围。参数值必须和列表里的 full_name 完全一致;要选多个类型就重复传 --type:
rea inspect-managed-members "$DLL" \
--type Newtonsoft.Json.JsonConvert \
--type Newtonsoft.Json.JsonTextReader \
--json > members.json
jq '.normalized_result.selection' members.json
先检查 selection.unmatched_type_names。拼错的名字或猜错的命名空间都会列在这里,REA 不会悄悄忽略。另外要注意嵌套类型:目前它们只报告裸名,用裸名选择时可能一次命中好几个同名嵌套类型。
按类型选择还有一个作用。文档规定元数据和 CIL 解码有 64 MiB 的表示预算。如果整份分析返回 resource-limit 和 partial 覆盖,按类型选择就能让关心的部分在预算内解码完。不过成员引用(member references)始终是整个程序集的清单,所以输出仍可能不小。
接着看具体方法体:
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
每个方法上值得关注的字段:
body.status:present、partial、absent或malformed。抽象方法和 extern 方法本来就没有方法体。body.anchors:已解码的指令,包含il_offset、opcode、operand_kind、operand。body.exception_regions:try/catch/finally 的布局。body.il_sha256:原始 CIL 字节的精确摘要。body.normalized_il_sha256:解码后[opcode, operand_kind, operand]元组序列的摘要。文档称它是“可复现的指纹”,不等于语义身份,也不会跨构建解析 token。
不借助反编译器,也可以用两组关系数据顺着代码往下读:
# 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
让 AI agent 从这一层起步最稳妥。调用边和字段访问是绑定到 token 和偏移的观测事实,反编译出来的 C# 只是重建结果。需要 C# 的话,REA 可以导入你自备的反编译器输出(import-managed-reconstruction)。导入结果会作为“重建推断”关联到精确的静态成员 Evidence 上,不会取代后者。
原生边界(P/Invoke)
如果程序集调用了原生代码,可以列出这些声明:
rea inspect-managed-native-boundaries "$DLL" --json > native.json
结果包括 P/Invoke 导入(import_name、import_scope_name、char_set、call_convention)和原生实现标记。每条导入都标着 verification: "managed-declaration-only",这个标注很准确:声明只能证明程序集“声明了”这个调用,证明不了它真的执行过。Newtonsoft.Json 这类纯托管库可能几乎没有这类声明,在封装原生库的程序集上这条命令更有用。要把声明和原生分析得到的真实导出对照起来,用 verify-managed-native-boundaries。
第 4 步:用 compare-managed-members 对比两个版本
回到版本问题:13.0.1 到 13.0.3 之间,netstandard2.0 构建改了什么?
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
第一个参数是基线,第二个是候选版本。两个路径依次检查,每个文件都受和单独检查时相同的 128 MiB 准入限制。
REA 的配对顺序如下,弄清它才能读懂结果:
- 先尝试 CIL 与签名完全一致。
- 不行的话,用 声明类型 + 方法名 + 原始签名 三者合起来精确配对。单凭名字永远配不上。
- 再用 结构形态:规范化签名、解码 CIL 指纹、常量和有界上下文。
- 有多个候选时保持 ambiguous,不会按 token 顺序随便挑一个。
所以 summary 给出 unchanged、changed、added、removed、unknown 的数量,matching 统计每一层各配上了多少对(每个条目的 match.basis 写明它属于哪一层)。列出有变化的方法及变化维度:
jq -r '.normalized_result.methods[]
| select(.status == "changed")
| "\(.left.declaring_type)::\(.left.name) \(.dimensions | join(","))"' compare.json | head -40
维度包括 signature、cil、opcode-shape、call-shape、field-shape、exception-shape、availability、body-coverage 和 metadata。其中 metadata 会捕捉可访问性等标志位的变化,所以 CIL 完全相同,不代表方法元数据没变。
unknown 要如实对待。文档说明了两种情况:成员自身的签名没解码出来,或者另一边有一个同名、未配对、签名也未解码的成员。这时未匹配的成员会标为 unknown,不会标为新增或删除。覆盖为 partial 时,单侧缺失也不能当作“已删除”的证据。
最后,不要把 token 带到另一个构建里用。compare-managed-members 会根据观测到的结构重新映射 token。如果 13.0.1 的笔记里写着 0x06000ABC,去 compare.json 里找到对应的配对项,才能拿到它在 13.0.3 里的 token。
Newtonsoft.Json 是开源的,所以可以自行核对:在 GitHub 上打开两个发布 tag 之间的 diff,看 REA 标为 changed 的方法是否对应真实的源码改动。上游的发布说明只在 13.0.2 里列了新增 API(例如 UnixDateTimeConverter.AllowPreEpoch 和 JsonCloneSettings),13.0.3 只列了 bug 修复。所以可以预期会出现一些 added 条目,但 netstandard2.0 构建里到底有什么,以 REA 的结果为准,而不是以发布说明为准。
第 5 步:识别 NativeAOT 并切换路线
NativeAOT 发布(dotnet publish -p:PublishAot=true)会把程序编译成原生映像,普通的 CIL 方法体不再存在。托管分析文档写得很直接:CIL 反编译器无法把这些方法体还原成 C#,inspect_managed_artifact 也不会从普通原生 PE 推断出 NativeAOT 身份。把托管工具用在 NativeAOT 二进制上,预期结果是 not-managed,不要硬套。
NativeAOT 映像可能是 PE、ELF 或 Mach-O,甚至可以是共享库。要按字节内容判断,别看文件名。确认之后走原生路线:
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
这三条命令来自 REA 的 NativeAOT 文档,占位地址要换成你自己结果里的值。动手前先了解边界:
- 内置的只读解析器只支持 x64 PE32+、.NET 8 RTR 9.1 元数据。其他 RTR 版本和架构会报告 unsupported 或 partial。
- 对 x64 PE,
inspect-native-load-image会返回observations.metadata_recovery,内容包括 RTR 头、MethodTable 地址和冻结字符串。外层 load-image 状态可能显示unsupported,意思是没有做独立的 PE 加载映像校验,不代表解析器失败。 - 恢复出来的名字是生成的,不是原始名称。原始类名、成员名和自定义字段布局都仍是未知;伪代码是恢复的原生逻辑,不是原始 C#。
- 另有一个可选的 headless Ghidra 适配器(需要本地构建 JAR,并通过
REA_GHIDRA_NATIVEAOT_JAR指定),可以往数据库里写注解。REA 不会替你安装它。
练手时用自己构建的 NativeAOT 程序最合适:用 .NET 8 为 win-x64 发布一个小控制台程序,复制到分析机上。这样既满足“分析自己的程序”这条原则,也正好符合解析器支持的格式。Ghidra 的配置见 REA + Ghidra 免费原生二进制分析。
常见问题和排错
明明是 .NET 文件,classification.status 却是 not-managed。 可能是 single-file 宿主包(REA 不负责解包),也可能是 NativeAOT 映像。前者先用可信的工具提取内嵌程序集再检查,后者改走原生路线。
Unity 游戏的程序集。 基于 Mono 的 Unity Assembly-CSharp.dll 是普通的 PE/CLI,可能被分类为 unity-mono。IL2CPP 构建不同,REA 不解码 IL2CPP 元数据。
出现 resource-limit 且覆盖为 partial。 触到了 64 MiB 的解码预算。用 --type 只选需要的类型,重新运行 inspect-managed-members。超过 128 MiB 的输入在准入阶段就会被拒绝。
selection.unmatched_type_names 不为空。 名字必须和 full_name 完全一致,包括命名空间和大小写。最好从完整运行的结果里直接复制。
对比结果里有大量 unknown。 检查 coverage.left_status 和 coverage.right_status。任何一侧元数据为 partial,单侧成员都会变成 unknown,这是有意的设计。
runtime_family 是 unknown。 netstandard 库本来就是这样,目标框架记在 target_frameworks 里。
终端输出太长。 --json 在管道中会输出紧凑格式。养成重定向到文件、再用 jq 查询的习惯。默认终端格式是 TOON,agent 读起来方便,脚本处理不方便。
下一步
- 把这些结果保存下来,用 Evidence 包做长期对比:REA 的 Evidence、快照与版本对比。
- NativeAOT 等原生目标需要先配好 provider:REA + Ghidra 免费原生二进制分析。
- 还没接入 agent:安装 REA 并接入 Claude Code / Cursor / Codex。
- REA 以证据为中心的做法和其他逆向 MCP 有什么区别:REA 与 GhidraMCP、IDA Pro MCP 对比。
- 权威资料:docs/managed-code-analysis.md 和 docs/cli.md。