Merge remote-tracking branch 'origin/feat/dylib-resolution' into feat/dyld-shared-cache
# Conflicts: # docs/product-catalog.json # docs/verification/managed-conformance-manifest.json # skills/reverse-engineer-anything/SKILL.md # src/domain/dylibResolution.test.ts # src/domain/dylibResolution.ts # src/domain/dylibResolutionFindings.ts # src/generatedMcpToolCatalog.ts
This commit is contained in:
@@ -168,7 +168,9 @@ cache rather than on disk, so REA does not check them against the host.
|
||||
|
||||
`findings` are derived, not observed:
|
||||
|
||||
- `required-load-unresolved` and `weak-load-unresolved`.
|
||||
- `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.
|
||||
- `earlier-rpath-candidate-absent`: a Mach-O placed at an earlier search path
|
||||
would load first. Whether code-signing library validation would reject it is
|
||||
not evaluated; use `inspect_signature`.
|
||||
|
||||
@@ -677,9 +677,9 @@
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "83437501fe3f9adc3379519c647b7455198dd46f9e3a0b04494993f5e785d966",
|
||||
"tools_sha256": "e4d84e74452636d26901d1e0d5e571d62d5c07db202d37c8940ce90d7bd71a9e",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "57ee3158835cbbcca13a350e568ba98930e8e48a114b3bc64bc3f747c522c0a5",
|
||||
"combined_sha256": "917eb82467a3cd28d3dab6b0030835448dfe541fa03510ba957679a1708dac27",
|
||||
"providers_sha256": "14d933c5926c106e8cde60a37747e9758313fbfd651485ec96733bc608476255"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_6436db80a38269a223490eca662314eed71848b05258b423305a884007a63c24",
|
||||
"manifest_id": "ecm_25a6aa3fc65f10fff27c6bab739a3a1c5fb489778f08b5b17c9dc4daddd1a913",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "98323e93bc3e4378ca3feac459c6aca4f905f72fd7932241cfaf75e510a4231a"
|
||||
"sha256": "7864e85986f59b04114aad1aae89b21b8ae974d5d2604eaae2e8121a2e181a92"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
|
||||
metadata:
|
||||
version: "29"
|
||||
tool_count: 133
|
||||
catalog_digest: "57ee3158835cbbcca13a350e568ba98930e8e48a114b3bc64bc3f747c522c0a5"
|
||||
catalog_digest: "917eb82467a3cd28d3dab6b0030835448dfe541fa03510ba957679a1708dac27"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -136,6 +136,13 @@ export const traceDylibResolution = async (options: {
|
||||
"Dylib resolution was cancelled",
|
||||
{ cause },
|
||||
);
|
||||
const denied = permissionDenied(cause);
|
||||
if (denied !== undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
`Permission denied (${denied.code}) reading ${denied.path === undefined ? "a file in the analyzed root" : relative(root, denied.path) || denied.path}`,
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
} finally {
|
||||
await cache?.close();
|
||||
@@ -280,6 +287,21 @@ const cancelled = (signal?: AbortSignal): void => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Host permission denials, kept distinct from malformed or missing files. */
|
||||
const permissionDenied = (
|
||||
cause: unknown,
|
||||
): { readonly code: string; readonly path: string | undefined } | undefined => {
|
||||
if (!(cause instanceof Error) || !("code" in cause)) return undefined;
|
||||
if (cause.code !== "EACCES" && cause.code !== "EPERM") return undefined;
|
||||
return {
|
||||
code: cause.code,
|
||||
path:
|
||||
"path" in cause && typeof cause.path === "string"
|
||||
? cause.path
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const missing = (cause: unknown): boolean =>
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
|
||||
@@ -210,6 +210,35 @@ describe("Mach-O load command reader failures", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires 8-byte command alignment in 64-bit images", async () => {
|
||||
const command = new Uint8Array(12);
|
||||
const view = new DataView(command.buffer);
|
||||
view.setUint32(0, LC.CODE_SIGNATURE, true);
|
||||
view.setUint32(4, 12, true);
|
||||
expect(await read(machoImage({ commands: [command] }))).toMatchObject({
|
||||
status: "malformed",
|
||||
reason: expect.stringContaining("invalid cmdsize 12"),
|
||||
});
|
||||
expect(
|
||||
await read(
|
||||
machoImage({
|
||||
cpu: { type: 7, subtype: 3 },
|
||||
wide: false,
|
||||
commands: [command],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ status: "parsed" });
|
||||
});
|
||||
|
||||
it("accepts FAT tables up to 128 records that name a Mach-O CPU", async () => {
|
||||
const slices = Array.from({ length: 24 }, () => ({
|
||||
cpu: CPU.arm64,
|
||||
bytes: machoImage({}),
|
||||
}));
|
||||
const facts = await read(fatImage(slices));
|
||||
expect(facts.status === "parsed" && facts.slices).toHaveLength(24);
|
||||
});
|
||||
|
||||
it("rejects string commands too short to hold their string offset", async () => {
|
||||
const short = new Uint8Array(8);
|
||||
const view = new DataView(short.buffer);
|
||||
|
||||
@@ -9,8 +9,12 @@ 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;
|
||||
/** Real universal binaries carry a handful of slices; Java class files share the FAT magic. */
|
||||
const MAX_FAT_ARCHITECTURES = 20;
|
||||
/** Target opening accepts up to 128 FAT records; Java class files share the FAT magic. */
|
||||
const MAX_FAT_ARCHITECTURES = 128;
|
||||
/** CPU types of real Mach-O slices (x86, x86_64, arm, arm64, arm64_32, ppc, ppc64). */
|
||||
const KNOWN_CPU_TYPES: readonly number[] = [
|
||||
7, 0x01000007, 12, 0x0100000c, 0x0200000c, 18, 0x01000012,
|
||||
];
|
||||
|
||||
const MH_MAGIC = 0xfeedface;
|
||||
const MH_MAGIC_64 = 0xfeedfacf;
|
||||
@@ -91,12 +95,26 @@ export const readMachoImage = async (
|
||||
// FAT headers are big-endian; the swapped magics store them little-endian.
|
||||
const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
|
||||
const count = view.getUint32(4, littleEndian);
|
||||
if (count === 0 || count >= MAX_FAT_ARCHITECTURES)
|
||||
const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64;
|
||||
if (
|
||||
count === 0 ||
|
||||
count > MAX_FAT_ARCHITECTURES ||
|
||||
8 + count * (wide ? 32 : 20) > size
|
||||
)
|
||||
return { status: "not-mach-o" };
|
||||
const table = viewOf(
|
||||
await readExact(readAt, 8, count * (wide ? 32 : 20), "FAT table"),
|
||||
);
|
||||
// A Java class file's version fields read as a FAT count; its "table" names no CPU.
|
||||
const cpuTypes = Array.from({ length: count }, (_, index) =>
|
||||
table.getUint32(index * (wide ? 32 : 20), littleEndian),
|
||||
);
|
||||
if (!cpuTypes.some((type) => KNOWN_CPU_TYPES.includes(type)))
|
||||
return { status: "not-mach-o" };
|
||||
return {
|
||||
status: "parsed",
|
||||
slices: await readFatSlices(readAt, size, {
|
||||
wide: magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64,
|
||||
wide,
|
||||
littleEndian,
|
||||
count,
|
||||
}),
|
||||
@@ -179,6 +197,7 @@ const readSlice = async (
|
||||
"load commands",
|
||||
);
|
||||
return decodeCommands(commands, {
|
||||
wide,
|
||||
cpuType: header.getUint32(4, true),
|
||||
cpuSubtype: header.getUint32(8, true),
|
||||
fileType: header.getUint32(12, true),
|
||||
@@ -198,12 +217,15 @@ const readSlice = async (
|
||||
const decodeCommands = (
|
||||
bytes: Uint8Array,
|
||||
header: {
|
||||
readonly wide: boolean;
|
||||
readonly cpuType: number;
|
||||
readonly cpuSubtype: number;
|
||||
readonly fileType: number;
|
||||
readonly commandCount: number;
|
||||
},
|
||||
): MachoSlice => {
|
||||
// loader.h: load commands are 8-byte aligned in 64-bit images, 4 in 32-bit.
|
||||
const alignment = header.wide ? 8 : 4;
|
||||
const view = viewOf(bytes);
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
@@ -223,7 +245,7 @@ const decodeCommands = (
|
||||
);
|
||||
const command = view.getUint32(offset, true);
|
||||
const size = view.getUint32(offset + 4, true);
|
||||
if (size < 8 || size % 4 !== 0 || offset + size > bytes.byteLength)
|
||||
if (size < 8 || size % alignment !== 0 || offset + size > bytes.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
|
||||
|
||||
@@ -589,3 +589,32 @@ describe("shared cache resolution", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lazily loaded dependencies", () => {
|
||||
it("resolves lazy loads without traversing them or reporting launch failure", async () => {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
rpaths: ["@executable_path/../Frameworks"],
|
||||
dependencies: [
|
||||
dependency("@rpath/liblazy.dylib", {
|
||||
command: "LC_LAZY_LOAD_DYLIB",
|
||||
}),
|
||||
dependency("@rpath/libmissing.dylib", {
|
||||
command: "LC_LAZY_LOAD_DYLIB",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
"Contents/Frameworks/liblazy.dylib": parsed(
|
||||
slice({ dependencies: [dependency("@rpath/libdeep.dylib")] }),
|
||||
),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(trace.edges.map(({ loader }) => loader)).toEqual([MAIN, MAIN]);
|
||||
expect(trace.edges[0]?.resolution.status).toBe("resolved");
|
||||
expect(trace.findings.map(({ kind }) => kind)).toEqual([
|
||||
"lazy-load-unresolved",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +139,7 @@ const findingSchema = z.strictObject({
|
||||
kind: z.enum([
|
||||
"required-load-unresolved",
|
||||
"weak-load-unresolved",
|
||||
"lazy-load-unresolved",
|
||||
"earlier-rpath-candidate-absent",
|
||||
"dyld-environment-present",
|
||||
]),
|
||||
@@ -409,6 +410,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;
|
||||
const image = edge.resolution.image;
|
||||
if (image === null || context.loaded.has(image)) continue;
|
||||
const facts = context.images.get(image);
|
||||
|
||||
@@ -15,15 +15,7 @@ export const deriveFindings = (
|
||||
const findings: Finding[] = [];
|
||||
edges.forEach((edge, index) => {
|
||||
if (edge.resolution.status === "unresolved")
|
||||
findings.push({
|
||||
kind: edge.weak ? "weak-load-unresolved" : "required-load-unresolved",
|
||||
edge_index: index,
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
explanation: edge.weak
|
||||
? `No candidate for weak dependency ${edge.install_name} exists in the analyzed root; dyld continues without it.`
|
||||
: `No candidate for ${edge.install_name} exists in the analyzed root; dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
|
||||
});
|
||||
findings.push(unresolvedFinding(edge, index));
|
||||
const resolvedAt = edge.candidates.findIndex(
|
||||
({ outcome }) => outcome === "resolved" || outcome === "shared-cache",
|
||||
);
|
||||
@@ -61,7 +53,33 @@ export const deriveFindings = (
|
||||
return findings;
|
||||
};
|
||||
|
||||
const unresolvedFinding = (edge: Edge, index: number): Finding => {
|
||||
const base = {
|
||||
edge_index: index,
|
||||
image: edge.loader,
|
||||
basis: "derived",
|
||||
} as const;
|
||||
if (edge.command === "LC_LAZY_LOAD_DYLIB")
|
||||
return {
|
||||
...base,
|
||||
kind: "lazy-load-unresolved",
|
||||
explanation: `No candidate for lazily loaded ${edge.install_name} exists in the analyzed root; dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
|
||||
};
|
||||
return edge.weak
|
||||
? {
|
||||
...base,
|
||||
kind: "weak-load-unresolved",
|
||||
explanation: `No candidate for weak dependency ${edge.install_name} exists in the analyzed root; dyld continues without it.`,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
kind: "required-load-unresolved",
|
||||
explanation: `No candidate for ${edge.install_name} exists in the analyzed root; dyld would fail to launch ${edge.root} (${edge.architecture}) 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.",
|
||||
"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.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
@@ -238,3 +238,45 @@ describe("trace-dylib-resolution CLI inputs", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe.skipIf(process.getuid?.() === 0)(
|
||||
"trace-dylib-resolution permissions",
|
||||
() => {
|
||||
cliTest(
|
||||
"reports an unreadable dependency as a permission failure",
|
||||
async ({ cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-dylib-denied-");
|
||||
await writeFiles(directory, {
|
||||
tool: machoImage({
|
||||
commands: [
|
||||
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libsecret.dylib"),
|
||||
],
|
||||
}),
|
||||
"libsecret.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
|
||||
});
|
||||
await chmod(join(directory, "libsecret.dylib"), 0o000);
|
||||
try {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"trace-dylib-resolution",
|
||||
join(directory, "tool"),
|
||||
"--json",
|
||||
],
|
||||
environment: ENVIRONMENT,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
details: {
|
||||
reason: "unavailable",
|
||||
detail: expect.stringContaining(
|
||||
"Permission denied (EACCES) reading libsecret.dylib",
|
||||
),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await chmod(join(directory, "libsecret.dylib"), 0o644);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user