[GH-ISSUE #1775] [Bug] Mach-O LC_BUILD_VERSION accepts tool records outside cmdsize #599

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

Originally created by @N0zoM1z0 on GitHub (Oct 11, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/1775

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

trace-dylib-resolution reports a malformed LC_BUILD_VERSION as parse_status: "parsed" and publishes platform 1 even when the command declares build-tool records that do not fit within cmdsize.

This was reproduced through the built public CLI, with an inert synthetic ARM64 Mach-O in a local .app directory. No target execution or native analysis provider is involved.

Reproduction

  1. Build revision 40b37aeee10e89d85ee8b7747cbdcd3ad1a34db4 with the pinned Node/npm toolchain using npm ci and npm run build:cached.
  2. Generate the 56-byte fixture with Node:
// Save as repro-build-version.mjs; run with node repro-build-version.mjs.
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const app = join(mkdtempSync(join(tmpdir(), 'rea-build-version-')), 'Fixture.app');
mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true });
writeFileSync(join(app, 'Contents', 'Info.plist'),
  '<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>');
const bytes = Buffer.alloc(56);
// mach_header_64: ARM64, MH_EXECUTE, one 24-byte load command.
[0xfeedfacf, 0x0100000c, 0, 2, 1, 24, 0, 0]
  .forEach((value, index) => bytes.writeUInt32LE(value, index * 4));
bytes.writeUInt32LE(0x32, 32); // LC_BUILD_VERSION
bytes.writeUInt32LE(24, 36);   // cmdsize: fixed header only
bytes.writeUInt32LE(1, 40);    // platform: macOS
bytes.writeUInt32LE(1, 52);    // ntools: one absent 8-byte record
writeFileSync(join(app, 'Contents', 'MacOS', 'App'), bytes);
console.log(app);
  1. Run the public CLI using the directory printed by the script:
node scripts/rea.mjs trace-dylib-resolution /tmp/PRINTED-DIRECTORY/Fixture.app \
  --root Contents/MacOS/App --json
  1. Inspect normalized_result.images[0]: it reports parsed with platform 1 instead of malformed.

Expected behavior

Validate that the load command contains the declared build-tool records before accepting its platform. Preserve the existing structured malformed-image outcome and a reason identifying the command/count inconsistency. A successful CLI exit may carry malformed-image evidence; a nonzero exit is not the acceptance criterion here.

Impact and scope

Malformed Mach-O metadata is presented as successfully parsed, and its platform can enter dylib-resolution evidence. Both ntools=1 and ntools=0xffffffff were accepted with cmdsize=24. This report establishes incorrect parser/evidence classification; it does not establish a crash, target execution, or a demonstrated downstream compatibility decision.

REA version

6.4.0 source build, commit 40b37aeee10e89d85ee8b7747cbdcd3ad1a34db4.

Runtime and platform

Linux x64; Node.js 24.18.0; npm 11.16.0. CLI runs were serialized on one CPU, with a 512 MiB Node heap setting. No Hopper, Ghidra, IDA, or macOS runtime was used.

Target and artifact context

Synthetic little-endian 64-bit ARM64 Mach-O; 56-byte failing file inside a minimal .app directory. The failing file's SHA-256 is 329239da4880df8e7dbf7732e0011cb864e7af2e9b682b1255b22700de20bf5d.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

{
  "path": "Contents/MacOS/App",
  "parse_status": "parsed",
  "reason": null,
  "slices": [{"architecture": "arm64", "platform": 1, "platforms": [1]}]
}

All five CLI runs exited 0; classification is visible in their JSON:

cmdsize ntools Actual parse status Expected
24 0 parsed parsed
32 1, with its 8-byte record parsed parsed
24 1, record absent parsed malformed
24 4294967295, records absent parsed malformed
16 fixed header truncated malformed malformed

Typed outcome or recovery expected

An image with parse_status: "malformed", an explanatory reason, and no accepted slice/platform claim from the invalid command, consistent with the existing short-header case.

Analysis or suspected cause

Source-derived and consistent with the runtime reproduction: the LC_BUILD_VERSION branch checks the fixed 24-byte header and reads platform, but does not validate ntools at offset 20 against the available command bytes.

Apple's format header defines the command size as the fixed header plus the declared 8-byte tool records. Validation should use the command's own bounds, even if bytes happen to follow elsewhere in the file.

Additional context

The runtime evidence and format contract were independently checked during this audit. A regression should use the public CLI boundary or the actual file reader, covering both malformed counts and the valid no-tool/one-tool controls. This is separate from Mach-O chained-pointer decoding in #1709. Performance profiling is unnecessary for this 56-byte correctness reproduction.

