[GH-ISSUE #3327] [Bug] Equivalent type addresses omit NativeAOT metadata #1196

Closed
opened 2026-10-15 18:31:55 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 14, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/3327

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

MCP inspect_native_data_type silently omits recovered NativeAOT MethodTable metadata when a valid address uses bare hexadecimal digits or a default-space selector, including a percent-encoded selector. The same address with a 0x prefix returns the recovered relationships and slots. Every call succeeds, so callers receive different analysis coverage for equivalent addresses without an explanation.

The reproduction completed with exit status 0. Bare hexadecimal and both default-space selectors returned only the database-unavailable result, without metadata_recovery. Canonical and uppercase-prefixed controls returned recovered metadata. The omitted metadata and successful controls were observed with a recording Ghidra client; the equivalent Ghidra address resolution is supported by source inspection. This reproduction exercises the public MCP tool and production NativeAOT parser without starting Ghidra and does not establish real-engine behavior.

Reproduction

  1. Start from a clean checkout of https://github.com/morluto/rea at the baseline, install dependencies and build the runtime:
git clone https://github.com/morluto/rea.git
cd rea
git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063
npm ci
npm run build:cached
  1. Run the following exact command from the checkout root. It creates a native PE fixture using the repository's tests/fixtures/nativeaotPe.ts, connects an MCP client to the production server and queries the same MethodTable with five accepted spellings. The recording client supplies the documented database-unavailable result for every spelling; recovered metadata comes from the production snapshot parser. The command includes all fixture creation and removes its created files afterward.
node --input-type=module <<'JS'
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { nativeAotPeFixture } from './tests/fixtures/nativeaotPe.ts';
import { composeBinarySession } from './dist/application/binary/BinarySessionComposition.js';
import { AnalysisProviderRegistry } from './dist/application/binary/AnalysisProviderRegistry.js';
import { parseConfig } from './dist/config/parseConfig.js';
import { parseEvidence } from './dist/domain/evidence.js';
import { ok } from './dist/domain/result.js';
import { GhidraProvider } from './dist/ghidra/GhidraProvider.js';
import { GHIDRA_SESSION_CAPABILITIES } from './dist/ghidra/GhidraSessionValues.js';
import { silentLogger } from './dist/logger.js';
import { createServer } from './dist/server/createServer.js';

const root = await mkdtemp(join(tmpdir(), 'rea-nativeaot-repro-'));
const path = join(root, 'sample.exe');
const bytes = nativeAotPeFixture();
bytes.writeUInt16LE(0x22, 0x96);
await writeFile(path, bytes);
const config = parseConfig({ GHIDRA_INSTALL_DIR: root });
if (!config.ok) throw config.error;
const host = {
  platform: 'linux', architecture: 'x64',
  readText: () => 'application.version=12.1.4\napplication.java.min=21\n',
  executable: () => true,
  probeJava: () => ({ osArch: 'amd64', osName: 'Linux', version: '21.0.11', major: 21, home: root, bits: 64, runtime: 'jdk' }),
};
const provider = new GhidraProvider(config.value, silentLogger, {}, host, options => ({
  start: async () => ok({
    name: 'REA Ghidra bridge', run_id: '11111111-1111-4111-8111-111111111111',
    profile_digest: options.profileDigest, provider: { id: 'ghidra', version: '12.1.4' },
    read_only: false, analysis_complete: true, analysis_timed_out: false,
    capabilities: [...GHIDRA_SESSION_CAPABILITIES],
    target: { name: 'sample', language_id: 'x86:LE:64:default', compiler_spec_id: 'gcc', image_base: '0x140000000', default_address_space: 'ram', sha256: options.targetSha256 },
  }),
  callTool: async operation => {
    if (operation !== 'inspect_native_data_type') throw new Error(operation);
    return ok({ status: 'unavailable', reason: 'No Ghidra DataType is defined at this address.', id: null, name: null, kind: 'unavailable', source: 'analysis-database', source_archive: null, address: null, size_bytes: null, alignment_bytes: null, packing_enabled: null, referenced_type: null, array_count: null, array_stride_bytes: null, fields: [], members: [], total_fields: 0, truncated: false, limitations: [] });
  },
  readTargetSnapshot: async () => ok({ kind: 'captured', bytes }),
  close: async () => ok(null),
}));
const session = composeBinarySession(new AnalysisProviderRegistry([provider]));
const server = createServer({ kind: 'session', session }, { logger: silentLogger });
const mcp = new Client({ name: 'nativeaot-repro', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
  await server.connect(serverTransport);
  await mcp.connect(clientTransport);
  const opened = await mcp.callTool({ name: 'open_binary', arguments: { path, provider_id: 'ghidra' } });
  if (opened.isError) throw new Error(JSON.stringify(opened));
  for (const address of ['0x140002280', '140002280', 'ram:0x140002280', '%72am:0x140002280', '0X0140002280']) {
    const reply = await mcp.callTool({ name: 'inspect_native_data_type', arguments: { address } });
    if (reply.isError) throw new Error(JSON.stringify(reply));
    const result = parseEvidence(reply.structuredContent).normalized_result;
    console.log(JSON.stringify({ address, metadata_present: result.metadata_recovery !== undefined }));
  }
} finally {
  await Promise.allSettled([mcp.close(), server.close()]);
  await session.close();
  await rm(root, { recursive: true, force: true });
}
JS
  1. Observe the successful calls and the spelling-dependent metadata_present values shown under Relevant output or evidence.

Expected behavior

Each accepted spelling of this default-space MethodTable address should return the same metadata_recovery, including method_table_address: "0x140002280". The database result can remain unavailable, because the read-only parser does not create a Ghidra DataType. Valid address spelling must not remove the independently recovered metadata.

docs/ghidra-nativeaot.md:68 promises address-based MethodTable inspection with inline read-only metadata even when no database DataType exists. src/ghidra/GhidraInventoryValues.ts:64 explicitly accepts hexadecimal with an optional prefix and percent-encoded address spaces; src/ghidra/GhidraFunctionValues.ts:79 applies that address contract to this operation.

Impact and scope

Impact level: P2. Assessment status: supported. Valid successful type inspections lose recovered metadata based only on address spelling. The observed effect is bounded to the derived NativeAOT type detail; no executable modification or incorrect decompilation was observed.

Workaround status: available. The same MCP sequence verified that the canonical 0x140002280 spelling returns the metadata, so callers can use the canonical default-space address emitted by metadata recovery.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0 on macOS 27.0.1. The host architecture was not recorded in the reproduction evidence. The recording boundary supplies a synthetic Linux x64 installation observation, Ghidra 12.1.4 and Java 21.0.11 with amd64, 64-bit JDK identity. These are supplied observations, not a live Ghidra or Java runtime verification.

Target and artifact context

The repository's RTR 9.1 PE fixture, written as sample.exe, with the inline bytes.writeUInt16LE(0x22, 0x96) adjustment. The supplied target identity is x86:LE:64:default, compiler specification gcc, image base 0x140000000 and default address space ram. The production parser reads the captured fixture bytes; no executable runs and no Ghidra process starts.

Interface

MCP

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

The public MCP sequence completed with exit status 0 and printed:

{"address":"0x140002280","metadata_present":true}
{"address":"140002280","metadata_present":false}
{"address":"ram:0x140002280","metadata_present":false}
{"address":"%72am:0x140002280","metadata_present":false}
{"address":"0X0140002280","metadata_present":true}

Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete. The observations cover the recording engine boundary; address-resolution equivalence comes from source inspection.

Typed outcome or recovery expected

Rerun the public MCP sequence and require recovered metadata for all five spellings, with identical MethodTable identity, relationships and slots. Keep the database-unavailable result intact. A selector in a different address space must not be treated as a default-space MethodTable merely because its numeric offset matches.

Analysis or suspected cause

Source-derived reasoning: src/ghidra/GhidraProviderClient.ts:867 passes the caller's unnormalized address to the snapshot owner's type lookup; src/domain/native/nativeAotPe.ts:1609 accepts only prefixed hexadecimal for that lookup. bridge/ghidra/ReaGhidraBridge.java:4506 resolves bare hexadecimal and decoded default-space selectors to the same Ghidra address, so these spellings do not select different objects. The source comparison explains the spelling-dependent metadata lookup; real-engine behavior was not established by this reproduction.

Additional context

  • #743, closed as completed, introduced NativeAOT metadata recovery; this report concerns accepted address spellings silently losing metadata that the canonical spelling already returns.
  • #744 requests broader NativeAOT decompilation and reconstruction support; this report concerns inconsistent existing MethodTable metadata inspection.
  • #1170, closed as completed, concerns selecting a function by an imported symbol alias; this report concerns successful numeric type-address calls that omit supplemental metadata.
  • #1186, closed as completed, concerns CLI xrefs rejecting advertised symbol names during validation; the numeric type-address spellings here pass validation and succeed.
  • #1185, closed as completed, concerns unsupported NativeAOT host rejection and remediation; this report concerns metadata omission after successful type inspection.
  • #1947 proposes normalization of address-derived comparison text between builds; this report concerns equivalent input address spellings within one type inspection.
Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3327 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior MCP `inspect_native_data_type` silently omits recovered NativeAOT MethodTable metadata when a valid address uses bare hexadecimal digits or a default-space selector, including a percent-encoded selector. The same address with a `0x` prefix returns the recovered relationships and slots. Every call succeeds, so callers receive different analysis coverage for equivalent addresses without an explanation. The reproduction completed with exit status 0. Bare hexadecimal and both default-space selectors returned only the database-unavailable result, without `metadata_recovery`. Canonical and uppercase-prefixed controls returned recovered metadata. The omitted metadata and successful controls were observed with a recording Ghidra client; the equivalent Ghidra address resolution is supported by source inspection. This reproduction exercises the public MCP tool and production NativeAOT parser without starting Ghidra and does not establish real-engine behavior. ### Reproduction 1. Start from a clean checkout of `https://github.com/morluto/rea` at the baseline, install dependencies and build the runtime: ```sh git clone https://github.com/morluto/rea.git cd rea git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063 npm ci npm run build:cached ``` 2. Run the following exact command from the checkout root. It creates a native PE fixture using the repository's `tests/fixtures/nativeaotPe.ts`, connects an MCP client to the production server and queries the same MethodTable with five accepted spellings. The recording client supplies the documented database-unavailable result for every spelling; recovered metadata comes from the production snapshot parser. The command includes all fixture creation and removes its created files afterward. ```sh node --input-type=module <<'JS' import { mkdtemp, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { nativeAotPeFixture } from './tests/fixtures/nativeaotPe.ts'; import { composeBinarySession } from './dist/application/binary/BinarySessionComposition.js'; import { AnalysisProviderRegistry } from './dist/application/binary/AnalysisProviderRegistry.js'; import { parseConfig } from './dist/config/parseConfig.js'; import { parseEvidence } from './dist/domain/evidence.js'; import { ok } from './dist/domain/result.js'; import { GhidraProvider } from './dist/ghidra/GhidraProvider.js'; import { GHIDRA_SESSION_CAPABILITIES } from './dist/ghidra/GhidraSessionValues.js'; import { silentLogger } from './dist/logger.js'; import { createServer } from './dist/server/createServer.js'; const root = await mkdtemp(join(tmpdir(), 'rea-nativeaot-repro-')); const path = join(root, 'sample.exe'); const bytes = nativeAotPeFixture(); bytes.writeUInt16LE(0x22, 0x96); await writeFile(path, bytes); const config = parseConfig({ GHIDRA_INSTALL_DIR: root }); if (!config.ok) throw config.error; const host = { platform: 'linux', architecture: 'x64', readText: () => 'application.version=12.1.4\napplication.java.min=21\n', executable: () => true, probeJava: () => ({ osArch: 'amd64', osName: 'Linux', version: '21.0.11', major: 21, home: root, bits: 64, runtime: 'jdk' }), }; const provider = new GhidraProvider(config.value, silentLogger, {}, host, options => ({ start: async () => ok({ name: 'REA Ghidra bridge', run_id: '11111111-1111-4111-8111-111111111111', profile_digest: options.profileDigest, provider: { id: 'ghidra', version: '12.1.4' }, read_only: false, analysis_complete: true, analysis_timed_out: false, capabilities: [...GHIDRA_SESSION_CAPABILITIES], target: { name: 'sample', language_id: 'x86:LE:64:default', compiler_spec_id: 'gcc', image_base: '0x140000000', default_address_space: 'ram', sha256: options.targetSha256 }, }), callTool: async operation => { if (operation !== 'inspect_native_data_type') throw new Error(operation); return ok({ status: 'unavailable', reason: 'No Ghidra DataType is defined at this address.', id: null, name: null, kind: 'unavailable', source: 'analysis-database', source_archive: null, address: null, size_bytes: null, alignment_bytes: null, packing_enabled: null, referenced_type: null, array_count: null, array_stride_bytes: null, fields: [], members: [], total_fields: 0, truncated: false, limitations: [] }); }, readTargetSnapshot: async () => ok({ kind: 'captured', bytes }), close: async () => ok(null), })); const session = composeBinarySession(new AnalysisProviderRegistry([provider])); const server = createServer({ kind: 'session', session }, { logger: silentLogger }); const mcp = new Client({ name: 'nativeaot-repro', version: '1.0.0' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { await server.connect(serverTransport); await mcp.connect(clientTransport); const opened = await mcp.callTool({ name: 'open_binary', arguments: { path, provider_id: 'ghidra' } }); if (opened.isError) throw new Error(JSON.stringify(opened)); for (const address of ['0x140002280', '140002280', 'ram:0x140002280', '%72am:0x140002280', '0X0140002280']) { const reply = await mcp.callTool({ name: 'inspect_native_data_type', arguments: { address } }); if (reply.isError) throw new Error(JSON.stringify(reply)); const result = parseEvidence(reply.structuredContent).normalized_result; console.log(JSON.stringify({ address, metadata_present: result.metadata_recovery !== undefined })); } } finally { await Promise.allSettled([mcp.close(), server.close()]); await session.close(); await rm(root, { recursive: true, force: true }); } JS ``` 3. Observe the successful calls and the spelling-dependent `metadata_present` values shown under Relevant output or evidence. ### Expected behavior Each accepted spelling of this default-space MethodTable address should return the same `metadata_recovery`, including `method_table_address: "0x140002280"`. The database result can remain `unavailable`, because the read-only parser does not create a Ghidra DataType. Valid address spelling must not remove the independently recovered metadata. `docs/ghidra-nativeaot.md:68` promises address-based MethodTable inspection with inline read-only metadata even when no database DataType exists. `src/ghidra/GhidraInventoryValues.ts:64` explicitly accepts hexadecimal with an optional prefix and percent-encoded address spaces; `src/ghidra/GhidraFunctionValues.ts:79` applies that address contract to this operation. ### Impact and scope Impact level: P2. Assessment status: supported. Valid successful type inspections lose recovered metadata based only on address spelling. The observed effect is bounded to the derived NativeAOT type detail; no executable modification or incorrect decompilation was observed. Workaround status: available. The same MCP sequence verified that the canonical `0x140002280` spelling returns the metadata, so callers can use the canonical default-space address emitted by metadata recovery. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0 on macOS 27.0.1. The host architecture was not recorded in the reproduction evidence. The recording boundary supplies a synthetic Linux x64 installation observation, Ghidra 12.1.4 and Java 21.0.11 with `amd64`, 64-bit JDK identity. These are supplied observations, not a live Ghidra or Java runtime verification. ### Target and artifact context The repository's RTR 9.1 PE fixture, written as `sample.exe`, with the inline `bytes.writeUInt16LE(0x22, 0x96)` adjustment. The supplied target identity is `x86:LE:64:default`, compiler specification `gcc`, image base `0x140000000` and default address space `ram`. The production parser reads the captured fixture bytes; no executable runs and no Ghidra process starts. ### Interface MCP ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence The public MCP sequence completed with exit status 0 and printed: ```text {"address":"0x140002280","metadata_present":true} {"address":"140002280","metadata_present":false} {"address":"ram:0x140002280","metadata_present":false} {"address":"%72am:0x140002280","metadata_present":false} {"address":"0X0140002280","metadata_present":true} ``` Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete. The observations cover the recording engine boundary; address-resolution equivalence comes from source inspection. ### Typed outcome or recovery expected Rerun the public MCP sequence and require recovered metadata for all five spellings, with identical MethodTable identity, relationships and slots. Keep the database-unavailable result intact. A selector in a different address space must not be treated as a default-space MethodTable merely because its numeric offset matches. ### Analysis or suspected cause Source-derived reasoning: `src/ghidra/GhidraProviderClient.ts:867` passes the caller's unnormalized address to the snapshot owner's type lookup; `src/domain/native/nativeAotPe.ts:1609` accepts only prefixed hexadecimal for that lookup. `bridge/ghidra/ReaGhidraBridge.java:4506` resolves bare hexadecimal and decoded default-space selectors to the same Ghidra address, so these spellings do not select different objects. The source comparison explains the spelling-dependent metadata lookup; real-engine behavior was not established by this reproduction. ### Additional context - [#743](https://github.com/morluto/rea/issues/743), closed as completed, introduced NativeAOT metadata recovery; this report concerns accepted address spellings silently losing metadata that the canonical spelling already returns. - [#744](https://github.com/morluto/rea/issues/744) requests broader NativeAOT decompilation and reconstruction support; this report concerns inconsistent existing MethodTable metadata inspection. - [#1170](https://github.com/morluto/rea/issues/1170), closed as completed, concerns selecting a function by an imported symbol alias; this report concerns successful numeric type-address calls that omit supplemental metadata. - [#1186](https://github.com/morluto/rea/issues/1186), closed as completed, concerns CLI `xrefs` rejecting advertised symbol names during validation; the numeric type-address spellings here pass validation and succeed. - [#1185](https://github.com/morluto/rea/issues/1185), closed as completed, concerns unsupported NativeAOT host rejection and remediation; this report concerns metadata omission after successful type inspection. - [#1947](https://github.com/morluto/rea/issues/1947) proposes normalization of address-derived comparison text between builds; this report concerns equivalent input address spellings within one type inspection.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1196
No description provided.