Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23ec9931ba |
No files matched your search
@@ -180,10 +180,8 @@ rea trace-dylib-resolution MyApp.app --root Contents/MacOS/MyApp --architecture
|
||||
|
||||
Load commands are parsed in TypeScript from Mach-O and FAT headers, including
|
||||
the `dylib_use_command` encoding newer linkers emit for flags such as delayed
|
||||
initialization, and the `LC_LAZY_LOAD_DYLIB_INFO` records they emit for
|
||||
`-lazy_library`. Lazy-info records carry no dylib versions, so their
|
||||
`current_version` and `compatibility_version` are null. Nothing is executed and
|
||||
no Apple tool is required, so the tool also works off macOS.
|
||||
initialization. Nothing is executed and no Apple tool is required, so the tool
|
||||
also works off macOS.
|
||||
|
||||
Resolution follows dyld:
|
||||
|
||||
@@ -224,8 +222,7 @@ cache rather than on disk, so REA does not check them against the host.
|
||||
`findings` are derived, not observed:
|
||||
|
||||
- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`.
|
||||
Lazy dependencies (`lazy: true`, from `LC_LAZY_LOAD_DYLIB` or
|
||||
`LC_LAZY_LOAD_DYLIB_INFO`) are resolved but not traversed, because dyld
|
||||
`LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
|
||||
loads them on first use; an unresolved one is not a launch failure.
|
||||
- `earlier-rpath-candidate-absent`: a compatible Mach-O placed at an earlier
|
||||
modeled search path could take precedence, subject to unmodeled search inputs
|
||||
|
||||
@@ -7,7 +7,6 @@ export const LC = {
|
||||
REEXPORT_DYLIB: 0x8000001f,
|
||||
LAZY_LOAD_DYLIB: 0x20,
|
||||
LOAD_UPWARD_DYLIB: 0x80000023,
|
||||
LAZY_LOAD_DYLIB_INFO: 0x3a,
|
||||
RPATH: 0x8000001c,
|
||||
DYLD_ENVIRONMENT: 0x27,
|
||||
CODE_SIGNATURE: 0x1d,
|
||||
@@ -103,71 +102,18 @@ export const versionMinCommand = (command: number): Uint8Array => {
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/**
|
||||
* dyld's `LazyLoadDylibLinkEdit` payload as Apple's linker lays it out: the
|
||||
* 24-byte header, the symbol offset array, the path and the symbol names.
|
||||
*/
|
||||
export const lazyLoadDylibInfo = (
|
||||
path: string,
|
||||
options: {
|
||||
readonly flags?: number;
|
||||
readonly symbols?: readonly string[];
|
||||
} = {},
|
||||
): Uint8Array => {
|
||||
const symbols = (options.symbols ?? ["_lf"]).map((name) =>
|
||||
encoder.encode(name),
|
||||
);
|
||||
const name = encoder.encode(path);
|
||||
const pathOffset = 24 + symbols.length * 4;
|
||||
let end = pathOffset + name.length + 1;
|
||||
const symbolOffsets = symbols.map((symbol) => {
|
||||
const offset = end;
|
||||
end += symbol.length + 1;
|
||||
return offset;
|
||||
});
|
||||
const bytes = new Uint8Array(Math.ceil(end / 8) * 8);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, pathOffset, true);
|
||||
view.setUint32(4, 0x8008, true);
|
||||
view.setUint16(8, options.flags ?? 0, true);
|
||||
view.setUint16(10, 6, true);
|
||||
view.setUint32(12, 0x8000, true);
|
||||
view.setUint32(16, symbols.length, true);
|
||||
view.setUint32(20, 24, true);
|
||||
symbolOffsets.forEach((offset, index) => {
|
||||
view.setUint32(24 + index * 4, offset, true);
|
||||
bytes.set(symbols[index] ?? new Uint8Array(), offset);
|
||||
});
|
||||
bytes.set(name, pathOffset);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */
|
||||
export const machoImage = (options: {
|
||||
readonly cpu?: { readonly type: number; readonly subtype: number };
|
||||
readonly fileType?: number;
|
||||
readonly commands?: readonly Uint8Array[];
|
||||
/** `linkedit_data_command`s appended to `commands`, with data placed after them. */
|
||||
readonly linkedit?: readonly {
|
||||
readonly command: number;
|
||||
readonly data: Uint8Array;
|
||||
}[];
|
||||
readonly wide?: boolean;
|
||||
}): Uint8Array => {
|
||||
const wide = options.wide ?? true;
|
||||
const linkedit = options.linkedit ?? [];
|
||||
const commands = [
|
||||
...(options.commands ?? []),
|
||||
...linkedit.map(() => new Uint8Array(16)),
|
||||
];
|
||||
const commands = options.commands ?? [];
|
||||
const headerSize = wide ? 32 : 28;
|
||||
const commandBytes = commands.reduce((total, item) => total + item.length, 0);
|
||||
const dataStart = headerSize + commandBytes + 16;
|
||||
const dataBytes = linkedit.reduce(
|
||||
(total, item) => total + item.data.length,
|
||||
0,
|
||||
);
|
||||
const bytes = new Uint8Array(dataStart + dataBytes);
|
||||
const bytes = new Uint8Array(headerSize + commandBytes + 16);
|
||||
const view = new DataView(bytes.buffer);
|
||||
const cpu = options.cpu ?? CPU.arm64;
|
||||
view.setUint32(0, wide ? 0xfeedfacf : 0xfeedface, true);
|
||||
@@ -181,17 +127,6 @@ export const machoImage = (options: {
|
||||
bytes.set(command, offset);
|
||||
offset += command.length;
|
||||
}
|
||||
let dataOffset = dataStart;
|
||||
let commandOffset = headerSize + commandBytes - linkedit.length * 16;
|
||||
for (const { command, data } of linkedit) {
|
||||
view.setUint32(commandOffset, command, true);
|
||||
view.setUint32(commandOffset + 4, 16, true);
|
||||
view.setUint32(commandOffset + 8, dataOffset, true);
|
||||
view.setUint32(commandOffset + 12, data.length, true);
|
||||
bytes.set(data, dataOffset);
|
||||
commandOffset += 16;
|
||||
dataOffset += data.length;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
dylibCommand,
|
||||
dylibUseCommand,
|
||||
fatImage,
|
||||
lazyLoadDylibInfo,
|
||||
machoImage,
|
||||
readerOf,
|
||||
rpathCommand,
|
||||
@@ -354,98 +353,6 @@ describe("Mach-O load command reader: decodes thin-image load commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader: decodes LC_LAZY_LOAD_DYLIB_INFO", () => {
|
||||
it("decodes LC_LAZY_LOAD_DYLIB_INFO records in load-command order", async () => {
|
||||
const facts = await read(
|
||||
machoImage({
|
||||
commands: [dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib")],
|
||||
linkedit: [
|
||||
{
|
||||
command: LC.LAZY_LOAD_DYLIB_INFO,
|
||||
data: lazyLoadDylibInfo("@rpath/libl.dylib"),
|
||||
},
|
||||
{
|
||||
command: LC.LAZY_LOAD_DYLIB_INFO,
|
||||
data: lazyLoadDylibInfo("@rpath/libmaybe.dylib", {
|
||||
flags: 0x1,
|
||||
symbols: ["_a", "_b"],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (facts.status !== "parsed") throw new Error(facts.status);
|
||||
expect(facts.slices[0]?.dependencies.slice(1)).toEqual([
|
||||
{
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
install_name: "@rpath/libl.dylib",
|
||||
weak: false,
|
||||
upward: false,
|
||||
reexport: false,
|
||||
delayed_init: false,
|
||||
lazy: true,
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
},
|
||||
expect.objectContaining({
|
||||
install_name: "@rpath/libmaybe.dylib",
|
||||
weak: true,
|
||||
lazy: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects LC_LAZY_LOAD_DYLIB_INFO data that dyld would reject", async () => {
|
||||
const withData = (data: Uint8Array) =>
|
||||
read(
|
||||
machoImage({
|
||||
linkedit: [{ command: LC.LAZY_LOAD_DYLIB_INFO, data }],
|
||||
}),
|
||||
);
|
||||
const valid = lazyLoadDylibInfo("@rpath/libl.dylib");
|
||||
const patched = (offset: number, value: number) => {
|
||||
const bytes = valid.slice();
|
||||
new DataView(bytes.buffer).setUint32(offset, value, true);
|
||||
return bytes;
|
||||
};
|
||||
await expect(withData(valid.subarray(0, 20))).resolves.toEqual({
|
||||
status: "malformed",
|
||||
reason:
|
||||
"load command 0 LC_LAZY_LOAD_DYLIB_INFO data is too short for its header",
|
||||
});
|
||||
await expect(withData(patched(0, valid.length))).resolves.toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("path offset"),
|
||||
});
|
||||
await expect(withData(patched(16, 1000))).resolves.toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("symbol table exceeds its data"),
|
||||
});
|
||||
await expect(
|
||||
withData(patched(24, valid.length + 1)),
|
||||
).resolves.toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("symbol 0 is outside its data"),
|
||||
});
|
||||
const unterminated = valid.slice();
|
||||
unterminated.fill(0x41, 28);
|
||||
await expect(withData(unterminated)).resolves.toMatchObject({
|
||||
status: "malformed",
|
||||
reason: "load command 0 has an unterminated string",
|
||||
});
|
||||
const beyond = machoImage({
|
||||
linkedit: [{ command: LC.LAZY_LOAD_DYLIB_INFO, data: valid }],
|
||||
});
|
||||
await expect(
|
||||
readMachoImage(readerOf(beyond), beyond.length - 1),
|
||||
).resolves.toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("data extends beyond the Mach-O slice"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O load command reader: reads and validates universal binary slices", () => {
|
||||
it("reads every slice of FAT and FAT_64 containers in either byte order", async () => {
|
||||
for (const [wide, littleEndian] of [
|
||||
|
||||
@@ -33,7 +33,6 @@ const LC_LOAD_WEAK_DYLIB = 0x80000018;
|
||||
const LC_REEXPORT_DYLIB = 0x8000001f;
|
||||
const LC_LAZY_LOAD_DYLIB = 0x20;
|
||||
const LC_LOAD_UPWARD_DYLIB = 0x80000023;
|
||||
const LC_LAZY_LOAD_DYLIB_INFO = 0x3a;
|
||||
const LC_RPATH = 0x8000001c;
|
||||
const LC_DYLD_ENVIRONMENT = 0x27;
|
||||
const LC_CODE_SIGNATURE = 0x1d;
|
||||
@@ -91,16 +90,6 @@ const DYLIB_USE_REEXPORT = 0x2;
|
||||
const DYLIB_USE_UPWARD = 0x4;
|
||||
const DYLIB_USE_DELAYED_INIT = 0x8;
|
||||
|
||||
/**
|
||||
* dyld's `LazyLoadDylibLinkEdit` (dyld `mach_o/LazyLoadDylib.h`), the
|
||||
* __LINKEDIT payload of `LC_LAZY_LOAD_DYLIB_INFO`: loadPathOffset,
|
||||
* flagImageOffset, flags, pointerFormat, chainStartImageOffset, symbolsCount
|
||||
* and symbolStringArrayOffset, followed by the path and symbol strings.
|
||||
*/
|
||||
const LAZY_LOAD_DYLIB_INFO_HEADER_BYTES = 24;
|
||||
/** `LazyLoadDylib::dylibMayBeMissing()`: the lazy dylib is weak linked. */
|
||||
const LAZY_LOAD_DYLIB_WEAK_LINK = 0x1;
|
||||
|
||||
const DEPENDENCY_COMMANDS: ReadonlyMap<number, MachoDependency["command"]> =
|
||||
new Map([
|
||||
[LC_LOAD_DYLIB, "LC_LOAD_DYLIB"],
|
||||
@@ -304,7 +293,7 @@ const readSlice = async (
|
||||
commandBytes,
|
||||
"load commands",
|
||||
);
|
||||
const slice = await decodeCommands(readAt, commands, {
|
||||
const slice = decodeCommands(commands, {
|
||||
wide,
|
||||
cpuType: identity.cpuType,
|
||||
cpuSubtype: identity.cpuSubtype,
|
||||
@@ -331,8 +320,7 @@ const readSlice = async (
|
||||
);
|
||||
};
|
||||
|
||||
const decodeCommands = async (
|
||||
readAt: ReadAt,
|
||||
const decodeCommands = (
|
||||
bytes: Uint8Array,
|
||||
header: {
|
||||
readonly wide: boolean;
|
||||
@@ -346,7 +334,7 @@ const decodeCommands = async (
|
||||
readonly fatCpuSubtype: number | null;
|
||||
readonly fatAlignmentExponent: number | null;
|
||||
},
|
||||
): Promise<MachoSlice> => {
|
||||
): MachoSlice => {
|
||||
const view = viewOf(bytes);
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
@@ -386,11 +374,7 @@ const decodeCommands = async (
|
||||
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
|
||||
);
|
||||
const body = bytes.subarray(offset, offset + size);
|
||||
if (command === LC_LAZY_LOAD_DYLIB_INFO)
|
||||
slice.dependencies.push(
|
||||
await readLazyLoadDylibInfo(readAt, body, index, header),
|
||||
);
|
||||
else decodeCommand(command, body, index, slice);
|
||||
decodeCommand(command, body, index, slice);
|
||||
offset += size;
|
||||
}
|
||||
if (offset !== bytes.byteLength)
|
||||
@@ -489,89 +473,11 @@ const decodeDependency = (
|
||||
reexport:
|
||||
command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0,
|
||||
delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0,
|
||||
lazy: command === "LC_LAZY_LOAD_DYLIB",
|
||||
current_version: version(view.getUint32(16, true)),
|
||||
compatibility_version: version(view.getUint32(20, true)),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode one `LC_LAZY_LOAD_DYLIB_INFO` record from its __LINKEDIT payload.
|
||||
* Validation follows dyld's `LazyLoadDylib::valid()` except for the flag and
|
||||
* chain-start image offsets, which need the VM layout and do not affect
|
||||
* which dylib is named.
|
||||
*/
|
||||
const readLazyLoadDylibInfo = async (
|
||||
readAt: ReadAt,
|
||||
body: Uint8Array,
|
||||
index: number,
|
||||
slice: { readonly sliceOffset: number; readonly sliceSize: number },
|
||||
): Promise<MachoDependency> => {
|
||||
// linkedit_data_command: cmd, cmdsize, dataoff, datasize.
|
||||
if (body.byteLength < 16)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} is too short for LC_LAZY_LOAD_DYLIB_INFO`,
|
||||
);
|
||||
const command = viewOf(body);
|
||||
const dataOffset = command.getUint32(8, true);
|
||||
const dataSize = command.getUint32(12, true);
|
||||
if (dataSize > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data occupies ${dataSize} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
|
||||
);
|
||||
if (dataOffset + dataSize > slice.sliceSize)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data extends beyond the Mach-O slice`,
|
||||
);
|
||||
if (dataSize < LAZY_LOAD_DYLIB_INFO_HEADER_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data is too short for its header`,
|
||||
);
|
||||
const blob = await readExact(
|
||||
readAt,
|
||||
slice.sliceOffset + dataOffset,
|
||||
dataSize,
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO data`,
|
||||
);
|
||||
const view = viewOf(blob);
|
||||
const symbolCount = view.getUint32(16, true);
|
||||
const symbolArrayOffset = view.getUint32(20, true);
|
||||
if (symbolArrayOffset + symbolCount * 4 > dataSize)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol table exceeds its data`,
|
||||
);
|
||||
for (let symbol = 0; symbol < symbolCount; symbol++)
|
||||
if (view.getUint32(symbolArrayOffset + symbol * 4, true) > dataSize)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol ${symbol} is outside its data`,
|
||||
);
|
||||
const pathOffset = view.getUint32(0, true);
|
||||
if (pathOffset >= dataSize)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO path offset ${pathOffset} is outside its data`,
|
||||
);
|
||||
return {
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
install_name: terminatedString(blob, pathOffset, index),
|
||||
weak: (view.getUint16(8, true) & LAZY_LOAD_DYLIB_WEAK_LINK) !== 0,
|
||||
upward: false,
|
||||
reexport: false,
|
||||
delayed_init: false,
|
||||
lazy: true,
|
||||
// The record carries no dylib versions.
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
};
|
||||
};
|
||||
|
||||
/** NUL-terminated UTF-8 string at a `lc_str` offset inside one command. */
|
||||
const commandString = (
|
||||
body: Uint8Array,
|
||||
@@ -589,16 +495,7 @@ const commandString = (
|
||||
"malformed",
|
||||
`load command ${index} has string offset ${offset} outside the command`,
|
||||
);
|
||||
return terminatedString(body, offset, index);
|
||||
};
|
||||
|
||||
/** NUL-terminated UTF-8 string starting at `offset` inside one record. */
|
||||
const terminatedString = (
|
||||
bytes: Uint8Array,
|
||||
offset: number,
|
||||
index: number,
|
||||
): string => {
|
||||
const end = bytes.indexOf(0, offset);
|
||||
const end = body.indexOf(0, offset);
|
||||
if (end < 0)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
@@ -607,7 +504,7 @@ const terminatedString = (
|
||||
try {
|
||||
// lc_str contains name/path data, so a leading U+FEFF is part of its identity.
|
||||
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes.subarray(offset, end),
|
||||
body.subarray(offset, end),
|
||||
);
|
||||
} catch {
|
||||
throw new MachoFormatIssue(
|
||||
|
||||
@@ -47,7 +47,6 @@ const dependency = (
|
||||
upward: false,
|
||||
reexport: false,
|
||||
delayed_init: false,
|
||||
lazy: overrides.command === "LC_LAZY_LOAD_DYLIB",
|
||||
current_version: "1.0.0",
|
||||
compatibility_version: "1.0.0",
|
||||
...overrides,
|
||||
|
||||
@@ -47,26 +47,18 @@ const dependencyCommandSchema = z.enum([
|
||||
"LC_REEXPORT_DYLIB",
|
||||
"LC_LAZY_LOAD_DYLIB",
|
||||
"LC_LOAD_UPWARD_DYLIB",
|
||||
"LC_LAZY_LOAD_DYLIB_INFO",
|
||||
]);
|
||||
|
||||
const dependencySchema = z.strictObject({
|
||||
command: dependencyCommandSchema,
|
||||
encoding: z.enum([
|
||||
"dylib_command",
|
||||
"dylib_use_command",
|
||||
"lazy_load_dylib_info",
|
||||
]),
|
||||
encoding: z.enum(["dylib_command", "dylib_use_command"]),
|
||||
install_name: z.string(),
|
||||
weak: z.boolean(),
|
||||
upward: z.boolean(),
|
||||
reexport: z.boolean(),
|
||||
delayed_init: z.boolean(),
|
||||
/** dyld loads the dylib on first use of one of its symbols, not at launch. */
|
||||
lazy: z.boolean(),
|
||||
/** Null when the encoding records no dylib versions. */
|
||||
current_version: z.string().nullable(),
|
||||
compatibility_version: z.string().nullable(),
|
||||
current_version: z.string(),
|
||||
compatibility_version: z.string(),
|
||||
});
|
||||
|
||||
/** Observed dylib-loading facts of one Mach-O slice. */
|
||||
@@ -536,8 +528,8 @@ const traceProcess = async (
|
||||
for (const dependency of current.slice.dependencies) {
|
||||
const edge = await resolveDependency(dependency, loader, context);
|
||||
edges.push(edge);
|
||||
// dyld loads lazy dylibs on first use, not at launch.
|
||||
if (dependency.lazy) continue;
|
||||
// dyld loads LC_LAZY_LOAD_DYLIB images on first use, not at launch.
|
||||
if (dependency.command === "LC_LAZY_LOAD_DYLIB") continue;
|
||||
const image = edge.resolution.image;
|
||||
if (image === null || context.loaded.has(image)) continue;
|
||||
const facts = context.images.get(image);
|
||||
|
||||
@@ -62,18 +62,16 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
} as const;
|
||||
const missing = `No candidate for ${edge.lazy ? `lazily loaded ${edge.weak ? "weak " : ""}` : edge.weak ? "weak dependency " : ""}${edge.install_name} exists in the analyzed root`;
|
||||
const missing = `No candidate for ${edge.command === "LC_LAZY_LOAD_DYLIB" ? "lazily loaded " : edge.weak ? "weak dependency " : ""}${edge.install_name} exists in the analyzed root`;
|
||||
// Below a conditional fallback, every consequence applies only if it loads.
|
||||
const when = edge.loader_conditional
|
||||
? `. ${edge.loader} loads only conditionally; if it loads, `
|
||||
: "; ";
|
||||
if (edge.lazy)
|
||||
if (edge.command === "LC_LAZY_LOAD_DYLIB")
|
||||
return {
|
||||
...base,
|
||||
kind: "lazy-load-unresolved",
|
||||
explanation: edge.weak
|
||||
? `${missing}${when}dyld tries to load it on first use and continues without it.`
|
||||
: `${missing}${when}dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
|
||||
explanation: `${missing}${when}dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
|
||||
};
|
||||
return edge.weak
|
||||
? {
|
||||
@@ -89,7 +87,7 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
|
||||
};
|
||||
|
||||
export const DYLIB_RESOLUTION_LIMITATIONS = [
|
||||
"Lazy dependencies (LC_LAZY_LOAD_DYLIB and LC_LAZY_LOAD_DYLIB_INFO) are resolved but not traversed, because dyld loads them only on first use.",
|
||||
"LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.",
|
||||
"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.",
|
||||
|
||||
@@ -1193,3 +1193,44 @@ describe("malformed Objective-C protocol method lists", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Objective-C method lists cut by the record budget", () => {
|
||||
it("never reports a declaration decoded while the budget omitted its methods", () => {
|
||||
const complete = decodeAppleDispatchMetadata(fixture(), 100, provenance);
|
||||
const classKey = (item: { name: string; is_meta_class: boolean | null }) =>
|
||||
`${item.name}:${String(item.is_meta_class)}`;
|
||||
const completeProtocols = new Map(
|
||||
complete.objc_protocol_records.map((item) => [item.name, item]),
|
||||
);
|
||||
const completeClasses = new Map(
|
||||
complete.objc_classes.map((item) => [classKey(item), item]),
|
||||
);
|
||||
const cutReasons = new Set<string>();
|
||||
for (let budget = 1; budget < 40; budget++) {
|
||||
const result = decodeAppleDispatchMetadata(fixture(), budget, provenance);
|
||||
for (const record of result.objc_protocol_records) {
|
||||
const full = completeProtocols.get(record.name);
|
||||
if (record.decode.status === "decoded") {
|
||||
expect(record.methods).toEqual(full?.methods);
|
||||
expect(record.optional_methods).toEqual(full?.optional_methods);
|
||||
} else if (record.decode.reason !== null)
|
||||
cutReasons.add(`protocol:${record.decode.reason}`);
|
||||
}
|
||||
for (const record of result.objc_classes) {
|
||||
const full = completeClasses.get(classKey(record));
|
||||
if (record.decode?.status === "decoded")
|
||||
expect(record.methods).toEqual(full?.methods);
|
||||
else if (record.decode?.reason != null)
|
||||
cutReasons.add(`class:${record.decode.reason}`);
|
||||
}
|
||||
}
|
||||
// The fixture has methods on both declaration kinds, so some budget must
|
||||
// admit each declaration but stop inside its method list.
|
||||
expect([...cutReasons]).toEqual(
|
||||
expect.arrayContaining([
|
||||
"protocol:method_list_record_budget_exhausted",
|
||||
"class:method_list_record_budget_exhausted",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export const decodeObjcCategories = (input: {
|
||||
className: string,
|
||||
methodType: "instance" | "class",
|
||||
category: string,
|
||||
) => string[];
|
||||
) => { selectors: string[]; budgetExhausted: boolean };
|
||||
readonly protocols: (list: bigint) => string[];
|
||||
/** Name of a class defined in this image, from its class_ro_t. */
|
||||
readonly localClassName: (address: bigint) => string;
|
||||
@@ -71,6 +71,18 @@ export const decodeObjcCategories = (input: {
|
||||
);
|
||||
}
|
||||
const owner = className ?? `(${name})`;
|
||||
const instanceMethods = input.methods(
|
||||
read.pointer(category + 16n),
|
||||
owner,
|
||||
"instance",
|
||||
name,
|
||||
);
|
||||
const classMethods = input.methods(
|
||||
read.pointer(category + 24n),
|
||||
owner,
|
||||
"class",
|
||||
name,
|
||||
);
|
||||
result.objc_categories.push({
|
||||
name,
|
||||
class_name: className,
|
||||
@@ -80,18 +92,8 @@ export const decodeObjcCategories = (input: {
|
||||
: className === null
|
||||
? "unresolved"
|
||||
: "local",
|
||||
instance_methods: input.methods(
|
||||
read.pointer(category + 16n),
|
||||
owner,
|
||||
"instance",
|
||||
name,
|
||||
),
|
||||
class_methods: input.methods(
|
||||
read.pointer(category + 24n),
|
||||
owner,
|
||||
"class",
|
||||
name,
|
||||
),
|
||||
instance_methods: instanceMethods.selectors,
|
||||
class_methods: classMethods.selectors,
|
||||
protocols: input.protocols(read.pointer(category + 32n)),
|
||||
properties: readObjcPropertiesOf(read, read.pointer(category + 40n), {
|
||||
owner: `category ${name}`,
|
||||
@@ -101,9 +103,14 @@ export const decodeObjcCategories = (input: {
|
||||
}),
|
||||
location: read.location(category),
|
||||
decode:
|
||||
className === null
|
||||
? { status: "partial", reason: "category_class_unresolved" }
|
||||
: { status: "decoded", reason: null },
|
||||
instanceMethods.budgetExhausted || classMethods.budgetExhausted
|
||||
? {
|
||||
status: "partial",
|
||||
reason: "method_list_record_budget_exhausted",
|
||||
}
|
||||
: className === null
|
||||
? { status: "partial", reason: "category_class_unresolved" }
|
||||
: { status: "decoded", reason: null },
|
||||
evidence: read.evidence(category, "Objective-C category_t record"),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -103,9 +103,9 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
className: string,
|
||||
methodType: "instance" | "class",
|
||||
category?: string,
|
||||
): string[] => {
|
||||
): { selectors: string[]; budgetExhausted: boolean } => {
|
||||
const selectors: string[] = [];
|
||||
if (list === 0n) return selectors;
|
||||
if (list === 0n) return { selectors, budgetExhausted: false };
|
||||
const flags = read.u32(list),
|
||||
count = read.u32(list + 4n),
|
||||
relative = (flags & 0x80000000) !== 0;
|
||||
@@ -113,7 +113,7 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
if (stride !== (relative ? 12 : 24))
|
||||
throw new TypeError(`Unsupported method stride/flags at ${hex(list)}`);
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!budget.admit()) break;
|
||||
if (!budget.admit()) return { selectors, budgetExhausted: true };
|
||||
const entry = list + 8n + BigInt(index * stride);
|
||||
const rel = (field: bigint) => {
|
||||
const displacement = bytes.readInt32LE(read.offset(field, 4));
|
||||
@@ -196,7 +196,7 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
),
|
||||
});
|
||||
}
|
||||
return selectors;
|
||||
return { selectors, budgetExhausted: false };
|
||||
};
|
||||
const protocolReader = createObjcProtocolReader({
|
||||
readers: {
|
||||
@@ -326,7 +326,11 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
});
|
||||
}
|
||||
}
|
||||
methods(read.pointer(ro + 32n), name, meta ? "class" : "instance");
|
||||
const { budgetExhausted } = methods(
|
||||
read.pointer(ro + 32n),
|
||||
name,
|
||||
meta ? "class" : "instance",
|
||||
);
|
||||
result.objc_classes.push({
|
||||
name,
|
||||
super_class: superclass.name,
|
||||
@@ -362,8 +366,9 @@ export const decodeObjcDispatchFacets = (input: {
|
||||
location: read.location(address),
|
||||
superclass_address: superclass.address,
|
||||
metaclass_address: meta ? null : hex(read.pointer(address)),
|
||||
decode:
|
||||
superclass.name === null && !root
|
||||
decode: budgetExhausted
|
||||
? { status: "partial", reason: "method_list_record_budget_exhausted" }
|
||||
: superclass.name === null && !root
|
||||
? { status: "partial", reason: "superclass_binding_unresolved" }
|
||||
: decoded,
|
||||
evidence: read.evidence(
|
||||
|
||||
@@ -39,6 +39,8 @@ export const createObjcProtocolReader = (input: {
|
||||
instance: boolean,
|
||||
/** Entries skipped for an empty selector, reported by the declaring protocol. */
|
||||
emptySelectors: string[],
|
||||
/** Set when the record budget stops the list before its last entry. */
|
||||
budget: { exhausted: boolean },
|
||||
) => {
|
||||
const output: ObjcSwiftMetadata["objc_protocols"][number]["methods"] = [];
|
||||
if (address === 0n) return output;
|
||||
@@ -49,7 +51,10 @@ export const createObjcProtocolReader = (input: {
|
||||
if (stride !== (relative ? 12 : 24))
|
||||
throw new TypeError("Unsupported Objective-C protocol method entry size");
|
||||
for (let index = 0; index < count; index++) {
|
||||
if (!read.admit()) break;
|
||||
if (!read.admit()) {
|
||||
budget.exhausted = true;
|
||||
break;
|
||||
}
|
||||
const entry = address + 8n + BigInt(index * stride);
|
||||
let selector = relative
|
||||
? entry + BigInt(read.i32(entry))
|
||||
@@ -88,13 +93,14 @@ export const createObjcProtocolReader = (input: {
|
||||
if (protocolName.length === 0)
|
||||
throw new TypeError("Objective-C protocol has an empty name");
|
||||
const skipped: string[] = [];
|
||||
const budget = { exhausted: false };
|
||||
const required = [
|
||||
...methods(read.pointer(address + 24n), true, true, skipped),
|
||||
...methods(read.pointer(address + 32n), true, false, skipped),
|
||||
...methods(read.pointer(address + 24n), true, true, skipped, budget),
|
||||
...methods(read.pointer(address + 32n), true, false, skipped, budget),
|
||||
];
|
||||
const optional = [
|
||||
...methods(read.pointer(address + 40n), false, true, skipped),
|
||||
...methods(read.pointer(address + 48n), false, false, skipped),
|
||||
...methods(read.pointer(address + 40n), false, true, skipped, budget),
|
||||
...methods(read.pointer(address + 48n), false, false, skipped, budget),
|
||||
];
|
||||
for (const message of skipped)
|
||||
failures.push(
|
||||
@@ -112,8 +118,11 @@ export const createObjcProtocolReader = (input: {
|
||||
methods: required,
|
||||
optional_methods: optional,
|
||||
location: read.location(address),
|
||||
decode:
|
||||
skipped.length === 0
|
||||
// A budget-cut list is recoverable with a larger budget, so that
|
||||
// cause is reported first; an empty list is otherwise complete.
|
||||
decode: budget.exhausted
|
||||
? { status: "partial", reason: "method_list_record_budget_exhausted" }
|
||||
: skipped.length === 0
|
||||
? { status: "decoded", reason: null }
|
||||
: { status: "partial", reason: "protocol_method_selector_empty" },
|
||||
evidence: read.evidence(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
dylibCommand,
|
||||
dyldEnvironmentCommand,
|
||||
fatImage,
|
||||
lazyLoadDylibInfo,
|
||||
machoImage,
|
||||
rpathCommand,
|
||||
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
@@ -158,73 +157,6 @@ cliTest(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"resolves LC_LAZY_LOAD_DYLIB_INFO dependencies as lazy loads",
|
||||
async ({ cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-dylib-lazy-");
|
||||
const executable = join(directory, "lazy");
|
||||
// Apple's linker encodes `-lazy_library` as LC_LAZY_LOAD_DYLIB_INFO.
|
||||
await writeFiles(directory, {
|
||||
lazy: machoImage({
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
|
||||
rpathCommand("@executable_path/lib"),
|
||||
],
|
||||
linkedit: [
|
||||
{
|
||||
command: LC.LAZY_LOAD_DYLIB_INFO,
|
||||
data: lazyLoadDylibInfo("@rpath/libl.dylib"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
"lib/libl.dylib": machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dylibCommand(LC.ID_DYLIB, "@rpath/libl.dylib"),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const trace = async () => {
|
||||
const output = await cli.run({
|
||||
arguments: ["trace-dylib-resolution", executable, "--json"],
|
||||
environment: ENVIRONMENT,
|
||||
});
|
||||
expect(output.exitCode, output.stderr).toBe(0);
|
||||
return dylibResolutionResultSchema.parse(
|
||||
parseEvidence(output.json).normalized_result,
|
||||
);
|
||||
};
|
||||
const lazyEdge = (result: Awaited<ReturnType<typeof trace>>) =>
|
||||
result.edges.find(
|
||||
({ install_name }) => install_name === "@rpath/libl.dylib",
|
||||
);
|
||||
const present = await trace();
|
||||
expect(lazyEdge(present)).toMatchObject({
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
lazy: true,
|
||||
weak: false,
|
||||
current_version: null,
|
||||
resolution: { status: "resolved", image: "lib/libl.dylib" },
|
||||
});
|
||||
expect(present.findings).toEqual([]);
|
||||
expect(present.coverage.status).toBe("complete");
|
||||
await rm(join(directory, "lib/libl.dylib"));
|
||||
const absent = await trace();
|
||||
expect(lazyEdge(absent)?.resolution).toEqual({
|
||||
status: "unresolved",
|
||||
image: null,
|
||||
});
|
||||
// A missing lazy dylib fails on first use, not at launch.
|
||||
expect(absent.findings.map(({ kind }) => kind)).toEqual([
|
||||
"lazy-load-unresolved",
|
||||
]);
|
||||
expect(absent.coverage.status).toBe("complete");
|
||||
},
|
||||
);
|
||||
|
||||
describe("trace-dylib-resolution CLI", () => {
|
||||
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
|
||||
const app = await fixtureApp();
|
||||
|
||||
@@ -176,13 +176,7 @@ it.each(["caller", "host"] as const)(
|
||||
);
|
||||
let closing: ReturnType<NonNullable<typeof host.close>> | undefined;
|
||||
try {
|
||||
// The preceding Ghidra probe may prepare Darwin process ownership before
|
||||
// Kuna starts; keep startup separate from the bounded post-abort join.
|
||||
await expect
|
||||
.poll(async () => (await fakeKunaCalls(kuna)).length, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(1);
|
||||
await expect.poll(async () => (await fakeKunaCalls(kuna)).length).toBe(1);
|
||||
const probe = (await fakeKunaCalls(kuna))[0];
|
||||
if (probe === undefined) throw new Error("Missing Kuna version probe");
|
||||
expect(probe.argv).toEqual(["--version"]);
|
||||
|
||||
Reference in new issue
Block a user