[GH-ISSUE #7389] [Bug] inspect-native-exports accepts a populated ELF .dynsym without its null symbol #4394

Open
opened 2026-10-31 00:49:28 +00:00 by bsakaguchi · 0 comments
Owner

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

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

node scripts/rea.mjs inspect-native-exports "$WORK/missing.elf" --json exits 0 for a synthetic ELF64 whose two-record dynamic symbol table starts with a named, defined global function instead of the required null symbol. The result contains only the second named record, second_export, with coverage.status: "complete", examined_entries: 1, and issues: [].

The same fixture with a zeroed null symbol before both named records returns both names. A zero-byte dynamic symbol table is rejected with invalid_request and invalid_format. These observations show that the populated malformed table escapes the existing null-entry rejection. The target was inspected statically; it was never loaded or executed.

Reproduction

Run from a clean prepared checkout at the source revision below with its locked dependencies and compiled dist/ available, using Node.js v24.22.1. The script creates its own synthetic files and isolates HOME, every listed XDG state directory, and TMPDIR. No external provider is needed. The three-case comparison was executed twice, including the complete script below; both executions produced the same result.

#!/bin/sh
set -eu
export WORK=$(mktemp -d)
export HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/config"
export XDG_CACHE_HOME="$WORK/cache" XDG_DATA_HOME="$WORK/data"
export XDG_STATE_HOME="$WORK/state" XDG_RUNTIME_DIR="$WORK/run"
export TMPDIR="$WORK/temp"
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" \
  "$XDG_STATE_HOME" "$XDG_RUNTIME_DIR" "$TMPDIR"
node --input-type=module <<'JS'
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
for (const mode of ['missing', 'present', 'empty']) {
  const b = Buffer.alloc(0x440);
  b.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]);
  const u16 = (o, v) => b.writeUInt16LE(v, o);
  const u32 = (o, v) => b.writeUInt32LE(v, o);
  const u64 = (o, v) => b.writeBigUInt64LE(BigInt(v), o);
  u16(16, 3); u16(18, 62); u32(20, 1);
  u64(32, 64); u64(40, 0x300);
  u16(52, 64); u16(54, 56); u16(56, 1);
  u16(58, 64); u16(60, 5); u16(62, 4);
  u32(64, 1); u32(68, 5); u64(80, 0x400000);
  u64(88, 0x400000); u64(96, b.length); u64(104, b.length); u64(112, 0x1000);
  b.fill(0xc3, 0x100, 0x110);
  const names = '\0first_export\0second_export\0';
  b.write(names, 0x200, 'ascii');
  b.write('\0.text\0.dynsym\0.dynstr\0.shstrtab\0', 0x240, 'ascii');
  const count = mode === 'empty' ? 0 : mode === 'present' ? 3 : 2;
  const section = (i, name, type, flags, addr, offset, size, link, info, align, entsize) => {
    const s = 0x300 + i * 64;
    u32(s, name); u32(s + 4, type); u64(s + 8, flags); u64(s + 16, addr);
    u64(s + 24, offset); u64(s + 32, size); u32(s + 40, link);
    u32(s + 44, info); u64(s + 48, align); u64(s + 56, entsize);
  };
  section(1, 1, 1, 6, 0x400100, 0x100, 16, 0, 0, 16, 0);
  section(2, 7, 11, 2, 0x400120, 0x120, count * 24, 3, 1, 8, 24);
  section(3, 15, 3, 2, 0x400200, 0x200, names.length, 0, 0, 1, 0);
  section(4, 23, 3, 0, 0, 0x240, 33, 0, 0, 1, 0);
  if (count) {
    for (let i = 0; i < 2; i++) {
      const at = 0x120 + (i + (mode === 'present' ? 1 : 0)) * 24;
      u32(at, i === 0 ? 1 : 14); b[at + 4] = 0x12; u16(at + 6, 1);
      u64(at + 8, 0x400100 + i * 8); u64(at + 16, 8);
    }
  }
  writeFileSync(join(process.env.WORK, mode + '.elf'), b);
}
JS
for mode in missing present empty; do
  status=0
  node scripts/rea.mjs inspect-native-exports "$WORK/$mode.elf" --json \
    > "$WORK/$mode.json" 2> "$WORK/$mode.stderr" || status=$?
  printf '%s\n' "$status" > "$WORK/$mode.exit"
