[GH-ISSUE #5696] [Bug] trace-dylib-resolution skips a loadable platform-less dylib and its required dependencies #3445

Closed
opened 2026-10-31 00:40:34 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 20, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5696

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

trace-dylib-resolution skips the dependencies of a parsed in-bundle dylib that has no platform load command. The candidate is undetermined, the edge has image: null, and the trace reports complete coverage with no findings even when that dylib's required child is absent. The returned edge does expose uncertainty; this report does not treat complete coverage as a successful-launch verdict.

An independent verifier and the submitter ran self-built arm64 fixtures through the built CLI and actual dyld. With the child present, the platform-less dylib loads and the program prints 3. With it absent, dyld aborts naming the missing child, but REA omits that child entirely.

Reproduction

From the built source checkout on macOS arm64, with Apple clang, Python 3, codesign, and otool, run this block. It builds disposable local binaries, physically removes the platform load command without changing later file offsets, and re-signs the modified dylib. REA uses an isolated HOME/XDG/TMPDIR profile. Only the self-built fixture executable is executed for the dyld comparison.

work_dir="$(mktemp -d)"
mkdir -p "$work_dir/Probe.app/Contents/MacOS" "$work_dir/Probe.app/Contents/Frameworks" "$work_dir/link"
mkdir -p "$work_dir/home" "$work_dir/cfg" "$work_dir/data" "$work_dir/cache" "$work_dir/state" "$work_dir/tmp"
cat > "$work_dir/missing.c" <<'C'
int missing(void) { return 3; }
C
cat > "$work_dir/a.c" <<'C'
extern int missing(void);
int a(void) { return missing(); }
C
cat > "$work_dir/main.c" <<'C'
#include <stdio.h>
extern int a(void);
int main(void) { printf("%d\n", a()); return 0; }
C
cat > "$work_dir/Probe.app/Contents/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>probe</string>
<key>CFBundleIdentifier</key><string>example.probe</string>
<key>CFBundlePackageType</key><string>APPL</string>
</dict></plist>
PLIST
clang -dynamiclib -install_name @rpath/libMissing.dylib -o "$work_dir/link/libMissing.dylib" "$work_dir/missing.c"
clang -dynamiclib -install_name @rpath/libA.dylib -o "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" "$work_dir/a.c" -L"$work_dir/link" -lMissing -Wl,-rpath,@loader_path
clang -o "$work_dir/Probe.app/Contents/MacOS/probe" "$work_dir/main.c" -L"$work_dir/Probe.app/Contents/Frameworks" -lA -Wl,-rpath,@executable_path/../Frameworks
cp "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" "$work_dir/link/libA-control.dylib"
python3 - "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" <<'PY'
import pathlib, struct, sys
path = pathlib.Path(sys.argv[1])
data = bytearray(path.read_bytes())
assert struct.unpack_from('<I', data)[0] == 0xfeedfacf
count, total = struct.unpack_from('<II', data, 16)
offset = 32
kept = []
removed = 0
for _ in range(count):
    command, size = struct.unpack_from('<II', data, offset)
    if command in (0x32, 0x24, 0x25, 0x2f, 0x30):
        removed += 1
    else:
        kept.append(data[offset:offset + size])
    offset += size
assert removed > 0
commands = b''.join(kept)
data[32:32 + total] = commands + bytes(total - len(commands))
struct.pack_into('<II', data, 16, len(kept), len(commands))
path.write_bytes(data)
PY
codesign --force --sign - "$work_dir/Probe.app/Contents/Frameworks/libA.dylib"
otool -l "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" > "$work_dir/platformless-load-commands.txt"
trace_app() {
  env -i PATH="$PATH" HOME="$work_dir/home" XDG_CONFIG_HOME="$work_dir/cfg" XDG_DATA_HOME="$work_dir/data" XDG_CACHE_HOME="$work_dir/cache" XDG_STATE_HOME="$work_dir/state" TMPDIR="$work_dir/tmp" REA_LOG_LEVEL=silent node scripts/rea.mjs trace-dylib-resolution "$work_dir/Probe.app" --json
}
ulimit -c 0
DYLD_PRINT_LIBRARIES=1 "$work_dir/Probe.app/Contents/MacOS/probe" > "$work_dir/platformless-missing.stdout" 2> "$work_dir/platformless-missing.stderr" || true
trace_app > "$work_dir/platformless-missing.json"
cp "$work_dir/link/libMissing.dylib" "$work_dir/Probe.app/Contents/Frameworks/libMissing.dylib"
DYLD_PRINT_LIBRARIES=1 "$work_dir/Probe.app/Contents/MacOS/probe" > "$work_dir/platformless-present.stdout" 2> "$work_dir/platformless-present.stderr"
trace_app > "$work_dir/platformless-present.json"
cp "$work_dir/link/libA-control.dylib" "$work_dir/Probe.app/Contents/Frameworks/libA.dylib"
rm "$work_dir/Probe.app/Contents/Frameworks/libMissing.dylib"
DYLD_PRINT_LIBRARIES=1 "$work_dir/Probe.app/Contents/MacOS/probe" > "$work_dir/control-missing.stdout" 2> "$work_dir/control-missing.stderr" || true
trace_app > "$work_dir/control-missing.json"

Compare normalized_result in the three JSON outputs. The independent verifier also ran the platform-bearing/present-child control. The submitter replayed all four variants once at the baseline and observed the same outcomes below.

Expected behavior

Trace the loadable in-bundle image and its ordinary required dependencies: report the missing child when absent and traverse it when present. If platform compatibility cannot be established, preserve that limitation and incomplete graph coverage rather than claiming all relevant dependency records were examined.

The public tool contract promises each-load tracing and unresolved-required-load findings. The Apple guide describes in-root resolution and traversal. These dependencies use concrete in-bundle rpaths, outside the documented relative/leaf-name limitation. Completeness guidance ties coverage to examined records.

Impact and scope

P2: a bounded input class loses transitive dependency evidence and an actionable required-load failure. This was observed on self-built arm64 macOS images; production frequency and other platforms/architectures were not measured. No security-boundary crossing or data loss was demonstrated.

Workaround: inspect the skipped dylib's dependency/rpath commands manually and analyze its children in the original process-root search context. The independent verifier observed that tracing libA directly finds this fixture's missing child; standalone tracing does not generally retain inherited app-root rpaths.

REA version

rea-agents 6.7.0, source commit 9d8e7803326a382cbbb3ffdb900f5e93e5442d68, built source CLI. Fetched and source-rechecked origin/main at 949966712e68d8857a50a165ecddf04c2d7e6d3b: none of the cited contract, documentation, resolver, or the two Mach-O/dylib reader files changed since the baseline. No runtime replay on that newer revision is claimed.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1 arm64; Apple clang 21.0.0. No Hopper/Ghidra provider is involved.

Target and artifact context

Self-built thin arm64 Mach-O app: probe loads @rpath/libA.dylib; libA loads @rpath/libMissing.dylib. The executable retains macOS platform 1. The modified libA has no LC_BUILD_VERSION or LC_VERSION_MIN_*, remains parsed with platforms: [], and is signed ad hoc. The child is supplied or omitted without changing libA.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Observed CLI/dyld comparison:

libA variant Required child dyld REA edges REA findings
Platform command retained Absent SIGABRT; missing child 4 required-load-unresolved
Platform command retained Present Exit 0; prints 3 5 None
Platform command removed Absent SIGABRT; same missing child 2 None
Platform command removed Present Exit 0; prints 3; both dylibs loaded 2 None

All REA calls exit 0. Both platform-less results contain only the executable's two dependency edges; neither includes libA's dependency edges. The candidate retains resolved_path: "Contents/Frameworks/libA.dylib", but the edge has resolution: {"status":"undetermined","image":null}. Both report:

{"coverage":{"status":"complete","unparsed_images":[],"roots_without_architecture":[]},"findings":[]}

The missing-child dyld diagnostic is Library not loaded: @rpath/libMissing.dylib, referenced from libA. The present-child execution establishes that the platform-less image is loadable in this tested process. Evidence provenance: observed CLI, otool and dyld comparisons; local verification: independently reproduced and replayed by the submitter.

Typed outcome or recovery expected

The app-root trace should include libA's dependency edges. With the child absent, return required-load-unresolved; with it present, traverse both images and resolve the child. Retain uncertainty for genuinely external/unsupported candidates and disclose any unexamined dependency graph. A generic coverage change alone would not restore the omitted child evidence.

Analysis or suspected cause

Source-derived reasoning: candidate evaluation and platform loadability turn an empty platform list into an undetermined candidate. Traversal skips an edge whose resolved image is null. Coverage and limitations do not account for this skipped parsed image. This is REA resolver logic; no bundled dependency is identified as the cause.

Additional context

Prepared with AI assistance. Related #5694 requests missing documentation for candidate outcomes; it does not cover this traversal defect. #5637 concerns absolute path containment, while this fixture uses ordinary in-bundle rpaths. Closed #5551 and merged PR #5650 concern lazy-info dependency parsing; this fixture uses ordinary LC_LOAD_DYLIB commands that are parsed before traversal is skipped. #5191 concerns format/slice/platform presentation, rather than platform-less candidate loadability.

Last known good version: NOT_ENOUGH_INFO; no earlier-version comparison was run. No fix or successful restoration has been tested.

Originally created by @coygeek on GitHub (Oct 20, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5696 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior `trace-dylib-resolution` skips the dependencies of a parsed in-bundle dylib that has no platform load command. The candidate is `undetermined`, the edge has `image: null`, and the trace reports complete coverage with no findings even when that dylib's required child is absent. The returned edge does expose uncertainty; this report does not treat complete coverage as a successful-launch verdict. An independent verifier and the submitter ran self-built arm64 fixtures through the built CLI and actual dyld. With the child present, the platform-less dylib loads and the program prints `3`. With it absent, dyld aborts naming the missing child, but REA omits that child entirely. ### Reproduction From the built source checkout on macOS arm64, with Apple clang, Python 3, `codesign`, and `otool`, run this block. It builds disposable local binaries, physically removes the platform load command without changing later file offsets, and re-signs the modified dylib. REA uses an isolated HOME/XDG/TMPDIR profile. Only the self-built fixture executable is executed for the dyld comparison. ```sh work_dir="$(mktemp -d)" mkdir -p "$work_dir/Probe.app/Contents/MacOS" "$work_dir/Probe.app/Contents/Frameworks" "$work_dir/link" mkdir -p "$work_dir/home" "$work_dir/cfg" "$work_dir/data" "$work_dir/cache" "$work_dir/state" "$work_dir/tmp" cat > "$work_dir/missing.c" <<'C' int missing(void) { return 3; } C cat > "$work_dir/a.c" <<'C' extern int missing(void); int a(void) { return missing(); } C cat > "$work_dir/main.c" <<'C' #include <stdio.h> extern int a(void); int main(void) { printf("%d\n", a()); return 0; } C cat > "$work_dir/Probe.app/Contents/Info.plist" <<'PLIST' <?xml version="1.0" encoding="UTF-8"?> <plist version="1.0"><dict> <key>CFBundleExecutable</key><string>probe</string> <key>CFBundleIdentifier</key><string>example.probe</string> <key>CFBundlePackageType</key><string>APPL</string> </dict></plist> PLIST clang -dynamiclib -install_name @rpath/libMissing.dylib -o "$work_dir/link/libMissing.dylib" "$work_dir/missing.c" clang -dynamiclib -install_name @rpath/libA.dylib -o "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" "$work_dir/a.c" -L"$work_dir/link" -lMissing -Wl,-rpath,@loader_path clang -o "$work_dir/Probe.app/Contents/MacOS/probe" "$work_dir/main.c" -L"$work_dir/Probe.app/Contents/Frameworks" -lA -Wl,-rpath,@executable_path/../Frameworks cp "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" "$work_dir/link/libA-control.dylib" python3 - "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" <<'PY' import pathlib, struct, sys path = pathlib.Path(sys.argv[1]) data = bytearray(path.read_bytes()) assert struct.unpack_from('<I', data)[0] == 0xfeedfacf count, total = struct.unpack_from('<II', data, 16) offset = 32 kept = [] removed = 0 for _ in range(count): command, size = struct.unpack_from('<II', data, offset) if command in (0x32, 0x24, 0x25, 0x2f, 0x30): removed += 1 else: kept.append(data[offset:offset + size]) offset += size assert removed > 0 commands = b''.join(kept) data[32:32 + total] = commands + bytes(total - len(commands)) struct.pack_into('<II', data, 16, len(kept), len(commands)) path.write_bytes(data) PY codesign --force --sign - "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" otool -l "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" > "$work_dir/platformless-load-commands.txt" trace_app() { env -i PATH="$PATH" HOME="$work_dir/home" XDG_CONFIG_HOME="$work_dir/cfg" XDG_DATA_HOME="$work_dir/data" XDG_CACHE_HOME="$work_dir/cache" XDG_STATE_HOME="$work_dir/state" TMPDIR="$work_dir/tmp" REA_LOG_LEVEL=silent node scripts/rea.mjs trace-dylib-resolution "$work_dir/Probe.app" --json } ulimit -c 0 DYLD_PRINT_LIBRARIES=1 "$work_dir/Probe.app/Contents/MacOS/probe" > "$work_dir/platformless-missing.stdout" 2> "$work_dir/platformless-missing.stderr" || true trace_app > "$work_dir/platformless-missing.json" cp "$work_dir/link/libMissing.dylib" "$work_dir/Probe.app/Contents/Frameworks/libMissing.dylib" DYLD_PRINT_LIBRARIES=1 "$work_dir/Probe.app/Contents/MacOS/probe" > "$work_dir/platformless-present.stdout" 2> "$work_dir/platformless-present.stderr" trace_app > "$work_dir/platformless-present.json" cp "$work_dir/link/libA-control.dylib" "$work_dir/Probe.app/Contents/Frameworks/libA.dylib" rm "$work_dir/Probe.app/Contents/Frameworks/libMissing.dylib" DYLD_PRINT_LIBRARIES=1 "$work_dir/Probe.app/Contents/MacOS/probe" > "$work_dir/control-missing.stdout" 2> "$work_dir/control-missing.stderr" || true trace_app > "$work_dir/control-missing.json" ``` Compare `normalized_result` in the three JSON outputs. The independent verifier also ran the platform-bearing/present-child control. The submitter replayed all four variants once at the baseline and observed the same outcomes below. ### Expected behavior Trace the loadable in-bundle image and its ordinary required dependencies: report the missing child when absent and traverse it when present. If platform compatibility cannot be established, preserve that limitation and incomplete graph coverage rather than claiming all relevant dependency records were examined. The [public tool contract](https://github.com/morluto/rea/blob/9d8e7803326a382cbbb3ffdb900f5e93e5442d68/src/contracts/artifactToolContracts.ts#L117-L119) promises each-load tracing and unresolved-required-load findings. The [Apple guide](https://github.com/morluto/rea/blob/9d8e7803326a382cbbb3ffdb900f5e93e5442d68/docs/apple-application-analysis.md#L161-L229) describes in-root resolution and traversal. These dependencies use concrete in-bundle rpaths, outside the documented relative/leaf-name limitation. [Completeness guidance](https://github.com/morluto/rea/blob/9d8e7803326a382cbbb3ffdb900f5e93e5442d68/docs/tool-design.md#L170-L176) ties coverage to examined records. ### Impact and scope P2: a bounded input class loses transitive dependency evidence and an actionable required-load failure. This was observed on self-built arm64 macOS images; production frequency and other platforms/architectures were not measured. No security-boundary crossing or data loss was demonstrated. Workaround: inspect the skipped dylib's dependency/rpath commands manually and analyze its children in the original process-root search context. The independent verifier observed that tracing libA directly finds this fixture's missing child; standalone tracing does not generally retain inherited app-root rpaths. ### REA version `rea-agents` 6.7.0, source commit `9d8e7803326a382cbbb3ffdb900f5e93e5442d68`, built source CLI. Fetched and source-rechecked `origin/main` at `949966712e68d8857a50a165ecddf04c2d7e6d3b`: none of the cited contract, documentation, resolver, or the two Mach-O/dylib reader files changed since the baseline. No runtime replay on that newer revision is claimed. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1 arm64; Apple clang 21.0.0. No Hopper/Ghidra provider is involved. ### Target and artifact context Self-built thin arm64 Mach-O app: `probe` loads `@rpath/libA.dylib`; libA loads `@rpath/libMissing.dylib`. The executable retains macOS platform 1. The modified libA has no `LC_BUILD_VERSION` or `LC_VERSION_MIN_*`, remains parsed with `platforms: []`, and is signed ad hoc. The child is supplied or omitted without changing libA. ### Interface CLI ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Observed CLI/dyld comparison: | libA variant | Required child | dyld | REA edges | REA findings | | --- | --- | --- | --- | --- | | Platform command retained | Absent | SIGABRT; missing child | 4 | `required-load-unresolved` | | Platform command retained | Present | Exit 0; prints `3` | 5 | None | | Platform command removed | Absent | SIGABRT; same missing child | 2 | None | | Platform command removed | Present | Exit 0; prints `3`; both dylibs loaded | 2 | None | All REA calls exit 0. Both platform-less results contain only the executable's two dependency edges; neither includes libA's dependency edges. The candidate retains `resolved_path: "Contents/Frameworks/libA.dylib"`, but the edge has `resolution: {"status":"undetermined","image":null}`. Both report: ```json {"coverage":{"status":"complete","unparsed_images":[],"roots_without_architecture":[]},"findings":[]} ``` The missing-child dyld diagnostic is `Library not loaded: @rpath/libMissing.dylib`, referenced from libA. The present-child execution establishes that the platform-less image is loadable in this tested process. Evidence provenance: observed CLI, otool and dyld comparisons; local verification: independently reproduced and replayed by the submitter. ### Typed outcome or recovery expected The app-root trace should include libA's dependency edges. With the child absent, return `required-load-unresolved`; with it present, traverse both images and resolve the child. Retain uncertainty for genuinely external/unsupported candidates and disclose any unexamined dependency graph. A generic coverage change alone would not restore the omitted child evidence. ### Analysis or suspected cause Source-derived reasoning: [candidate evaluation and platform loadability](https://github.com/morluto/rea/blob/9d8e7803326a382cbbb3ffdb900f5e93e5442d68/src/domain/apple/dylibResolution.ts#L305-L355) turn an empty platform list into an undetermined candidate. [Traversal](https://github.com/morluto/rea/blob/9d8e7803326a382cbbb3ffdb900f5e93e5442d68/src/domain/apple/dylibResolution.ts#L543-L544) skips an edge whose resolved image is null. [Coverage and limitations](https://github.com/morluto/rea/blob/9d8e7803326a382cbbb3ffdb900f5e93e5442d68/src/domain/apple/dylibResolution.ts#L661-L678) do not account for this skipped parsed image. This is REA resolver logic; no bundled dependency is identified as the cause. ### Additional context Prepared with AI assistance. Related #5694 requests missing documentation for candidate outcomes; it does not cover this traversal defect. #5637 concerns absolute path containment, while this fixture uses ordinary in-bundle rpaths. Closed #5551 and merged PR #5650 concern lazy-info dependency parsing; this fixture uses ordinary `LC_LOAD_DYLIB` commands that are parsed before traversal is skipped. #5191 concerns format/slice/platform presentation, rather than platform-less candidate loadability. Last known good version: NOT_ENOUGH_INFO; no earlier-version comparison was run. No fix or successful restoration has been tested.
Author
Owner

@matbrgz commented on GitHub (Oct 20, 2026):

Cross-referencing with canonical issue #5362 (Dylib path expansion rejects line terminators in dependency names and rpaths) and sibling dylib resolution reports #5551, #5637, #5649.

Consolidated under Cluster BC (Mach-O Dylib Resolution & Dependency Tracing) in tracker #4330.

<!-- gh-comment-id:6304473445 --> @matbrgz commented on GitHub (Oct 20, 2026): Cross-referencing with canonical issue #5362 (`Dylib path expansion rejects line terminators in dependency names and rpaths`) and sibling dylib resolution reports #5551, #5637, #5649. Consolidated under **Cluster BC (Mach-O Dylib Resolution & Dependency Tracing)** in tracker #4330.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3445
No description provided.