feat(artifacts): trace Mach-O dylib resolution in bundles

Add trace_dylib_resolution (CLI trace-dylib-resolution) to the artifact
family. For the active Mach-O, or every executable in the active .app
(the main app, XPC services, extensions, login items, helpers), it
reports which file each dylib load reaches and how.

A pure TypeScript reader decodes FAT and Mach-O load commands:
LC_LOAD/WEAK/REEXPORT/LAZY/UPWARD_DYLIB, including the newer
dylib_use_command encoding with weak, re-export, upward and delayed-init
flags, plus LC_ID_DYLIB, LC_RPATH, LC_DYLD_ENVIRONMENT and code-signature
presence. Malformed tables are reported with the failing command instead
of being guessed.

Resolution follows dyld:
- @executable_path comes from each process root;
- @loader_path comes from the loading image, or from the owner of the
  LC_RPATH entry being expanded;
- @rpath searches the loader's rpaths first, then up the load chain;
- dependencies load dependents-first in load-command order, once per
  process, and a matching install name reuses an already loaded image;
- symlinks are followed inside the analyzed root, and escapes stop.

Every candidate path is reported with its outcome. Each edge resolves to
resolved, conditional (an outside-root candidate such as /usr/lib/swift
precedes), unresolved, or undetermined. Absolute and shared-cache system
paths are never checked against the host. Derived findings cover
unresolved required or weak loads, absent earlier @rpath candidates (with
library validation left unknown), and LC_DYLD_ENVIRONMENT.

verify:macos-bundle now also:
- checks every traced image against otool -l;
- asserts each resolution status on a clang-built fixture that adds a
  missing Overrides rpath, an absolute rpath, and a delay-init library;
- compares the predicted load order of two process roots with what dyld
  loads under DYLD_PRINT_LIBRARIES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-07 10:31:17 +00:00
1 parent 55249704c5
commit 11b7f918c2
35 files changed
+2820 -29

No files matched your search

