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:
authored and GitHub committed 2026-10-21 03:33:07 +08:00
1 parent 0add6a5aae
commit 23196daa11
12 files changed
+1492 -23

No files matched your search

+21 -4
View File
@@ -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
+5 -1
View File
@@ -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 ===
+124
View File
@@ -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 [
+475 -6
View File
@@ -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 = (
+1 -1
View File
@@ -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[];
+87
View File
@@ -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", () => {
+15 -5
View File
@@ -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);
+30 -4
View File
@@ -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.",
+53
View File
@@ -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", () => {
+20 -1
View File
@@ -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,
+10
View File
@@ -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);
};