Files
reverse-engineering-anything/src/artifacts/MachoLoadCommandReader.test.ts
T
Kaoru0822andClaude Opus 5.5 11b7f918c2 feat(artifacts): trace Mach-O dylib resolution in bundles
Add trace_dylib_resolution (CLI trace-dylib-resolution) to the artifact
family. For the active Mach-O, or every executable in the active .app
(the main app, XPC services, extensions, login items, helpers), it
reports which file each dylib load reaches and how.

A pure TypeScript reader decodes FAT and Mach-O load commands:
LC_LOAD/WEAK/REEXPORT/LAZY/UPWARD_DYLIB, including the newer
dylib_use_command encoding with weak, re-export, upward and delayed-init
flags, plus LC_ID_DYLIB, LC_RPATH, LC_DYLD_ENVIRONMENT and code-signature
presence. Malformed tables are reported with the failing command instead
of being guessed.

Resolution follows dyld:
- @executable_path comes from each process root;
- @loader_path comes from the loading image, or from the owner of the
  LC_RPATH entry being expanded;
- @rpath searches the loader's rpaths first, then up the load chain;
- dependencies load dependents-first in load-command order, once per
  process, and a matching install name reuses an already loaded image;
- symlinks are followed inside the analyzed root, and escapes stop.

Every candidate path is reported with its outcome. Each edge resolves to
resolved, conditional (an outside-root candidate such as /usr/lib/swift
precedes), unresolved, or undetermined. Absolute and shared-cache system
paths are never checked against the host. Derived findings cover
unresolved required or weak loads, absent earlier @rpath candidates (with
library validation left unknown), and LC_DYLD_ENVIRONMENT.

verify:macos-bundle now also:
- checks every traced image against otool -l;
- asserts each resolution status on a clang-built fixture that adds a
  missing Overrides rpath, an absolute rpath, and a delay-init library;
- compares the predicted load order of two process roots with what dyld
  loads under DYLD_PRINT_LIBRARIES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:31:17 +00:00

