mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(apple): read LC_LAZY_LOAD_DYLIB_INFO dependency payloads (#5650)
* fix(apple): read LC_LAZY_LOAD_DYLIB_INFO dependency payloads Parse the linkedit install name for lazy-load info commands, resolve those edges without traversing them, and stop inspect-macho from treating an otool dependency list as exhaustive when that payload is omitted. Fixes #5551 Fixes #5649 Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(apple): bound lazy-load payload reads and image offsets Reuse identical LC_LAZY_LOAD_DYLIB_INFO ranges, cap their combined size, and reject flag or chain-start offsets outside the slice VM range. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(apple): bound lazy-load string scans and contained reads Reject payload strings that start in the 24-byte header, validate each file-offset string once, and reuse payload bytes already read for a containing range. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(apple): reject cached lazy-load strings outside a payload Remember each cached string's terminating offset and require that NUL to fall inside the payload being decoded. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(apple): bound lazy-load payload range lookups Count containment checks across earlier payload reads and stop at the existing 16 MiB work limit. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(apple): count every lazy-info string reference Charge repeated and overlapping LC_LAZY_LOAD_DYLIB_INFO scans against the 16 MiB validation budget, and stop a scan once that budget is exhausted. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(apple): cap retained lazy-info dependency results Stop after 1024 LC_LAZY_LOAD_DYLIB_INFO commands so a load-command region full of identical commands cannot become a million traced edges. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(apple): reject a lazy-load path NUL in the final byte dyld stops the load-path scan before the payload's last byte, so a terminator in that byte leaves the image malformed. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(apple): keep may-be-missing lazy loads optional A missing LC_LAZY_LOAD_DYLIB_INFO with the may-be-missing bit is weak, so the lazy finding says dyld continues on first use instead of failing. Co-authored-by: morluto <morluto@users.noreply.github.com> * fix(apple): cap lazy-info symbol string references A payload of distinct one-byte symbol strings stayed under the byte budgets while caching one entry per offset. A slice now stops at 65536 symbol references. Co-authored-by: morluto <morluto@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
@@ -180,8 +180,21 @@ 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 `LC_LAZY_LOAD_DYLIB_INFO` (`0x3a`). That command stores its
|
||||
install name in a linkedit payload rather than in the load command itself.
|
||||
Its flag and chain-start image offsets must lie in the slice VM range, which
|
||||
is dyld's one-page floor extended by every segment except `__LINKEDIT`,
|
||||
measured from the `__TEXT` vmaddr. Payload strings must start after the 24-byte
|
||||
header. The load-path NUL must occur before the payload's final byte, matching
|
||||
dyld's scan. Distinct payload bytes and payload-range lookups stay within the 16 MiB
|
||||
limit, and a file range already read is reused. String scanning and UTF-8
|
||||
checks count every reference, including repeats and overlaps, and stop at that
|
||||
same aggregate. A slice retains at most 1024 of these commands; further copies are
|
||||
unsupported instead of becoming one traced edge each. Symbol references,
|
||||
including repeats, stop at 65536 per slice. A larger table is unsupported
|
||||
instead of caching one string per offset.
|
||||
Nothing is executed and no Apple tool is required, so the tool also works off
|
||||
macOS.
|
||||
|
||||
Resolution follows dyld:
|
||||
|
||||
@@ -222,8 +235,12 @@ 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
|
||||
loads them on first use; an unresolved one is not a launch failure.
|
||||
`LC_LAZY_LOAD_DYLIB` and `LC_LAZY_LOAD_DYLIB_INFO` dependencies are resolved
|
||||
but not traversed, because dyld loads them on first use; an unresolved one is
|
||||
not a launch failure. `LC_LAZY_LOAD_DYLIB_INFO` has no compatibility version,
|
||||
so those fields are null. Its may-be-missing flag is reported as `weak`, and
|
||||
an unresolved dependency with that flag is not a first-use failure: dyld
|
||||
continues if the image is absent.
|
||||
- `earlier-rpath-candidate-absent`: a compatible Mach-O placed at an earlier
|
||||
modeled search path could take precedence, subject to unmodeled search inputs
|
||||
and code-signing library validation. These findings do not establish which
|
||||
|
||||
@@ -140,8 +140,12 @@ const verifyParserConformance = async (app, trace) => {
|
||||
join(app, image.path),
|
||||
slice.architecture,
|
||||
);
|
||||
// otool -l prints LC_LAZY_LOAD_DYLIB_INFO without its linkedit install
|
||||
// name, so this comparison cannot expect that edge. The structural
|
||||
// reader tests own the payload.
|
||||
const edges = trace.edges.filter(
|
||||
({ loader, architecture, root }) =>
|
||||
({ loader, architecture, root, command }) =>
|
||||
command !== "LC_LAZY_LOAD_DYLIB_INFO" &&
|
||||
loader === image.path &&
|
||||
architecture === slice.architecture &&
|
||||
root ===
|
||||
|
||||
@@ -130,6 +130,130 @@ export const machoImage = (options: {
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Little-endian `LC_LAZY_LOAD_DYLIB_INFO` (0x3a) payload.
|
||||
* The 24-byte header is load-path offset, flag image offset, flags,
|
||||
* pointer format, chain-start image offset, symbol count, and the
|
||||
* symbol-offset array offset. Strings are NUL-terminated and the payload
|
||||
* is padded to 8 bytes, matching a one-symbol `@rpath/libl.dylib` payload
|
||||
* of 56 bytes.
|
||||
*/
|
||||
export const lazyLoadDylibInfoPayload = (options: {
|
||||
readonly path: string;
|
||||
readonly symbols?: readonly string[];
|
||||
readonly flags?: number;
|
||||
readonly flagImageOffset?: number;
|
||||
readonly chainStartImageOffset?: number;
|
||||
}): Uint8Array => {
|
||||
const symbols = options.symbols ?? [];
|
||||
const headerSize = 24;
|
||||
const pathBytes = encoder.encode(options.path);
|
||||
const symbolBytes = symbols.map((symbol) => encoder.encode(symbol));
|
||||
const symbolArrayOffset = headerSize;
|
||||
const pathOffset = symbolArrayOffset + symbols.length * 4;
|
||||
let cursor = pathOffset + pathBytes.length + 1;
|
||||
const symbolOffsets = symbolBytes.map((bytes) => {
|
||||
const offset = cursor;
|
||||
cursor += bytes.length + 1;
|
||||
return offset;
|
||||
});
|
||||
const payload = new Uint8Array(Math.ceil(cursor / 8) * 8);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, pathOffset, true);
|
||||
view.setUint32(4, options.flagImageOffset ?? 0, true);
|
||||
view.setUint16(8, options.flags ?? 0, true);
|
||||
view.setUint32(12, options.chainStartImageOffset ?? 0, true);
|
||||
view.setUint32(16, symbols.length, true);
|
||||
view.setUint32(20, symbolArrayOffset, true);
|
||||
symbolOffsets.forEach((offset, index) =>
|
||||
view.setUint32(symbolArrayOffset + index * 4, offset, true),
|
||||
);
|
||||
payload.set(pathBytes, pathOffset);
|
||||
symbolBytes.forEach((bytes, index) => {
|
||||
const offset = symbolOffsets[index];
|
||||
if (offset !== undefined) payload.set(bytes, offset);
|
||||
});
|
||||
return payload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Place one `LC_LAZY_LOAD_DYLIB_INFO` command among the other commands and
|
||||
* append its payload after the image. `dataoff` is relative to this slice.
|
||||
*/
|
||||
/** `LC_SEGMENT` or `LC_SEGMENT_64` with a name and VM range, and no sections. */
|
||||
export const segmentCommand = (options: {
|
||||
readonly name: string;
|
||||
readonly vmaddr: bigint;
|
||||
readonly vmsize: bigint;
|
||||
readonly wide?: boolean;
|
||||
}): Uint8Array => {
|
||||
const wide = options.wide ?? true;
|
||||
const bytes = new Uint8Array(wide ? 72 : 56);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, wide ? 0x19 : 0x1, true);
|
||||
view.setUint32(4, bytes.length, true);
|
||||
bytes.set(encoder.encode(options.name).subarray(0, 16), 8);
|
||||
if (wide) {
|
||||
view.setBigUint64(24, options.vmaddr, true);
|
||||
view.setBigUint64(32, options.vmsize, true);
|
||||
} else {
|
||||
view.setUint32(24, Number(options.vmaddr), true);
|
||||
view.setUint32(28, Number(options.vmsize), true);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** 16-byte `LC_LAZY_LOAD_DYLIB_INFO` pointing at a slice-relative payload. */
|
||||
export const lazyLoadDylibInfoCommand = (
|
||||
dataOffset: number,
|
||||
dataSize: number,
|
||||
): Uint8Array => {
|
||||
const bytes = new Uint8Array(16);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(0, 0x3a, true);
|
||||
view.setUint32(4, bytes.length, true);
|
||||
view.setUint32(8, dataOffset, true);
|
||||
view.setUint32(12, dataSize, true);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const machoImageWithLazyLoadDylibInfo = (options: {
|
||||
readonly path: string;
|
||||
readonly symbols?: readonly string[];
|
||||
readonly flags?: number;
|
||||
readonly flagImageOffset?: number;
|
||||
readonly chainStartImageOffset?: number;
|
||||
readonly cpu?: { readonly type: number; readonly subtype: number };
|
||||
readonly fileType?: number;
|
||||
readonly wide?: boolean;
|
||||
readonly commandsBefore?: readonly Uint8Array[];
|
||||
readonly commandsAfter?: readonly Uint8Array[];
|
||||
}): Uint8Array => {
|
||||
const payload = lazyLoadDylibInfoPayload(options);
|
||||
const command = new Uint8Array(16);
|
||||
const commandView = new DataView(command.buffer);
|
||||
commandView.setUint32(0, 0x3a, true);
|
||||
commandView.setUint32(4, command.length, true);
|
||||
const wide = options.wide ?? true;
|
||||
const commandsBefore = options.commandsBefore ?? [];
|
||||
const image = machoImage({
|
||||
...(options.cpu === undefined ? {} : { cpu: options.cpu }),
|
||||
...(options.fileType === undefined ? {} : { fileType: options.fileType }),
|
||||
wide,
|
||||
commands: [...commandsBefore, command, ...(options.commandsAfter ?? [])],
|
||||
});
|
||||
const out = new Uint8Array(image.length + payload.length);
|
||||
out.set(image);
|
||||
out.set(payload, image.length);
|
||||
const headerSize = wide ? 32 : 28;
|
||||
const commandAt =
|
||||
headerSize + commandsBefore.reduce((total, item) => total + item.length, 0);
|
||||
const view = new DataView(out.buffer);
|
||||
view.setUint32(commandAt + 8, image.length, true);
|
||||
view.setUint32(commandAt + 12, payload.length, true);
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Thin Mach-O with one unknown command, for width and command-size regressions. */
|
||||
export const machoImageWithUnknownCommand = (
|
||||
wide: boolean,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -14,12 +14,18 @@ import {
|
||||
dylibCommand,
|
||||
dylibUseCommand,
|
||||
fatImage,
|
||||
lazyLoadDylibInfoCommand,
|
||||
lazyLoadDylibInfoPayload,
|
||||
machoImage,
|
||||
machoImageWithLazyLoadDylibInfo,
|
||||
segmentCommand,
|
||||
readerOf,
|
||||
rpathCommand,
|
||||
versionMinCommand,
|
||||
} from "./MachoImage.fixture.js";
|
||||
import {
|
||||
MAX_LAZY_INFO_DEPENDENCIES,
|
||||
MAX_LAZY_INFO_SYMBOL_REFERENCES,
|
||||
MAX_LOAD_COMMAND_BYTES,
|
||||
hasMachoMagic,
|
||||
readMachoImage,
|
||||
@@ -353,6 +359,650 @@ describe("Mach-O load command reader: decodes thin-image load commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO payload", () => {
|
||||
it("reads install names from the linkedit payload in load-command order", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
});
|
||||
expect(payload).toHaveLength(56);
|
||||
const image = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
commandsBefore: [
|
||||
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
|
||||
],
|
||||
commandsAfter: [dylibCommand(LC.LOAD_DYLIB, "@rpath/libafter.dylib")],
|
||||
});
|
||||
const facts = await read(image);
|
||||
if (facts.status !== "parsed")
|
||||
throw new Error(
|
||||
facts.status === "not-mach-o" ? facts.status : facts.reason,
|
||||
);
|
||||
expect(
|
||||
facts.slices[0]?.dependencies.map(
|
||||
({ command, install_name: name, encoding, weak, current_version }) => ({
|
||||
command,
|
||||
name,
|
||||
encoding,
|
||||
weak,
|
||||
current_version,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
command: "LC_LOAD_DYLIB",
|
||||
name: "/usr/lib/libSystem.B.dylib",
|
||||
encoding: "dylib_command",
|
||||
weak: false,
|
||||
current_version: "1.0.0",
|
||||
},
|
||||
{
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
name: "@rpath/libl.dylib",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
weak: false,
|
||||
current_version: null,
|
||||
},
|
||||
{
|
||||
command: "LC_LOAD_DYLIB",
|
||||
name: "@rpath/libafter.dylib",
|
||||
encoding: "dylib_command",
|
||||
weak: false,
|
||||
current_version: "1.0.0",
|
||||
},
|
||||
]);
|
||||
const weak = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flags: 0x1,
|
||||
}),
|
||||
);
|
||||
if (weak.status !== "parsed")
|
||||
throw new Error(weak.status === "not-mach-o" ? weak.status : weak.reason);
|
||||
expect(weak.slices[0]?.dependencies[0]).toMatchObject({ weak: true });
|
||||
const universal = await read(
|
||||
fatImage([
|
||||
{
|
||||
cpu: CPU.arm64,
|
||||
bytes: machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (universal.status !== "parsed")
|
||||
throw new Error(
|
||||
universal.status === "not-mach-o" ? universal.status : universal.reason,
|
||||
);
|
||||
expect(universal.slices[0]?.dependencies[0]?.install_name).toBe(
|
||||
"@rpath/libl.dylib",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a lazy-info payload that is truncated, mis-pointed, or has unknown flags", async () => {
|
||||
const outside = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
});
|
||||
const view = new DataView(outside.buffer);
|
||||
const commandAt = 32;
|
||||
view.setUint32(commandAt + 8, outside.length, true);
|
||||
expect((await read(outside)).status).toBe("malformed");
|
||||
|
||||
const badPayload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
});
|
||||
const badPath = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
});
|
||||
const payloadAt = badPath.length - badPayload.length;
|
||||
new DataView(badPath.buffer).setUint32(payloadAt, badPath.length, true);
|
||||
expect(await read(badPath)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("load path"),
|
||||
});
|
||||
|
||||
const unknownFlags = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flags: 0x2,
|
||||
});
|
||||
expect(await read(unknownFlags)).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("unrecognized bits"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO image offsets", () => {
|
||||
it("rejects flag and chain-start offsets outside the slice VM range", async () => {
|
||||
const beyondPage = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flagImageOffset: 0x4001,
|
||||
}),
|
||||
);
|
||||
expect(beyondPage).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("flag image offset 0x4001"),
|
||||
});
|
||||
const beyondChain = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
chainStartImageOffset: 0x4001,
|
||||
}),
|
||||
);
|
||||
expect(beyondChain).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("chain start image offset 0x4001"),
|
||||
});
|
||||
const onPage = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flagImageOffset: 0x4000,
|
||||
chainStartImageOffset: 0x4000,
|
||||
}),
|
||||
);
|
||||
expect(onPage.status).toBe("parsed");
|
||||
});
|
||||
|
||||
it("measures the VM range from __TEXT and ignores __LINKEDIT", async () => {
|
||||
const text = segmentCommand({
|
||||
name: "__TEXT",
|
||||
vmaddr: 0x100000000n,
|
||||
vmsize: 0x8000n,
|
||||
});
|
||||
const inside = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flagImageOffset: 0x8000,
|
||||
commandsBefore: [text],
|
||||
}),
|
||||
);
|
||||
expect(inside.status).toBe("parsed");
|
||||
const outside = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
chainStartImageOffset: 0x8001,
|
||||
commandsBefore: [text],
|
||||
}),
|
||||
);
|
||||
expect(outside).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("slice VM range 0x8000"),
|
||||
});
|
||||
const linkedit = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
flagImageOffset: 0x4001,
|
||||
commandsBefore: [
|
||||
segmentCommand({
|
||||
name: "__LINKEDIT",
|
||||
vmaddr: 0n,
|
||||
vmsize: 0x100000n,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(linkedit.status).toBe("malformed");
|
||||
});
|
||||
|
||||
it("keeps a short segment parseable until a lazy-info command needs its VM range", async () => {
|
||||
const short = new Uint8Array(16);
|
||||
const view = new DataView(short.buffer);
|
||||
view.setUint32(0, 0x19, true);
|
||||
view.setUint32(4, short.length, true);
|
||||
expect((await read(machoImage({ commands: [short] }))).status).toBe(
|
||||
"parsed",
|
||||
);
|
||||
const withLazy = await read(
|
||||
machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
commandsBefore: [short],
|
||||
}),
|
||||
);
|
||||
expect(withLazy).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("slice VM range"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO payload budget", () => {
|
||||
it("reads one shared payload range once", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
});
|
||||
const dataOffset = 32 + 16 + 16 + 16;
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(dataOffset, payload.length),
|
||||
lazyLoadDylibInfoCommand(dataOffset, payload.length),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
let payloadReads = 0;
|
||||
const facts = await readMachoImage(async (offset, length) => {
|
||||
if (offset === image.length && length === payload.length)
|
||||
payloadReads += 1;
|
||||
return bytes.subarray(offset, offset + length);
|
||||
}, bytes.length);
|
||||
expect(payloadReads).toBe(1);
|
||||
expect(facts).toMatchObject({
|
||||
status: "parsed",
|
||||
slices: [
|
||||
{
|
||||
dependencies: [
|
||||
{ install_name: "@rpath/libl.dylib" },
|
||||
{ install_name: "@rpath/libl.dylib" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses distinct lazy-info payloads whose combined size exceeds the aggregate limit", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({ path: "@rpath/libl.dylib" });
|
||||
const dataOffset = 32 + 16 + 32;
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(dataOffset, payload.length),
|
||||
lazyLoadDylibInfoCommand(0, MAX_LOAD_COMMAND_BYTES),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(MAX_LOAD_COMMAND_BYTES);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
expect(await read(bytes)).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("aggregate limit"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reread a payload range contained in an earlier read", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
});
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(0, payload.length + 8),
|
||||
lazyLoadDylibInfoCommand(0, payload.length),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length + 8);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(32 + 8, image.length, true);
|
||||
view.setUint32(48 + 8, image.length, true);
|
||||
let payloadReads = 0;
|
||||
const facts = await readMachoImage(async (offset, length) => {
|
||||
if (offset === image.length) payloadReads += 1;
|
||||
return bytes.subarray(offset, offset + length);
|
||||
}, bytes.length);
|
||||
expect(payloadReads).toBe(1);
|
||||
expect(facts).toMatchObject({
|
||||
status: "parsed",
|
||||
slices: [
|
||||
{
|
||||
dependencies: [
|
||||
{ install_name: "@rpath/libl.dylib" },
|
||||
{ install_name: "@rpath/libl.dylib" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses distinct non-nested lazy-info payloads under the dependency limit", async () => {
|
||||
const count = 8;
|
||||
const payloadSize = 26;
|
||||
const payload = new Uint8Array(payloadSize);
|
||||
new DataView(payload.buffer).setUint32(0, 24, true);
|
||||
new DataView(payload.buffer).setUint32(20, 24, true);
|
||||
const dataOffset = 32 + 16 + count * 16;
|
||||
const commands = Array.from({ length: count }, (_, index) =>
|
||||
lazyLoadDylibInfoCommand(dataOffset + index * payloadSize, payloadSize),
|
||||
);
|
||||
const image = machoImage({ commands });
|
||||
const bytes = new Uint8Array(image.length + count * payloadSize);
|
||||
bytes.set(image);
|
||||
for (let index = 0; index < count; index += 1)
|
||||
bytes.set(payload, image.length + index * payloadSize);
|
||||
const facts = await read(bytes);
|
||||
if (facts.status !== "parsed")
|
||||
throw new Error(
|
||||
facts.status === "not-mach-o" ? facts.status : facts.reason,
|
||||
);
|
||||
expect(facts.slices[0]?.dependencies).toHaveLength(count);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO dependency limit", () => {
|
||||
it("retains one result per command until the dependency limit", async () => {
|
||||
const payload = lazyLoadDylibInfoPayload({ path: "@rpath/libl.dylib" });
|
||||
const imageFor = (count: number) => {
|
||||
const dataOffset = 32 + 16 + count * 16;
|
||||
const image = machoImage({
|
||||
commands: Array.from({ length: count }, () =>
|
||||
lazyLoadDylibInfoCommand(dataOffset, payload.length),
|
||||
),
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
return bytes;
|
||||
};
|
||||
const atLimit = await read(imageFor(MAX_LAZY_INFO_DEPENDENCIES));
|
||||
if (atLimit.status !== "parsed")
|
||||
throw new Error(
|
||||
atLimit.status === "not-mach-o" ? atLimit.status : atLimit.reason,
|
||||
);
|
||||
expect(atLimit.slices[0]?.dependencies).toHaveLength(
|
||||
MAX_LAZY_INFO_DEPENDENCIES,
|
||||
);
|
||||
expect(await read(imageFor(MAX_LAZY_INFO_DEPENDENCIES + 1))).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("dependency limit"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO string bounds", () => {
|
||||
it("does not reuse a cached path that extends past a shorter payload", async () => {
|
||||
const payload = new Uint8Array(48);
|
||||
const view = new DataView(payload.buffer);
|
||||
const path = new TextEncoder().encode("@rpath/libl.dylib");
|
||||
view.setUint32(0, 24, true);
|
||||
view.setUint32(20, 24, true);
|
||||
payload.set(path, 24);
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(0, payload.length),
|
||||
lazyLoadDylibInfoCommand(0, 24),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
const patched = new DataView(bytes.buffer);
|
||||
patched.setUint32(32 + 8, image.length, true);
|
||||
patched.setUint32(48 + 8, image.length, true);
|
||||
expect(await read(bytes)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("outside the payload"),
|
||||
});
|
||||
const truncated = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(0, payload.length),
|
||||
lazyLoadDylibInfoCommand(0, 40),
|
||||
],
|
||||
});
|
||||
const truncatedBytes = new Uint8Array(truncated.length + payload.length);
|
||||
truncatedBytes.set(truncated);
|
||||
truncatedBytes.set(payload, truncated.length);
|
||||
const truncatedView = new DataView(truncatedBytes.buffer);
|
||||
truncatedView.setUint32(32 + 8, truncated.length, true);
|
||||
truncatedView.setUint32(48 + 8, truncated.length, true);
|
||||
expect(await read(truncatedBytes)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("unterminated"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a load path taken from the payload header", async () => {
|
||||
const payload = new Uint8Array(24);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, 4, true);
|
||||
view.setUint32(20, 24, true);
|
||||
const image = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, payload.length)],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
new DataView(bytes.buffer).setUint32(32 + 8, image.length, true);
|
||||
expect(await read(bytes)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("inside the payload header"),
|
||||
});
|
||||
const symbolPayload = lazyLoadDylibInfoPayload({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
});
|
||||
new DataView(symbolPayload.buffer).setUint32(24, 4, true);
|
||||
const symbolImage = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, symbolPayload.length)],
|
||||
});
|
||||
const symbolBytes = new Uint8Array(
|
||||
symbolImage.length + symbolPayload.length,
|
||||
);
|
||||
symbolBytes.set(symbolImage);
|
||||
symbolBytes.set(symbolPayload, symbolImage.length);
|
||||
new DataView(symbolBytes.buffer).setUint32(
|
||||
32 + 8,
|
||||
symbolImage.length,
|
||||
true,
|
||||
);
|
||||
expect(await read(symbolBytes)).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("symbol 0"),
|
||||
});
|
||||
});
|
||||
|
||||
it("charges repeated and overlapping symbol scans against the string budget", async () => {
|
||||
const path = new TextEncoder().encode("@rpath/libl.dylib");
|
||||
const stringBytes = 64 * 1024;
|
||||
const imageFor = (symbolCount: number, step: number) => {
|
||||
const pathOffset = 24 + symbolCount * 4;
|
||||
const stringOffset = pathOffset + path.length + 1;
|
||||
const payload = new Uint8Array(stringOffset + stringBytes + 1);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, pathOffset, true);
|
||||
view.setUint32(16, symbolCount, true);
|
||||
view.setUint32(20, 24, true);
|
||||
payload.set(path, pathOffset);
|
||||
payload.fill(0x61, stringOffset, stringOffset + stringBytes);
|
||||
for (let symbol = 0; symbol < symbolCount; symbol += 1)
|
||||
view.setUint32(
|
||||
24 + symbol * 4,
|
||||
stringOffset + (step === 0 ? 0 : symbol % 8),
|
||||
true,
|
||||
);
|
||||
const image = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, payload.length)],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
new DataView(bytes.buffer).setUint32(32 + 8, image.length, true);
|
||||
return bytes;
|
||||
};
|
||||
expect(imageFor(2, 0).length).toBeLessThan(MAX_LOAD_COMMAND_BYTES);
|
||||
expect(await read(imageFor(2, 0))).toMatchObject({
|
||||
status: "parsed",
|
||||
slices: [{ dependencies: [{ install_name: "@rpath/libl.dylib" }] }],
|
||||
});
|
||||
const over = Math.floor(MAX_LOAD_COMMAND_BYTES / (stringBytes + 1)) + 2;
|
||||
expect(await read(imageFor(over, 0))).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("string validation"),
|
||||
});
|
||||
expect(await read(imageFor(over, 1))).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("string validation"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const lazyInfoPayloadWithSymbolCount = (
|
||||
symbolCount: number,
|
||||
pathText: string,
|
||||
): Uint8Array => {
|
||||
const path = new TextEncoder().encode(pathText);
|
||||
const pathOffset = 24 + symbolCount * 4;
|
||||
// One byte after the path NUL, because a load-path terminator in the
|
||||
// payload's final byte is unterminated.
|
||||
const payload = new Uint8Array(pathOffset + path.length + 2);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, pathOffset, true);
|
||||
view.setUint32(16, symbolCount, true);
|
||||
view.setUint32(20, 24, true);
|
||||
payload.set(path, pathOffset);
|
||||
const emptySymbol = pathOffset + path.length;
|
||||
for (let symbol = 0; symbol < symbolCount; symbol += 1)
|
||||
view.setUint32(24 + symbol * 4, emptySymbol, true);
|
||||
return payload;
|
||||
};
|
||||
|
||||
const imageWithLazyPayload = (payload: Uint8Array): Uint8Array => {
|
||||
const image = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, payload.length)],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(32 + 8, image.length, true);
|
||||
view.setUint32(32 + 12, payload.length, true);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO symbol cardinality", () => {
|
||||
it("stops before caching one string for every symbol offset", async () => {
|
||||
const atLimit = lazyInfoPayloadWithSymbolCount(
|
||||
MAX_LAZY_INFO_SYMBOL_REFERENCES,
|
||||
"@rpath/libl.dylib",
|
||||
);
|
||||
expect(await read(imageWithLazyPayload(atLimit))).toMatchObject({
|
||||
status: "parsed",
|
||||
slices: [{ dependencies: [{ install_name: "@rpath/libl.dylib" }] }],
|
||||
});
|
||||
const overLimit = lazyInfoPayloadWithSymbolCount(
|
||||
MAX_LAZY_INFO_SYMBOL_REFERENCES + 1,
|
||||
"@rpath/libl.dylib",
|
||||
);
|
||||
expect(await read(imageWithLazyPayload(overLimit))).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("65536-reference limit"),
|
||||
});
|
||||
});
|
||||
|
||||
it("counts symbol references across distinct lazy-info payloads", async () => {
|
||||
const first = lazyInfoPayloadWithSymbolCount(
|
||||
MAX_LAZY_INFO_SYMBOL_REFERENCES,
|
||||
"@rpath/libl.dylib",
|
||||
);
|
||||
const second = lazyInfoPayloadWithSymbolCount(1, "@rpath/libm.dylib");
|
||||
const image = machoImage({
|
||||
commands: [
|
||||
lazyLoadDylibInfoCommand(0, first.length),
|
||||
lazyLoadDylibInfoCommand(0, second.length),
|
||||
],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + first.length + second.length);
|
||||
bytes.set(image);
|
||||
bytes.set(first, image.length);
|
||||
bytes.set(second, image.length + first.length);
|
||||
const view = new DataView(bytes.buffer);
|
||||
view.setUint32(32 + 8, image.length, true);
|
||||
view.setUint32(32 + 12, first.length, true);
|
||||
view.setUint32(48 + 8, image.length + first.length, true);
|
||||
view.setUint32(48 + 12, second.length, true);
|
||||
expect(await read(bytes)).toMatchObject({
|
||||
status: "unsupported",
|
||||
reason: expect.stringContaining("65536-reference limit"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO path terminator", () => {
|
||||
it("rejects a load path whose NUL is the payload's final byte", async () => {
|
||||
const payload = new Uint8Array(27);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint32(0, 24, true);
|
||||
view.setUint32(20, 24, true);
|
||||
payload[24] = 0x61;
|
||||
const imageFor = (size: number) => {
|
||||
const image = machoImage({
|
||||
commands: [lazyLoadDylibInfoCommand(0, size)],
|
||||
});
|
||||
const bytes = new Uint8Array(image.length + payload.length);
|
||||
bytes.set(image);
|
||||
bytes.set(payload, image.length);
|
||||
new DataView(bytes.buffer).setUint32(32 + 8, image.length, true);
|
||||
new DataView(bytes.buffer).setUint32(32 + 12, size, true);
|
||||
return bytes;
|
||||
};
|
||||
expect((await read(imageFor(payload.length))).status).toBe("parsed");
|
||||
expect(await read(imageFor(26))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("unterminated"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LC_LAZY_LOAD_DYLIB_INFO resolution", () => {
|
||||
it("reports the lazy edge and does not treat an absent image as a launch failure", async () => {
|
||||
const root = await createTestTempDirectory("rea-lazy-info-");
|
||||
const executablePath = "lazy";
|
||||
const dylibPath = "lib/libl.dylib";
|
||||
const executable = machoImageWithLazyLoadDylibInfo({
|
||||
path: "@rpath/libl.dylib",
|
||||
symbols: ["_lf"],
|
||||
commandsBefore: [
|
||||
buildVersionCommand(1),
|
||||
rpathCommand("@executable_path/lib"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
|
||||
],
|
||||
});
|
||||
const dylib = machoImage({
|
||||
fileType: FILE_TYPE.dylib,
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dylibCommand(LC.ID_DYLIB, "@rpath/libl.dylib"),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@rpath/libdeep.dylib"),
|
||||
],
|
||||
});
|
||||
await mkdir(join(root, "lib"));
|
||||
await writeFile(join(root, executablePath), executable);
|
||||
await writeFile(join(root, dylibPath), dylib);
|
||||
const trace = () =>
|
||||
traceDylibResolution({
|
||||
rootPath: root,
|
||||
targetPath: join(root, executablePath),
|
||||
targetSha256: createHash("sha256").update(executable).digest("hex"),
|
||||
enumerateRoots: false,
|
||||
parameters: { roots: [executablePath] },
|
||||
});
|
||||
const present = await trace();
|
||||
expect(present.coverage.status).toBe("complete");
|
||||
expect(present.edges.map(({ install_name: name }) => name)).toEqual([
|
||||
"/usr/lib/libSystem.B.dylib",
|
||||
"@rpath/libl.dylib",
|
||||
]);
|
||||
expect(present.edges[1]).toMatchObject({
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
resolution: { status: "resolved", image: dylibPath },
|
||||
});
|
||||
expect(present.findings).toEqual([]);
|
||||
await rename(join(root, dylibPath), `${join(root, dylibPath)}.moved`);
|
||||
const absent = await trace();
|
||||
expect(absent.coverage.status).toBe("complete");
|
||||
expect(absent.findings.map(({ kind }) => kind)).toEqual([
|
||||
"lazy-load-unresolved",
|
||||
]);
|
||||
expect(absent.edges[1]?.resolution.status).toBe("unresolved");
|
||||
});
|
||||
});
|
||||
|
||||
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 [
|
||||
|
||||
@@ -18,6 +18,18 @@ export type ReadAt = (offset: number, length: number) => Promise<Uint8Array>;
|
||||
|
||||
/** Load commands larger than this are refused instead of buffered. */
|
||||
export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024;
|
||||
/**
|
||||
* Identical `LC_LAZY_LOAD_DYLIB_INFO` commands are 16 bytes, so the load-command
|
||||
* byte cap alone would retain about a million dependency results. Stop before
|
||||
* those results are kept or traced.
|
||||
*/
|
||||
export const MAX_LAZY_INFO_DEPENDENCIES = 1024;
|
||||
/**
|
||||
* Symbol names are validated and then discarded. Distinct one-byte strings
|
||||
* stay under the 16 MiB byte budgets while retaining one cache entry per
|
||||
* offset, so stop before that map and those decodes grow.
|
||||
*/
|
||||
export const MAX_LAZY_INFO_SYMBOL_REFERENCES = 64 * 1024;
|
||||
const MH_MAGIC = 0xfeedface;
|
||||
const MH_MAGIC_64 = 0xfeedfacf;
|
||||
const MH_CIGAM = 0xcefaedfe;
|
||||
@@ -27,11 +39,14 @@ const FAT_MAGIC_64 = 0xcafebabf;
|
||||
const FAT_CIGAM = 0xbebafeca;
|
||||
const FAT_CIGAM_64 = 0xbfbafeca;
|
||||
|
||||
const LC_SEGMENT = 0x1;
|
||||
const LC_SEGMENT_64 = 0x19;
|
||||
const LC_ID_DYLIB = 0xd;
|
||||
const LC_LOAD_DYLIB = 0xc;
|
||||
const LC_LOAD_WEAK_DYLIB = 0x80000018;
|
||||
const LC_REEXPORT_DYLIB = 0x8000001f;
|
||||
const LC_LAZY_LOAD_DYLIB = 0x20;
|
||||
const LC_LAZY_LOAD_DYLIB_INFO = 0x3a;
|
||||
const LC_LOAD_UPWARD_DYLIB = 0x80000023;
|
||||
const LC_RPATH = 0x8000001c;
|
||||
const LC_DYLD_ENVIRONMENT = 0x27;
|
||||
@@ -85,6 +100,14 @@ const legacyVersionMinPlatform = (
|
||||
|
||||
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
|
||||
const DYLIB_USE_MARKER = 0x1a741800;
|
||||
const LAZY_INFO_HEADER_BYTES = 24;
|
||||
/** ld64 may-be-missing bit: the lazy image is allowed to be absent. */
|
||||
const LAZY_INFO_MAY_BE_MISSING = 0x1;
|
||||
/**
|
||||
* dyld's linkedit image-offset checks start at one page, then grow to the
|
||||
* runtime end of every segment except `__LINKEDIT`.
|
||||
*/
|
||||
const LAZY_INFO_MIN_VM_OFFSET = 0x4000n;
|
||||
const DYLIB_USE_WEAK_LINK = 0x1;
|
||||
const DYLIB_USE_REEXPORT = 0x2;
|
||||
const DYLIB_USE_UPWARD = 0x4;
|
||||
@@ -293,7 +316,7 @@ const readSlice = async (
|
||||
commandBytes,
|
||||
"load commands",
|
||||
);
|
||||
const slice = decodeCommands(commands, {
|
||||
const decoded = decodeCommands(commands, {
|
||||
wide,
|
||||
cpuType: identity.cpuType,
|
||||
cpuSubtype: identity.cpuSubtype,
|
||||
@@ -305,6 +328,15 @@ const readSlice = async (
|
||||
fatCpuSubtype: fat?.fatCpuSubtype ?? null,
|
||||
fatAlignmentExponent: fat?.alignmentExponent ?? null,
|
||||
});
|
||||
const slice = decoded.slice;
|
||||
await appendDependencies(
|
||||
slice,
|
||||
decoded.dependencies,
|
||||
readAt,
|
||||
base,
|
||||
length,
|
||||
decoded.maxVmOffset,
|
||||
);
|
||||
const distinctPlatforms = [...new Set(slice.platforms)];
|
||||
slice.platform =
|
||||
distinctPlatforms.length === 1 ? (distinctPlatforms[0] ?? null) : null;
|
||||
@@ -334,7 +366,14 @@ const decodeCommands = (
|
||||
readonly fatCpuSubtype: number | null;
|
||||
readonly fatAlignmentExponent: number | null;
|
||||
},
|
||||
): MachoSlice => {
|
||||
): {
|
||||
readonly slice: MachoSlice;
|
||||
readonly dependencies: readonly (
|
||||
| { readonly kind: "parsed"; readonly value: MachoDependency }
|
||||
| LazyLoadDylibInfoCommand
|
||||
)[];
|
||||
readonly maxVmOffset: bigint;
|
||||
} => {
|
||||
const view = viewOf(bytes);
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
@@ -355,6 +394,14 @@ const decodeCommands = (
|
||||
dyld_environment: [],
|
||||
code_signature_present: false,
|
||||
};
|
||||
const dependencies: Array<
|
||||
| { readonly kind: "parsed"; readonly value: MachoDependency }
|
||||
| LazyLoadDylibInfoCommand
|
||||
> = [];
|
||||
const segments: SegmentSpan[] = [];
|
||||
let unreadableSegment: number | undefined;
|
||||
let lazyInfo = false;
|
||||
let lazyInfoCount = 0;
|
||||
let offset = 0;
|
||||
for (let index = 0; index < header.commandCount; index++) {
|
||||
if (offset + 8 > bytes.byteLength)
|
||||
@@ -374,7 +421,22 @@ 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_SEGMENT || command === LC_SEGMENT_64) {
|
||||
const span = readSegmentSpan(body, command === LC_SEGMENT_64);
|
||||
if (span === undefined) unreadableSegment ??= index;
|
||||
else segments.push(span);
|
||||
}
|
||||
if (command === LC_LAZY_LOAD_DYLIB_INFO) {
|
||||
lazyInfoCount += 1;
|
||||
if (lazyInfoCount > MAX_LAZY_INFO_DEPENDENCIES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO commands exceed the ${MAX_LAZY_INFO_DEPENDENCIES}-dependency limit`,
|
||||
);
|
||||
lazyInfo = true;
|
||||
}
|
||||
const decoded = decodeCommand(command, body, index, slice);
|
||||
if (decoded !== undefined) dependencies.push(decoded);
|
||||
offset += size;
|
||||
}
|
||||
if (offset !== bytes.byteLength)
|
||||
@@ -382,7 +444,150 @@ const decodeCommands = (
|
||||
"malformed",
|
||||
`load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`,
|
||||
);
|
||||
return slice;
|
||||
if (lazyInfo && unreadableSegment !== undefined)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${unreadableSegment} is too short to establish the slice VM range for LC_LAZY_LOAD_DYLIB_INFO`,
|
||||
);
|
||||
return { slice, dependencies, maxVmOffset: maximumVmOffset(segments) };
|
||||
};
|
||||
|
||||
interface LazyLoadDylibInfoCommand {
|
||||
readonly kind: "lazy-info";
|
||||
readonly index: number;
|
||||
readonly dataOffset: number;
|
||||
readonly dataSize: number;
|
||||
}
|
||||
|
||||
interface SegmentSpan {
|
||||
readonly name: string;
|
||||
readonly vmaddr: bigint;
|
||||
readonly vmsize: bigint;
|
||||
}
|
||||
|
||||
/** Runtime end of the mapped image, excluding `__LINKEDIT`, floored at one page. */
|
||||
const maximumVmOffset = (segments: readonly SegmentSpan[]): bigint => {
|
||||
let textVmAddr = 0n;
|
||||
for (const segment of segments) {
|
||||
if (segment.name === "__TEXT") {
|
||||
textVmAddr = segment.vmaddr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let max = LAZY_INFO_MIN_VM_OFFSET;
|
||||
for (const segment of segments) {
|
||||
if (segment.name === "__LINKEDIT") continue;
|
||||
const end = segment.vmaddr - textVmAddr + segment.vmsize;
|
||||
if (end > max) max = end;
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const readSegmentSpan = (
|
||||
body: Uint8Array,
|
||||
wide: boolean,
|
||||
): SegmentSpan | undefined => {
|
||||
if (body.byteLength < (wide ? 72 : 56)) return undefined;
|
||||
const view = viewOf(body);
|
||||
const nameBytes = body.subarray(8, 24);
|
||||
const end = nameBytes.indexOf(0);
|
||||
return {
|
||||
name: new TextDecoder("utf-8", { fatal: false }).decode(
|
||||
end < 0 ? nameBytes : nameBytes.subarray(0, end),
|
||||
),
|
||||
vmaddr: wide
|
||||
? view.getBigUint64(24, true)
|
||||
: BigInt(view.getUint32(24, true)),
|
||||
vmsize: wide
|
||||
? view.getBigUint64(32, true)
|
||||
: BigInt(view.getUint32(28, true)),
|
||||
};
|
||||
};
|
||||
|
||||
interface CachedLazyString {
|
||||
readonly text: string;
|
||||
/** Absolute file offset of the terminating NUL. */
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
interface LazyPayloadWork {
|
||||
readonly dependencies: Map<string, MachoDependency>;
|
||||
readonly reads: { fileOffset: number; bytes: Uint8Array }[];
|
||||
readonly strings: Map<number, CachedLazyString>;
|
||||
payloadBytes: number;
|
||||
/** Scanned string bytes. Repeats and overlaps each count in full. */
|
||||
stringBytes: number;
|
||||
/** Symbol-string references. Repeats each count. */
|
||||
symbolReferences: number;
|
||||
rangeChecks: number;
|
||||
}
|
||||
|
||||
const containedPayload = (
|
||||
reads: readonly { fileOffset: number; bytes: Uint8Array }[],
|
||||
fileOffset: number,
|
||||
size: number,
|
||||
work: LazyPayloadWork,
|
||||
): Uint8Array | undefined => {
|
||||
for (const read of reads) {
|
||||
work.rangeChecks += 1;
|
||||
if (work.rangeChecks > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO payload range lookup exceeds the ${MAX_LOAD_COMMAND_BYTES}-check limit`,
|
||||
);
|
||||
const start = fileOffset - read.fileOffset;
|
||||
if (
|
||||
start >= 0 &&
|
||||
Number.isSafeInteger(start) &&
|
||||
start + size <= read.bytes.byteLength
|
||||
)
|
||||
return read.bytes.subarray(start, start + size);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const appendDependencies = async (
|
||||
slice: MachoSlice,
|
||||
dependencies: readonly (
|
||||
| { readonly kind: "parsed"; readonly value: MachoDependency }
|
||||
| LazyLoadDylibInfoCommand
|
||||
)[],
|
||||
readAt: ReadAt,
|
||||
base: number,
|
||||
length: number,
|
||||
maxVmOffset: bigint,
|
||||
): Promise<void> => {
|
||||
const work: LazyPayloadWork = {
|
||||
dependencies: new Map(),
|
||||
reads: [],
|
||||
strings: new Map(),
|
||||
payloadBytes: 0,
|
||||
stringBytes: 0,
|
||||
symbolReferences: 0,
|
||||
rangeChecks: 0,
|
||||
};
|
||||
for (const dependency of dependencies) {
|
||||
if (dependency.kind === "parsed") {
|
||||
slice.dependencies.push(dependency.value);
|
||||
continue;
|
||||
}
|
||||
const key = `${dependency.dataOffset}:${dependency.dataSize}`;
|
||||
const cached = work.dependencies.get(key);
|
||||
if (cached !== undefined) {
|
||||
slice.dependencies.push(cached);
|
||||
continue;
|
||||
}
|
||||
const value = await readLazyLoadDylibInfo(
|
||||
readAt,
|
||||
base,
|
||||
length,
|
||||
dependency,
|
||||
maxVmOffset,
|
||||
work,
|
||||
);
|
||||
work.dependencies.set(key, value);
|
||||
slice.dependencies.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
const decodeCommand = (
|
||||
@@ -390,7 +595,10 @@ const decodeCommand = (
|
||||
body: Uint8Array,
|
||||
index: number,
|
||||
slice: MachoSlice,
|
||||
): void => {
|
||||
):
|
||||
| { readonly kind: "parsed"; readonly value: MachoDependency }
|
||||
| LazyLoadDylibInfoCommand
|
||||
| undefined => {
|
||||
if (command === LC_ID_DYLIB) {
|
||||
slice.install_name = commandString(body, 24, index);
|
||||
return;
|
||||
@@ -440,9 +648,270 @@ const decodeCommand = (
|
||||
slice.platforms.push(legacyPlatform);
|
||||
return;
|
||||
}
|
||||
if (command === LC_LAZY_LOAD_DYLIB_INFO)
|
||||
return decodeLazyLoadDylibInfoCommand(body, index);
|
||||
const name = DEPENDENCY_COMMANDS.get(command);
|
||||
if (name !== undefined)
|
||||
slice.dependencies.push(decodeDependency(name, body, index));
|
||||
return { kind: "parsed", value: decodeDependency(name, body, index) };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* `LC_LAZY_LOAD_DYLIB_INFO` is a linkedit-data command. The install name is
|
||||
* not in the command; it is a NUL-terminated string in the payload.
|
||||
*/
|
||||
const decodeLazyLoadDylibInfoCommand = (
|
||||
body: Uint8Array,
|
||||
index: number,
|
||||
): LazyLoadDylibInfoCommand => {
|
||||
if (body.byteLength < 16)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} is too short for LC_LAZY_LOAD_DYLIB_INFO`,
|
||||
);
|
||||
const view = viewOf(body);
|
||||
const dataOffset = view.getUint32(8, true);
|
||||
const dataSize = view.getUint32(12, true);
|
||||
if (dataSize < LAZY_INFO_HEADER_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO payload is shorter than its 24-byte header`,
|
||||
);
|
||||
if (dataSize > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO payload occupies ${dataSize} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
|
||||
);
|
||||
return { kind: "lazy-info", index, dataOffset, dataSize };
|
||||
};
|
||||
|
||||
const readLazyPayloadBytes = async (
|
||||
readAt: ReadAt,
|
||||
fileOffset: number,
|
||||
size: number,
|
||||
index: number,
|
||||
work: LazyPayloadWork,
|
||||
): Promise<Uint8Array> => {
|
||||
const covered = containedPayload(work.reads, fileOffset, size, work);
|
||||
if (covered !== undefined) return covered;
|
||||
work.payloadBytes += size;
|
||||
if (work.payloadBytes > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO payloads occupy ${work.payloadBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte aggregate limit`,
|
||||
);
|
||||
const payload = await readExact(
|
||||
readAt,
|
||||
fileOffset,
|
||||
size,
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO payload`,
|
||||
);
|
||||
work.reads.push({ fileOffset, bytes: payload });
|
||||
return payload;
|
||||
};
|
||||
|
||||
const readLazyLoadDylibInfo = async (
|
||||
readAt: ReadAt,
|
||||
base: number,
|
||||
length: number,
|
||||
command: LazyLoadDylibInfoCommand,
|
||||
maxVmOffset: bigint,
|
||||
work: LazyPayloadWork,
|
||||
): Promise<MachoDependency> => {
|
||||
if (
|
||||
command.dataOffset > length ||
|
||||
command.dataSize > length - command.dataOffset
|
||||
)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${command.index} LC_LAZY_LOAD_DYLIB_INFO payload extends beyond the Mach-O slice`,
|
||||
);
|
||||
const fileOffset = base + command.dataOffset;
|
||||
if (!Number.isSafeInteger(fileOffset))
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${command.index} LC_LAZY_LOAD_DYLIB_INFO payload offset is not a safe integer`,
|
||||
);
|
||||
const payload = await readLazyPayloadBytes(
|
||||
readAt,
|
||||
fileOffset,
|
||||
command.dataSize,
|
||||
command.index,
|
||||
work,
|
||||
);
|
||||
return decodeLazyLoadDylibInfoPayload(
|
||||
payload,
|
||||
command.index,
|
||||
fileOffset,
|
||||
maxVmOffset,
|
||||
work,
|
||||
);
|
||||
};
|
||||
|
||||
const decodeLazyLoadDylibInfoPayload = (
|
||||
payload: Uint8Array,
|
||||
index: number,
|
||||
fileOffset: number,
|
||||
maxVmOffset: bigint,
|
||||
work: LazyPayloadWork,
|
||||
): MachoDependency => {
|
||||
const view = viewOf(payload);
|
||||
const pathOffset = view.getUint32(0, true);
|
||||
const flagImageOffset = view.getUint32(4, true);
|
||||
const flags = view.getUint16(8, true);
|
||||
const chainStartImageOffset = view.getUint32(12, true);
|
||||
const symbolCount = view.getUint32(16, true);
|
||||
const symbolArrayOffset = view.getUint32(20, true);
|
||||
if (BigInt(flagImageOffset) > maxVmOffset)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO flag image offset 0x${flagImageOffset.toString(16)} is beyond the slice VM range 0x${maxVmOffset.toString(16)}`,
|
||||
);
|
||||
if (BigInt(chainStartImageOffset) > maxVmOffset)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO chain start image offset 0x${chainStartImageOffset.toString(16)} is beyond the slice VM range 0x${maxVmOffset.toString(16)}`,
|
||||
);
|
||||
if ((flags & ~LAZY_INFO_MAY_BE_MISSING) !== 0)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO flags 0x${flags.toString(16)} include unrecognized bits`,
|
||||
);
|
||||
if (
|
||||
symbolArrayOffset > payload.byteLength ||
|
||||
symbolCount > (payload.byteLength - symbolArrayOffset) / 4
|
||||
)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO symbol table extends beyond the payload`,
|
||||
);
|
||||
const installName = payloadString(
|
||||
payload,
|
||||
pathOffset,
|
||||
fileOffset,
|
||||
index,
|
||||
"load path",
|
||||
work,
|
||||
true,
|
||||
);
|
||||
const symbolReferences = work.symbolReferences + symbolCount;
|
||||
if (symbolReferences > MAX_LAZY_INFO_SYMBOL_REFERENCES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO symbol validation exceeds the ${MAX_LAZY_INFO_SYMBOL_REFERENCES}-reference limit`,
|
||||
);
|
||||
work.symbolReferences = symbolReferences;
|
||||
for (let symbol = 0; symbol < symbolCount; symbol += 1) {
|
||||
const offset = view.getUint32(symbolArrayOffset + symbol * 4, true);
|
||||
payloadString(
|
||||
payload,
|
||||
offset,
|
||||
fileOffset,
|
||||
index,
|
||||
`symbol ${symbol}`,
|
||||
work,
|
||||
false,
|
||||
);
|
||||
}
|
||||
return {
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
install_name: installName,
|
||||
weak: (flags & LAZY_INFO_MAY_BE_MISSING) !== 0,
|
||||
upward: false,
|
||||
reexport: false,
|
||||
delayed_init: false,
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
};
|
||||
};
|
||||
|
||||
const chargeLazyString = (work: LazyPayloadWork, examined: number): void => {
|
||||
const occupied = work.stringBytes + examined;
|
||||
if (occupied > MAX_LOAD_COMMAND_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO string validation occupies ${occupied} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
|
||||
);
|
||||
work.stringBytes = occupied;
|
||||
};
|
||||
|
||||
/**
|
||||
* NUL-terminated UTF-8 string after the fixed payload header.
|
||||
* dyld's load-path scan stops before the payload's final byte, so a NUL
|
||||
* in that byte does not terminate the path.
|
||||
*/
|
||||
const payloadString = (
|
||||
payload: Uint8Array,
|
||||
offset: number,
|
||||
fileOffset: number,
|
||||
index: number,
|
||||
label: string,
|
||||
work: LazyPayloadWork,
|
||||
excludeFinalByte: boolean,
|
||||
): string => {
|
||||
if (offset < LAZY_INFO_HEADER_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} offset ${offset} is inside the payload header`,
|
||||
);
|
||||
if (offset >= payload.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} offset ${offset} is outside the payload`,
|
||||
);
|
||||
const absolute = Number.isSafeInteger(fileOffset + offset)
|
||||
? fileOffset + offset
|
||||
: undefined;
|
||||
const cached =
|
||||
absolute === undefined ? undefined : work.strings.get(absolute);
|
||||
const terminatorLimit = excludeFinalByte
|
||||
? payload.byteLength - 1
|
||||
: payload.byteLength;
|
||||
if (cached !== undefined && absolute !== undefined) {
|
||||
const end = cached.end - fileOffset;
|
||||
if (end < offset || end >= terminatorLimit)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`,
|
||||
);
|
||||
chargeLazyString(work, cached.end - absolute + 1);
|
||||
return cached.text;
|
||||
}
|
||||
if (offset >= terminatorLimit)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`,
|
||||
);
|
||||
const remaining = MAX_LOAD_COMMAND_BYTES - work.stringBytes;
|
||||
const windowEnd = Math.min(terminatorLimit, offset + remaining);
|
||||
const end = payload.subarray(0, windowEnd).indexOf(0, offset);
|
||||
if (end < 0) {
|
||||
if (windowEnd < terminatorLimit)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
`LC_LAZY_LOAD_DYLIB_INFO string validation occupies ${work.stringBytes + (windowEnd - offset) + 1} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
|
||||
);
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is unterminated`,
|
||||
);
|
||||
}
|
||||
chargeLazyString(work, end - offset + 1);
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
payload.subarray(offset, end),
|
||||
);
|
||||
} catch {
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} LC_LAZY_LOAD_DYLIB_INFO ${label} is not UTF-8`,
|
||||
);
|
||||
}
|
||||
if (absolute !== undefined && Number.isSafeInteger(fileOffset + end))
|
||||
work.strings.set(absolute, { text, end: fileOffset + end });
|
||||
return text;
|
||||
};
|
||||
|
||||
const decodeDependency = (
|
||||
|
||||
@@ -115,7 +115,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
),
|
||||
artifact(
|
||||
"trace_dylib_resolution",
|
||||
"Trace how dyld would resolve each Mach-O dylib load for the active Mach-O, or for every executable in the active .app bundle (the main app, XPC services, extensions, login items, helpers). Expands @executable_path, @loader_path, and @rpath through each process's LC_RPATH stack in dependents-first load order, and reports every candidate path with its outcome, the resolved image and digest, weak, re-export, and upward flags, and derived findings such as unresolved required loads or absent earlier @rpath candidates. Pure header parsing: nothing is executed, and absolute or system paths outside the analyzed root stay undetermined. Select roots or one architecture to narrow large bundles.",
|
||||
"Trace how dyld would resolve each Mach-O dylib load for the active Mach-O, or for every executable in the active .app bundle (the main app, XPC services, extensions, login items, helpers). Expands @executable_path, @loader_path, and @rpath through each process's LC_RPATH stack in dependents-first load order, and reports every candidate path with its outcome, the resolved image and digest, weak, re-export, upward, and lazy-load flags, including LC_LAZY_LOAD_DYLIB_INFO linkedit payloads, and derived findings such as unresolved required loads or absent earlier @rpath candidates. Pure header parsing: nothing is executed, and absolute or system paths outside the analyzed root stay undetermined. Select roots or one architecture to narrow large bundles.",
|
||||
dylibResolutionInputSchema,
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
@@ -840,6 +840,93 @@ describe("lazily loaded dependencies", () => {
|
||||
"lazy-load-unresolved",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves LC_LAZY_LOAD_DYLIB_INFO without traversing it", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["@executable_path/../Frameworks"],
|
||||
dependencies: [
|
||||
dependency("@rpath/libinfo.dylib", {
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
weak: true,
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
}),
|
||||
dependency("@rpath/libmissing.dylib", {
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
"Contents/Frameworks/libinfo.dylib": parsed(
|
||||
slice({ dependencies: [dependency("@rpath/libdeep.dylib")] }),
|
||||
),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(trace.edges.map(({ install_name: name }) => name)).toEqual([
|
||||
"@rpath/libinfo.dylib",
|
||||
"@rpath/libmissing.dylib",
|
||||
]);
|
||||
expect(trace.edges[0]).toMatchObject({
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
weak: true,
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
resolution: {
|
||||
status: "resolved",
|
||||
image: "Contents/Frameworks/libinfo.dylib",
|
||||
},
|
||||
});
|
||||
expect(trace.coverage.status).toBe("complete");
|
||||
expect(trace.findings.map(({ kind }) => kind)).toEqual([
|
||||
"lazy-load-unresolved",
|
||||
]);
|
||||
expect(trace.findings[0]?.explanation).toContain(
|
||||
"which would fail unless the image is supplied elsewhere",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a missing may-be-missing lazy image as optional", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["@executable_path/../Frameworks"],
|
||||
dependencies: [
|
||||
dependency("@rpath/liboptional.dylib", {
|
||||
command: "LC_LAZY_LOAD_DYLIB_INFO",
|
||||
encoding: "lazy_load_dylib_info",
|
||||
weak: true,
|
||||
current_version: null,
|
||||
compatibility_version: null,
|
||||
}),
|
||||
dependency("@rpath/libweaklazy.dylib", {
|
||||
command: "LC_LAZY_LOAD_DYLIB",
|
||||
weak: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(trace.findings).toMatchObject([
|
||||
{
|
||||
kind: "lazy-load-unresolved",
|
||||
explanation:
|
||||
"No candidate for lazily loaded weak dependency @rpath/liboptional.dylib exists in the analyzed root; dyld loads it on first use and continues if it is absent.",
|
||||
},
|
||||
{
|
||||
kind: "lazy-load-unresolved",
|
||||
explanation:
|
||||
"No candidate for lazily loaded weak dependency @rpath/libweaklazy.dylib exists in the analyzed root; dyld loads it on first use and continues if it is absent.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional loads", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
DYLIB_RESOLUTION_LIMITATIONS,
|
||||
deriveFindings,
|
||||
isLazyLoadCommand,
|
||||
type TracedRootSlice,
|
||||
} from "./dylibResolutionFindings.js";
|
||||
|
||||
@@ -46,19 +47,28 @@ const dependencyCommandSchema = z.enum([
|
||||
"LC_LOAD_WEAK_DYLIB",
|
||||
"LC_REEXPORT_DYLIB",
|
||||
"LC_LAZY_LOAD_DYLIB",
|
||||
"LC_LAZY_LOAD_DYLIB_INFO",
|
||||
"LC_LOAD_UPWARD_DYLIB",
|
||||
]);
|
||||
|
||||
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(),
|
||||
/**
|
||||
* Null for `lazy_load_dylib_info`: that payload has no version fields.
|
||||
* Classic dylib commands still carry both versions.
|
||||
*/
|
||||
current_version: z.string().nullable(),
|
||||
compatibility_version: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Observed dylib-loading facts of one Mach-O slice. */
|
||||
@@ -528,8 +538,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 images on first use, not at launch.
|
||||
if (isLazyLoadCommand(dependency.command)) continue;
|
||||
const image = edge.resolution.image;
|
||||
if (image === null || context.loaded.has(image)) continue;
|
||||
const facts = context.images.get(image);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type {
|
||||
DylibEdge as Edge,
|
||||
DylibFinding as Finding,
|
||||
MachoDependency,
|
||||
MachoSlice,
|
||||
} from "./dylibResolution.js";
|
||||
|
||||
/** Both lazy encodings load on first use and are not process-launch dependencies. */
|
||||
export const isLazyLoadCommand = (
|
||||
command: MachoDependency["command"],
|
||||
): boolean =>
|
||||
command === "LC_LAZY_LOAD_DYLIB" || command === "LC_LAZY_LOAD_DYLIB_INFO";
|
||||
|
||||
/** One traced process root with the exact slice it was traced from. */
|
||||
export interface TracedRootSlice {
|
||||
readonly image: string;
|
||||
@@ -62,16 +69,17 @@ 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 lazy = isLazyLoadCommand(edge.command);
|
||||
const missing = `No candidate for ${dependencyPhrase(edge, lazy)} 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 (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: `${missing}${when}${lazyConsequence(edge.weak)}`,
|
||||
};
|
||||
return edge.weak
|
||||
? {
|
||||
@@ -86,8 +94,26 @@ const unresolvedFinding = (edge: Edge, index: number): Finding => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Name the dependency the way the finding's consequence treats it. */
|
||||
const dependencyPhrase = (edge: Edge, lazy: boolean): string => {
|
||||
if (lazy && edge.weak)
|
||||
return `lazily loaded weak dependency ${edge.install_name}`;
|
||||
if (lazy) return `lazily loaded ${edge.install_name}`;
|
||||
if (edge.weak) return `weak dependency ${edge.install_name}`;
|
||||
return edge.install_name;
|
||||
};
|
||||
|
||||
/**
|
||||
* A may-be-missing lazy image is optional. Every other lazy image fails
|
||||
* when first use cannot find it.
|
||||
*/
|
||||
const lazyConsequence = (weak: boolean): string =>
|
||||
weak
|
||||
? "dyld loads it on first use and continues if it is absent."
|
||||
: "dyld loads it on first use, which would fail unless the image is supplied elsewhere.";
|
||||
|
||||
export const DYLIB_RESOLUTION_LIMITATIONS = [
|
||||
"LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.",
|
||||
"LC_LAZY_LOAD_DYLIB and LC_LAZY_LOAD_DYLIB_INFO 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.",
|
||||
|
||||
@@ -178,6 +178,59 @@ describe("Mach-O inspection container format", () => {
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.result).toMatchObject({ format: expected });
|
||||
});
|
||||
|
||||
it("does not call a lazy-info payload dependency exhaustive", async () => {
|
||||
const runner = new NativeFixtureRunner({
|
||||
lipo: "Non-fat file: /fixture/app is architecture: arm64\n",
|
||||
"otool:-h": withOtoolMachHeader(
|
||||
[
|
||||
"Load command 0",
|
||||
" cmd LC_LOAD_DYLIB",
|
||||
" cmdsize 56",
|
||||
" name /usr/lib/libSystem.B.dylib (offset 24)",
|
||||
" time stamp 2 Thu Jan 1 00:00:02 1970",
|
||||
" current version 1.0.0",
|
||||
"compatibility version 1.0.0",
|
||||
"Load command 1",
|
||||
" cmd LC_LAZY_LOAD_DYLIB_INFO",
|
||||
" cmdsize 16",
|
||||
" dataoff 49304",
|
||||
" datasize 56",
|
||||
].join("\n"),
|
||||
),
|
||||
});
|
||||
const result = await inspectNativeMacho({
|
||||
target: nativeMachoTarget("/fixture/app"),
|
||||
run: (tool, args) => runner.run(tool, args),
|
||||
invocation: (captured) => ({
|
||||
tool: captured.tool,
|
||||
command: [captured.executable, ...captured.arguments],
|
||||
tool_version: captured.toolVersion,
|
||||
version_reason: captured.versionReason,
|
||||
executable_sha256: captured.executableSha256,
|
||||
exit: { code: captured.exitCode, signal: captured.signal },
|
||||
stdout_bytes: captured.stdoutBytes,
|
||||
stderr_bytes: captured.stderrBytes,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.result).toMatchObject({
|
||||
load_commands: {
|
||||
items: [{ kind: "LC_LOAD_DYLIB" }, { kind: "LC_LAZY_LOAD_DYLIB_INFO" }],
|
||||
},
|
||||
dependencies: {
|
||||
exhaustive: false,
|
||||
total: null,
|
||||
items: [
|
||||
{
|
||||
path: "/usr/lib/libSystem.B.dylib",
|
||||
kind: "LC_LOAD_DYLIB",
|
||||
},
|
||||
],
|
||||
limitations: [expect.stringContaining("1 LC_LAZY_LOAD_DYLIB_INFO")],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mach-O UUID reconciliation", () => {
|
||||
|
||||
@@ -324,7 +324,11 @@ const normalizeMacho = (
|
||||
architectures: covered(architectures, true),
|
||||
build_metadata: covered(load.builds, true),
|
||||
load_commands: covered(load.commands, true),
|
||||
dependencies: covered(load.dependencies, true),
|
||||
dependencies: covered(
|
||||
load.dependencies,
|
||||
load.omittedDependencyCommands.length === 0,
|
||||
dependencyPayloadLimitations(load.omittedDependencyCommands),
|
||||
),
|
||||
imports: covered(imports, false, [
|
||||
"dyld_info textual imports may omit chained or toolchain-unsupported metadata.",
|
||||
...(missingImportsUuid ? [missingUuidLimitation("imports")] : []),
|
||||
@@ -405,6 +409,21 @@ const parseNmExports = (output: string) =>
|
||||
source: "nm",
|
||||
}));
|
||||
|
||||
const dependencyPayloadLimitations = (
|
||||
commands: readonly string[],
|
||||
): string[] => {
|
||||
if (commands.length === 0) return [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const command of commands)
|
||||
counts.set(command, (counts.get(command) ?? 0) + 1);
|
||||
const described = [...counts].map(
|
||||
([command, count]) => `${count} ${command}`,
|
||||
);
|
||||
return [
|
||||
`Dependency inventory omits ${described.join(", ")} because otool -l does not print that command's linkedit install name. trace_dylib_resolution reads the payload.`,
|
||||
];
|
||||
};
|
||||
|
||||
const covered = <Value>(
|
||||
items: readonly Value[],
|
||||
exhaustive: boolean,
|
||||
|
||||
@@ -41,6 +41,7 @@ interface ParsedLoadCommands {
|
||||
}>;
|
||||
uuid: string | null;
|
||||
readonly flags: Set<string>;
|
||||
readonly omittedDependencyCommands: string[];
|
||||
}
|
||||
|
||||
const DYLIB_COMMANDS = new Set([
|
||||
@@ -51,6 +52,11 @@ const DYLIB_COMMANDS = new Set([
|
||||
"LC_LAZY_LOAD_DYLIB",
|
||||
"LC_LOAD_UPWARD_DYLIB",
|
||||
]);
|
||||
/**
|
||||
* Dependency commands whose install name is not in the otool command text.
|
||||
* `LC_LAZY_LOAD_DYLIB_INFO` stores it in a linkedit payload.
|
||||
*/
|
||||
const PAYLOAD_DEPENDENCY_COMMANDS = new Set(["LC_LAZY_LOAD_DYLIB_INFO"]);
|
||||
|
||||
const DYLIB_FIELDS = [
|
||||
"cmd",
|
||||
@@ -142,6 +148,7 @@ export const parseOtoolLoadCommands = (raw: string) => {
|
||||
commands: state.commands,
|
||||
segments: state.segments,
|
||||
dependencies: state.dependencies,
|
||||
omittedDependencyCommands: state.omittedDependencyCommands,
|
||||
entrypoints: state.entrypoints,
|
||||
builds: state.builds,
|
||||
};
|
||||
@@ -265,6 +272,7 @@ const createLoadCommandState = (
|
||||
builds: [],
|
||||
uuid: null,
|
||||
flags: new Set(headerFlags ?? []),
|
||||
omittedDependencyCommands: [],
|
||||
});
|
||||
|
||||
const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => {
|
||||
@@ -290,6 +298,8 @@ const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => {
|
||||
collectEntrypoint(kind, fields, state);
|
||||
collectBuild(kind, block, fields, state);
|
||||
collectDependency(kind, fields, state);
|
||||
if (PAYLOAD_DEPENDENCY_COMMANDS.has(kind))
|
||||
state.omittedDependencyCommands.push(kind);
|
||||
collectSegment(kind, block, fields, state);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user