fix(macho): preserve subtype labels across dylib consumers
This commit is contained in:
1 parent
abf7d300b3
commit
0c49878b9a
11 files changed
+235
-50
No files matched your search
@@ -515,11 +515,11 @@ their failure outcome.
|
||||
and its `container_file_offset` in the input file. A thin Mach-O has a known
|
||||
offset of zero; an unknown universal-slice offset stays null. The Evidence
|
||||
subject format describes the input container: `mach-o-universal` for FAT files,
|
||||
including those with one slice. The normalized result describes the selected
|
||||
Mach-O image. Architecture
|
||||
`file_offset` values are container-relative. Entrypoint, segment and section
|
||||
`file_offset` values retain their source meaning and are relative to the
|
||||
selected Mach-O slice. Raw load-command fields retain their original values.
|
||||
including those with one slice. The normalized result also reports the container
|
||||
in `format`; `selected_architecture` identifies the selected Mach-O image.
|
||||
Architecture `file_offset` values are container-relative. Entrypoint, segment
|
||||
and section `file_offset` values retain their source meaning and are relative
|
||||
to the selected Mach-O slice. Raw load-command fields retain their original values.
|
||||
A field otool prints more than once, such as each `LC_BUILD_VERSION` tool, is
|
||||
keyed `name #n` in print order. Each `LC_LINKER_OPTION` string is keyed
|
||||
`string #n` and checked against the declared `count`. Evidence locations map the LC_MAIN entrypoint and nonempty file-backed segment
|
||||
@@ -556,6 +556,10 @@ complete observation.
|
||||
its outcome. Paths outside the analyzed root, including shared-cache system
|
||||
libraries, stay undetermined. See
|
||||
[Apple application analysis](apple-application-analysis.md#dylib-load-resolution).
|
||||
ARM64 slice labels distinguish `arm64v8`, `arm64e`, and `arm64e.x1` while
|
||||
retaining raw CPU subtype and capability bits. The `arm64` and `arm64e`
|
||||
request filters select their respective families; a shared family label
|
||||
does not establish dependency compatibility across different subtypes.
|
||||
Executable-root discovery preserves traversal uncertainty, including
|
||||
undecodable filenames and skipped symlinks, as partial coverage. Known
|
||||
executable roots remain available; unexamined names and symlink targets
|
||||
|
||||
@@ -17,11 +17,12 @@ const armSlices = async (
|
||||
readonly size?: number;
|
||||
readonly alignment?: number;
|
||||
} = {},
|
||||
cpuType = 12,
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-arm-slice-identity-");
|
||||
const path = join(root, "fat");
|
||||
const cpu = { type: 12, subtype };
|
||||
const bytes = machoImage({ cpu, wide: false });
|
||||
const cpu = { type: cpuType, subtype };
|
||||
const bytes = machoImage({ cpu, wide: cpuType !== 12 });
|
||||
await writeFile(path, fatImage([{ cpu, bytes }]));
|
||||
const reader = new MachOSliceArtifactReader(
|
||||
path,
|
||||
@@ -106,3 +107,25 @@ it.each([
|
||||
reason: "integrity",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["arm64v8", 1],
|
||||
["arm64", 1],
|
||||
["arm64e.x1", 12],
|
||||
["arm64e", 12],
|
||||
["arm64e.v1", 0x81000002],
|
||||
] as const)(
|
||||
"reconciles ARM64 source name %s with subtype %i",
|
||||
async (name, subtype) => {
|
||||
const entries = await armSlices(name, subtype, {}, 0x0100000c);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.path).toBe(`slices/${name}`);
|
||||
expect(entries[0]?.limitations).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not accept an ARM64 subtype label from a different slice", async () => {
|
||||
await expect(armSlices("arm64v8", 0, {}, 0x0100000c)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
type LipoArchitecture,
|
||||
} from "../native/parsers/lipo.js";
|
||||
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
|
||||
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
|
||||
import {
|
||||
machoArchitectureFamily,
|
||||
machoCpuSubtypeBase,
|
||||
} from "../domain/apple/machoCpuIdentity.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readMachoImage, type ReadAt } from "./apple/MachoLoadCommandReader.js";
|
||||
import {
|
||||
@@ -630,10 +633,10 @@ const lipoNameMatches = (reported: string, observed: MachoSlice): boolean => {
|
||||
ARM_SUBTYPES[reported] ===
|
||||
machoCpuSubtypeBase(observed.fat_cpu_subtype ?? observed.cpu_subtype)
|
||||
);
|
||||
// Newer lipo names pointer-auth ABI variants arm64e.<variant>. Their
|
||||
// physical type/subtype, range and alignment are still compared separately.
|
||||
if (reported === machoArchitectureFamily(observed.architecture)) return true;
|
||||
// Retain lipo's capability-variant spelling; physical identity is checked separately.
|
||||
return (
|
||||
observed.architecture === "arm64e" &&
|
||||
machoArchitectureFamily(observed.architecture) === "arm64e" &&
|
||||
/^arm64e\.[A-Za-z0-9_]+$/u.test(reported)
|
||||
);
|
||||
};
|
||||
@@ -1566,6 +1566,27 @@ describe("Mach-O load command reader: reads and validates universal binary slice
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1, "arm64v8"],
|
||||
[2, "arm64e"],
|
||||
[12, "arm64e.x1"],
|
||||
[0x8000000c, "arm64e.x1"],
|
||||
[0x81000002, "arm64e"],
|
||||
] as const)(
|
||||
"names ARM64 subtype %i as %s without losing source bits",
|
||||
async (subtype, architecture) => {
|
||||
const facts = await read(
|
||||
machoImage({ cpu: { type: CPU.arm64.type, subtype } }),
|
||||
);
|
||||
expect(facts).toMatchObject({
|
||||
status: "parsed",
|
||||
slices: [
|
||||
expect.objectContaining({ architecture, cpu_subtype: subtype }),
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("retains physical slice identity and declared build platform", async () => {
|
||||
const thin = machoImage({ commands: [buildVersionCommand(7)] });
|
||||
const parsedThin = await read(thin);
|
||||
|
||||
@@ -8,7 +8,10 @@ import type {
|
||||
MachoImageFacts,
|
||||
MachoSlice,
|
||||
} from "../../domain/apple/dylibResolution.js";
|
||||
import { machoCpuSubtypeBase } from "../../domain/apple/machoCpuIdentity.js";
|
||||
import {
|
||||
machoArchitectureName,
|
||||
machoCpuSubtypeBase,
|
||||
} from "../../domain/apple/machoCpuIdentity.js";
|
||||
import {
|
||||
legacyMachoPlatform,
|
||||
type MachoVersionMinPlatform,
|
||||
@@ -401,7 +404,7 @@ const decodeCommands = (
|
||||
readonly maxVmOffset: bigint;
|
||||
} => {
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
architecture: machoArchitectureName(header.cpuType, header.cpuSubtype),
|
||||
slice_offset: header.sliceOffset,
|
||||
slice_size: header.sliceSize,
|
||||
cpu_type: header.cpuType,
|
||||
@@ -1009,29 +1012,6 @@ const readExact = async (
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const CPU_ARCH_ABI64 = 0x01000000;
|
||||
const CPU_ARCH_ABI64_32 = 0x02000000;
|
||||
const CPU_TYPE_X86 = 7;
|
||||
const CPU_TYPE_ARM = 12;
|
||||
const CPU_SUBTYPE_ARM64E = 2;
|
||||
const CPU_SUBTYPE_ARM64E_X1 = 12;
|
||||
const CPU_SUBTYPE_X86_64_H = 8;
|
||||
|
||||
const architectureName = (cpuType: number, cpuSubtype: number): string => {
|
||||
const subtype = machoCpuSubtypeBase(cpuSubtype);
|
||||
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64))
|
||||
// Both pointer-authentication subtypes belong to the arm64e family.
|
||||
return subtype === CPU_SUBTYPE_ARM64E || subtype === CPU_SUBTYPE_ARM64E_X1
|
||||
? "arm64e"
|
||||
: "arm64";
|
||||
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64_32)) return "arm64_32";
|
||||
if (cpuType === (CPU_TYPE_X86 | CPU_ARCH_ABI64))
|
||||
return subtype === CPU_SUBTYPE_X86_64_H ? "x86_64h" : "x86_64";
|
||||
if (cpuType === CPU_TYPE_X86) return "i386";
|
||||
if (cpuType === CPU_TYPE_ARM) return "arm";
|
||||
return `cpu-${cpuType.toString(16)}`;
|
||||
};
|
||||
|
||||
const fileTypeName = (fileType: number): MachoSlice["file_type"] =>
|
||||
fileType === 2
|
||||
? "execute"
|
||||
|
||||
@@ -3,7 +3,10 @@ import type {
|
||||
DylibTreeView,
|
||||
MachoSlice,
|
||||
} from "./dylibResolution.js";
|
||||
import { machoCpuSubtypeBase } from "./machoCpuIdentity.js";
|
||||
import {
|
||||
machoArchitectureFamily,
|
||||
machoCpuSubtypeBase,
|
||||
} from "./machoCpuIdentity.js";
|
||||
|
||||
const MAX_SYMLINK_HOPS = 32;
|
||||
|
||||
@@ -155,13 +158,14 @@ export const compatibleSlice = (
|
||||
}
|
||||
return { kind: "missing" };
|
||||
}
|
||||
for (const architecture of COMPATIBLE_ARCHITECTURES[process.architecture] ?? [
|
||||
process.architecture,
|
||||
const processFamily = machoArchitectureFamily(process.architecture);
|
||||
for (const architecture of COMPATIBLE_ARCHITECTURES[processFamily] ?? [
|
||||
processFamily,
|
||||
]) {
|
||||
const slice = slices.find(
|
||||
(item) =>
|
||||
item.architecture === architecture &&
|
||||
(process.architecture !== "arm64e" ||
|
||||
machoArchitectureFamily(item.architecture) === architecture &&
|
||||
(processFamily !== "arm64e" ||
|
||||
(item.cpu_type === process.cpu_type &&
|
||||
machoCpuSubtypeBase(item.cpu_subtype) ===
|
||||
machoCpuSubtypeBase(process.cpu_subtype))),
|
||||
|
||||
@@ -91,6 +91,44 @@ it("uses hidden ARM64 slices as selectable images while preserving their marker"
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ architecture: "arm64e", expected: ["arm64e", "arm64e.x1"] },
|
||||
{ architecture: "arm64", expected: ["arm64", "arm64v8"] },
|
||||
{ architecture: "x86_64", expected: [] },
|
||||
{
|
||||
architecture: undefined,
|
||||
expected: ["arm64", "arm64v8", "arm64e", "arm64e.x1"],
|
||||
},
|
||||
] as const)(
|
||||
"preserves exact root labels with family filter $architecture",
|
||||
async ({ architecture, expected }) => {
|
||||
const facts = parsed(
|
||||
...(
|
||||
[
|
||||
[0, "arm64"],
|
||||
[1, "arm64v8"],
|
||||
[2, "arm64e"],
|
||||
[12, "arm64e.x1"],
|
||||
] as const
|
||||
).map(([subtype, name]) =>
|
||||
slice({
|
||||
architecture: name,
|
||||
cpu_subtype: subtype,
|
||||
file_type: "execute",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const trace = await traceDylibLoading(memoryView({ [MAIN]: facts }), {
|
||||
roots: [MAIN],
|
||||
...(architecture === undefined ? {} : { architecture }),
|
||||
});
|
||||
expect(trace.roots.map((root) => root.architecture)).toEqual(expected);
|
||||
expect(trace.coverage.roots_without_architecture).toEqual(
|
||||
expected.length === 0 ? [MAIN] : [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** In-memory tree: files are images (or "data"), symlinks map to targets. */
|
||||
const memoryView = (
|
||||
files: Readonly<Record<string, MachoImageFacts | "data">>,
|
||||
@@ -897,6 +935,71 @@ describe("loader-relative dylib resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
rootName: "arm64v8",
|
||||
rootSubtype: 1,
|
||||
dylibName: "arm64",
|
||||
dylibSubtype: 0,
|
||||
outcome: "resolved",
|
||||
},
|
||||
{
|
||||
rootName: "arm64",
|
||||
rootSubtype: 0,
|
||||
dylibName: "arm64v8",
|
||||
dylibSubtype: 1,
|
||||
outcome: "resolved",
|
||||
},
|
||||
{
|
||||
rootName: "arm64e.x1",
|
||||
rootSubtype: 12,
|
||||
dylibName: "arm64e",
|
||||
dylibSubtype: 2,
|
||||
outcome: "architecture-missing",
|
||||
},
|
||||
{
|
||||
rootName: "arm64e",
|
||||
rootSubtype: 2,
|
||||
dylibName: "arm64e.x1",
|
||||
dylibSubtype: 12,
|
||||
outcome: "architecture-missing",
|
||||
},
|
||||
{
|
||||
rootName: "arm64e.x1",
|
||||
rootSubtype: 12,
|
||||
dylibName: "arm64e.x1",
|
||||
dylibSubtype: 12,
|
||||
outcome: "resolved",
|
||||
},
|
||||
] as const)(
|
||||
"preserves compatibility from $rootName to $dylibName",
|
||||
async ({ rootName, rootSubtype, dylibName, dylibSubtype, outcome }) => {
|
||||
const library = "Contents/MacOS/libfamily.dylib";
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
architecture: rootName,
|
||||
cpu_subtype: rootSubtype,
|
||||
dependencies: [dependency("@executable_path/libfamily.dylib")],
|
||||
}),
|
||||
[library]: parsed(
|
||||
slice({ architecture: dylibName, cpu_subtype: dylibSubtype }),
|
||||
),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(trace.edges).toHaveLength(1);
|
||||
expect(trace.edges[0]).toMatchObject({
|
||||
architecture: rootName,
|
||||
candidates: [expect.objectContaining({ outcome })],
|
||||
resolution:
|
||||
outcome === "resolved"
|
||||
? { status: "resolved", image: library }
|
||||
: { status: "unresolved", image: null },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("dyld slice compatibility and coverage", () => {
|
||||
it("keeps an unmodeled ARM32 subtype uncertain when matching slices exist", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { machoArchitectureFamily } from "./machoCpuIdentity.js";
|
||||
import { AnalysisCancelledError } from "../analysisErrorCore.js";
|
||||
|
||||
import {
|
||||
@@ -751,7 +752,7 @@ export const traceDylibLoading = async (
|
||||
const slices = machoImageSlices(facts).filter(
|
||||
({ architecture }) =>
|
||||
request.architecture === undefined ||
|
||||
architecture === request.architecture,
|
||||
machoArchitectureFamily(architecture) === request.architecture,
|
||||
);
|
||||
if (slices.length === 0) withoutArchitecture.push(root);
|
||||
for (const slice of slices) {
|
||||
|
||||
@@ -43,7 +43,7 @@ export const deriveFindings = (
|
||||
explanation: `The modeled dyld search checks ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A compatible Mach-O placed at an earlier modeled path could take precedence over that fallback, subject to unmodeled search inputs and code-signing library validation; library validation is not evaluated here (see inspect_signature).${edge.loader_conditional ? " This loader loads only conditionally; if it never loads, this search never occurs." : ""}${edge.resolution.status === "conditional" ? " Unmodeled environment overrides or earlier unknown candidates may take precedence; placing an image at this rpath does not establish which image dyld will load." : ""}`,
|
||||
});
|
||||
});
|
||||
// Slices can share an architecture name (arm64e and arm64e.x1), so each
|
||||
// Capability-bit variants can share an architecture name, so each
|
||||
// finding uses the slice its root was traced from, not a name lookup.
|
||||
for (const { image, slice } of roots) {
|
||||
const { architecture } = slice;
|
||||
|
||||
@@ -4,3 +4,33 @@ const CPU_SUBTYPE_CAPABILITY_MASK = 0xff000000;
|
||||
/** Return the CPU subtype identity shared across capability-flag variations. */
|
||||
export const machoCpuSubtypeBase = (cpuSubtype: number): number =>
|
||||
cpuSubtype & ~CPU_SUBTYPE_CAPABILITY_MASK;
|
||||
|
||||
/**
|
||||
* Display known byte-header identities without inferring pointer-auth ABI names.
|
||||
* Subtype 12: llvm-project b8007a8e4020b8bca2b12e941660e10bf5bf6716,
|
||||
* MachO.h / MachOObjectFile.cpp. Capability bits remain independent source facts.
|
||||
*/
|
||||
export const machoArchitectureName = (
|
||||
cpuType: number,
|
||||
cpuSubtype: number,
|
||||
): string => {
|
||||
const subtype = machoCpuSubtypeBase(cpuSubtype);
|
||||
if (cpuType === 0x0100000c) {
|
||||
if (subtype === 12) return "arm64e.x1";
|
||||
if (subtype === 2) return "arm64e";
|
||||
if (subtype === 1) return "arm64v8";
|
||||
return "arm64";
|
||||
}
|
||||
if (cpuType === 0x0200000c) return "arm64_32";
|
||||
if (cpuType === 0x01000007) return subtype === 8 ? "x86_64h" : "x86_64";
|
||||
if (cpuType === 7) return "i386";
|
||||
if (cpuType === 12) return "arm";
|
||||
return `cpu-${cpuType.toString(16)}`;
|
||||
};
|
||||
|
||||
/** Known exact display aliases retain the existing family-based request semantics. */
|
||||
export const machoArchitectureFamily = (architecture: string): string => {
|
||||
if (architecture === "arm64e.x1") return "arm64e";
|
||||
if (architecture === "arm64v8") return "arm64";
|
||||
return architecture;
|
||||
};
|
||||
@@ -643,21 +643,33 @@ cliTest(
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reports each same-named slice's own embedded dyld environment",
|
||||
"preserves exact architecture labels and each same-named slice's dyld environment",
|
||||
async ({ cli }) => {
|
||||
// arm64e and arm64e.x1 slices both report the architecture name arm64e.
|
||||
// Capability variants can share arm64e's display name, while .x1 stays exact.
|
||||
// A plain x86_64 slice keeps the bundle openable on x64 hosts.
|
||||
for (const [settings, expected] of [
|
||||
[[null, "DYLD_PRINT_RPATHS=1"], ["DYLD_PRINT_RPATHS=1"]],
|
||||
[[null, null, "DYLD_PRINT_RPATHS=1"], ["DYLD_PRINT_RPATHS=1"]],
|
||||
[
|
||||
["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"],
|
||||
["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"],
|
||||
[
|
||||
"DYLD_PRINT_LIBRARIES=1",
|
||||
"DYLD_PRINT_RPATHS=1",
|
||||
"DYLD_PRINT_BINDINGS=1",
|
||||
],
|
||||
[
|
||||
"DYLD_PRINT_LIBRARIES=1",
|
||||
"DYLD_PRINT_RPATHS=1",
|
||||
"DYLD_PRINT_BINDINGS=1",
|
||||
],
|
||||
],
|
||||
] as const) {
|
||||
const app = await fixtureApp();
|
||||
await writeFiles(app, {
|
||||
"Contents/MacOS/App": fatImage([
|
||||
...[CPU.arm64e, CPU.arm64eX1].map((cpu, index) => {
|
||||
...[
|
||||
CPU.arm64e,
|
||||
CPU.arm64eX1,
|
||||
{ type: CPU.arm64.type, subtype: 0x81000002 },
|
||||
].map((cpu, index) => {
|
||||
const setting = settings[index];
|
||||
return {
|
||||
cpu,
|
||||
@@ -689,7 +701,11 @@ cliTest(
|
||||
const trace = dylibResolutionResultSchema.parse(
|
||||
parseEvidence(result.json).normalized_result,
|
||||
);
|
||||
expect(trace.roots).toHaveLength(2);
|
||||
expect(trace.roots.map(({ architecture }) => architecture)).toEqual([
|
||||
"arm64e",
|
||||
"arm64e.x1",
|
||||
"arm64e",
|
||||
]);
|
||||
const environments = trace.findings
|
||||
.filter(({ kind }) => kind === "dyld-environment-present")
|
||||
.map(({ explanation }) =>
|
||||
|
||||
Reference in new issue
Block a user