256 lines
8.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
CPU,
FILE_TYPE,
LC,
codeSignatureCommand,
dyldEnvironmentCommand,
dylibCommand,
dylibUseCommand,
fatImage,
machoImage,
readerOf,
rpathCommand,
} from "./MachoImage.fixture.js";
import {
MAX_LOAD_COMMAND_BYTES,
hasMachoMagic,
readMachoImage,
} from "./MachoLoadCommandReader.js";
const read = (bytes: Uint8Array) =>
readMachoImage(readerOf(bytes), bytes.length);
describe("Mach-O load command reader", () => {
it("decodes dylib loading commands of a thin image", async () => {
const image = machoImage({
fileType: FILE_TYPE.dylib,
commands: [
dylibCommand(LC.ID_DYLIB, "@rpath/Core.framework/Core"),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib", {
current: 0x050f0400,
compatibility: 0x10000,
}),
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libweak.dylib"),
dylibCommand(LC.REEXPORT_DYLIB, "@loader_path/libre.dylib"),
dylibCommand(LC.LOAD_UPWARD_DYLIB, "@rpath/libup.dylib"),
dylibCommand(LC.LAZY_LOAD_DYLIB, "@rpath/liblazy.dylib"),
rpathCommand("@loader_path/Frameworks"),
rpathCommand("/usr/lib/swift"),
dyldEnvironmentCommand("DYLD_FRAMEWORK_PATH=@executable_path"),
codeSignatureCommand(),
],
});
const facts = await read(image);
expect(facts.status).toBe("parsed");
if (facts.status !== "parsed") return;
expect(facts.slices).toHaveLength(1);
const [slice] = facts.slices;
expect(slice).toMatchObject({
architecture: "arm64",
file_type: "dylib",
install_name: "@rpath/Core.framework/Core",
rpaths: ["@loader_path/Frameworks", "/usr/lib/swift"],
dyld_environment: ["DYLD_FRAMEWORK_PATH=@executable_path"],
code_signature_present: true,
});
expect(
slice?.dependencies.map(
({ command, install_name: name, weak, reexport, upward }) => [
command,
name,
weak,
reexport,
upward,
],
),
).toEqual([
["LC_LOAD_DYLIB", "/usr/lib/libSystem.B.dylib", false, false, false],
["LC_LOAD_WEAK_DYLIB", "@rpath/libweak.dylib", true, false, false],
["LC_REEXPORT_DYLIB", "@loader_path/libre.dylib", false, true, false],
["LC_LOAD_UPWARD_DYLIB", "@rpath/libup.dylib", false, false, true],
["LC_LAZY_LOAD_DYLIB", "@rpath/liblazy.dylib", false, false, false],
]);
expect(slice?.dependencies[0]).toMatchObject({
encoding: "dylib_command",
current_version: "1295.4.0",
compatibility_version: "1.0.0",
});
});
it("decodes dylib_use_command flags", async () => {
const facts = await read(
machoImage({
commands: [
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libdelay.dylib", 0x8),
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libweakup.dylib", 0x1 | 0x4),
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libre.dylib", 0x2),
],
}),
);
if (facts.status !== "parsed") throw new Error(facts.status);
expect(
facts.slices[0]?.dependencies.map(
({ encoding, weak, upward, reexport, delayed_init: delayed }) => ({
encoding,
weak,
upward,
reexport,
delayed,
}),
),
).toEqual([
{
encoding: "dylib_use_command",
weak: false,
upward: false,
reexport: false,
delayed: true,
},
{
encoding: "dylib_use_command",
weak: true,
upward: true,
reexport: false,
delayed: false,
},
{
encoding: "dylib_use_command",
weak: false,
upward: false,
reexport: true,
delayed: false,
},
]);
});
it("reads every slice of FAT and FAT_64 containers", async () => {
for (const wide of [false, true]) {
const facts = await read(
fatImage(
[
{
cpu: CPU.arm64e,
bytes: machoImage({
cpu: CPU.arm64e,
commands: [rpathCommand("@executable_path/a")],
}),
},
{
cpu: CPU.x86_64,
bytes: machoImage({
cpu: CPU.x86_64,
commands: [rpathCommand("@executable_path/b")],
}),
},
],
wide,
),
);
if (facts.status !== "parsed") throw new Error(facts.status);
expect(
facts.slices.map(({ architecture, rpaths }) => [architecture, rpaths]),
).toEqual([
["arm64e", ["@executable_path/a"]],
["x86_64", ["@executable_path/b"]],
]);
}
});
});
describe("Mach-O load command reader inputs", () => {
it("names 32-bit and non-Mach-O inputs without guessing", async () => {
const i386 = await read(
machoImage({ cpu: { type: 7, subtype: 3 }, wide: false }),
);
expect(i386.status === "parsed" && i386.slices[0]?.architecture).toBe(
"i386",
);
// A Java class file shares the FAT magic; its version is not a slice count.
const javaClass = Uint8Array.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34]);
expect(hasMachoMagic(javaClass)).toBe(true);
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
status: "not-mach-o",
});
expect(await read(Uint8Array.from([0xcf, 0xfa]))).toEqual({
status: "not-mach-o",
});
});
});
describe("Mach-O load command reader failures", () => {
const withCommandSize = (size: number): Uint8Array => {
const image = machoImage({ commands: [rpathCommand("@loader_path")] });
new DataView(image.buffer).setUint32(32 + 4, size, true);
return image;
};
it("reports malformed command tables with the failing command", async () => {
expect(await read(withCommandSize(0))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("load command 0"),
});
expect(await read(withCommandSize(4096))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("invalid cmdsize"),
});
const extraCommand = machoImage({ commands: [rpathCommand("x")] });
new DataView(extraCommand.buffer).setUint32(16, 2, true);
expect(await read(extraCommand)).toMatchObject({
status: "malformed",
reason: "load command 1 starts beyond sizeofcmds",
});
});
it("rejects strings that are unterminated, misplaced or not UTF-8", async () => {
const unterminated = rpathCommand("abcd");
unterminated.fill(0x41, 12);
expect(await read(machoImage({ commands: [unterminated] }))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("unterminated"),
});
const misplaced = rpathCommand("abcd");
new DataView(misplaced.buffer).setUint32(8, 4, true);
expect(await read(machoImage({ commands: [misplaced] }))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("string offset 4"),
});
const invalid = rpathCommand("ab");
invalid[12] = 0xff;
expect(await read(machoImage({ commands: [invalid] }))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("not UTF-8"),
});
});
it("refuses truncated, oversized, big-endian and out-of-range images", async () => {
const image = machoImage({ commands: [rpathCommand("@loader_path")] });
expect(await read(image.subarray(0, 20))).toMatchObject({
status: "malformed",
reason: "Mach-O header is truncated",
});
const oversized = machoImage({});
new DataView(oversized.buffer).setUint32(
20,
MAX_LOAD_COMMAND_BYTES + 8,
true,
);
expect(await read(oversized)).toMatchObject({ status: "unsupported" });
const beyond = machoImage({ commands: [rpathCommand("x")] });
new DataView(beyond.buffer).setUint32(20, beyond.length, true);
expect(await read(beyond)).toMatchObject({
status: "malformed",
reason: "load commands extend beyond the Mach-O slice",
});
const bigEndian = Uint8Array.from([0xfe, 0xed, 0xfa, 0xcf, 0, 0, 0, 0]);
expect(await read(bigEndian)).toMatchObject({ status: "unsupported" });
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
expect(await read(fat.subarray(0, 4100))).toMatchObject({
status: "malformed",
reason: "FAT architecture 0 extends beyond the file",
});
});
});