Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ede7a0864 |
No files matched your search
@@ -180,8 +180,10 @@ 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. Nothing is executed and no Apple tool is required, so the tool
|
||||
also works off macOS.
|
||||
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.
|
||||
|
||||
Resolution follows dyld:
|
||||
|
||||
@@ -222,7 +224,8 @@ 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`.
|
||||
`LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
|
||||
Lazy dependencies (`lazy: true`, from `LC_LAZY_LOAD_DYLIB` or
|
||||
`LC_LAZY_LOAD_DYLIB_INFO`) 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,6 +7,7 @@ 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,
|
||||
@@ -102,18 +103,71 @@ 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 commands = options.commands ?? [];
|
||||
const linkedit = options.linkedit ?? [];
|
||||
const commands = [
|
||||
...(options.commands ?? []),
|
||||
...linkedit.map(() => new Uint8Array(16)),
|
||||
];
|
||||
const headerSize = wide ? 32 : 28;
|
||||
const commandBytes = commands.reduce((total, item) => total + item.length, 0);
|
||||
const bytes = new Uint8Array(headerSize + commandBytes + 16);
|
||||
const dataStart = headerSize + commandBytes + 16;
|
||||
const dataBytes = linkedit.reduce(
|
||||
(total, item) => total + item.data.length,
|
||||
0,
|
||||
);
|
||||
const bytes = new Uint8Array(dataStart + dataBytes);
|
||||
const view = new DataView(bytes.buffer);
|
||||
const cpu = options.cpu ?? CPU.arm64;
|
||||
view.setUint32(0, wide ? 0xfeedfacf : 0xfeedface, true);
|
||||
@@ -127,6 +181,17 @@ 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,6 +14,7 @@ import {
|
||||
dylibCommand,
|
||||
dylibUseCommand,
|
||||
fatImage,
|
||||
lazyLoadDylibInfo,
|
||||
machoImage,
|
||||
readerOf,
|
||||
rpathCommand,
|
||||
@@ -353,6 +354,98 @@ 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,6 +33,7 @@ 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;
|
||||
@@ -90,6 +91,16 @@ 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"],
|
||||
@@ -293,7 +304,7 @@ const readSlice = async (
|
||||
commandBytes,
|
||||
"load commands",
|
||||
);
|
||||
const slice = decodeCommands(commands, {
|
||||
const slice = await decodeCommands(readAt, commands, {
|
||||
wide,
|
||||
cpuType: identity.cpuType,
|
||||
cpuSubtype: identity.cpuSubtype,
|
||||
@@ -320,7 +331,8 @@ const readSlice = async (
|
||||
);
|
||||
};
|
||||
|
||||
const decodeCommands = (
|
||||
const decodeCommands = async (
|
||||
readAt: ReadAt,
|
||||
bytes: Uint8Array,
|
||||
header: {
|
||||
readonly wide: boolean;
|
||||
@@ -334,7 +346,7 @@ const decodeCommands = (
|
||||
readonly fatCpuSubtype: number | null;
|
||||
readonly fatAlignmentExponent: number | null;
|
||||
},
|
||||
): MachoSlice => {
|
||||
): Promise<MachoSlice> => {
|
||||
const view = viewOf(bytes);
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
@@ -374,7 +386,11 @@ const decodeCommands = (
|
||||
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
|
||||
);
|
||||
const body = bytes.subarray(offset, offset + size);
|
||||
decodeCommand(command, body, index, slice);
|
||||
if (command === LC_LAZY_LOAD_DYLIB_INFO)
|
||||
slice.dependencies.push(
|
||||
await readLazyLoadDylibInfo(readAt, body, index, header),
|
||||
);
|
||||
else decodeCommand(command, body, index, slice);
|
||||
offset += size;
|
||||
}
|
||||
if (offset !== bytes.byteLength)
|
||||
@@ -473,11 +489,89 @@ 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,
|
||||
@@ -495,7 +589,16 @@ const commandString = (
|
||||
"malformed",
|
||||
`load command ${index} has string offset ${offset} outside the command`,
|
||||
);
|
||||
const end = body.indexOf(0, offset);
|
||||
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);
|
||||
if (end < 0)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
@@ -504,7 +607,7 @@ const commandString = (
|
||||
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(
|
||||
body.subarray(offset, end),
|
||||
bytes.subarray(offset, end),
|
||||
);
|
||||
} catch {
|
||||
throw new MachoFormatIssue(
|
||||
|
||||
@@ -47,6 +47,7 @@ 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,18 +47,26 @@ 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"]),
|
||||
encoding: z.enum([
|
||||
"dylib_command",
|
||||
"dylib_use_command",
|
||||
"lazy_load_dylib_info",
|
||||
]),
|
||||
install_name: z.string(),
|
||||
weak: z.boolean(),
|
||||
upward: z.boolean(),
|
||||
reexport: z.boolean(),
|
||||
delayed_init: z.boolean(),
|
||||
current_version: z.string(),
|
||||
compatibility_version: z.string(),
|
||||
/** 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(),
|
||||
});
|
||||
|
||||
/** Observed dylib-loading facts of one Mach-O slice. */
|
||||
@@ -528,8 +536,8 @@ const traceProcess = async (
|
||||
for (const dependency of current.slice.dependencies) {
|
||||
const edge = await resolveDependency(dependency, loader, context);
|
||||
edges.push(edge);
|
||||
// dyld loads LC_LAZY_LOAD_DYLIB images on first use, not at launch.
|
||||
if (dependency.command === "LC_LAZY_LOAD_DYLIB") continue;
|
||||
// dyld loads lazy dylibs on first use, not at launch.
|
||||
if (dependency.lazy) continue;
|
||||
const image = edge.resolution.image;
|
||||
if (image === null || context.loaded.has(image)) continue;
|
||||
const facts = context.images.get(image);
|
||||
|
||||
@@ -62,16 +62,18 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
} as const;
|
||||
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`;
|
||||
const missing = `No candidate for ${edge.lazy ? `lazily loaded ${edge.weak ? "weak " : ""}` : 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.command === "LC_LAZY_LOAD_DYLIB")
|
||||
if (edge.lazy)
|
||||
return {
|
||||
...base,
|
||||
kind: "lazy-load-unresolved",
|
||||
explanation: `${missing}${when}dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
|
||||
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.`,
|
||||
};
|
||||
return edge.weak
|
||||
? {
|
||||
@@ -87,7 +89,7 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
|
||||
};
|
||||
|
||||
export const DYLIB_RESOLUTION_LIMITATIONS = [
|
||||
"LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.",
|
||||
"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.",
|
||||
"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.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
dylibCommand,
|
||||
dyldEnvironmentCommand,
|
||||
fatImage,
|
||||
lazyLoadDylibInfo,
|
||||
machoImage,
|
||||
rpathCommand,
|
||||
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
@@ -157,6 +158,73 @@ 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();
|
||||
|
||||
Reference in new issue
Block a user