done
node --input-type=module <<'JS'
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const read = (mode, ext) => readFileSync(`${process.env.WORK}/${mode}.${ext}`, 'utf8');
for (const mode of ['missing', 'present']) {
  assert.equal(read(mode, 'exit').trim(), '0');
  assert.equal(read(mode, 'stderr'), '');
  const result = JSON.parse(read(mode, 'json')).normalized_result;
  const names = result.exports.map(row => row.name);
  assert.deepEqual(names, mode === 'missing' ? ['second_export'] : ['first_export', 'second_export']);
  assert.equal(result.coverage.status, 'complete');
  assert.equal(result.coverage.examined_entries, mode === 'missing' ? 1 : 2);
  assert.deepEqual(result.issues, []);
  console.log(JSON.stringify({ mode, names, coverage: result.coverage, issues: result.issues }));
}
assert.equal(read('empty', 'exit').trim(), '1');
assert.equal(read('empty', 'stderr'), '');
const error = JSON.parse(read('empty', 'json'));
assert.equal(error.code, 'invalid_request');
assert.equal(error.details.issues[0].reason, 'invalid_format');
assert.deepEqual(error.details.issues[0].path, ['path']);
console.log('empty: invalid_request / invalid_format');
JS

The assertions intentionally describe the observed defect so a failing comparison is visible if behavior changes. They do not assert that accepting the malformed image is correct.

Expected behavior

Reject a populated dynamic symbol table whose first record is not the mandatory null symbol as malformed, with the same invalid_request / invalid_format outcome used for an empty table. The documented contract explicitly classifies a dynamic symbol table without its null entry as malformed. Returning a complete export inventory silently assumes validity that was never checked.

Expected source: docs/binary-diagnostics.md:455 states the null-entry requirement. Expected source: src/domain/native/nativeExports.ts:95 describes complete coverage as reading every table entry. Expected source: src/native/exports/NativeExportsProvider.ts:130 maps malformed binary failures to the input error contract.

Impact and scope

P2: this is a bounded validation and evidence-integrity defect. A caller receives a successful complete inventory from malformed metadata, with a named first record absent and no diagnostic explaining that omission. The verified scope is the authored ELF64 little-endian x86-64 fixture; ordinary compiler-produced libraries, other ELF classes, and production prevalence were not tested. This report does not claim that the malformed image is loadable or that the skipped record is a valid runtime export.

No same-artifact REA workaround was verified. The null-prefixed control demonstrates correct reporting for repaired input, but repairing a table changes artifact identity. No input mutation, crash, target execution, or security boundary crossing was observed.

REA version

Exact node scripts/rea.mjs --version output: 6.8.0.
Source commit: f8f7ac96de8a66a4ab9a23d87e04779bfcd11045.

Runtime and platform

Node.js v24.22.1; macOS 27.0.1; Darwin 27.0.0; arm64. No Hopper, Ghidra, IDA, or other external analysis provider was used.

Target and artifact context

Three self-authored 1088-byte ELF64 ET_DYN x86-64 parser fixtures with a PT_LOAD and named .text, .dynsym, .dynstr, and .shstrtab sections. The two named symbols identify offsets in .text. The cases contain two non-null symbol records with no null prefix, a null prefix plus those same records, and an empty symbol table. They are synthetic parser inputs, not demonstrated runnable shared libraries.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

The public CLI returned the following reduced results. All three stderr streams were empty.

{"mode":"missing","exit":0,"names":["second_export"],"coverage":{"status":"complete","reason":null,"examined_entries":1,"exports":1},"issues":[]}
{"mode":"present","exit":0,"names":["first_export","second_export"],"coverage":{"status":"complete","reason":null,"examined_entries":2,"exports":2},"issues":[]}
{"mode":"empty","exit":1,"code":"invalid_request","field":["path"],"reason":"invalid_format"}

Failure source: src/native/exports/NativeExportParser.ts:538 checks table width, length, minimum capacity and string-table type without validating record zero. Failure source: src/native/exports/NativeExportParser.ts:592 begins iteration at record one. Failure source: src/native/exports/NativeExportParser.ts:684 derives examined entries from the table count minus one.

The executed CLI establishes reachability and delivered behavior. Source inspection explains the omission. The existing null-entry regression at src/native/exports/NativeExportParser.test.ts:669 only supplies an empty dynamic symbol table, so it does not establish populated-record validation.

Typed outcome or recovery expected

Exit 1 with code: "invalid_request", an issue at ["path"], and reason: "invalid_format" identifying the malformed null-symbol record. The empty-table control already demonstrates this error family through the same CLI.

Analysis or suspected cause

Source-derived: the parser proves that space exists for a null record, then assumes that the first record is null. It never examines that record's fields before skipping it. With the invalid first record excluded and no later issue, coverage remains complete. This is the missing validation supported by the reproduction; no implementation change or fix was tested.

Additional context

Open and closed issue searches covered dynsym, null-symbol/null-entry terms, native exports, and first-record terms. Related reports #6792, #6533, #6745, #6647, #6900, #6768, #6884 and #4057 address other contracts: missing-section coverage, symbol versions, encoding, selected views, Dart dynamic metadata, or managed/native integration. None reports this populated non-null first record.