Originally created by @N0zoM1z0 on GitHub (Oct 11, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/1775 ### 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 `trace-dylib-resolution` reports a malformed `LC_BUILD_VERSION` as `parse_status: "parsed"` and publishes platform `1` even when the command declares build-tool records that do not fit within `cmdsize`. This was reproduced through the built public CLI, with an inert synthetic ARM64 Mach-O in a local `.app` directory. No target execution or native analysis provider is involved. ### Reproduction 1. Build revision `40b37aeee10e89d85ee8b7747cbdcd3ad1a34db4` with the pinned Node/npm toolchain using `npm ci` and `npm run build:cached`. 2. Generate the 56-byte fixture with Node: ```js // Save as repro-build-version.mjs; run with node repro-build-version.mjs. import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const app = join(mkdtempSync(join(tmpdir(), 'rea-build-version-')), 'Fixture.app'); mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true }); writeFileSync(join(app, 'Contents', 'Info.plist'), '<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>'); const bytes = Buffer.alloc(56); // mach_header_64: ARM64, MH_EXECUTE, one 24-byte load command. [0xfeedfacf, 0x0100000c, 0, 2, 1, 24, 0, 0] .forEach((value, index) => bytes.writeUInt32LE(value, index * 4)); bytes.writeUInt32LE(0x32, 32); // LC_BUILD_VERSION bytes.writeUInt32LE(24, 36); // cmdsize: fixed header only bytes.writeUInt32LE(1, 40); // platform: macOS bytes.writeUInt32LE(1, 52); // ntools: one absent 8-byte record writeFileSync(join(app, 'Contents', 'MacOS', 'App'), bytes); console.log(app); ``` 3. Run the public CLI using the directory printed by the script: ```sh node scripts/rea.mjs trace-dylib-resolution /tmp/PRINTED-DIRECTORY/Fixture.app \ --root Contents/MacOS/App --json ``` 4. Inspect `normalized_result.images[0]`: it reports `parsed` with platform `1` instead of `malformed`. ### Expected behavior Validate that the load command contains the declared build-tool records before accepting its platform. Preserve the existing structured malformed-image outcome and a reason identifying the command/count inconsistency. A successful CLI exit may carry malformed-image evidence; a nonzero exit is not the acceptance criterion here. ### Impact and scope Malformed Mach-O metadata is presented as successfully parsed, and its platform can enter dylib-resolution evidence. Both `ntools=1` and `ntools=0xffffffff` were accepted with `cmdsize=24`. This report establishes incorrect parser/evidence classification; it does not establish a crash, target execution, or a demonstrated downstream compatibility decision. ### REA version 6.4.0 source build, commit `40b37aeee10e89d85ee8b7747cbdcd3ad1a34db4`. ### Runtime and platform Linux x64; Node.js 24.18.0; npm 11.16.0. CLI runs were serialized on one CPU, with a 512 MiB Node heap setting. No Hopper, Ghidra, IDA, or macOS runtime was used. ### Target and artifact context Synthetic little-endian 64-bit ARM64 Mach-O; 56-byte failing file inside a minimal `.app` directory. The failing file's SHA-256 is `329239da4880df8e7dbf7732e0011cb864e7af2e9b682b1255b22700de20bf5d`. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence ```json { "path": "Contents/MacOS/App", "parse_status": "parsed", "reason": null, "slices": [{"architecture": "arm64", "platform": 1, "platforms": [1]}] } ``` All five CLI runs exited `0`; classification is visible in their JSON: | cmdsize | ntools | Actual parse status | Expected | | --- | --- | --- | --- | | 24 | 0 | parsed | parsed | | 32 | 1, with its 8-byte record | parsed | parsed | | 24 | 1, record absent | parsed | malformed | | 24 | 4294967295, records absent | parsed | malformed | | 16 | fixed header truncated | malformed | malformed | ### Typed outcome or recovery expected An image with `parse_status: "malformed"`, an explanatory `reason`, and no accepted slice/platform claim from the invalid command, consistent with the existing short-header case. ### Analysis or suspected cause Source-derived and consistent with the runtime reproduction: [the `LC_BUILD_VERSION` branch](https://github.com/morluto/rea/blob/40b37aeee10e89d85ee8b7747cbdcd3ad1a34db4/src/artifacts/apple/MachoLoadCommandReader.ts#L411-L419) checks the fixed 24-byte header and reads `platform`, but does not validate `ntools` at offset 20 against the available command bytes. [Apple's format header](https://github.com/apple-oss-distributions/xnu/blob/f6217f891ac0bb64f3d375211650a4c1ff8ca1ea/EXTERNAL_HEADERS/mach-o/loader.h#L1260-L1273) defines the command size as the fixed header plus the declared 8-byte tool records. Validation should use the command's own bounds, even if bytes happen to follow elsewhere in the file. ### Additional context The runtime evidence and format contract were independently checked during this audit. A regression should use the public CLI boundary or the actual file reader, covering both malformed counts and the valid no-tool/one-tool controls. This is separate from Mach-O chained-pointer decoding in #1709. Performance profiling is unnecessary for this 56-byte correctness reproduction.
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#599
No description provided.