diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md index b0a3cdeecb..8425877e62 100644 --- a/docs/apple-application-analysis.md +++ b/docs/apple-application-analysis.md @@ -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 diff --git a/scripts/verify/apple/macos-dylib-e2e.mjs b/scripts/verify/apple/macos-dylib-e2e.mjs index eacd06e9fb..d9538c4cce 100644 --- a/scripts/verify/apple/macos-dylib-e2e.mjs +++ b/scripts/verify/apple/macos-dylib-e2e.mjs @@ -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 === diff --git a/src/artifacts/apple/MachoImage.fixture.ts b/src/artifacts/apple/MachoImage.fixture.ts index e965da22ae..c7e0c5ade9 100644 --- a/src/artifacts/apple/MachoImage.fixture.ts +++ b/src/artifacts/apple/MachoImage.fixture.ts @@ -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, diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index f360dfecd7..fdd9a79815 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -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 [ diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 5c67bd2af7..0e22cd4d28 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -18,6 +18,18 @@ export type ReadAt = (offset: number, length: number) => Promise; /** 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; + readonly reads: { fileOffset: number; bytes: Uint8Array }[]; + readonly strings: Map; + 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 => { + 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 => { + 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 => { + 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 = ( diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index 23657b7d7a..afb8a10de4 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -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[]; diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts index 88a9e379fa..f1c44572b9 100644 --- a/src/domain/apple/dylibResolution.test.ts +++ b/src/domain/apple/dylibResolution.test.ts @@ -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", () => { diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts index 630fdd6942..7e46976390 100644 --- a/src/domain/apple/dylibResolution.ts +++ b/src/domain/apple/dylibResolution.ts @@ -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); diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts index eac488143a..7783ab91db 100644 --- a/src/domain/apple/dylibResolutionFindings.ts +++ b/src/domain/apple/dylibResolutionFindings.ts @@ -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.", diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts index 0a31b24e4c..32c9222a33 100644 --- a/src/native/NativeMachoInspection.test.ts +++ b/src/native/NativeMachoInspection.test.ts @@ -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", () => { diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 1151342d75..6cccf0bfa2 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -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(); + 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 = ( items: readonly Value[], exhaustive: boolean, diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index bf135bf9d5..6da69d6a0d 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -41,6 +41,7 @@ interface ParsedLoadCommands { }>; uuid: string | null; readonly flags: Set; + 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); };