The separately installed npm release and real-world malformed binaries were not tested. No known-good historical REA revision was established.

Originally created by @coygeek on GitHub (Oct 27, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7389 ## 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 `node scripts/rea.mjs inspect-native-exports "$WORK/missing.elf" --json` exits 0 for a synthetic ELF64 whose two-record dynamic symbol table starts with a named, defined global function instead of the required null symbol. The result contains only the second named record, `second_export`, with `coverage.status: "complete"`, `examined_entries: 1`, and `issues: []`. The same fixture with a zeroed null symbol before both named records returns both names. A zero-byte dynamic symbol table is rejected with `invalid_request` and `invalid_format`. These observations show that the populated malformed table escapes the existing null-entry rejection. The target was inspected statically; it was never loaded or executed. ## Reproduction Run from a clean prepared checkout at the source revision below with its locked dependencies and compiled `dist/` available, using Node.js v24.22.1. The script creates its own synthetic files and isolates HOME, every listed XDG state directory, and TMPDIR. No external provider is needed. The three-case comparison was executed twice, including the complete script below; both executions produced the same result. ```sh #!/bin/sh set -eu export WORK=$(mktemp -d) export HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/config" export XDG_CACHE_HOME="$WORK/cache" XDG_DATA_HOME="$WORK/data" export XDG_STATE_HOME="$WORK/state" XDG_RUNTIME_DIR="$WORK/run" export TMPDIR="$WORK/temp" mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" \ "$XDG_STATE_HOME" "$XDG_RUNTIME_DIR" "$TMPDIR" node --input-type=module <<'JS' import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; for (const mode of ['missing', 'present', 'empty']) { const b = Buffer.alloc(0x440); b.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]); const u16 = (o, v) => b.writeUInt16LE(v, o); const u32 = (o, v) => b.writeUInt32LE(v, o); const u64 = (o, v) => b.writeBigUInt64LE(BigInt(v), o); u16(16, 3); u16(18, 62); u32(20, 1); u64(32, 64); u64(40, 0x300); u16(52, 64); u16(54, 56); u16(56, 1); u16(58, 64); u16(60, 5); u16(62, 4); u32(64, 1); u32(68, 5); u64(80, 0x400000); u64(88, 0x400000); u64(96, b.length); u64(104, b.length); u64(112, 0x1000); b.fill(0xc3, 0x100, 0x110); const names = '\0first_export\0second_export\0'; b.write(names, 0x200, 'ascii'); b.write('\0.text\0.dynsym\0.dynstr\0.shstrtab\0', 0x240, 'ascii'); const count = mode === 'empty' ? 0 : mode === 'present' ? 3 : 2; const section = (i, name, type, flags, addr, offset, size, link, info, align, entsize) => { const s = 0x300 + i * 64; u32(s, name); u32(s + 4, type); u64(s + 8, flags); u64(s + 16, addr); u64(s + 24, offset); u64(s + 32, size); u32(s + 40, link); u32(s + 44, info); u64(s + 48, align); u64(s + 56, entsize); }; section(1, 1, 1, 6, 0x400100, 0x100, 16, 0, 0, 16, 0); section(2, 7, 11, 2, 0x400120, 0x120, count * 24, 3, 1, 8, 24); section(3, 15, 3, 2, 0x400200, 0x200, names.length, 0, 0, 1, 0); section(4, 23, 3, 0, 0, 0x240, 33, 0, 0, 1, 0); if (count) { for (let i = 0; i < 2; i++) { const at = 0x120 + (i + (mode === 'present' ? 1 : 0)) * 24; u32(at, i === 0 ? 1 : 14); b[at + 4] = 0x12; u16(at + 6, 1); u64(at + 8, 0x400100 + i * 8); u64(at + 16, 8); } } writeFileSync(join(process.env.WORK, mode + '.elf'), b); } JS for mode in missing present empty; do status=0 node scripts/rea.mjs inspect-native-exports "$WORK/$mode.elf" --json \ > "$WORK/$mode.json" 2> "$WORK/$mode.stderr" || status=$? printf '%s\n' "$status" > "$WORK/$mode.exit" done node --input-type=module <<'JS' import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; const read = (mode, ext) => readFileSync(`${process.env.WORK}/${mode}.${ext}`, 'utf8'); for (const mode of ['missing', 'present']) { assert.equal(read(mode, 'exit').trim(), '0'); assert.equal(read(mode, 'stderr'), ''); const result = JSON.parse(read(mode, 'json')).normalized_result; const names = result.exports.map(row => row.name); assert.deepEqual(names, mode === 'missing' ? ['second_export'] : ['first_export', 'second_export']); assert.equal(result.coverage.status, 'complete'); assert.equal(result.coverage.examined_entries, mode === 'missing' ? 1 : 2); assert.deepEqual(result.issues, []); console.log(JSON.stringify({ mode, names, coverage: result.coverage, issues: result.issues })); } assert.equal(read('empty', 'exit').trim(), '1'); assert.equal(read('empty', 'stderr'), ''); const error = JSON.parse(read('empty', 'json')); assert.equal(error.code, 'invalid_request'); assert.equal(error.details.issues[0].reason, 'invalid_format'); assert.deepEqual(error.details.issues[0].path, ['path']); console.log('empty: invalid_request / invalid_format'); JS ``` The assertions intentionally describe the observed defect so a failing comparison is visible if behavior changes. They do not assert that accepting the malformed image is correct. ## Expected behavior Reject a populated dynamic symbol table whose first record is not the mandatory null symbol as malformed, with the same `invalid_request` / `invalid_format` outcome used for an empty table. The documented contract explicitly classifies a dynamic symbol table without its null entry as malformed. Returning a complete export inventory silently assumes validity that was never checked. Expected source: `docs/binary-diagnostics.md:455` states the null-entry requirement. Expected source: `src/domain/native/nativeExports.ts:95` describes complete coverage as reading every table entry. Expected source: `src/native/exports/NativeExportsProvider.ts:130` maps malformed binary failures to the input error contract. ## Impact and scope P2: this is a bounded validation and evidence-integrity defect. A caller receives a successful complete inventory from malformed metadata, with a named first record absent and no diagnostic explaining that omission. The verified scope is the authored ELF64 little-endian x86-64 fixture; ordinary compiler-produced libraries, other ELF classes, and production prevalence were not tested. This report does not claim that the malformed image is loadable or that the skipped record is a valid runtime export. No same-artifact REA workaround was verified. The null-prefixed control demonstrates correct reporting for repaired input, but repairing a table changes artifact identity. No input mutation, crash, target execution, or security boundary crossing was observed. ## REA version Exact `node scripts/rea.mjs --version` output: `6.8.0`. Source commit: `f8f7ac96de8a66a4ab9a23d87e04779bfcd11045`. ## Runtime and platform Node.js v24.22.1; macOS 27.0.1; Darwin 27.0.0; arm64. No Hopper, Ghidra, IDA, or other external analysis provider was used. ## Target and artifact context Three self-authored 1088-byte ELF64 ET_DYN x86-64 parser fixtures with a PT_LOAD and named `.text`, `.dynsym`, `.dynstr`, and `.shstrtab` sections. The two named symbols identify offsets in `.text`. The cases contain two non-null symbol records with no null prefix, a null prefix plus those same records, and an empty symbol table. They are synthetic parser inputs, not demonstrated runnable shared libraries. ## Interface CLI ## Earliest failure stage Native analysis or reconstruction ## Relevant output or evidence The public CLI returned the following reduced results. All three stderr streams were empty. ```json {"mode":"missing","exit":0,"names":["second_export"],"coverage":{"status":"complete","reason":null,"examined_entries":1,"exports":1},"issues":[]} {"mode":"present","exit":0,"names":["first_export","second_export"],"coverage":{"status":"complete","reason":null,"examined_entries":2,"exports":2},"issues":[]} {"mode":"empty","exit":1,"code":"invalid_request","field":["path"],"reason":"invalid_format"} ``` Failure source: `src/native/exports/NativeExportParser.ts:538` checks table width, length, minimum capacity and string-table type without validating record zero. Failure source: `src/native/exports/NativeExportParser.ts:592` begins iteration at record one. Failure source: `src/native/exports/NativeExportParser.ts:684` derives examined entries from the table count minus one. The executed CLI establishes reachability and delivered behavior. Source inspection explains the omission. The existing null-entry regression at `src/native/exports/NativeExportParser.test.ts:669` only supplies an empty dynamic symbol table, so it does not establish populated-record validation. ## Typed outcome or recovery expected Exit 1 with `code: "invalid_request"`, an issue at `["path"]`, and `reason: "invalid_format"` identifying the malformed null-symbol record. The empty-table control already demonstrates this error family through the same CLI. ## Analysis or suspected cause Source-derived: the parser proves that space exists for a null record, then assumes that the first record is null. It never examines that record's fields before skipping it. With the invalid first record excluded and no later issue, coverage remains complete. This is the missing validation supported by the reproduction; no implementation change or fix was tested. ## Additional context Open and closed issue searches covered dynsym, null-symbol/null-entry terms, native exports, and first-record terms. Related reports #6792, #6533, #6745, #6647, #6900, #6768, #6884 and #4057 address other contracts: missing-section coverage, symbol versions, encoding, selected views, Dart dynamic metadata, or managed/native integration. None reports this populated non-null first record. The separately installed npm release and real-world malformed binaries were not tested. No known-good historical REA revision was established.
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#4394
No description provided.