+1 -1
View File
@@ -404,7 +404,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
| Artifact graph | 6 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, Mach-O dylib load resolution, and extraction |
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
+1 -1
View File
@@ -216,7 +216,7 @@ rea setup
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift دون تشغيل Hopper |
| مخطط المخرجات | 5 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple والاستخراج |
| مخطط المخرجات | 6 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وحل تحميل مكتبات Mach-O الديناميكية والاستخراج |
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
+1 -1
View File
@@ -205,7 +205,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
| macOS ネイティブ | 7 | Hopper を起動せずに扱える Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
| 成果物グラフ | 5 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、抽出 |
| 成果物グラフ | 6 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib 読み込みの解決、抽出 |
| マネージド PE/CLI | 7 | .NET の識別、メタデータ、CIL 命令、ネイティブ依存関係、再構築インポート、ビルド比較 |
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッド逆コンパイル、静的参照 |
+1 -1
View File
@@ -206,7 +206,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
| macOS 네이티브 | 7 | Hopper 실행 없이 처리하는 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
| 아티팩트 그래프 | 5 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, 추출 |
| 아티팩트 그래프 | 6 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, Mach-O dylib 로드 해석, 추출 |
| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
+1 -1
View File
@@ -205,7 +205,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
| 产物图 | 5 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录与提取 |
| 产物图 | 6 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析与提取 |
| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
| 固件 | 2 | Linux 固件区域检查与显式提取 |
| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
+66
View File
@@ -108,3 +108,69 @@ The projection does not read plists. Use `inspect_plist` on a bundle's
`info_plist_path` or a launchd plist to read `CFBundleExecutable`, identifiers,
`SMPrivilegedExecutables`, `BundleProgram`, URL schemes, and usage descriptions.
Use `inspect_signature` on an executable candidate for code-signing claims.
## Dylib load resolution
`trace_dylib_resolution` answers which file each Mach-O dylib load reaches. It
runs on the active Mach-O, or on every executable in the active `.app`. Each
executable in a bundle (the main app, XPC services, app extensions, login items,
privileged helpers, and helper tools) is a separate process root with its own
`@executable_path`.
```sh
rea trace-dylib-resolution MyApp.app --json
rea trace-dylib-resolution MyApp.app --root Contents/MacOS/MyApp --architecture arm64 --json
```
```json
{
"name": "trace_dylib_resolution",
"arguments": { "roots": ["Contents/MacOS/MyApp"], "architecture": "arm64" }
}
```
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.
Resolution follows dyld:
- `@executable_path` is the directory of the process root's executable. It is
undetermined when the root is a library.
- `@loader_path` is the directory of the image that holds the load command, or
of the image that owns the `LC_RPATH` entry being expanded.
- `@rpath` tries each `LC_RPATH` of the loading image first, then those of the
image that loaded it, up to the process root.
- Dependencies load dependents-first in load-command order, and each image
loads once per process. A later request whose install name matches an
already loaded image reuses it.
- Symlinks such as `Versions/Current` are followed inside the analyzed root.
An escape through `..` or an absolute link is `escapes-target`.
Each edge lists every candidate path tried, with its outcome: `resolved`,
`absent`, `not-mach-o`, `malformed`, `architecture-missing`, `outside-target`,
`escapes-target`, or `undetermined`. The edge's `resolution` is one of:
| Status | Meaning |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
| `conditional` | An image inside the root resolves, but an earlier candidate outside the root (for example `/usr/lib/swift`) would win if it exists on the running system. |
| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
Absolute install names such as `/usr/lib/libSystem.B.dylib` are
`outside-target`. On macOS 11 and later most of them live in the dyld shared
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`.
- `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`.
- `dyld-environment-present`: `LC_DYLD_ENVIRONMENT` search paths are not modeled.
`verify:macos-bundle` checks the parser against `otool -l` for every traced
image. It also compares the predicted load order of two process roots with the
images dyld actually loads (`DYLD_PRINT_LIBRARIES`).
+7
View File
@@ -12,6 +12,13 @@ input file and include the observed lipo slice offset for universal binaries.
If that offset is unavailable, segment evidence locations are omitted with an
explicit limitation; architecture inventory locations remain available.
- `trace_dylib_resolution` / `rea trace-dylib-resolution <app-or-mach-o>`
parses Mach-O load commands in TypeScript and follows dyld's path expansion
for every executable in an app bundle, or for one Mach-O within its directory.
It reports each `@rpath`, `@loader_path` and `@executable_path` candidate with
its outcome. Paths outside the analyzed root, including shared-cache system
libraries, stay undetermined. See
[Apple application analysis](apple-application-analysis.md#dylib-load-resolution).
- `inspect_asset_catalog` / `rea inspect-asset-catalog <app>` reads compiled
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
rendition fields, pagination and exact UI resource-name matches are returned.
+14 -11
View File
@@ -7,10 +7,10 @@
"client": "2.3.1",
"core": "2.3.1"
},
"skill_version": "27"
"skill_version": "28"
},
"tools": {
"total": 131,
"total": 132,
"families": [
{
"id": "direct",
@@ -98,13 +98,14 @@
{
"id": "artifact",
"surface": "artifact-provider",
"count": 5,
"count": 6,
"tools": [
"decode_interface_builder",
"extract_artifact",
"inspect_artifact",
"inspect_asset_catalog",
"inspect_keyed_archive"
"inspect_keyed_archive",
"trace_dylib_resolution"
]
},
{
@@ -376,7 +377,8 @@
"extract_artifact",
"inspect_artifact",
"inspect_asset_catalog",
"inspect_keyed_archive"
"inspect_keyed_archive",
"trace_dylib_resolution"
]
},
{
@@ -566,7 +568,7 @@
}
],
"cli": {
"primary_count": 86,
"primary_count": 87,
"commands": [
"address-to-file-offset",
"analyze",
@@ -645,6 +647,7 @@
"trace",
"trace-android-references",
"trace-application-feature",
"trace-dylib-resolution",
"trace-javascript-semantics",
"trace-native-ui-action",
"trace-native-values",
@@ -664,15 +667,15 @@
},
"runtime_catalog": {
"counts": {
"cli_commands": 86,
"mcp_tools": 131,
"cli_commands": 87,
"mcp_tools": 132,
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "db85863d0b0947f21a14fcffe1e0e293842c68a209beb4125900d797f1242ccd",
"tools_sha256": "de9574754aec25583ad8afd7707decd98cf44be2045116df58382d543e0a531c",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "7521c178e76e19b8fd552c2ca7e1ed4930dd86d2bf01d035951d974e4f6edab9",
"providers_sha256": "a7d885531ee25d42c51385444faec544d34cd7eba6250a7dc75c4faedd4454a3"
"combined_sha256": "bc890fb6dd3df37c65925265dba308cf9dcfdd479a71b55ac73e268e5bbfdc5f",
"providers_sha256": "56e95f15d7ee4cc262e9f758dc74db0eabf670fdfea0be878079f0286114e65b"
}
}
}
+10 -1
View File
@@ -77,7 +77,16 @@ extension, a login item, a privileged helper, launchd plists, and a helper tool,
signed ad hoc. It packs the app as a directory, a `ditto` ZIP, and an APFS DMG,
then checks that `inspect-artifact` plus `project-apple-application-graph`
report the same bundle anatomy for all three through the CLI, with stdio MCP
parity. It also checks that the DMG is detached afterwards. It runs in macOS CI.
parity. It also checks that the DMG is detached afterwards. The same app
covers `trace-dylib-resolution`:
- each resolution status and finding, with CLI/MCP parity;
- for every traced image, dependencies, rpaths, and install names equal to
`otool -l`;
- for the main executable and an XPC service, a predicted load order equal to
the images dyld actually loads under `DYLD_PRINT_LIBRARIES`.
It runs in macOS CI.
MCP SDK transport tests with recording providers remain integration tests.
They are useful for schema drift and failure projection but do not prove that
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_7c7f2d7520a5b3ba8f4811c9ca9efcb4a7d9a7d7b68c57b396cc418c9cbea2ee",
"manifest_id": "ecm_833521668577f2ca68cb5f93d29c32bf514a2f5cffd17c32f04584e341177e39",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "acc3d70d97ac210557d5e80c0cd84c236474b7162fddf67753c5a2518b66bb54"
"sha256": "9e3fff18418fb7ee5d59de8ca3346fc6607cd810394893ffbcee4b134f2532e0"
}
],
"claims": [
+22 -2
View File
@@ -138,7 +138,11 @@ const char *core_chain(void) { return sibling_value() == 7 ? chain_variant() : "
return join(version, "Core");
};
/** The main executable, with a weak dependency deleted after linking. */
/**
* The main executable. It searches a missing `Overrides` rpath before
* `Frameworks`, weakly links a library deleted after linking, and
* delay-links a library, which the linker encodes as `dylib_use_command`.
*/
const buildMainExecutable = async (root, contents, coreBinary) => {
const gone = join(contents, "Frameworks", "libgone.dylib");
await clang(root, {
@@ -147,22 +151,33 @@ const buildMainExecutable = async (root, contents, coreBinary) => {
output: gone,
flags: ["-dynamiclib", "-install_name", "@rpath/libgone.dylib"],
});
const delayed = join(contents, "Frameworks", "libdelay.dylib");
await clang(root, {
name: "delay",
source: `int delay_value(void) { return 3; }\n`,
output: delayed,
flags: ["-dynamiclib", "-install_name", "@rpath/libdelay.dylib"],
});
const executable = join(contents, "MacOS", "MacFixture");
await clang(root, {
name: "main",
source: `#include <stdio.h>
const char *core_chain(void);
int delay_value(void);
extern int gone_value(void) __attribute__((weak_import));
int main(void) {
if (delay_value() != 3) return 1;
printf("chain=%s weak=%s\\n", core_chain(), gone_value ? "present" : "absent");
return 0;
}
`,
output: executable,
flags: [
"-Wl,-rpath,@executable_path/../Overrides",
"-Wl,-rpath,@executable_path/../Frameworks",
coreBinary,
`-Wl,-weak_library,${gone}`,
`-Wl,-delay_library,${delayed}`,
],
});
await rm(gone);
@@ -191,7 +206,12 @@ const buildNestedBundles = async (root, contents, coreBinary) => {
int main(void) { return core_chain()[0] == 'b'; }
`,
output: join(service, "MacOS", "Svc"),
flags: ["-Wl,-rpath,@executable_path/../../../../Frameworks", coreBinary],
// An absolute rpath outside the bundle precedes the bundled framework.
flags: [
"-Wl,-rpath,/opt/rea-fixture-absent/lib",
"-Wl,-rpath,@executable_path/../../../../Frameworks",
coreBinary,
],
});
await write(
join(service, "Info.plist"),
+209
View File
@@ -0,0 +1,209 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { realpath } from "node:fs/promises";
import { join, relative } from "node:path";
import { promisify } from "node:util";
import {
artifactCliEvidence,
artifactMcpResult,
withArtifactMcp,
} from "./artifact-e2e.mjs";
const exec = promisify(execFile);
const MAIN = "Contents/MacOS/MacFixture";
const SERVICE = "Contents/XPCServices/Svc.xpc/Contents/MacOS/Svc";
const CORE = "Contents/Frameworks/Core.framework/Versions/A/Core";
const DEPENDENCY_COMMANDS = new Set([
"LC_LOAD_DYLIB",
"LC_LOAD_WEAK_DYLIB",
"LC_REEXPORT_DYLIB",
"LC_LAZY_LOAD_DYLIB",
"LC_LOAD_UPWARD_DYLIB",
]);
/** Dependencies, rpaths and install name of one slice as Apple's otool reports them. */
const otoolLoadCommands = async (path, architecture) => {
const { stdout } = await exec(
"/usr/bin/xcrun",
["otool", "-arch", architecture, "-l", path],
{ maxBuffer: 64 * 1024 * 1024 },
);
const result = { dependencies: [], rpaths: [], install_name: null };
for (const block of stdout.split(/\nLoad command \d+\n/u)) {
const command = /^\s*cmd (\S+)/mu.exec(block)?.[1];
const name = /^\s*name (.+?) \(offset \d+\)$/mu.exec(block)?.[1];
if (command === "LC_RPATH")
result.rpaths.push(/^\s*path (.+?) \(offset \d+\)$/mu.exec(block)?.[1]);
else if (command === "LC_ID_DYLIB") result.install_name = name;
else if (DEPENDENCY_COMMANDS.has(command))
result.dependencies.push(`${command} ${name}`);
}
return result;
};
/** In-bundle images dyld actually loaded, in load order. */
const loadedImages = async (app, executable) => {
const { stderr } = await exec(join(app, executable), [], {
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_LIBRARIES: "1" },
});
const appRoot = await realpath(app);
const loaded = [];
for (const line of stderr.split("\n")) {
const path = /^dyld\[\d+\]: <[0-9A-F-]+> (.+)$/u.exec(line)?.[1];
if (path === undefined) continue;
// Shared-cache libraries are reported by path but are not files on disk.
const real = await realpath(path).catch(() => undefined);
if (real?.startsWith(`${appRoot}/`) === true)
loaded.push(relative(appRoot, real));
}
return loaded;
};
/** Images a process root loads according to the trace, in first-load order. */
const predictedImages = (trace, root) => {
const loaded = [root];
for (const edge of trace.edges)
if (
edge.root === root &&
["resolved", "conditional"].includes(edge.resolution.status) &&
!loaded.includes(edge.resolution.image)
)
loaded.push(edge.resolution.image);
return loaded;
};
const findEdge = (trace, root, loader, installName) => {
const edge = trace.edges.find(
(candidate) =>
candidate.root === root &&
candidate.loader === loader &&
candidate.install_name === installName,
);
assert.ok(edge, `missing edge ${root}: ${loader} -> ${installName}`);
return edge;
};
const verifyResolutionShapes = (trace) => {
const core = findEdge(
trace,
MAIN,
MAIN,
"@rpath/Core.framework/Versions/A/Core",
);
assert.deepEqual(
core.candidates.map(({ source, outcome }) => [source, outcome]),
[
["rpath", "absent"],
["rpath", "resolved"],
],
);
assert.deepEqual(core.resolution, { status: "resolved", image: CORE });
const weak = findEdge(trace, MAIN, MAIN, "@rpath/libgone.dylib");
assert.equal(weak.weak, true);
assert.equal(weak.resolution.status, "unresolved");
const delayed = findEdge(trace, MAIN, MAIN, "@rpath/libdelay.dylib");
assert.equal(delayed.encoding, "dylib_use_command");
assert.equal(delayed.delayed_init, true);
const chain = findEdge(trace, MAIN, CORE, "@rpath/libchain.dylib");
assert.equal(
chain.resolution.image,
"Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib",
"the loading image's own LC_RPATH must be searched first",
);
const service = findEdge(
trace,
SERVICE,
SERVICE,
"@rpath/Core.framework/Versions/A/Core",
);
assert.equal(service.candidates[0]?.outcome, "outside-target");
assert.deepEqual(service.resolution, { status: "conditional", image: CORE });
const system = trace.edges.filter(({ install_name: name }) =>
name.startsWith("/usr/lib/"),
);
assert.ok(system.length > 0);
assert.ok(
system.every(({ resolution }) => resolution.status === "undetermined"),
);
const kinds = trace.findings.map(({ kind, image }) => `${kind}:${image}`);
assert.ok(kinds.includes(`weak-load-unresolved:${MAIN}`));
assert.ok(kinds.includes(`earlier-rpath-candidate-absent:${MAIN}`));
};
const verifyParserConformance = async (app, trace) => {
let slices = 0;
for (const image of trace.images)
for (const slice of image.slices) {
const expected = await otoolLoadCommands(
join(app, image.path),
slice.architecture,
);
const edges = trace.edges.filter(
({ loader, architecture, root }) =>
loader === image.path &&
architecture === slice.architecture &&
root ===
trace.edges.find(
(edge) =>
edge.loader === image.path &&
edge.architecture === slice.architecture,
)?.root,
);
assert.deepEqual(
{
dependencies: edges.map(
({ command, install_name: name }) => `${command} ${name}`,
),
rpaths: slice.rpaths,
install_name: slice.install_name,
},
expected,
`${image.path} (${slice.architecture}) differs from otool -l`,
);
slices += 1;
}
return slices;
};
/** Verify trace_dylib_resolution against otool and the real dyld on the fixture app. */
export async function verifyDylibResolution(app) {
const trace = (await artifactCliEvidence("trace-dylib-resolution", app))
.normalized_result;
await withArtifactMcp(app, async (client) => {
assert.deepEqual(
await artifactMcpResult(client, "trace_dylib_resolution"),
trace,
);
const invalid = await client.callTool({
name: "trace_dylib_resolution",
arguments: { roots: ["../outside"] },
});
assert.equal(invalid.isError, true);
});
assert.equal(trace.coverage.status, "complete");
assert.deepEqual(trace.roots.map(({ image }) => image).sort(), [
"Contents/Frameworks/Core.framework/Versions/A/XPCServices/FwSvc.xpc/Contents/MacOS/FwSvc",
"Contents/Helpers/rea-tool",
"Contents/Library/LaunchServices/com.example.rea.helper",
"Contents/Library/LoginItems/Login.app/Contents/MacOS/Login",
MAIN,
"Contents/PlugIns/Ext.appex/Contents/MacOS/Ext",
SERVICE,
]);
verifyResolutionShapes(trace);
const slices = await verifyParserConformance(app, trace);
for (const root of [MAIN, SERVICE])
assert.deepEqual(
await loadedImages(app, root),
predictedImages(trace, root),
`${root} load order differs from DYLD_PRINT_LIBRARIES`,
);
return {
roots: trace.roots.length,
edges: trace.edges.length,
otool_slices: slices,
dyld_runtime_roots: 2,
};
}
+3
View File
@@ -14,6 +14,7 @@ import {
buildMacosBundleFixture,
preflightMacosBundleFixture,
} from "./lib/macos-bundle-fixture.mjs";
import { verifyDylibResolution } from "./lib/macos-dylib-e2e.mjs";
const exec = promisify(execFile);
@@ -150,6 +151,7 @@ try {
assert.deepEqual(anatomies[2], anatomies[0], "DMG anatomy differs");
const { stdout: attached } = await exec("/usr/bin/hdiutil", ["info"]);
assert.ok(!attached.includes(dmg), "DMG remained attached after inventory");
const dylibs = await verifyDylibResolution(app);
process.stdout.write(
`${JSON.stringify({
@@ -160,6 +162,7 @@ try {
containers: containers.map(({ format }) => format),
bundles: EXPECTED_BUNDLES.length,
dmg_detached: true,
dylib_resolution: dylibs,
})}\n`,
);
} finally {
+3 -3
View File
@@ -2,9 +2,9 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "27"
tool_count: 131
catalog_digest: "7521c178e76e19b8fd552c2ca7e1ed4930dd86d2bf01d035951d974e4f6edab9"
version: "28"
tool_count: 132
catalog_digest: "bc890fb6dd3df37c65925265dba308cf9dcfdd479a71b55ac73e268e5bbfdc5f"
---
# REA
@@ -77,6 +77,13 @@ helpers, launchd plists, symlinks, and each bundle's `info_plist_path` and
executable candidates. Roles are path conventions, not parsed plists; read the
listed plists with `inspect_plist`.
After `open_binary` on a `.app` or Mach-O, use `trace_dylib_resolution` to see
which file each `@rpath`, `@loader_path` and `@executable_path` load reaches for
every executable in the bundle. It also lists missing or weak loads and earlier
`@rpath` candidates that are absent. Narrow large bundles with `roots` or
`architecture`. System paths stay undetermined because the dyld shared cache
provides them.
Use `extract_artifact` when materialized files are needed. It takes no arguments
and materializes all regular files into a fresh temporary directory chosen by
REA. Symlinks and encrypted entries are inventory facts, not extractable files.
+4 -2
View File
@@ -319,7 +319,8 @@ export class BinarySession
lookupSnapshot: (target, profile, operation, parameters) =>
operation === "decode_interface_builder" ||
operation === "inspect_asset_catalog" ||
operation === "inspect_keyed_archive"
operation === "inspect_keyed_archive" ||
operation === "trace_dylib_resolution"
? undefined
: this.lookupSnapshot(target, profile, operation, parameters),
});
@@ -342,7 +343,8 @@ export class BinarySession
profile !== undefined &&
name !== "decode_interface_builder" &&
name !== "inspect_asset_catalog" &&
name !== "inspect_keyed_archive"
name !== "inspect_keyed_archive" &&
name !== "trace_dylib_resolution"
) {
const evidence = createEvidence(
profiled.value.subject ?? active.target,
+34
View File
@@ -8,6 +8,7 @@ import {
type ExecutionOptions,
} from "../application/AnalysisProvider.js";
import { inspectBundleKeyedArchive } from "./KeyedArchiveReader.js";
import { traceDylibResolution } from "./DylibResolutionReader.js";
import { basename, dirname } from "node:path";
import { inventoryArtifact } from "../application/ArtifactInventory.js";
import { extractArtifact } from "../application/ArtifactExtraction.js";
@@ -153,6 +154,8 @@ class ArtifactClient implements AnalysisClient {
if (operation === "inspect_asset_catalog") {
return await this.inspectAssetCatalog(parameters, options);
}
if (operation === "trace_dylib_resolution")
return await this.traceDylibResolution(parameters, options);
if (operation === "extract_artifact") {
const parsed = artifactExtractionExecutionSchema.parse(parameters);
const result = await extractArtifact(
@@ -234,6 +237,37 @@ class ArtifactClient implements AnalysisClient {
);
}
private async traceDylibResolution(
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
) {
if (this.target.kind !== "executable" || this.target.format !== "mach-o")
throw new ArtifactReaderFailure(
"unavailable",
"trace_dylib_resolution requires an active Mach-O or .app bundle target",
);
const bundle = this.target.sourcePath?.toLowerCase().endsWith(".app")
? this.target.sourcePath
: undefined;
const result = await traceDylibResolution({
rootPath: bundle ?? dirname(this.target.path),
targetPath: this.target.path,
targetSha256: this.target.sha256,
enumerateRoots: bundle !== undefined,
parameters,
...(options?.signal === undefined ? {} : { signal: options.signal }),
});
return ok(
createAnalysisExecution(result, IDENTITY, {
limitations: result.limitations,
locations: result.images.map(({ path }) => ({
kind: "artifact-path" as const,
path,
})),
}),
);
}
private async inspectArtifact(
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
+2 -1
View File
@@ -24,7 +24,8 @@ export const artifactCapabilities = (
mutatesArtifact: false,
launchesProcess:
operation !== "decode_interface_builder" &&
operation !== "inspect_keyed_archive",
operation !== "inspect_keyed_archive" &&
operation !== "trace_dylib_resolution",
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem:
+235
View File
@@ -0,0 +1,235 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, readlink, realpath } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import {
dylibResolutionInputSchema,
dylibResolutionResultSchema,
traceDylibLoading,
type DylibResolutionResult,
type DylibTreeEntry,
type DylibTreeView,
type MachoImageFacts,
} from "../domain/dylibResolution.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
const HASH_CHUNK_BYTES = 1024 * 1024;
/** Lazily probed, symlink-preserving view of one analyzed directory. */
class FilesystemTreeView implements DylibTreeView {
readonly #entries = new Map<string, Promise<DylibTreeEntry | undefined>>();
readonly #images = new Map<string, Promise<MachoImageFacts>>();
constructor(
private readonly root: string,
private readonly signal?: AbortSignal,
) {}
entry(path: string): Promise<DylibTreeEntry | undefined> {
const cached = this.#entries.get(path);
if (cached !== undefined) return cached;
const pending = this.#readEntry(path);
this.#entries.set(path, pending);
return pending;
}
image(path: string): Promise<MachoImageFacts> {
const cached = this.#images.get(path);
if (cached !== undefined) return cached;
const pending = readImage(join(this.root, path), this.signal);
this.#images.set(path, pending);
return pending;
}
async #readEntry(path: string): Promise<DylibTreeEntry | undefined> {
cancelled(this.signal);
const absolute = join(this.root, path);
try {
const metadata = await lstat(absolute);
if (metadata.isSymbolicLink())
return { kind: "symlink", target: await readlink(absolute) };
if (metadata.isDirectory()) return { kind: "directory" };
return metadata.isFile() ? { kind: "file" } : undefined;
} catch (cause: unknown) {
if (missing(cause)) return undefined;
throw cause;
}
}
}
/** Resolve dyld load paths for the active Mach-O or every executable in its bundle. */
export const traceDylibResolution = async (options: {
readonly rootPath: string;
readonly targetPath: string;
readonly targetSha256: string;
readonly enumerateRoots: boolean;
readonly parameters: unknown;
readonly signal?: AbortSignal;
}): Promise<DylibResolutionResult> => {
const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
if (!parsed.success)
throw new ArtifactReaderFailure(
"path",
"roots must be normalized paths relative to the analyzed root",
);
const root = await realpath(options.rootPath);
const target = relative(root, await realpath(options.targetPath))
.split(sep)
.join("/");
const view = new FilesystemTreeView(root, options.signal);
try {
const roots =
parsed.data.roots ??
(options.enumerateRoots
? await executableRoots(root, view, options.signal)
: [target]);
await requireMachoRoots(view, roots);
const trace = await traceDylibLoading(view, {
roots,
...(parsed.data.architecture === undefined
? {}
: { architecture: parsed.data.architecture }),
...(options.signal === undefined ? {} : { signal: options.signal }),
});
const digests = new Map<string, string>();
for (const { path } of trace.images)
digests.set(path, await fileSha256(join(root, path), options.signal));
const targetDigest =
digests.get(target) ??
(await fileSha256(join(root, target), options.signal));
if (targetDigest !== options.targetSha256)
throw new ArtifactReaderFailure(
"integrity",
`Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
);
return dylibResolutionResultSchema.parse({
...trace,
root_path: options.rootPath,
target_sha256: options.targetSha256,
images: trace.images.map((image) => ({
...image,
sha256: digests.get(image.path),
})),
});
} catch (cause: unknown) {
if (options.signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"Dylib resolution was cancelled",
{ cause },
);
throw cause;
}
};
/** Every Mach-O in the bundle with an executable slice is its own process root. */
const executableRoots = async (
root: string,
view: FilesystemTreeView,
signal?: AbortSignal,
): Promise<string[]> => {
const roots: string[] = [];
for await (const entry of new DirectoryArtifactReader(root).entries(signal)) {
if (
entry.kind !== "file" ||
!(await startsWithMachoMagic(entry.adapterKey))
)
continue;
const facts = await view.image(entry.path);
if (
facts.status === "parsed" &&
facts.slices.some(({ file_type: type }) => type === "execute")
)
roots.push(entry.path);
}
return roots.sort((left, right) =>
left < right ? -1 : left > right ? 1 : 0,
);
};
const requireMachoRoots = async (
view: FilesystemTreeView,
roots: readonly string[],
): Promise<void> => {
for (const path of roots) {
const entry = await view.entry(path);
if (entry?.kind !== "file")
throw new ArtifactReaderFailure(
"path",
`Root ${path} is not a regular file in the analyzed root`,
);
if ((await view.image(path)).status === "not-mach-o")
throw new ArtifactReaderFailure(
"format",
`Root ${path} is not a Mach-O image`,
);
}
};
const startsWithMachoMagic = async (path: string): Promise<boolean> => {
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const buffer = new Uint8Array(4);
const { bytesRead } = await handle.read(buffer, 0, 4, 0);
return hasMachoMagic(buffer.subarray(0, bytesRead));
} finally {
await handle.close();
}
};
const readImage = async (
path: string,
signal?: AbortSignal,
): Promise<MachoImageFacts> => {
cancelled(signal);
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const { size } = await handle.stat();
return await readMachoImage(async (offset, length) => {
cancelled(signal);
const buffer = new Uint8Array(
Math.max(0, Math.min(length, size - offset)),
);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
return buffer.subarray(0, bytesRead);
}, size);
} finally {
await handle.close();
}
};
const fileSha256 = async (
path: string,
signal?: AbortSignal,
): Promise<string> => {
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const hash = createHash("sha256");
const buffer = new Uint8Array(HASH_CHUNK_BYTES);
for (;;) {
cancelled(signal);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
}
return hash.digest("hex");
} finally {
await handle.close();
}
};
const cancelled = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"Dylib resolution was cancelled",
);
};
const missing = (cause: unknown): boolean =>
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR");
+148
View File
@@ -0,0 +1,148 @@
/** Little-endian Mach-O images and FAT containers built field by field for tests. */
export const LC = {
LOAD_DYLIB: 0xc,
ID_DYLIB: 0xd,
LOAD_WEAK_DYLIB: 0x80000018,
REEXPORT_DYLIB: 0x8000001f,
LAZY_LOAD_DYLIB: 0x20,
LOAD_UPWARD_DYLIB: 0x80000023,
RPATH: 0x8000001c,
DYLD_ENVIRONMENT: 0x27,
CODE_SIGNATURE: 0x1d,
} as const;
export const CPU = {
arm64: { type: 0x0100000c, subtype: 0 },
arm64e: { type: 0x0100000c, subtype: 0x80000002 },
x86_64: { type: 0x01000007, subtype: 3 },
} as const;
export const FILE_TYPE = { execute: 2, dylib: 6, bundle: 8 } as const;
const encoder = new TextEncoder();
/** One load command with a NUL-terminated string at `nameOffset`, padded to 8 bytes. */
const commandWithString = (
command: number,
fields: readonly number[],
text: string,
): Uint8Array => {
const nameOffset = 8 + fields.length * 4 + 4;
const name = encoder.encode(text);
const size = Math.ceil((nameOffset + name.length + 1) / 8) * 8;
const bytes = new Uint8Array(size);
const view = new DataView(bytes.buffer);
view.setUint32(0, command, true);
view.setUint32(4, size, true);
view.setUint32(8, nameOffset, true);
fields.forEach((value, index) => view.setUint32(12 + index * 4, value, true));
bytes.set(name, nameOffset);
return bytes;
};
/** Classic `dylib_command`: timestamp, current and compatibility versions. */
export const dylibCommand = (
command: number,
name: string,
versions: { readonly current?: number; readonly compatibility?: number } = {},
): Uint8Array =>
commandWithString(
command,
[2, versions.current ?? 0x10000, versions.compatibility ?? 0x10000],
name,
);
/** `dylib_use_command`: marker, versions and `DYLIB_USE_*` flags. */
export const dylibUseCommand = (
command: number,
name: string,
flags: number,
): Uint8Array =>
commandWithString(command, [0x1a741800, 0x10000, 0x10000, flags], name);
export const rpathCommand = (path: string): Uint8Array =>
commandWithString(LC.RPATH, [], path);
export const dyldEnvironmentCommand = (value: string): Uint8Array =>
commandWithString(LC.DYLD_ENVIRONMENT, [], value);
export const codeSignatureCommand = (): Uint8Array => {
const bytes = new Uint8Array(16);
const view = new DataView(bytes.buffer);
view.setUint32(0, LC.CODE_SIGNATURE, true);
view.setUint32(4, 16, true);
return bytes;
};
/** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */
export const machoImage = (options: {
readonly cpu?: { readonly type: number; readonly subtype: number };
readonly fileType?: number;
readonly commands?: readonly Uint8Array[];
readonly wide?: boolean;
}): Uint8Array => {
const wide = options.wide ?? true;
const commands = options.commands ?? [];
const headerSize = wide ? 32 : 28;
const commandBytes = commands.reduce((total, item) => total + item.length, 0);
const bytes = new Uint8Array(headerSize + commandBytes + 16);
const view = new DataView(bytes.buffer);
const cpu = options.cpu ?? CPU.arm64;
view.setUint32(0, wide ? 0xfeedfacf : 0xfeedface, true);
view.setUint32(4, cpu.type, true);
view.setUint32(8, cpu.subtype, true);
view.setUint32(12, options.fileType ?? FILE_TYPE.execute, true);
view.setUint32(16, commands.length, true);
view.setUint32(20, commandBytes, true);
let offset = headerSize;
for (const command of commands) {
bytes.set(command, offset);
offset += command.length;
}
return bytes;
};
/** A big-endian FAT container whose slices start on 4 KiB boundaries. */
export const fatImage = (
slices: readonly {
readonly cpu: { readonly type: number; readonly subtype: number };
readonly bytes: Uint8Array;
}[],
wide = false,
): Uint8Array => {
const entrySize = wide ? 32 : 20;
const offsets: number[] = [];
let end = 4096;
for (const slice of slices) {
offsets.push(end);
end += Math.ceil(slice.bytes.length / 4096) * 4096;
}
const bytes = new Uint8Array(end);
const view = new DataView(bytes.buffer);
view.setUint32(0, wide ? 0xcafebabf : 0xcafebabe, false);
view.setUint32(4, slices.length, false);
slices.forEach((slice, index) => {
const base = 8 + index * entrySize;
const offset = offsets[index] ?? 0;
view.setUint32(base, slice.cpu.type, false);
view.setUint32(base + 4, slice.cpu.subtype, false);
if (wide) {
view.setBigUint64(base + 8, BigInt(offset), false);
view.setBigUint64(base + 16, BigInt(slice.bytes.length), false);
view.setUint32(base + 24, 12, false);
} else {
view.setUint32(base + 8, offset, false);
view.setUint32(base + 12, slice.bytes.length, false);
view.setUint32(base + 16, 12, false);
}
bytes.set(slice.bytes, offset);
});
return bytes;
};
/** In-memory `ReadAt` over one buffer. */
export const readerOf =
(bytes: Uint8Array) =>
(offset: number, length: number): Promise<Uint8Array> =>
Promise.resolve(bytes.subarray(offset, offset + length));
@@ -0,0 +1,255 @@
import { describe, expect, it } from "vitest";
import {
CPU,
FILE_TYPE,
LC,
codeSignatureCommand,
dyldEnvironmentCommand,
dylibCommand,
dylibUseCommand,
fatImage,
machoImage,
readerOf,
rpathCommand,
} from "./MachoImage.fixture.js";
import {
MAX_LOAD_COMMAND_BYTES,
hasMachoMagic,
readMachoImage,
} from "./MachoLoadCommandReader.js";
const read = (bytes: Uint8Array) =>
readMachoImage(readerOf(bytes), bytes.length);
describe("Mach-O load command reader", () => {
it("decodes dylib loading commands of a thin image", async () => {
const image = machoImage({
fileType: FILE_TYPE.dylib,
commands: [
dylibCommand(LC.ID_DYLIB, "@rpath/Core.framework/Core"),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib", {
current: 0x050f0400,
compatibility: 0x10000,
}),
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libweak.dylib"),
dylibCommand(LC.REEXPORT_DYLIB, "@loader_path/libre.dylib"),
dylibCommand(LC.LOAD_UPWARD_DYLIB, "@rpath/libup.dylib"),
dylibCommand(LC.LAZY_LOAD_DYLIB, "@rpath/liblazy.dylib"),
rpathCommand("@loader_path/Frameworks"),
rpathCommand("/usr/lib/swift"),
dyldEnvironmentCommand("DYLD_FRAMEWORK_PATH=@executable_path"),
codeSignatureCommand(),
],
});
const facts = await read(image);
expect(facts.status).toBe("parsed");
if (facts.status !== "parsed") return;
expect(facts.slices).toHaveLength(1);
const [slice] = facts.slices;
expect(slice).toMatchObject({
architecture: "arm64",
file_type: "dylib",
install_name: "@rpath/Core.framework/Core",
rpaths: ["@loader_path/Frameworks", "/usr/lib/swift"],
dyld_environment: ["DYLD_FRAMEWORK_PATH=@executable_path"],
code_signature_present: true,
});
expect(
slice?.dependencies.map(
({ command, install_name: name, weak, reexport, upward }) => [
command,
name,
weak,
reexport,
upward,
],
),
).toEqual([
["LC_LOAD_DYLIB", "/usr/lib/libSystem.B.dylib", false, false, false],
["LC_LOAD_WEAK_DYLIB", "@rpath/libweak.dylib", true, false, false],
["LC_REEXPORT_DYLIB", "@loader_path/libre.dylib", false, true, false],
["LC_LOAD_UPWARD_DYLIB", "@rpath/libup.dylib", false, false, true],
["LC_LAZY_LOAD_DYLIB", "@rpath/liblazy.dylib", false, false, false],
]);
expect(slice?.dependencies[0]).toMatchObject({
encoding: "dylib_command",
current_version: "1295.4.0",
compatibility_version: "1.0.0",
});
});
it("decodes dylib_use_command flags", async () => {
const facts = await read(
machoImage({
commands: [
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libdelay.dylib", 0x8),
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libweakup.dylib", 0x1 | 0x4),
dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libre.dylib", 0x2),
],
}),
);
if (facts.status !== "parsed") throw new Error(facts.status);
expect(
facts.slices[0]?.dependencies.map(
({ encoding, weak, upward, reexport, delayed_init: delayed }) => ({
encoding,
weak,
upward,
reexport,
delayed,
}),
),
).toEqual([
{
encoding: "dylib_use_command",
weak: false,
upward: false,
reexport: false,
delayed: true,
},
{
encoding: "dylib_use_command",
weak: true,
upward: true,
reexport: false,
delayed: false,
},
{
encoding: "dylib_use_command",
weak: false,
upward: false,
reexport: true,
delayed: false,
},
]);
});
it("reads every slice of FAT and FAT_64 containers", async () => {
for (const wide of [false, true]) {
const facts = await read(
fatImage(
[
{
cpu: CPU.arm64e,
bytes: machoImage({
cpu: CPU.arm64e,
commands: [rpathCommand("@executable_path/a")],
}),
},
{
cpu: CPU.x86_64,
bytes: machoImage({
cpu: CPU.x86_64,
commands: [rpathCommand("@executable_path/b")],
}),
},
],
wide,
),
);
if (facts.status !== "parsed") throw new Error(facts.status);
expect(
facts.slices.map(({ architecture, rpaths }) => [architecture, rpaths]),
).toEqual([
["arm64e", ["@executable_path/a"]],
["x86_64", ["@executable_path/b"]],
]);
}
});
});
describe("Mach-O load command reader inputs", () => {
it("names 32-bit and non-Mach-O inputs without guessing", async () => {
const i386 = await read(
machoImage({ cpu: { type: 7, subtype: 3 }, wide: false }),
);
expect(i386.status === "parsed" && i386.slices[0]?.architecture).toBe(
"i386",
);
// A Java class file shares the FAT magic; its version is not a slice count.
const javaClass = Uint8Array.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34]);
expect(hasMachoMagic(javaClass)).toBe(true);
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
status: "not-mach-o",
});
expect(await read(Uint8Array.from([0xcf, 0xfa]))).toEqual({
status: "not-mach-o",
});
});
});
describe("Mach-O load command reader failures", () => {
const withCommandSize = (size: number): Uint8Array => {
const image = machoImage({ commands: [rpathCommand("@loader_path")] });
new DataView(image.buffer).setUint32(32 + 4, size, true);
return image;
};
it("reports malformed command tables with the failing command", async () => {
expect(await read(withCommandSize(0))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("load command 0"),
});
expect(await read(withCommandSize(4096))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("invalid cmdsize"),
});
const extraCommand = machoImage({ commands: [rpathCommand("x")] });
new DataView(extraCommand.buffer).setUint32(16, 2, true);
expect(await read(extraCommand)).toMatchObject({
status: "malformed",
reason: "load command 1 starts beyond sizeofcmds",
});
});
it("rejects strings that are unterminated, misplaced or not UTF-8", async () => {
const unterminated = rpathCommand("abcd");
unterminated.fill(0x41, 12);
expect(await read(machoImage({ commands: [unterminated] }))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("unterminated"),
});
const misplaced = rpathCommand("abcd");
new DataView(misplaced.buffer).setUint32(8, 4, true);
expect(await read(machoImage({ commands: [misplaced] }))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("string offset 4"),
});
const invalid = rpathCommand("ab");
invalid[12] = 0xff;
expect(await read(machoImage({ commands: [invalid] }))).toMatchObject({
status: "malformed",
reason: expect.stringContaining("not UTF-8"),
});
});
it("refuses truncated, oversized, big-endian and out-of-range images", async () => {
const image = machoImage({ commands: [rpathCommand("@loader_path")] });
expect(await read(image.subarray(0, 20))).toMatchObject({
status: "malformed",
reason: "Mach-O header is truncated",
});
const oversized = machoImage({});
new DataView(oversized.buffer).setUint32(
20,
MAX_LOAD_COMMAND_BYTES + 8,
true,
);
expect(await read(oversized)).toMatchObject({ status: "unsupported" });
const beyond = machoImage({ commands: [rpathCommand("x")] });
new DataView(beyond.buffer).setUint32(20, beyond.length, true);
expect(await read(beyond)).toMatchObject({
status: "malformed",
reason: "load commands extend beyond the Mach-O slice",
});
const bigEndian = Uint8Array.from([0xfe, 0xed, 0xfa, 0xcf, 0, 0, 0, 0]);
expect(await read(bigEndian)).toMatchObject({ status: "unsupported" });
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
expect(await read(fat.subarray(0, 4100))).toMatchObject({
status: "malformed",
reason: "FAT architecture 0 extends beyond the file",
});
});
});
+376
View File
@@ -0,0 +1,376 @@
import type {
MachoDependency,
MachoImageFacts,
MachoSlice,
} from "../domain/dylibResolution.js";
/** Read `length` bytes at `offset`; shorter results mean end of file. */
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;
const MH_MAGIC = 0xfeedface;
const MH_MAGIC_64 = 0xfeedfacf;
const MH_CIGAM = 0xcefaedfe;
const MH_CIGAM_64 = 0xcffaedfe;
const FAT_MAGIC = 0xcafebabe;
const FAT_MAGIC_64 = 0xcafebabf;
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_LOAD_UPWARD_DYLIB = 0x80000023;
const LC_RPATH = 0x8000001c;
const LC_DYLD_ENVIRONMENT = 0x27;
const LC_CODE_SIGNATURE = 0x1d;
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
const DYLIB_USE_MARKER = 0x1a741800;
const DYLIB_USE_WEAK_LINK = 0x1;
const DYLIB_USE_REEXPORT = 0x2;
const DYLIB_USE_UPWARD = 0x4;
const DYLIB_USE_DELAYED_INIT = 0x8;
const DEPENDENCY_COMMANDS: ReadonlyMap<number, MachoDependency["command"]> =
new Map([
[LC_LOAD_DYLIB, "LC_LOAD_DYLIB"],
[LC_LOAD_WEAK_DYLIB, "LC_LOAD_WEAK_DYLIB"],
[LC_REEXPORT_DYLIB, "LC_REEXPORT_DYLIB"],
[LC_LAZY_LOAD_DYLIB, "LC_LAZY_LOAD_DYLIB"],
[LC_LOAD_UPWARD_DYLIB, "LC_LOAD_UPWARD_DYLIB"],
]);
/** Malformed or unsupported structure found while decoding one image. */
class MachoFormatIssue extends Error {
constructor(
readonly status: "malformed" | "unsupported",
message: string,
) {
super(message);
}
}
/** Report whether the first bytes carry a Mach-O or FAT magic number. */
export const hasMachoMagic = (head: Uint8Array): boolean => {
if (head.byteLength < 4) return false;
const magic = new DataView(
head.buffer,
head.byteOffset,
head.byteLength,
).getUint32(0, false);
return [
MH_MAGIC,
MH_MAGIC_64,
MH_CIGAM,
MH_CIGAM_64,
FAT_MAGIC,
FAT_MAGIC_64,
].includes(magic);
};
/** Decode every slice's dylib-loading load commands without reading code. */
export const readMachoImage = async (
readAt: ReadAt,
size: number,
): Promise<MachoImageFacts> => {
try {
const head = await readAt(0, Math.min(size, 8));
if (head.byteLength < 8) return { status: "not-mach-o" };
const view = viewOf(head);
const magic = view.getUint32(0, false);
if (magic === FAT_MAGIC || magic === FAT_MAGIC_64) {
const count = view.getUint32(4, false);
if (count === 0 || count >= MAX_FAT_ARCHITECTURES)
return { status: "not-mach-o" };
return {
status: "parsed",
slices: await readFatSlices(
readAt,
size,
magic === FAT_MAGIC_64,
count,
),
};
}
if (magic === MH_CIGAM || magic === MH_CIGAM_64)
return { status: "parsed", slices: [await readSlice(readAt, 0, size)] };
if (magic === MH_MAGIC || magic === MH_MAGIC_64)
throw new MachoFormatIssue(
"unsupported",
"big-endian Mach-O images are not supported",
);
return { status: "not-mach-o" };
} catch (cause: unknown) {
if (cause instanceof MachoFormatIssue)
return { status: cause.status, reason: cause.message };
throw cause;
}
};
const readFatSlices = async (
readAt: ReadAt,
size: number,
wide: boolean,
count: number,
): Promise<MachoSlice[]> => {
const entrySize = wide ? 32 : 20;
const table = await readExact(readAt, 8, count * entrySize, "FAT table");
const view = viewOf(table);
const slices: MachoSlice[] = [];
for (let index = 0; index < count; index++) {
const base = index * entrySize;
const offset = wide
? safeNumber(view.getBigUint64(base + 8, false))
: view.getUint32(base + 8, false);
const length = wide
? safeNumber(view.getBigUint64(base + 16, false))
: view.getUint32(base + 12, false);
if (offset + length > size)
throw new MachoFormatIssue(
"malformed",
`FAT architecture ${index} extends beyond the file`,
);
slices.push(await readSlice(readAt, offset, length));
}
return slices;
};
const readSlice = async (
readAt: ReadAt,
base: number,
length: number,
): Promise<MachoSlice> => {
const header = viewOf(await readExact(readAt, base, 28, "Mach-O header"));
const magic = header.getUint32(0, true);
if (magic === MH_MAGIC_64 || magic === MH_MAGIC) {
const wide = magic === MH_MAGIC_64;
const headerSize = wide ? 32 : 28;
const commandCount = header.getUint32(16, true);
const commandBytes = header.getUint32(20, true);
if (commandBytes > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
`load commands occupy ${commandBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
);
if (headerSize + commandBytes > length)
throw new MachoFormatIssue(
"malformed",
"load commands extend beyond the Mach-O slice",
);
const commands = await readExact(
readAt,
base + headerSize,
commandBytes,
"load commands",
);
return decodeCommands(commands, {
cpuType: header.getUint32(4, true),
cpuSubtype: header.getUint32(8, true),
fileType: header.getUint32(12, true),
commandCount,
});
}
throw new MachoFormatIssue(
magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
? "unsupported"
: "malformed",
magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
? "big-endian Mach-O slices are not supported"
: `slice at offset ${base} has no Mach-O header`,
);
};
const decodeCommands = (
bytes: Uint8Array,
header: {
readonly cpuType: number;
readonly cpuSubtype: number;
readonly fileType: number;
readonly commandCount: number;
},
): MachoSlice => {
const view = viewOf(bytes);
const slice: MachoSlice = {
architecture: architectureName(header.cpuType, header.cpuSubtype),
file_type: fileTypeName(header.fileType),
install_name: null,
dependencies: [],
rpaths: [],
dyld_environment: [],
code_signature_present: false,
};
let offset = 0;
for (let index = 0; index < header.commandCount; index++) {
if (offset + 8 > bytes.byteLength)
throw new MachoFormatIssue(
"malformed",
`load command ${index} starts beyond sizeofcmds`,
);
const command = view.getUint32(offset, true);
const size = view.getUint32(offset + 4, true);
if (size < 8 || size % 4 !== 0 || offset + size > bytes.byteLength)
throw new MachoFormatIssue(
"malformed",
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
);
const body = bytes.subarray(offset, offset + size);
decodeCommand(command, body, index, slice);
offset += size;
}
return slice;
};
const decodeCommand = (
command: number,
body: Uint8Array,
index: number,
slice: MachoSlice,
): void => {
if (command === LC_ID_DYLIB) {
slice.install_name = commandString(body, 24, index);
return;
}
if (command === LC_RPATH) {
slice.rpaths.push(commandString(body, 12, index));
return;
}
if (command === LC_DYLD_ENVIRONMENT) {
slice.dyld_environment.push(commandString(body, 12, index));
return;
}
if (command === LC_CODE_SIGNATURE) {
slice.code_signature_present = true;
return;
}
const name = DEPENDENCY_COMMANDS.get(command);
if (name !== undefined)
slice.dependencies.push(decodeDependency(name, body, index));
};
const decodeDependency = (
command: MachoDependency["command"],
body: Uint8Array,
index: number,
): MachoDependency => {
if (body.byteLength < 24)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for a dylib command`,
);
const view = viewOf(body);
const nameOffset = view.getUint32(8, true);
const usesFlags =
nameOffset === 28 &&
body.byteLength >= 28 &&
view.getUint32(12, true) === DYLIB_USE_MARKER;
const flags = usesFlags ? view.getUint32(24, true) : 0;
return {
command,
encoding: usesFlags ? "dylib_use_command" : "dylib_command",
install_name: commandString(body, usesFlags ? 28 : 24, index),
weak:
command === "LC_LOAD_WEAK_DYLIB" || (flags & DYLIB_USE_WEAK_LINK) !== 0,
upward:
command === "LC_LOAD_UPWARD_DYLIB" || (flags & DYLIB_USE_UPWARD) !== 0,
reexport:
command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0,
delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0,
current_version: version(view.getUint32(16, true)),
compatibility_version: version(view.getUint32(20, true)),
};
};
/** NUL-terminated UTF-8 string at a `lc_str` offset inside one command. */
const commandString = (
body: Uint8Array,
minimumOffset: number,
index: number,
): string => {
const offset = viewOf(body).getUint32(8, true);
if (offset < minimumOffset || offset >= body.byteLength)
throw new MachoFormatIssue(
"malformed",
`load command ${index} has string offset ${offset} outside the command`,
);
const end = body.indexOf(0, offset);
if (end < 0)
throw new MachoFormatIssue(
"malformed",
`load command ${index} has an unterminated string`,
);
try {
return new TextDecoder("utf-8", { fatal: true }).decode(
body.subarray(offset, end),
);
} catch {
throw new MachoFormatIssue(
"malformed",
`load command ${index} has a string that is not UTF-8`,
);
}
};
const readExact = async (
readAt: ReadAt,
offset: number,
length: number,
label: string,
): Promise<Uint8Array> => {
const bytes = await readAt(offset, length);
if (bytes.byteLength < length)
throw new MachoFormatIssue("malformed", `${label} is truncated`);
return bytes;
};
const CPU_ARCH_ABI64 = 0x01000000;
const CPU_ARCH_ABI64_32 = 0x02000000;
const CPU_TYPE_X86 = 7;
const CPU_TYPE_ARM = 12;
const CPU_SUBTYPE_MASK = 0xff000000;
const CPU_SUBTYPE_ARM64E = 2;
const CPU_SUBTYPE_X86_64_H = 8;
const architectureName = (cpuType: number, cpuSubtype: number): string => {
const subtype = cpuSubtype & ~CPU_SUBTYPE_MASK;
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64))
return subtype === CPU_SUBTYPE_ARM64E ? "arm64e" : "arm64";
if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64_32)) return "arm64_32";
if (cpuType === (CPU_TYPE_X86 | CPU_ARCH_ABI64))
return subtype === CPU_SUBTYPE_X86_64_H ? "x86_64h" : "x86_64";
if (cpuType === CPU_TYPE_X86) return "i386";
if (cpuType === CPU_TYPE_ARM) return "arm";
return `cpu-${cpuType.toString(16)}`;
};
const fileTypeName = (fileType: number): MachoSlice["file_type"] =>
fileType === 2
? "execute"
: fileType === 6
? "dylib"
: fileType === 8
? "bundle"
: "other";
const version = (value: number): string =>
`${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`;
const swap = (value: number): number =>
(((value & 0xff) << 24) |
((value & 0xff00) << 8) |
((value >>> 8) & 0xff00) |
(value >>> 24)) >>>
0;
const safeNumber = (value: bigint): number => {
if (value > BigInt(Number.MAX_SAFE_INTEGER))
throw new MachoFormatIssue("malformed", "FAT offset exceeds safe range");
return Number(value);
};
const viewOf = (bytes: Uint8Array): DataView =>
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+40
View File
@@ -15,6 +15,7 @@ export const registerArtifactCommands = (
registerExtractionCommand(cli, logger);
registerInterfaceBuilderCommand(cli, logger);
registerAssetCatalogCommand(cli, logger);
registerDylibResolutionCommand(cli, logger);
cli.command(CLI_COMMANDS.inspectKeyedArchive, {
description:
"Inspect a Foundation keyed archive as an object-reference graph",
@@ -58,6 +59,45 @@ export const registerArtifactCommands = (
});
};
const registerDylibResolutionCommand = (
cli: CliInstance,
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.traceDylibResolution, {
description:
"Trace dyld load-path resolution for a Mach-O or every executable in an app bundle",
args: z.object({
path: z.string().describe("Mach-O file or Apple .app bundle path"),
}),
options: z.object({
root: z
.array(z.string())
.optional()
.describe(
"Process root relative to the bundle or the Mach-O's directory (repeatable)",
),
architecture: z
.enum(["arm64", "arm64e", "x86_64"])
.optional()
.describe("Trace only this slice of each root"),
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.traceDylibResolution, () =>
runProviderAnalysis(
args.path,
"trace_dylib_resolution",
{
...(options.root === undefined ? {} : { roots: options.root }),
...(options.architecture === undefined
? {}
: { architecture: options.architecture }),
},
logger,
),
),
});
};
const registerAssetCatalogCommand = (
cli: CliInstance,
logger: Logger,
+1
View File
@@ -25,6 +25,7 @@ export const CLI_COMMANDS = Object.freeze({
extractArtifact: "extract-artifact",
decodeInterfaceBuilder: "decode-interface-builder",
inspectKeyedArchive: "inspect-keyed-archive",
traceDylibResolution: "trace-dylib-resolution",
inspectNativeDataType: "inspect-native-data-type",
inspectNativeLoadImage: "inspect-native-load-image",
readBytes: "read-bytes",
+10
View File
@@ -6,6 +6,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
import { toolContractMetadata } from "./toolEffects.js";
import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
import { appleAssetCatalogInputSchema } from "../domain/appleAssetCatalog.js";
import { dylibResolutionInputSchema } from "../domain/dylibResolution.js";
import { keyedArchiveInputSchema } from "../domain/keyedArchive.js";
/** Exact caller boundary for deterministic artifact inventory. */
export const artifactInventoryInputSchema = z.strictObject({
@@ -27,6 +28,10 @@ const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
decode_interface_builder: {},
inspect_asset_catalog: { offset: 0, limit: 1000 },
inspect_keyed_archive: { path: "Contents/Resources/Model.plist" },
trace_dylib_resolution: {
roots: ["Contents/MacOS/App"],
architecture: "arm64",
},
};
const artifact = <
@@ -85,6 +90,11 @@ export const ARTIFACT_TOOL_CONTRACTS = [
"Inspect compiled Assets.car metadata in an active Apple app bundle. Returns stable paginated catalog and rendition records with raw assetutil metadata and catalog digests; rendition bytes are not extracted.",
appleAssetCatalogInputSchema,
),
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.",
dylibResolutionInputSchema,
),
] as const satisfies readonly ToolContract[];
/** Provider operations; inventory remains an internal primitive of inspection. */
+1
View File
@@ -186,6 +186,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
decode_interface_builder: evidence,
inspect_asset_catalog: nativeEvidence,
inspect_keyed_archive: evidence,
trace_dylib_resolution: evidence,
inspect_managed_artifact: evidence,
inspect_managed_members: evidence,
inspect_managed_native_boundaries: evidence,
+2
View File
@@ -31,6 +31,7 @@ import {
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import { interfaceBuilderAnalysisSchema } from "../domain/interfaceBuilderGraph.js";
import { dylibResolutionResultSchema } from "../domain/dylibResolution.js";
import { keyedArchiveResultSchema } from "../domain/keyedArchive.js";
import { appleAssetCatalogResultSchema } from "../domain/appleAssetCatalog.js";
import {
@@ -336,6 +337,7 @@ export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
decode_interface_builder: resultOf(interfaceBuilderAnalysisSchema),
inspect_keyed_archive: resultOf(keyedArchiveResultSchema),
inspect_asset_catalog: resultOf(appleAssetCatalogResultSchema),
trace_dylib_resolution: resultOf(dylibResolutionResultSchema),
};
/** Exact Evidence schema for execution-free managed static analysis. */
+1
View File
@@ -12,6 +12,7 @@ export class ArtifactOperationError extends AnalysisError {
| "decode_interface_builder"
| "inspect_asset_catalog"
| "inspect_keyed_archive"
| "trace_dylib_resolution"
| "export_web_scripts"
| "trace_web_module_imports"
| "trace_web_source_location"
+87
View File
@@ -0,0 +1,87 @@
import type { DylibTreeEntry, DylibTreeView } from "./dylibResolution.js";
const MAX_SYMLINK_HOPS = 32;
export type PathLookup =
| { readonly kind: "file"; readonly path: string }
| { readonly kind: "absent" | "escapes" | "not-file" };
/**
* Resolve a root-relative path one segment at a time, as `open` would:
* symlinks are followed inside the root, `..` applies to the real parent,
* and any escape above the root or through an absolute link stops.
*/
export const resolveTreePath = async (
view: DylibTreeView,
input: string,
): Promise<PathLookup> => {
const pending = segmentsOf(input);
const resolved: string[] = [];
let last: DylibTreeEntry | undefined;
let hops = 0;
while (pending.length > 0) {
const segment = pending.shift() ?? "";
if (segment === "..") {
if (resolved.length === 0) return { kind: "escapes" };
resolved.pop();
last = { kind: "directory" };
continue;
}
const path = [...resolved, segment].join("/");
const entry = await view.entry(path);
if (entry === undefined) return { kind: "absent" };
if (entry.kind === "symlink") {
hops += 1;
if (hops > MAX_SYMLINK_HOPS) return { kind: "absent" };
if (entry.target.startsWith("/")) return { kind: "escapes" };
pending.unshift(...segmentsOf(entry.target));
continue;
}
if (entry.kind === "file" && pending.length > 0) return { kind: "absent" };
resolved.push(segment);
last = entry;
}
return last?.kind === "file"
? { kind: "file", path: resolved.join("/") }
: { kind: "not-file" };
};
const segmentsOf = (path: string): string[] =>
path.split("/").filter((segment) => segment !== "" && segment !== ".");
export const directoryOf = (path: string): string => {
const index = path.lastIndexOf("/");
return index < 0 ? "" : path.slice(0, index);
};
export const joinPath = (directory: string, rest: string): string =>
directory === "" ? rest : rest === "" ? directory : `${directory}/${rest}`;
export type Expansion =
| { readonly scope: "target"; readonly path: string }
| { readonly scope: "outside"; readonly path: string }
| { readonly scope: "undetermined"; readonly path: string };
/** Expand one dyld path prefix relative to its owning image. */
export const expandPrefix = (
raw: string,
context: {
readonly ownerDirectory: string;
readonly executable: string | null;
},
): Expansion => {
const variable = /^@(executable_path|loader_path)(?:\/(.*))?$/u.exec(raw);
if (variable !== null) {
const rest = variable[2] ?? "";
if (variable[1] === "loader_path")
return { scope: "target", path: joinPath(context.ownerDirectory, rest) };
return context.executable === null
? { scope: "undetermined", path: raw }
: {
scope: "target",
path: joinPath(directoryOf(context.executable), rest),
};
}
if (raw.startsWith("/")) return { scope: "outside", path: raw };
return { scope: "undetermined", path: raw };
};
+440
View File
@@ -0,0 +1,440 @@
import { describe, expect, it } from "vitest";
import {
traceDylibLoading,
type DylibTreeEntry,
type DylibTreeView,
type MachoDependency,
type MachoImageFacts,
type MachoSlice,
} from "./dylibResolution.js";
const slice = (overrides: Partial<MachoSlice> = {}): MachoSlice => ({
architecture: "arm64",
file_type: "dylib",
install_name: null,
dependencies: [],
rpaths: [],
dyld_environment: [],
code_signature_present: true,
...overrides,
});
const dependency = (
installName: string,
overrides: Partial<MachoDependency> = {},
): MachoDependency => ({
command: "LC_LOAD_DYLIB",
encoding: "dylib_command",
install_name: installName,
weak: false,
upward: false,
reexport: false,
delayed_init: false,
current_version: "1.0.0",
compatibility_version: "1.0.0",
...overrides,
});
const parsed = (...slices: MachoSlice[]): MachoImageFacts => ({
status: "parsed",
slices,
});
const executable = (overrides: Partial<MachoSlice> = {}): MachoImageFacts =>
parsed(slice({ file_type: "execute", ...overrides }));
/** In-memory tree: files are images (or "data"), symlinks map to targets. */
const memoryView = (
files: Readonly<Record<string, MachoImageFacts | "data">>,
symlinks: Readonly<Record<string, string>> = {},
): DylibTreeView => {
const entries = new Map<string, DylibTreeEntry>();
const addParents = (path: string): void => {
const segments = path.split("/");
for (let length = 1; length < segments.length; length++)
entries.set(segments.slice(0, length).join("/"), { kind: "directory" });
};
for (const path of Object.keys(files)) {
addParents(path);
entries.set(path, { kind: "file" });
}
for (const [path, target] of Object.entries(symlinks)) {
addParents(path);
entries.set(path, { kind: "symlink", target });
}
return {
entry: (path) => Promise.resolve(entries.get(path)),
image: (path) => {
const facts = files[path];
return Promise.resolve(
facts === undefined || facts === "data"
? { status: "not-mach-o" as const }
: facts,
);
},
};
};
const MAIN = "Contents/MacOS/App";
const edgeFor = (
trace: Awaited<ReturnType<typeof traceDylibLoading>>,
loader: string,
installName: string,
) => {
const edge = trace.edges.find(
(candidate) =>
candidate.loader === loader && candidate.install_name === installName,
);
if (edge === undefined)
throw new Error(`missing ${loader} -> ${installName}`);
return edge;
};
describe("dyld path expansion", () => {
it("searches the loading image's rpaths before its loaders'", async () => {
const core = "Contents/Frameworks/Core.framework/Versions/A/Core";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: ["@executable_path/../Frameworks"],
dependencies: [dependency("@rpath/Core.framework/Versions/A/Core")],
}),
[core]: parsed(
slice({
rpaths: ["@loader_path/Libraries"],
dependencies: [dependency("@rpath/libchain.dylib")],
}),
),
"Contents/Frameworks/libchain.dylib": parsed(slice()),
"Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib":
parsed(slice()),
}),
{ roots: [MAIN] },
);
const chain = edgeFor(trace, core, "@rpath/libchain.dylib");
expect(chain.via).toEqual([MAIN, core]);
expect(chain.candidates).toEqual([
expect.objectContaining({
source: "rpath",
rpath: "@loader_path/Libraries",
rpath_owner: core,
outcome: "resolved",
}),
]);
expect(chain.resolution).toEqual({
status: "resolved",
image:
"Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib",
});
});
it("expands @loader_path in an rpath relative to the image that owns it", async () => {
const plugin = "Contents/PlugIns/Tool.bundle/Contents/MacOS/Tool";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: ["@loader_path/../Shared"],
dependencies: [
dependency(
`@executable_path/../PlugIns/Tool.bundle/Contents/MacOS/Tool`,
),
],
}),
[plugin]: parsed(
slice({
file_type: "bundle",
dependencies: [dependency("@rpath/libshared.dylib")],
}),
),
"Contents/Shared/libshared.dylib": parsed(slice()),
}),
{ roots: [MAIN] },
);
expect(edgeFor(trace, plugin, "@rpath/libshared.dylib").candidates).toEqual(
[
expect.objectContaining({
path: "Contents/MacOS/../Shared/libshared.dylib",
rpath_owner: MAIN,
outcome: "resolved",
resolved_path: "Contents/Shared/libshared.dylib",
}),
],
);
});
it("gives each executable root its own @executable_path", async () => {
const service = "Contents/XPCServices/Fetch.xpc/Contents/MacOS/Fetch";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [dependency("@executable_path/libhere.dylib")],
}),
[service]: executable({
dependencies: [dependency("@executable_path/libhere.dylib")],
}),
"Contents/MacOS/libhere.dylib": parsed(slice()),
}),
{ roots: [MAIN, service] },
);
expect(
edgeFor(trace, MAIN, "@executable_path/libhere.dylib").resolution.status,
).toBe("resolved");
expect(
edgeFor(trace, service, "@executable_path/libhere.dylib"),
).toMatchObject({
candidates: [
{
path: "Contents/XPCServices/Fetch.xpc/Contents/MacOS/libhere.dylib",
outcome: "absent",
},
],
resolution: { status: "unresolved", image: null },
});
});
it("follows Versions/Current inside the root and stops at escaping links", async () => {
const trace = await traceDylibLoading(
memoryView(
{
[MAIN]: executable({
rpaths: ["@executable_path/../Frameworks"],
dependencies: [
dependency("@rpath/Core.framework/Core"),
dependency("@rpath/Escape.framework/Escape"),
dependency("@executable_path/../../../outside.dylib"),
],
}),
"Contents/Frameworks/Core.framework/Versions/A/Core": parsed(slice()),
},
{
"Contents/Frameworks/Core.framework/Versions/Current": "A",
"Contents/Frameworks/Core.framework/Core": "Versions/Current/Core",
"Contents/Frameworks/Escape.framework":
"/Library/Frameworks/Escape.framework",
},
),
{ roots: [MAIN] },
);
expect(
edgeFor(trace, MAIN, "@rpath/Core.framework/Core").resolution,
).toEqual({
status: "resolved",
image: "Contents/Frameworks/Core.framework/Versions/A/Core",
});
for (const name of [
"@rpath/Escape.framework/Escape",
"@executable_path/../../../outside.dylib",
])
expect(edgeFor(trace, MAIN, name)).toMatchObject({
candidates: [{ outcome: "escapes-target" }],
resolution: { status: "undetermined", image: null },
});
});
});
describe("dyld resolution outcomes", () => {
it("keeps paths outside the root undetermined and in-root fallbacks conditional", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: ["/usr/lib/swift", "@executable_path/../Frameworks"],
dependencies: [
dependency("/usr/lib/libSystem.B.dylib"),
dependency("@rpath/libswiftCore.dylib"),
dependency("libleaf.dylib"),
],
}),
"Contents/Frameworks/libswiftCore.dylib": parsed(slice()),
}),
{ roots: [MAIN] },
);
expect(edgeFor(trace, MAIN, "/usr/lib/libSystem.B.dylib")).toMatchObject({
candidates: [
{ outcome: "outside-target", path: "/usr/lib/libSystem.B.dylib" },
],
resolution: { status: "undetermined", image: null },
});
expect(edgeFor(trace, MAIN, "@rpath/libswiftCore.dylib")).toMatchObject({
candidates: [
{
outcome: "outside-target",
path: "/usr/lib/swift/libswiftCore.dylib",
},
{ outcome: "resolved" },
],
resolution: {
status: "conditional",
image: "Contents/Frameworks/libswiftCore.dylib",
},
});
expect(edgeFor(trace, MAIN, "libleaf.dylib").resolution.status).toBe(
"undetermined",
);
});
it("derives unresolved and earlier-candidate findings separately for weak loads", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: [
"@executable_path/../Overrides",
"@executable_path/../Frameworks",
],
dependencies: [
dependency("@rpath/libfound.dylib"),
dependency("@rpath/libgone.dylib", {
command: "LC_LOAD_WEAK_DYLIB",
weak: true,
}),
dependency("@rpath/libmissing.dylib"),
],
dyld_environment: ["DYLD_LIBRARY_PATH=/tmp"],
}),
"Contents/Frameworks/libfound.dylib": parsed(slice()),
}),
{ roots: [MAIN] },
);
expect(
trace.findings.map(({ kind, edge_index: index }) => [kind, index]),
).toEqual([
["earlier-rpath-candidate-absent", 0],
["weak-load-unresolved", 1],
["required-load-unresolved", 2],
["dyld-environment-present", null],
]);
expect(trace.findings[0]?.explanation).toContain(
"Contents/MacOS/../Overrides/libfound.dylib",
);
});
it("reports non-Mach-O, malformed and wrong-architecture candidates", async () => {
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: [
"@executable_path/a",
"@executable_path/b",
"@executable_path/c",
],
dependencies: [dependency("@rpath/lib.dylib")],
}),
"Contents/MacOS/a/lib.dylib": "data",
"Contents/MacOS/b/lib.dylib": {
status: "malformed",
reason: "load command 0 has invalid cmdsize 0",
},
"Contents/MacOS/c/lib.dylib": parsed(slice({ architecture: "x86_64" })),
}),
{ roots: [MAIN] },
);
expect(
edgeFor(trace, MAIN, "@rpath/lib.dylib").candidates.map(
({ outcome }) => outcome,
),
).toEqual(["not-mach-o", "malformed", "architecture-missing"]);
expect(trace.coverage).toEqual({
status: "partial",
unparsed_images: ["Contents/MacOS/b/lib.dylib"],
roots_without_architecture: [],
});
expect(trace.images.map(({ path }) => path)).toEqual([
MAIN,
"Contents/MacOS/b/lib.dylib",
"Contents/MacOS/c/lib.dylib",
]);
});
});
describe("dyld load order", () => {
it("loads each image once per process and reuses matching install names", async () => {
const a = "Contents/Frameworks/libA.dylib";
const b = "Contents/Frameworks/libB.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
rpaths: ["@executable_path/../Frameworks"],
dependencies: [
dependency("@rpath/libA.dylib"),
dependency("@rpath/libB.dylib"),
],
}),
[a]: parsed(
slice({
install_name: "@rpath/libA.dylib",
dependencies: [
dependency("@rpath/libB.dylib"),
dependency("@rpath/libA.dylib"),
],
}),
),
[b]: parsed(
slice({
install_name: "@rpath/libB.dylib",
dependencies: [dependency("@rpath/libA.dylib")],
}),
),
}),
{ roots: [MAIN] },
);
expect(
trace.edges.map(
({ loader, install_name: name }) =>
`${loader.split("/").at(-1)}>${name}`,
),
).toEqual([
"App>@rpath/libA.dylib",
"App>@rpath/libB.dylib",
"libA.dylib>@rpath/libB.dylib",
"libA.dylib>@rpath/libA.dylib",
"libB.dylib>@rpath/libA.dylib",
]);
expect(edgeFor(trace, b, "@rpath/libA.dylib").candidates).toEqual([
expect.objectContaining({ source: "already-loaded", resolved_path: a }),
]);
expect(edgeFor(trace, a, "@rpath/libA.dylib").install_name_matches).toBe(
true,
);
});
it("leaves @executable_path undetermined for a library root and filters architectures", async () => {
const library = "Contents/Frameworks/libroot.dylib";
const trace = await traceDylibLoading(
memoryView({
[library]: parsed(
slice({ dependencies: [dependency("@executable_path/x.dylib")] }),
),
[MAIN]: executable(),
}),
{ roots: [library, MAIN], architecture: "x86_64" },
);
expect(trace.roots).toEqual([]);
expect(trace.coverage.roots_without_architecture).toEqual([library, MAIN]);
const arm = await traceDylibLoading(
memoryView({
[library]: parsed(
slice({ dependencies: [dependency("@executable_path/x.dylib")] }),
),
}),
{ roots: [library] },
);
expect(arm.edges[0]?.candidates).toEqual([
expect.objectContaining({
source: "executable_path",
outcome: "undetermined",
}),
]);
});
it("stops when cancelled", async () => {
const controller = new AbortController();
controller.abort();
await expect(
traceDylibLoading(memoryView({ [MAIN]: executable() }), {
roots: [MAIN],
signal: controller.signal,
}),
).rejects.toThrow();
});
});
+542
View File
@@ -0,0 +1,542 @@
import { z } from "zod";
import {
directoryOf,
expandPrefix,
joinPath,
resolveTreePath,
type Expansion,
} from "./dyldPaths.js";
import { digestSchema } from "./digests.js";
/** Normalized path below the analyzed root: no `.`/`..` or empty segments. */
const ROOT_RELATIVE_PATH =
/^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))(?!.*\/\/)(?!.*\/$)[^\\\0]+$/u;
const rootRelativePathSchema = z.string().min(1).regex(ROOT_RELATIVE_PATH);
/** Caller selection of process roots and one CPU slice. */
export const dylibResolutionInputSchema = z.strictObject({
roots: z.array(rootRelativePathSchema).min(1).optional(),
architecture: z.enum(["arm64", "arm64e", "x86_64"]).optional(),
});
const dependencyCommandSchema = z.enum([
"LC_LOAD_DYLIB",
"LC_LOAD_WEAK_DYLIB",
"LC_REEXPORT_DYLIB",
"LC_LAZY_LOAD_DYLIB",
"LC_LOAD_UPWARD_DYLIB",
]);
const dependencySchema = z.strictObject({
command: dependencyCommandSchema,
encoding: z.enum(["dylib_command", "dylib_use_command"]),
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(),
});
/** Observed dylib-loading facts of one Mach-O slice. */
export const machoSliceSchema = z.strictObject({
architecture: z.string().min(1),
file_type: z.enum(["execute", "dylib", "bundle", "other"]),
install_name: z.string().nullable(),
dependencies: z.array(dependencySchema),
rpaths: z.array(z.string()),
dyld_environment: z.array(z.string()),
code_signature_present: z.boolean(),
});
export type MachoDependency = z.infer<typeof dependencySchema>;
export type MachoSlice = z.infer<typeof machoSliceSchema>;
/** Header parse of one file; malformed and unsupported images keep their reason. */
export type MachoImageFacts =
| { readonly status: "parsed"; readonly slices: readonly MachoSlice[] }
| {
readonly status: "malformed" | "unsupported";
readonly reason: string;
}
| { readonly status: "not-mach-o" };
/** One observed path below the analyzed root. Symlink targets are not followed by the view. */
export type DylibTreeEntry =
| { readonly kind: "file" | "directory" }
| { readonly kind: "symlink"; readonly target: string };
/** Read-only, lazily probed view of the analyzed root. Paths are root-relative. */
export interface DylibTreeView {
entry(path: string): Promise<DylibTreeEntry | undefined>;
image(path: string): Promise<MachoImageFacts>;
}
const candidateSchema = z.strictObject({
path: z.string(),
source: z.enum([
"literal",
"executable_path",
"loader_path",
"rpath",
"already-loaded",
]),
rpath: z.string().nullable(),
rpath_owner: z.string().nullable(),
outcome: z.enum([
"resolved",
"absent",
"not-mach-o",
"malformed",
"architecture-missing",
"outside-target",
"escapes-target",
"undetermined",
]),
resolved_path: z.string().nullable(),
});
const edgeSchema = z.strictObject({
root: z.string(),
architecture: z.string(),
loader: z.string(),
via: z.array(z.string()),
...dependencySchema.shape,
candidates: z.array(candidateSchema),
resolution: z.strictObject({
status: z.enum(["resolved", "conditional", "unresolved", "undetermined"]),
image: z.string().nullable(),
}),
install_name_matches: z.boolean().nullable(),
});
const findingSchema = z.strictObject({
kind: z.enum([
"required-load-unresolved",
"weak-load-unresolved",
"earlier-rpath-candidate-absent",
"dyld-environment-present",
]),
edge_index: z.number().int().nonnegative().nullable(),
image: z.string().nullable(),
basis: z.literal("derived"),
explanation: z.string().min(1),
});
const imageSchema = z.strictObject({
path: z.string(),
sha256: digestSchema,
parse_status: z.enum(["parsed", "malformed", "unsupported"]),
reason: z.string().nullable(),
slices: z.array(machoSliceSchema.omit({ dependencies: true })),
});
/** Static dyld load-path resolution for every selected process root. */
export const dylibResolutionResultSchema = z.strictObject({
root_path: z.string(),
target_sha256: digestSchema,
roots: z.array(
z.strictObject({ image: z.string(), architecture: z.string() }),
),
images: z.array(imageSchema),
edges: z.array(edgeSchema),
findings: z.array(findingSchema),
coverage: z.strictObject({
status: z.enum(["complete", "partial"]),
unparsed_images: z.array(z.string()),
roots_without_architecture: z.array(z.string()),
}),
limitations: z.array(z.string().min(1)),
});
export type DylibResolutionResult = z.infer<typeof dylibResolutionResultSchema>;
type Candidate = z.infer<typeof candidateSchema>;
type Edge = z.infer<typeof edgeSchema>;
type Finding = z.infer<typeof findingSchema>;
/** Result before the adapter adds file digests and the analyzed root. */
export type DylibTrace = Omit<
DylibResolutionResult,
"root_path" | "target_sha256" | "images"
> & {
readonly images: readonly (Omit<
DylibResolutionResult["images"][number],
"sha256"
> & { readonly sha256?: never })[];
};
interface LoadedImage {
readonly slice: MachoSlice;
/** Images from the process root to this image; the rpath stack, outermost first. */
readonly chain: readonly string[];
}
interface ProcessContext {
readonly view: DylibTreeView;
readonly root: string;
readonly architecture: string;
readonly executable: string | null;
readonly loaded: Map<string, LoadedImage>;
readonly byInstallName: Map<string, string>;
readonly images: Map<string, MachoImageFacts>;
}
interface CandidateTemplate {
readonly expansion: Expansion;
readonly source: Candidate["source"];
readonly rpath: string | null;
readonly rpathOwner: string | null;
}
const candidateTemplates = (
dependency: MachoDependency,
loader: string,
context: ProcessContext,
): CandidateTemplate[] => {
const name = dependency.install_name;
if (!name.startsWith("@rpath/")) {
const source = name.startsWith("@executable_path")
? "executable_path"
: name.startsWith("@loader_path")
? "loader_path"
: "literal";
return [
{
expansion: expandPrefix(name, {
ownerDirectory: directoryOf(loader),
executable: context.executable,
}),
source,
rpath: null,
rpathOwner: null,
},
];
}
const rest = name.slice("@rpath/".length);
const owners = [...(context.loaded.get(loader)?.chain ?? [loader])].reverse();
return owners.flatMap((owner) =>
(context.loaded.get(owner)?.slice.rpaths ?? []).map((rpath) => {
const base = expandPrefix(rpath, {
ownerDirectory: directoryOf(owner),
executable: context.executable,
});
const expansion: Expansion = {
scope: base.scope,
path:
base.scope === "outside"
? `${base.path.replace(/\/+$/u, "")}/${rest}`
: joinPath(base.path, rest),
};
return { expansion, source: "rpath" as const, rpath, rpathOwner: owner };
}),
);
};
const evaluateCandidate = async (
template: CandidateTemplate,
context: ProcessContext,
): Promise<Candidate> => {
const base = {
path: template.expansion.path,
source: template.source,
rpath: template.rpath,
rpath_owner: template.rpathOwner,
};
if (template.expansion.scope === "outside")
return { ...base, outcome: "outside-target", resolved_path: null };
if (template.expansion.scope === "undetermined")
return { ...base, outcome: "undetermined", resolved_path: null };
const lookup = await resolveTreePath(context.view, template.expansion.path);
if (lookup.kind === "escapes")
return { ...base, outcome: "escapes-target", resolved_path: null };
if (lookup.kind !== "file")
return { ...base, outcome: "absent", resolved_path: null };
const facts = await imageFacts(context, lookup.path);
const outcome: Candidate["outcome"] =
facts.status === "not-mach-o"
? "not-mach-o"
: facts.status !== "parsed"
? "malformed"
: facts.slices.some(
({ architecture }) => architecture === context.architecture,
)
? "resolved"
: "architecture-missing";
return { ...base, outcome, resolved_path: lookup.path };
};
const imageFacts = async (
context: ProcessContext,
path: string,
): Promise<MachoImageFacts> => {
const known = context.images.get(path);
if (known !== undefined) return known;
const facts = await context.view.image(path);
context.images.set(path, facts);
return facts;
};
const resolution = (candidates: readonly Candidate[]): Edge["resolution"] => {
let external = false;
for (const candidate of candidates) {
if (candidate.outcome === "resolved")
return {
status: external ? "conditional" : "resolved",
image: candidate.resolved_path,
};
if (
candidate.outcome === "outside-target" ||
candidate.outcome === "escapes-target" ||
candidate.outcome === "undetermined"
)
external = true;
}
return { status: external ? "undetermined" : "unresolved", image: null };
};
const resolveDependency = async (
dependency: MachoDependency,
loader: string,
context: ProcessContext,
): Promise<Edge> => {
const chain = context.loaded.get(loader)?.chain ?? [loader];
const loaded = context.byInstallName.get(dependency.install_name);
const candidates: Candidate[] = [];
if (loaded !== undefined)
candidates.push({
path: dependency.install_name,
source: "already-loaded",
rpath: null,
rpath_owner: null,
outcome: "resolved",
resolved_path: loaded,
});
else
for (const template of candidateTemplates(dependency, loader, context)) {
const candidate = await evaluateCandidate(template, context);
candidates.push(candidate);
// dyld stops at the first loadable candidate.
if (candidate.outcome === "resolved") break;
}
const resolved = resolution(candidates);
const image =
resolved.image === null ? undefined : context.images.get(resolved.image);
const slice =
image?.status === "parsed"
? image.slices.find(
({ architecture }) => architecture === context.architecture,
)
: undefined;
return {
root: context.root,
architecture: context.architecture,
loader,
via: [...chain],
...dependency,
candidates,
resolution: resolved,
install_name_matches:
slice === undefined || slice.install_name === null
? null
: slice.install_name === dependency.install_name,
};
};
/**
* Follow dyld4's dependents-first order: resolve every dependency of one
* image, then recurse into the images it newly loaded, in load-command order.
*/
const traceProcess = async (
context: ProcessContext,
rootSlice: MachoSlice,
signal?: AbortSignal,
): Promise<Edge[]> => {
const edges: Edge[] = [];
context.loaded.set(context.root, { slice: rootSlice, chain: [context.root] });
if (rootSlice.install_name !== null)
context.byInstallName.set(rootSlice.install_name, context.root);
const pending = [context.root];
while (pending.length > 0) {
signal?.throwIfAborted();
const loader = pending.pop() ?? "";
const current = context.loaded.get(loader);
if (current === undefined) continue;
const created: string[] = [];
for (const dependency of current.slice.dependencies) {
const edge = await resolveDependency(dependency, loader, context);
edges.push(edge);
const image = edge.resolution.image;
if (image === null || context.loaded.has(image)) continue;
const facts = context.images.get(image);
const slice =
facts?.status === "parsed"
? facts.slices.find(
({ architecture }) => architecture === context.architecture,
)
: undefined;
if (slice === undefined) continue;
context.loaded.set(image, { slice, chain: [...current.chain, image] });
if (
slice.install_name !== null &&
!context.byInstallName.has(slice.install_name)
)
context.byInstallName.set(slice.install_name, image);
created.push(image);
}
pending.push(...created.reverse());
}
return edges;
};
/** Trace dyld load paths from each process root through the analyzed root. */
export const traceDylibLoading = async (
view: DylibTreeView,
request: {
readonly roots: readonly string[];
readonly architecture?: string;
readonly signal?: AbortSignal;
},
): Promise<DylibTrace> => {
const images = new Map<string, MachoImageFacts>();
const roots: DylibTrace["roots"][number][] = [];
const edges: Edge[] = [];
const withoutArchitecture: string[] = [];
for (const root of request.roots) {
const facts = await view.image(root);
images.set(root, facts);
if (facts.status !== "parsed") continue;
const slices = facts.slices.filter(
({ architecture }) =>
request.architecture === undefined ||
architecture === request.architecture,
);
if (slices.length === 0) withoutArchitecture.push(root);
for (const slice of slices) {
roots.push({ image: root, architecture: slice.architecture });
edges.push(
...(await traceProcess(
{
view,
root,
architecture: slice.architecture,
executable: slice.file_type === "execute" ? root : null,
loaded: new Map(),
byInstallName: new Map(),
images,
},
slice,
request.signal,
)),
);
}
}
const reported = [...images.entries()]
.filter(([, facts]) => facts.status !== "not-mach-o")
.sort(([left], [right]) => compare(left, right));
const unparsed = reported
.filter(([, facts]) => facts.status !== "parsed")
.map(([path]) => path);
return {
roots,
images: reported.map(([path, facts]) => ({
path,
parse_status:
facts.status === "not-mach-o" ? "unsupported" : facts.status,
reason:
facts.status === "parsed" || facts.status === "not-mach-o"
? null
: facts.reason,
slices:
facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [],
})),
edges,
findings: deriveFindings(edges, roots, images),
coverage: {
status:
unparsed.length === 0 && withoutArchitecture.length === 0
? "complete"
: "partial",
unparsed_images: unparsed,
roots_without_architecture: withoutArchitecture,
},
limitations: DYLIB_RESOLUTION_LIMITATIONS,
};
};
const withoutDependencies = (
slice: MachoSlice,
): Omit<MachoSlice, "dependencies"> => ({
architecture: slice.architecture,
file_type: slice.file_type,
install_name: slice.install_name,
rpaths: slice.rpaths,
dyld_environment: slice.dyld_environment,
code_signature_present: slice.code_signature_present,
});
const deriveFindings = (
edges: readonly Edge[],
roots: DylibTrace["roots"],
images: ReadonlyMap<string, MachoImageFacts>,
): Finding[] => {
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.`,
});
const resolvedAt = edge.candidates.findIndex(
({ outcome }) => outcome === "resolved",
);
const earlier = edge.candidates
.slice(0, Math.max(resolvedAt, 0))
.filter(
({ source, outcome }) => source === "rpath" && outcome === "absent",
)
.map(({ path }) => path);
if (resolvedAt > 0 && earlier.length > 0)
findings.push({
kind: "earlier-rpath-candidate-absent",
edge_index: index,
image: edge.loader,
basis: "derived",
explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
});
});
for (const { image, architecture } of roots) {
const facts = images.get(image);
const environment =
facts?.status === "parsed"
? (facts.slices.find((slice) => slice.architecture === architecture)
?.dyld_environment ?? [])
: [];
if (environment.length > 0)
findings.push({
kind: "dyld-environment-present",
edge_index: null,
image,
basis: "derived",
explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`,
});
}
return findings;
};
const DYLIB_RESOLUTION_LIMITATIONS = [
"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.",
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack of the first chain; a later request whose install name matches an already loaded image reuses it.",
"@loader_path uses each image's symlink-resolved path within the analyzed root.",
"Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.",
];
const compare = (left: string, right: string): number =>
left < right ? -1 : left > right ? 1 : 0;
+1 -1
View File
@@ -30,7 +30,7 @@ interface GeneratedPayload {
}
const GENERATED_PAYLOAD_JSON =
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
if (!isGeneratedPayload(generatedPayload))
throw new TypeError("Generated MCP catalog payload is invalid");
+1 -1
View File
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
skillVersion: "27",
skillVersion: "28",
} as const;
@@ -0,0 +1,167 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect } from "vitest";
import {
FILE_TYPE,
LC,
dylibCommand,
machoImage,
rpathCommand,
} from "../../../src/artifacts/MachoImage.fixture.js";
import { dylibResolutionResultSchema } from "../../../src/domain/dylibResolution.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const ENVIRONMENT = { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" };
const writeFiles = async (
root: string,
files: Readonly<Record<string, string | Uint8Array>>,
): Promise<void> => {
for (const [path, content] of Object.entries(files)) {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), content);
}
};
const fixtureApp = async (): Promise<string> => {
const app = join(
await createTestTempDirectory("rea-dylib-cli-"),
"Fixture.app",
);
await writeFiles(app, {
"Contents/Info.plist":
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
"Contents/MacOS/App": machoImage({
commands: [
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
],
}),
"Contents/Frameworks/libcore.dylib": machoImage({
fileType: FILE_TYPE.dylib,
commands: [dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib")],
}),
"Contents/Helpers/tool": machoImage({
commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib")],
}),
});
return app;
};
describe("trace-dylib-resolution CLI", () => {
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
const app = await fixtureApp();
const result = await cli.run({
arguments: ["trace-dylib-resolution", app, "--json"],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
(result.json as { normalized_result: unknown }).normalized_result,
);
expect(trace.root_path).toBe(app);
expect(trace.roots.map(({ image }) => image)).toEqual([
"Contents/Helpers/tool",
"Contents/MacOS/App",
]);
expect(
trace.edges.map(({ loader, install_name: name, resolution }) => [
loader,
name,
resolution.status,
resolution.image,
]),
).toEqual([
[
"Contents/Helpers/tool",
"@loader_path/libnone.dylib",
"unresolved",
null,
],
[
"Contents/MacOS/App",
"@rpath/libcore.dylib",
"resolved",
"Contents/Frameworks/libcore.dylib",
],
["Contents/MacOS/App", "@rpath/libgone.dylib", "unresolved", null],
]);
expect(trace.findings.map(({ kind }) => kind)).toEqual([
"required-load-unresolved",
"weak-load-unresolved",
]);
expect(
trace.images.every(({ sha256 }) => /^[0-9a-f]{64}$/u.test(sha256)),
).toBe(true);
});
cliTest(
"narrows roots and architecture, and rejects escaping roots",
async ({ cli }) => {
const app = await fixtureApp();
const narrowed = await cli.run({
arguments: [
"trace-dylib-resolution",
app,
"--root",
"Contents/MacOS/App",
"--architecture",
"arm64",
"--json",
],
environment: ENVIRONMENT,
});
expect(narrowed.exitCode).toBe(0);
expect(
(narrowed.json as { normalized_result: { roots: unknown } })
.normalized_result.roots,
).toEqual([{ image: "Contents/MacOS/App", architecture: "arm64" }]);
for (const root of ["../outside", "Contents/Info.plist"]) {
const rejected = await cli.run({
arguments: ["trace-dylib-resolution", app, "--root", root, "--json"],
environment: ENVIRONMENT,
});
expect(rejected.exitCode).toBe(1);
expect(rejected.json).toMatchObject({ error: "Analysis failed" });
}
},
);
cliTest(
"resolves a standalone Mach-O against its own directory",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-dylib-standalone-");
await writeFiles(directory, {
tool: machoImage({
commands: [
dylibCommand(LC.LOAD_DYLIB, "@loader_path/lib/libhelper.dylib"),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/../escape.dylib"),
],
}),
"lib/libhelper.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
"unrelated.txt": "not traversed",
});
const result = await cli.run({
arguments: [
"trace-dylib-resolution",
join(directory, "tool"),
"--json",
],
environment: ENVIRONMENT,
});
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
const trace = dylibResolutionResultSchema.parse(
(result.json as { normalized_result: unknown }).normalized_result,
);
expect(trace.roots).toEqual([{ image: "tool", architecture: "arm64" }]);
expect(trace.edges.map(({ resolution }) => resolution)).toEqual([
{ status: "resolved", image: "lib/libhelper.dylib" },
{ status: "undetermined", image: null },
]);
},
);
});
@@ -0,0 +1,125 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { z } from "zod";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import {
FILE_TYPE,
LC,
dylibCommand,
machoImage,
rpathCommand,
} from "../../../src/artifacts/MachoImage.fixture.js";
import { dylibResolutionResultSchema } from "../../../src/domain/dylibResolution.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const withClient = async (
verify: (client: Client) => Promise<void>,
): Promise<void> => {
const session = createTestBinarySession(new ArtifactProvider());
const server = createServer(session, session);
const client = new Client({ name: "dylib-mcp-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
await verify(client);
} finally {
await client.close();
await server.close();
}
};
const structuredResult = (value: unknown): unknown =>
z.object({ result: z.unknown() }).parse(value).result;
it("traces dylib resolution for an opened app bundle and rejects a changed target", async () => {
const app = join(
await createTestTempDirectory("rea-dylib-mcp-"),
"Fixture.app",
);
await mkdir(join(app, "Contents/MacOS"), { recursive: true });
await mkdir(join(app, "Contents/Frameworks"), { recursive: true });
await writeFile(
join(app, "Contents/Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
);
const executable = join(app, "Contents/MacOS/App");
await writeFile(
executable,
machoImage({
commands: [
rpathCommand("@executable_path/../Frameworks"),
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
],
}),
);
await writeFile(
join(app, "Contents/Frameworks/libcore.dylib"),
machoImage({ fileType: FILE_TYPE.dylib }),
);
await withClient(async (client) => {
const opened = await client.callTool({
name: "open_binary",
arguments: { path: app },
});
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const called = await client.callTool({
name: "trace_dylib_resolution",
arguments: {},
});
expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe(
true,
);
const trace = dylibResolutionResultSchema.parse(
structuredResult(called.structuredContent),
);
expect(trace.edges).toEqual([
expect.objectContaining({
install_name: "@rpath/libcore.dylib",
resolution: {
status: "resolved",
image: "Contents/Frameworks/libcore.dylib",
},
}),
]);
const escaping = await client.callTool({
name: "trace_dylib_resolution",
arguments: { roots: ["Contents/../../outside"] },
});
expect(escaping.isError).toBe(true);
await writeFile(executable, machoImage({}));
const changed = await client.callTool({
name: "trace_dylib_resolution",
arguments: {},
});
expect(changed.isError).toBe(true);
expect(JSON.stringify(changed.structuredContent)).toContain("integrity");
});
});
it("rejects targets that are not Mach-O", async () => {
const directory = await createTestTempDirectory("rea-dylib-mcp-plist-");
const path = join(directory, "Info.plist");
await writeFile(path, "<plist><dict/></plist>");
await withClient(async (client) => {
const opened = await client.callTool({
name: "open_binary",
arguments: { path },
});
expect(opened.isError).not.toBe(true);
const called = await client.callTool({
name: "trace_dylib_resolution",
arguments: {},
});
expect(called.isError).toBe(true);
expect(JSON.stringify(called.structuredContent)).toContain("unavailable");
});
});