[GH-ISSUE #6688] [Bug] PE export names crossing adjacent sections are dropped as unmapped #4065

Closed
opened 2026-10-31 00:46:30 +00:00 by bsakaguchi · 0 comments
Owner

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

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

inspect-native-exports drops a valid named export when its null-terminated ASCII name crosses two adjacent, fully file-backed PE sections. A control with the same name wholly inside the first section reports the expected export. The crossing case returns successful Evidence with exports: [], partial coverage, and name_unreadable: "Virtual address or string range is not file mapped". LLVM independently reports the full BoundaryName export in both fixtures.

Reproduction

Run this from the source checkout at the revision below, with its locked dependencies available and its pinned Node.js runtime on PATH. This complete inline reproduction was executed once per input through the real CLI registration and result projection (createCli().serve), loading source with the repository's pinned tsx; it does not require a build. A separate source CLI pass and a shared production service pass gave the same results before this report was written. Neither fixture was loaded or executed.

The 2,560-byte PE32 fixture uses three nonoverlapping sections with SectionAlignment = FileAlignment = 0x200 and matching file offsets/RVAs: .edata at 0x400, .names at 0x600, and .text at 0x800, each of size 0x200. All 13 name bytes, including the terminator, are mapped. The export directory covers 0x400..0x800; the EAT slot points to 0x800. The cases change the name's location from 0x5e0 to 0x5fc, so the latter's first four bytes are in .edata and its remainder is in .names.

node --import tsx --input-type=module <<'JS'
import { writeFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir, release } from 'node:os';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
const { createCli } = await import(pathToFileURL(resolve('src/cli.ts')).href);

function image(nameRva) {
  const b = Buffer.alloc(0xa00);
  const u16 = (p, n) => b.writeUInt16LE(n, p);
  const u32 = (p, n) => b.writeUInt32LE(n, p);
  b.write('MZ', 0); u16(2, 0x90); u16(4, 3); u16(8, 4); u16(24, 0x40); u32(60, 0x80);
  b.write('PE\0\0', 0x80); u16(0x84, 0x14c); u16(0x86, 3); u16(0x94, 224); u16(0x96, 0x2103);
  const o = 0x98;
  u16(o, 0x10b); b[o+2] = 14; u32(o+4, 0x200); u32(o+8, 0x400);
  u32(o+16, 0x800); u32(o+20, 0x800); u32(o+24, 0x400); u32(o+28, 0x10000000);
  u32(o+32, 0x200); u32(o+36, 0x200); u16(o+40, 6); u16(o+48, 6);
  u32(o+56, 0xa00); u32(o+60, 0x400); u16(o+68, 3);
  u32(o+72, 0x100000); u32(o+76, 0x1000); u32(o+80, 0x100000); u32(o+84, 0x1000);
  u32(o+92, 16); u32(o+96, 0x400); u32(o+100, 0x400);
  for (const [i, name, rva, flags] of [[0,'.edata',0x400,0x40000040],[1,'.names',0x600,0x40000040],[2,'.text',0x800,0x60000020]]) {
    const p = o+224+i*40;
    b.write(name, p); u32(p+8, 0x200); u32(p+12, rva); u32(p+16, 0x200); u32(p+20, rva); u32(p+36, flags);
  }
  u32(0x40c, 0x500); u32(0x410, 1); u32(0x414, 1); u32(0x418, 1);
  u32(0x41c, 0x450); u32(0x420, 0x460); u32(0x424, 0x470);
  u32(0x450, 0x800); u32(0x460, nameRva); u16(0x470, 0);
  b.write('fixture.dll\0', 0x500); b.write('BoundaryName\0', nameRva); b[0x800] = 0xc3;
  return b;
}
const root = await mkdtemp(join(tmpdir(), 'rea-export-boundary-'));
console.log(JSON.stringify({runtime:{node:process.version,platform:process.platform,release:release(),arch:process.arch}}));
try {
  for (const [name,rva] of [['within',0x5e0],['across',0x5fc]]) {
    const bytes = image(rva);
    const path = join(root, `${name}.dll`);
    await writeFile(path, bytes);
    let stdout = '';
    await createCli({}).serve(['inspect-native-exports',path,'--json'], {
      env:{}, stdout:text=>{stdout+=text;}, exit:()=>undefined,
    });
    const report = JSON.parse(stdout).normalized_result;
    console.log(JSON.stringify({case:name,exitCode:process.exitCode??0,
      bytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex'),
      exports:report.exports,coverage:report.coverage,issues:report.issues}));
  }
} finally { await rm(root,{recursive:true,force:true}); }
JS

Expected behavior

Both inputs should report BoundaryName, ordinal 1, preferred-base address 0x10000800, no forwarder, complete coverage, and no name issue. REA's PE inventory contract promises one row per export name. Microsoft's PE format defines variable-length null-terminated ASCII export names addressed by RVAs; it imposes no single-section requirement on each name. Its section-data rules support the fixture's equal, sub-page file/section alignment and matching offsets. The section-data rules and name-pointer table describe the format premises used here.

Impact and scope

A supported static PE export inventory omits the fixture's only used export because of the name's placement, although every byte is present and unambiguous. The incorrect partial-coverage notice prevents a complete inventory. The observed scope is these small self-authored PE32 fixtures; real-world prevalence, PE32+ behavior, and Windows loader behavior were not tested. The result explicitly reports partial coverage, so this report does not claim silent complete success or target-code execution.

A verified workaround for this inventory is llvm-readobj --coff-exports FILE: it reports BoundaryName, ordinal 1, and RVA 0x800 for both exact fixtures. This workaround does not supply REA Evidence.

REA version

Source revision a7f967cf240eba8309cbf4526b2fc6338a52a617, package version 6.7.0. The investigation parent checked npm latest as 6.7.0; the published package was not executed.

Runtime and platform

Measured Node.js v24.22.1, Linux x64, kernel 7.2.8-9-omarchy; locked tsx 4.23.15. Independent reader: LLVM 23.1.3. No Hopper or Ghidra provider was used.

Target and artifact context

Two inert, self-authored PE32/i386 DLL-format files described completely above. Control SHA-256: 810f0419358d3daee6d921152dcc4e0436eb74b349d1d596c34f021eef1b1d23; crossing-name SHA-256: 5cc8edd3278cfd28ba928e94c5e5470af1826b10739bde4bd612282d9dbd36e4.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

The inline source CLI reproduction printed these relevant fields; both cases left the process exit code at 0:

{"case":"within","exitCode":0,"exports":[{"name":"BoundaryName","ordinal":1,"address":"0x10000800","forwarder":null}],"coverage":{"status":"complete","reason":null,"examined_entries":1,"exports":1},"issues":[]}
{"case":"across","exitCode":0,"exports":[],"coverage":{"status":"partial","reason":"1 export table issue(s) left entries unreported; see issues","examined_entries":1,"exports":0},"issues":[{"code":"name_unreadable","location":"0x5fc","message":"Virtual address or string range is not file mapped"}]}

Separately, llvm-readobj --coff-exports was executed against saved copies with the exact hashes above and printed this export for each file:

Export {
  Ordinal: 1
  Name: BoundaryName
  RVA: 0x800
}

Evidence provenance: observed; local verification: reproduced; reproduction completeness: complete. The source CLI and shared service results are runtime observations. The following source explanation is source-derived, not an additional runtime claim. Windows loading and a packaged executable were not tested.

Typed outcome or recovery expected

Restore the complete named export row and complete coverage for the crossing-name case while preserving the identical control result and stable artifact identity. Both names are valid, bounded, and fully backed; no truncation or guessed string is needed. Keep genuine unmapped, ambiguous, and unterminated-name diagnostics distinguishable.

Analysis or suspected cause

Source-derived explanation: the PE string reader reads the complete string bytes, then requires the entire string to fit one mappedFileOffset mapping. That mapper skips each section unless it contains the entire requested span, so neither adjacent section qualifies. The name loop records the resulting failure as name_unreadable. The public CLI uses the same production factory exercised here.

Additional context

Open and closed issue searches included "native exports", "inspect-native-exports", "PE" "export", "export" "name_unreadable", and "export" "adjacent". The closest report found was #6577, about a last ordinal followed by an unused EAT slot; this fixture has ordinal base 1 and one used slot, so its trigger and restoration signal differ. #6533 concerns ELF version requirements and #6647 ELF symbol-name encoding; neither is used by this ASCII PE case. No matching crossing-name report was found. NOT_ENOUGH_INFO: no last-known-good version or real-world occurrence is established.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: A valid, fully backed PE export name is omitted from a supported static inventory and incorrectly classified as unreadable; the failure is bounded and reported as partial coverage.
  • Workaround status: available
  • Workaround basis: LLVM 23.1.3 llvm-readobj --coff-exports was observed to report the correct name, ordinal, and RVA for both exact fixtures, providing an independent static inventory without changing the target.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6688 ### 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 `inspect-native-exports` drops a valid named export when its null-terminated ASCII name crosses two adjacent, fully file-backed PE sections. A control with the same name wholly inside the first section reports the expected export. The crossing case returns successful Evidence with `exports: []`, partial coverage, and `name_unreadable: "Virtual address or string range is not file mapped"`. LLVM independently reports the full `BoundaryName` export in both fixtures. ### Reproduction Run this from the source checkout at the revision below, with its locked dependencies available and its pinned Node.js runtime on PATH. This complete inline reproduction was executed once per input through the real CLI registration and result projection (`createCli().serve`), loading source with the repository's pinned `tsx`; it does not require a build. A separate source CLI pass and a shared production service pass gave the same results before this report was written. Neither fixture was loaded or executed. The 2,560-byte PE32 fixture uses three nonoverlapping sections with `SectionAlignment = FileAlignment = 0x200` and matching file offsets/RVAs: `.edata` at `0x400`, `.names` at `0x600`, and `.text` at `0x800`, each of size `0x200`. All 13 name bytes, including the terminator, are mapped. The export directory covers `0x400..0x800`; the EAT slot points to `0x800`. The cases change the name's location from `0x5e0` to `0x5fc`, so the latter's first four bytes are in `.edata` and its remainder is in `.names`. ```sh node --import tsx --input-type=module <<'JS' import { writeFile, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir, release } from 'node:os'; import { resolve, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createHash } from 'node:crypto'; const { createCli } = await import(pathToFileURL(resolve('src/cli.ts')).href); function image(nameRva) { const b = Buffer.alloc(0xa00); const u16 = (p, n) => b.writeUInt16LE(n, p); const u32 = (p, n) => b.writeUInt32LE(n, p); b.write('MZ', 0); u16(2, 0x90); u16(4, 3); u16(8, 4); u16(24, 0x40); u32(60, 0x80); b.write('PE\0\0', 0x80); u16(0x84, 0x14c); u16(0x86, 3); u16(0x94, 224); u16(0x96, 0x2103); const o = 0x98; u16(o, 0x10b); b[o+2] = 14; u32(o+4, 0x200); u32(o+8, 0x400); u32(o+16, 0x800); u32(o+20, 0x800); u32(o+24, 0x400); u32(o+28, 0x10000000); u32(o+32, 0x200); u32(o+36, 0x200); u16(o+40, 6); u16(o+48, 6); u32(o+56, 0xa00); u32(o+60, 0x400); u16(o+68, 3); u32(o+72, 0x100000); u32(o+76, 0x1000); u32(o+80, 0x100000); u32(o+84, 0x1000); u32(o+92, 16); u32(o+96, 0x400); u32(o+100, 0x400); for (const [i, name, rva, flags] of [[0,'.edata',0x400,0x40000040],[1,'.names',0x600,0x40000040],[2,'.text',0x800,0x60000020]]) { const p = o+224+i*40; b.write(name, p); u32(p+8, 0x200); u32(p+12, rva); u32(p+16, 0x200); u32(p+20, rva); u32(p+36, flags); } u32(0x40c, 0x500); u32(0x410, 1); u32(0x414, 1); u32(0x418, 1); u32(0x41c, 0x450); u32(0x420, 0x460); u32(0x424, 0x470); u32(0x450, 0x800); u32(0x460, nameRva); u16(0x470, 0); b.write('fixture.dll\0', 0x500); b.write('BoundaryName\0', nameRva); b[0x800] = 0xc3; return b; } const root = await mkdtemp(join(tmpdir(), 'rea-export-boundary-')); console.log(JSON.stringify({runtime:{node:process.version,platform:process.platform,release:release(),arch:process.arch}})); try { for (const [name,rva] of [['within',0x5e0],['across',0x5fc]]) { const bytes = image(rva); const path = join(root, `${name}.dll`); await writeFile(path, bytes); let stdout = ''; await createCli({}).serve(['inspect-native-exports',path,'--json'], { env:{}, stdout:text=>{stdout+=text;}, exit:()=>undefined, }); const report = JSON.parse(stdout).normalized_result; console.log(JSON.stringify({case:name,exitCode:process.exitCode??0, bytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex'), exports:report.exports,coverage:report.coverage,issues:report.issues})); } } finally { await rm(root,{recursive:true,force:true}); } JS ``` ### Expected behavior Both inputs should report `BoundaryName`, ordinal `1`, preferred-base address `0x10000800`, no forwarder, complete coverage, and no name issue. [REA's PE inventory contract](https://github.com/morluto/rea/blob/a7f967cf240eba8309cbf4526b2fc6338a52a617/docs/binary-diagnostics.md#L323-L331) promises one row per export name. [Microsoft's PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#export-name-table) defines variable-length null-terminated ASCII export names addressed by RVAs; it imposes no single-section requirement on each name. Its section-data rules support the fixture's equal, sub-page file/section alignment and matching offsets. The [section-data rules](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#section-data) and [name-pointer table](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#export-name-pointer-table) describe the format premises used here. ### Impact and scope A supported static PE export inventory omits the fixture's only used export because of the name's placement, although every byte is present and unambiguous. The incorrect partial-coverage notice prevents a complete inventory. The observed scope is these small self-authored PE32 fixtures; real-world prevalence, PE32+ behavior, and Windows loader behavior were not tested. The result explicitly reports partial coverage, so this report does not claim silent complete success or target-code execution. A verified workaround for this inventory is `llvm-readobj --coff-exports FILE`: it reports `BoundaryName`, ordinal `1`, and RVA `0x800` for both exact fixtures. This workaround does not supply REA Evidence. ### REA version Source revision `a7f967cf240eba8309cbf4526b2fc6338a52a617`, package version `6.7.0`. The investigation parent checked npm latest as `6.7.0`; the published package was not executed. ### Runtime and platform Measured Node.js `v24.22.1`, Linux `x64`, kernel `7.2.8-9-omarchy`; locked `tsx` `4.23.15`. Independent reader: LLVM `23.1.3`. No Hopper or Ghidra provider was used. ### Target and artifact context Two inert, self-authored PE32/i386 DLL-format files described completely above. Control SHA-256: `810f0419358d3daee6d921152dcc4e0436eb74b349d1d596c34f021eef1b1d23`; crossing-name SHA-256: `5cc8edd3278cfd28ba928e94c5e5470af1826b10739bde4bd612282d9dbd36e4`. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence The inline source CLI reproduction printed these relevant fields; both cases left the process exit code at `0`: ```json {"case":"within","exitCode":0,"exports":[{"name":"BoundaryName","ordinal":1,"address":"0x10000800","forwarder":null}],"coverage":{"status":"complete","reason":null,"examined_entries":1,"exports":1},"issues":[]} {"case":"across","exitCode":0,"exports":[],"coverage":{"status":"partial","reason":"1 export table issue(s) left entries unreported; see issues","examined_entries":1,"exports":0},"issues":[{"code":"name_unreadable","location":"0x5fc","message":"Virtual address or string range is not file mapped"}]} ``` Separately, `llvm-readobj --coff-exports` was executed against saved copies with the exact hashes above and printed this export for each file: ```text Export { Ordinal: 1 Name: BoundaryName RVA: 0x800 } ``` Evidence provenance: observed; local verification: reproduced; reproduction completeness: complete. The source CLI and shared service results are runtime observations. The following source explanation is source-derived, not an additional runtime claim. Windows loading and a packaged executable were not tested. ### Typed outcome or recovery expected Restore the complete named export row and complete coverage for the crossing-name case while preserving the identical control result and stable artifact identity. Both names are valid, bounded, and fully backed; no truncation or guessed string is needed. Keep genuine unmapped, ambiguous, and unterminated-name diagnostics distinguishable. ### Analysis or suspected cause Source-derived explanation: [the PE string reader](https://github.com/morluto/rea/blob/a7f967cf240eba8309cbf4526b2fc6338a52a617/src/native/exports/NativeExportParser.ts#L115-L134) reads the complete string bytes, then requires the entire string to fit one `mappedFileOffset` mapping. [That mapper](https://github.com/morluto/rea/blob/a7f967cf240eba8309cbf4526b2fc6338a52a617/src/native/metadata/BinaryContainer.ts#L210-L237) skips each section unless it contains the entire requested span, so neither adjacent section qualifies. [The name loop](https://github.com/morluto/rea/blob/a7f967cf240eba8309cbf4526b2fc6338a52a617/src/native/exports/NativeExportParser.ts#L174-L185) records the resulting failure as `name_unreadable`. The [public CLI](https://github.com/morluto/rea/blob/a7f967cf240eba8309cbf4526b2fc6338a52a617/src/cli/binaryDiagnosticsCommands.ts#L170-L197) uses the [same production factory](https://github.com/morluto/rea/blob/a7f967cf240eba8309cbf4526b2fc6338a52a617/src/composition/binaryDiagnostics.ts#L27-L29) exercised here. ### Additional context Open and closed issue searches included `"native exports"`, `"inspect-native-exports"`, `"PE" "export"`, `"export" "name_unreadable"`, and `"export" "adjacent"`. The closest report found was [#6577](https://github.com/morluto/rea/issues/6577), about a last ordinal followed by an unused EAT slot; this fixture has ordinal base `1` and one used slot, so its trigger and restoration signal differ. [#6533](https://github.com/morluto/rea/issues/6533) concerns ELF version requirements and [#6647](https://github.com/morluto/rea/issues/6647) ELF symbol-name encoding; neither is used by this ASCII PE case. No matching crossing-name report was found. NOT_ENOUGH_INFO: no last-known-good version or real-world occurrence is established. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: A valid, fully backed PE export name is omitted from a supported static inventory and incorrectly classified as unreadable; the failure is bounded and reported as partial coverage. - Workaround status: available - Workaround basis: LLVM 23.1.3 `llvm-readobj --coff-exports` was observed to report the correct name, ordinal, and RVA for both exact fixtures, providing an independent static inventory without changing the target.
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#4065
No description provided.