fix(artifacts): follow dyld slice grading and report unparsed images

Address review findings in trace_dylib_resolution:
- Choose child slices by dyld's graded architectures, so an x86_64h
  process loads a generic x86_64 slice instead of reporting
  architecture-missing.
- Read FAT containers with the byte-swapped FAT_CIGAM and FAT_CIGAM_64
  magics, which target opening already accepts.
- Report string-bearing load commands too short to hold their string
  offset as malformed instead of failing with a RangeError.
- Keep recognized but unsupported images as an `unsupported` candidate
  outcome. It is undeterminable like an outside-root path, not
  malformed.
- List Mach-O files that do not parse during root enumeration as
  unparsed images, so coverage is partial rather than silently complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-07 11:07:31 +00:00
parent 63800ff5a2
commit a0ec3e2016
13 changed files with 245 additions and 63 deletions

View File

@ -149,8 +149,10 @@ Resolution follows dyld:
An escape through `..` or an absolute link is `escapes-target`.
Each edge lists every candidate path tried, with its outcome: `resolved`,
`absent`, `not-mach-o`, `malformed`, `architecture-missing`, `outside-target`,
`escapes-target`, or `undetermined`. The edge's `resolution` is one of:
`absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside
parser coverage, such as big-endian images), `architecture-missing`,
`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's
graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of:
| Status | Meaning |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@ -672,9 +672,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "80a81207beed8b4b85a533346bb6fb7048416369c2bb0f57b38ff64b8ead99e1",
"tools_sha256": "7ad6e57f4a82e441c28969242a6e2f098871b48388049cb6b1ebac161ba3d699",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "2165dbe16dd3870d4855803623fb813d6b5fc8c111bbb132e86846f63b9ba900",
"combined_sha256": "2de97ee6d2093dd6decdf0af1324b1a4be60292f5cb1b5a1a49b3ca9cc572200",
"providers_sha256": "56e95f15d7ee4cc262e9f758dc74db0eabf670fdfea0be878079f0286114e65b"
}
}

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_3eb6f1d8a4178eb2bffc5835693f29e7b11b40f2b466ff1c389ef0222954a8f5",
"manifest_id": "ecm_93f08d561a3f3decdeeb2350630274ea7404a6b072908b213c853b97c8adca1d",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "a584eaa4cf66fcaa03c8add46361b3346669b231a34b61edadc1a72dbc73d753"
"sha256": "d65a570cd63b6fb73dc44fc5a08122fdfa7d9ce43f3dfd4b4d2b18950ea2387b"
}
],
"claims": [

View File

@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "28"
tool_count: 132
catalog_digest: "2165dbe16dd3870d4855803623fb813d6b5fc8c111bbb132e86846f63b9ba900"
catalog_digest: "2de97ee6d2093dd6decdf0af1324b1a4be60292f5cb1b5a1a49b3ca9cc572200"
---
# REA

View File

@ -81,14 +81,16 @@ export const traceDylibResolution = async (options: {
.join("/");
const view = new FilesystemTreeView(root, options.signal);
try {
const roots =
parsed.data.roots ??
(options.enumerateRoots
? await executableRoots(root, view, options.signal)
: [target]);
const { roots, unclassified } =
parsed.data.roots !== undefined
? { roots: parsed.data.roots, unclassified: [] }
: options.enumerateRoots
? await executableRoots(root, view, options.signal)
: { roots: [target], unclassified: [] };
await requireMachoRoots(view, roots);
const trace = await traceDylibLoading(view, {
roots,
unclassified,
...(parsed.data.architecture === undefined
? {}
: { architecture: parsed.data.architecture }),
@ -125,13 +127,18 @@ export const traceDylibResolution = async (options: {
}
};
/** Every Mach-O in the bundle with an executable slice is its own process root. */
/**
* Every Mach-O in the bundle with an executable slice is its own process root.
* Mach-O files that do not parse are returned separately: whether they are
* executables is unknown, so they make coverage partial instead of vanishing.
*/
const executableRoots = async (
root: string,
view: FilesystemTreeView,
signal?: AbortSignal,
): Promise<string[]> => {
): Promise<{ readonly roots: string[]; readonly unclassified: string[] }> => {
const roots: string[] = [];
const unclassified: string[] = [];
for await (const entry of new DirectoryArtifactReader(root).entries(signal)) {
if (
entry.kind !== "file" ||
@ -139,17 +146,23 @@ const executableRoots = async (
)
continue;
const facts = await view.image(entry.path);
if (
if (facts.status === "malformed" || facts.status === "unsupported")
unclassified.push(entry.path);
else if (
facts.status === "parsed" &&
facts.slices.some(({ file_type: type }) => type === "execute")
)
roots.push(entry.path);
}
return roots.sort((left, right) =>
left < right ? -1 : left > right ? 1 : 0,
);
return {
roots: roots.sort(compare),
unclassified: unclassified.sort(compare),
};
};
const compare = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
const requireMachoRoots = async (
view: FilesystemTreeView,
roots: readonly string[],

View File

@ -103,13 +103,17 @@ export const machoImage = (options: {
return bytes;
};
/** A big-endian FAT container whose slices start on 4 KiB boundaries. */
/**
* A FAT container whose slices start on 4 KiB boundaries. Headers are
* big-endian unless `littleEndian` selects the swapped FAT_CIGAM magics.
*/
export const fatImage = (
slices: readonly {
readonly cpu: { readonly type: number; readonly subtype: number };
readonly bytes: Uint8Array;
}[],
wide = false,
littleEndian = false,
): Uint8Array => {
const entrySize = wide ? 32 : 20;
const offsets: number[] = [];
@ -120,21 +124,21 @@ export const fatImage = (
}
const bytes = new Uint8Array(end);
const view = new DataView(bytes.buffer);
view.setUint32(0, wide ? 0xcafebabf : 0xcafebabe, false);
view.setUint32(4, slices.length, false);
view.setUint32(0, wide ? 0xcafebabf : 0xcafebabe, littleEndian);
view.setUint32(4, slices.length, littleEndian);
slices.forEach((slice, index) => {
const base = 8 + index * entrySize;
const offset = offsets[index] ?? 0;
view.setUint32(base, slice.cpu.type, false);
view.setUint32(base + 4, slice.cpu.subtype, false);
view.setUint32(base, slice.cpu.type, littleEndian);
view.setUint32(base + 4, slice.cpu.subtype, littleEndian);
if (wide) {
view.setBigUint64(base + 8, BigInt(offset), false);
view.setBigUint64(base + 16, BigInt(slice.bytes.length), false);
view.setUint32(base + 24, 12, false);
view.setBigUint64(base + 8, BigInt(offset), littleEndian);
view.setBigUint64(base + 16, BigInt(slice.bytes.length), littleEndian);
view.setUint32(base + 24, 12, littleEndian);
} else {
view.setUint32(base + 8, offset, false);
view.setUint32(base + 12, slice.bytes.length, false);
view.setUint32(base + 16, 12, false);
view.setUint32(base + 8, offset, littleEndian);
view.setUint32(base + 12, slice.bytes.length, littleEndian);
view.setUint32(base + 16, 12, littleEndian);
}
bytes.set(slice.bytes, offset);
});

View File

@ -125,8 +125,13 @@ describe("Mach-O load command reader", () => {
]);
});
it("reads every slice of FAT and FAT_64 containers", async () => {
for (const wide of [false, true]) {
it("reads every slice of FAT and FAT_64 containers in either byte order", async () => {
for (const [wide, littleEndian] of [
[false, false],
[true, false],
[false, true],
[true, true],
] as const) {
const facts = await read(
fatImage(
[
@ -146,6 +151,7 @@ describe("Mach-O load command reader", () => {
},
],
wide,
littleEndian,
),
);
if (facts.status !== "parsed") throw new Error(facts.status);
@ -204,6 +210,17 @@ describe("Mach-O load command reader failures", () => {
});
});
it("rejects string commands too short to hold their string offset", async () => {
const short = new Uint8Array(8);
const view = new DataView(short.buffer);
view.setUint32(0, LC.RPATH, true);
view.setUint32(4, 8, true);
expect(await read(machoImage({ commands: [short] }))).toMatchObject({
status: "malformed",
reason: "load command 0 is too short to hold a string offset",
});
});
it("rejects strings that are unterminated, misplaced or not UTF-8", async () => {
const unterminated = rpathCommand("abcd");
unterminated.fill(0x41, 12);

View File

@ -18,6 +18,8 @@ const MH_CIGAM = 0xcefaedfe;
const MH_CIGAM_64 = 0xcffaedfe;
const FAT_MAGIC = 0xcafebabe;
const FAT_MAGIC_64 = 0xcafebabf;
const FAT_CIGAM = 0xbebafeca;
const FAT_CIGAM_64 = 0xbfbafeca;
const LC_ID_DYLIB = 0xd;
const LC_LOAD_DYLIB = 0xc;
@ -70,6 +72,8 @@ export const hasMachoMagic = (head: Uint8Array): boolean => {
MH_CIGAM_64,
FAT_MAGIC,
FAT_MAGIC_64,
FAT_CIGAM,
FAT_CIGAM_64,
].includes(magic);
};
@ -83,18 +87,19 @@ export const readMachoImage = async (
if (head.byteLength < 8) return { status: "not-mach-o" };
const view = viewOf(head);
const magic = view.getUint32(0, false);
if (magic === FAT_MAGIC || magic === FAT_MAGIC_64) {
const count = view.getUint32(4, false);
if ([FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic)) {
// FAT headers are big-endian; the swapped magics store them little-endian.
const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
const count = view.getUint32(4, littleEndian);
if (count === 0 || count >= MAX_FAT_ARCHITECTURES)
return { status: "not-mach-o" };
return {
status: "parsed",
slices: await readFatSlices(
readAt,
size,
magic === FAT_MAGIC_64,
slices: await readFatSlices(readAt, size, {
wide: magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64,
littleEndian,
count,
),
}),
};
}
if (magic === MH_CIGAM || magic === MH_CIGAM_64)
@ -115,21 +120,26 @@ export const readMachoImage = async (
const readFatSlices = async (
readAt: ReadAt,
size: number,
wide: boolean,
count: number,
table: {
readonly wide: boolean;
readonly littleEndian: boolean;
readonly count: number;
},
): Promise<MachoSlice[]> => {
const { wide, littleEndian, count } = table;
const entrySize = wide ? 32 : 20;
const table = await readExact(readAt, 8, count * entrySize, "FAT table");
const view = viewOf(table);
const view = viewOf(
await readExact(readAt, 8, count * entrySize, "FAT table"),
);
const slices: MachoSlice[] = [];
for (let index = 0; index < count; index++) {
const base = index * entrySize;
const offset = wide
? safeNumber(view.getBigUint64(base + 8, false))
: view.getUint32(base + 8, false);
? safeNumber(view.getBigUint64(base + 8, littleEndian))
: view.getUint32(base + 8, littleEndian);
const length = wide
? safeNumber(view.getBigUint64(base + 16, false))
: view.getUint32(base + 12, false);
? safeNumber(view.getBigUint64(base + 16, littleEndian))
: view.getUint32(base + 12, littleEndian);
if (offset + length > size)
throw new MachoFormatIssue(
"malformed",
@ -291,6 +301,11 @@ const commandString = (
minimumOffset: number,
index: number,
): string => {
if (body.byteLength < 12)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short to hold a string offset`,
);
const offset = viewOf(body).getUint32(8, true);
if (offset < minimumOffset || offset >= body.byteLength)
throw new MachoFormatIssue(

View File

@ -1,4 +1,8 @@
import type { DylibTreeEntry, DylibTreeView } from "./dylibResolution.js";
import type {
DylibTreeEntry,
DylibTreeView,
MachoSlice,
} from "./dylibResolution.js";
const MAX_SYMLINK_HOPS = 32;
@ -85,3 +89,22 @@ export const expandPrefix = (
if (raw.startsWith("/")) return { scope: "outside", path: raw };
return { scope: "undetermined", path: raw };
};
/**
* Slices a process may load, best first, following dyld's graded
* architectures: an x86_64h process also accepts generic x86_64.
*/
const COMPATIBLE_ARCHITECTURES: Readonly<Record<string, readonly string[]>> = {
x86_64h: ["x86_64h", "x86_64"],
};
export const compatibleSlice = (
slices: readonly MachoSlice[],
process: string,
): MachoSlice | undefined => {
for (const architecture of COMPATIBLE_ARCHITECTURES[process] ?? [process]) {
const slice = slices.find((item) => item.architecture === architecture);
if (slice !== undefined) return slice;
}
return undefined;
};

View File

@ -438,3 +438,83 @@ describe("dyld load order", () => {
).rejects.toThrow();
});
});
describe("dyld slice compatibility and coverage", () => {
it("lets an x86_64h process load generic x86_64 but not arm64e load arm64", async () => {
const files = {
[MAIN]: parsed(
slice({
architecture: "x86_64h",
file_type: "execute",
dependencies: [dependency("@executable_path/libgeneric.dylib")],
}),
slice({
architecture: "arm64e",
file_type: "execute",
dependencies: [dependency("@executable_path/libgeneric.dylib")],
}),
),
"Contents/MacOS/libgeneric.dylib": parsed(
slice({ architecture: "x86_64" }),
slice({ architecture: "arm64" }),
),
};
const trace = await traceDylibLoading(memoryView(files), { roots: [MAIN] });
expect(
trace.edges.map(({ architecture, candidates, resolution }) => [
architecture,
candidates[0]?.outcome,
resolution.status,
]),
).toEqual([
["x86_64h", "resolved", "resolved"],
["arm64e", "architecture-missing", "unresolved"],
]);
});
it("keeps unsupported candidates undeterminable rather than malformed", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [dependency("@executable_path/libbig.dylib")],
}),
"Contents/MacOS/libbig.dylib": {
status: "unsupported",
reason: "big-endian Mach-O images are not supported",
},
}),
{ roots: [MAIN] },
);
expect(trace.edges[0]).toMatchObject({
candidates: [{ outcome: "unsupported" }],
resolution: { status: "undetermined", image: null },
});
expect(
trace.images.find(({ path }) => path.endsWith("libbig.dylib")),
).toMatchObject({
parse_status: "unsupported",
});
});
it("reports unclassified Mach-O files as partial coverage", async () => {
const broken = "Contents/Helpers/broken";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable(),
[broken]: {
status: "malformed",
reason: "load command 0 has invalid cmdsize 0",
},
}),
{ roots: [MAIN], unclassified: [broken] },
);
expect(trace.coverage).toEqual({
status: "partial",
unparsed_images: [broken],
roots_without_architecture: [],
});
expect(trace.images.find(({ path }) => path === broken)?.reason).toBe(
"load command 0 has invalid cmdsize 0",
);
});
});

View File

@ -1,6 +1,7 @@
import { z } from "zod";
import {
compatibleSlice,
directoryOf,
expandPrefix,
joinPath,
@ -90,6 +91,7 @@ const candidateSchema = z.strictObject({
"absent",
"not-mach-o",
"malformed",
"unsupported",
"architecture-missing",
"outside-target",
"escapes-target",
@ -255,15 +257,11 @@ const evaluateCandidate = async (
return { ...base, outcome: "absent", resolved_path: null };
const facts = await imageFacts(context, lookup.path);
const outcome: Candidate["outcome"] =
facts.status === "not-mach-o"
? "not-mach-o"
: facts.status !== "parsed"
? "malformed"
: facts.slices.some(
({ architecture }) => architecture === context.architecture,
)
? "resolved"
: "architecture-missing";
facts.status !== "parsed"
? facts.status
: compatibleSlice(facts.slices, context.architecture) === undefined
? "architecture-missing"
: "resolved";
return { ...base, outcome, resolved_path: lookup.path };
};
@ -286,9 +284,11 @@ const resolution = (candidates: readonly Candidate[]): Edge["resolution"] => {
status: external ? "conditional" : "resolved",
image: candidate.resolved_path,
};
// dyld might load these; REA cannot decide them from the analyzed root.
if (
candidate.outcome === "outside-target" ||
candidate.outcome === "escapes-target" ||
candidate.outcome === "unsupported" ||
candidate.outcome === "undetermined"
)
external = true;
@ -325,9 +325,7 @@ const resolveDependency = async (
resolved.image === null ? undefined : context.images.get(resolved.image);
const slice =
image?.status === "parsed"
? image.slices.find(
({ architecture }) => architecture === context.architecture,
)
? compatibleSlice(image.slices, context.architecture)
: undefined;
return {
root: context.root,
@ -372,9 +370,7 @@ const traceProcess = async (
const facts = context.images.get(image);
const slice =
facts?.status === "parsed"
? facts.slices.find(
({ architecture }) => architecture === context.architecture,
)
? compatibleSlice(facts.slices, context.architecture)
: undefined;
if (slice === undefined) continue;
context.loaded.set(image, { slice, chain: [...current.chain, image] });
@ -395,11 +391,15 @@ export const traceDylibLoading = async (
view: DylibTreeView,
request: {
readonly roots: readonly string[];
/** Mach-O files whose root role could not be classified because they did not parse. */
readonly unclassified?: readonly string[];
readonly architecture?: string;
readonly signal?: AbortSignal;
},
): Promise<DylibTrace> => {
const images = new Map<string, MachoImageFacts>();
for (const path of request.unclassified ?? [])
images.set(path, await view.image(path));
const roots: DylibTrace["roots"][number][] = [];
const edges: Edge[] = [];
const withoutArchitecture: string[] = [];
@ -531,6 +531,7 @@ const deriveFindings = (
};
const DYLIB_RESOLUTION_LIMITATIONS = [
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack of the first chain; a later request whose install name matches an already loaded image reuses it.",

File diff suppressed because one or more lines are too long

View File

@ -52,6 +52,13 @@ const fixtureApp = async (): Promise<string> => {
return app;
};
const withBrokenHelper = async (app: string): Promise<void> => {
const broken = machoImage({ commands: [rpathCommand("x")] });
// cmdsize 0 makes the command table unreadable.
new DataView(broken.buffer).setUint32(32 + 4, 0, true);
await writeFiles(app, { "Contents/Helpers/broken": broken });
};
describe("trace-dylib-resolution CLI", () => {
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
const app = await fixtureApp();
@ -164,4 +171,24 @@ describe("trace-dylib-resolution CLI", () => {
]);
},
);
cliTest(
"reports unparsed Mach-O files found while enumerating roots",
async ({ cli }) => {
const app = await fixtureApp();
await withBrokenHelper(app);
const result = await cli.run({
arguments: ["trace-dylib-resolution", app, "--json"],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
(result.json as { normalized_result: unknown }).normalized_result,
);
expect(trace.coverage).toMatchObject({
status: "partial",
unparsed_images: ["Contents/Helpers/broken"],
});
},
);
});