# dylib-hijack-scan

Scan macOS for applications susceptible to (or already victims of) dylib hijacking - weak-dylib and rpath-order hijacks - and separate ordinary hijacks from privilege-escalation hijacks by comparing who can write each slot against who loads it. Pure standard library plus native codesign and ls; nothing to install. Produces a full per-app inventory report (every binary, every loaded dylib, marked hijackable / protected / clean) with a who-can-plant column and an elevation-only filter. Use when asked to "scan for dylib hijacking", "is this app hijackable", "check my Mac for dylib injection", "audit rpath load order", "find a dylib privilege escalation", or when handed a .app bundle to assess. macOS only.

- **Kind:** skill
- **Source:** https://github.com/forefy/.context
- **Page:** https://forefy.com/skills/4d6817e4-1109-4250-96ee-99bba277aa81
- **API (JSON + files):** https://forefy.com/api/asr/4d6817e4-1109-4250-96ee-99bba277aa81

---

## SKILL.md

---
name: dylib-hijack-scan
description: >
  Scan macOS for applications susceptible to (or already victims of) dylib hijacking - weak-dylib and rpath-order hijacks - and separate ordinary hijacks from privilege-escalation hijacks by comparing who can write each slot against who loads it. Pure standard library plus native codesign and ls; nothing to install. Produces a full per-app inventory report (every binary, every loaded dylib, marked hijackable / protected / clean) with a who-can-plant column and an elevation-only filter. Use when asked to "scan for dylib hijacking", "is this app hijackable", "check my Mac for dylib injection", "audit rpath load order", "find a dylib privilege escalation", or when handed a .app bundle to assess. macOS only.
---

# Dylib Hijack Scan

## Identity

macOS dylib-hijacking auditor. Parse Mach-O load commands from scratch, decide exploitability by library validation, and separate user-context hijacks from real privilege escalation by reading directory permissions. Read/query only - never plant, modify, or delete. Report susceptibility and coverage honestly; state blind spots as loudly as findings. Ambiguous slot is not a finding.

---

## When to use

Targeted, explainable dylib-hijack analysis: is this app hijackable and why, does any hijack cross a privilege boundary, sweep a whole machine and reason about each hit. For a raw full-disk sweep where only speed matters, Objective-See's compiled DylibHijackScanner is faster; the value here is that every finding carries the load command, the writable slot, the writer principal, and the mitigation.

## The two vulnerability classes

1. Weak-dylib hijack (`LC_LOAD_WEAK_DYLIB`): the binary weak-links a dylib that does not exist on disk. Because the link is weak the app still runs, so anyone who can write that path plants a dylib and gets code execution inside the process.
2. Rpath-order hijack (`@rpath/...` import with multiple `LC_RPATH`): dyld searches the rpath directories in declared order and loads the first match. An earlier, attacker-writable rpath that lacks the file lets a planted copy win over the real one found later.

## The mitigation that decides exploitability

Library validation. Under the hardened runtime, absent the `com.apple.security.cs.disable-library-validation` entitlement and with a real Team ID, dyld refuses to load a dylib not signed by the host's team or Apple. A plantable slot on such a host is not practically exploitable. Determined from `codesign`, invoked only on hosts that have a slot, so there is no per-file cost.

## Elevation: ordinary hijack vs privilege escalation

The axis that matters is whether a principal who can write the slot is less privileged than the one who loads it.

- Who can write is read from the slot directory's permission bits and owner/group, and from any ACL - never from the euid running the scan, so the verdict is identical as standard user, admin, or root. Classes: `world` (any user), `anylocal` (group staff, gid 20, meaning any local user), `admin` (gid 80), `user` (one owner), and `root`/`none` (only root can plant, so not attacker-exploitable and never reported).
- Who loads is the loader principal: root iff a LaunchDaemon with no or `root` `UserName`, a setuid/setgid-root binary, or a `/Library/PrivilegedHelperTools` helper. A normal app runs as whoever launches it.
- Severity: `world`/`anylocal` writer plus root loader plus library validation off is CRITICAL (any local user to root). `admin`/`user` writer plus root loader is HIGH elevation. Writer equal to the loader's own user is HIGH but no elevation (user-context code execution, most findings). Library validation on is LOW.

ACLs are honored. A `chmod +a` grant can widen access beyond the POSIX bits. `slot_writer()` checks for an extended ACL in-process via `acl_get_file` (one C call, no fork; dirs without an ACL are the vast majority) and parses `ls -lde` only for the rare dirs that carry one. An allow of a write right to a broader principal upgrades the writer class. Deny entries are not applied as a downgrade - for a scanner, over-reporting a writable slot is safer than missing one.

## Running it

```bash
python3 scripts/scan.py / --json /tmp/scan.json
python3 scripts/build_report.py /tmp/scan.json --html /tmp/report.html
```

`scan.py --json` always emits the full inventory: every analyzable Mach-O host (executable, dylib, bundle) with all its imports classified. It reads the magic bytes of every regular file, skipping known text and asset extensions so a full-disk walk stays tractable, and parses each Mach-O in process. Scan as a normal or admin user, not `sudo`, or the writability test no longer reflects a real attacker.

Scope options:

```bash
python3 scripts/scan.py "/Applications/Target.app"            # one app, fast
python3 scripts/build_report.py /tmp/scan.json --match target # focus report by substring
```

## The report

`build_report.py` renders the inventory into a per-app page. Flagged binaries get a full verdict table with a who-can-plant column; clean binaries are grouped at each app's foot as a compact dylib list, keeping a whole-system page inside the artifact size limit. It is theme-aware and self-contained, orders apps and rows most-severe first, starts collapsed, and offers a live text filter and an Elevation (privesc) only toggle. `--json` also writes the structured report.

## Reporting

Lead with a one-line verdict (elevation count first, then user-context hijackable, then protected). Group multiple binaries in one bundle rather than listing near-identical lines. State the coverage numbers and named blind spots the tool prints: files and directories unreadable without elevation, and the dyld shared cache, whose system dylibs are not on-disk files and are Apple-signed with library validation. Never claim literal full coverage.

## Verifying a finding by hand

```bash
otool -l "<host>" | grep -A2 -E 'LC_RPATH|LC_LOAD_WEAK_DYLIB|LC_LOAD_DYLIB'
codesign -d --entitlements - --xml "<host>" | grep disable-library-validation
codesign --display --verbose=2 "<host>"
ls -lde "<slot directory>"
```

See `references/methodology.md` for the full detection algorithm, the writer and loader classification, the ACL handling, and the blind spots.

## evals

```

```

## evals/evals.json

```json
{
  "skill_name": "dylib-hijack-scan",
  "evals": [
    {
      "id": 1,
      "prompt": "Check my whole Mac for dylib hijacking, and tell me which apps are actually exploitable rather than just theoretically susceptible.",
      "expected_output": "Runs the scanner across the system, produces the per-app report, leads with elevation count then user-context hijackable then protected, and states coverage and blind spots.",
      "files": []
    },
    {
      "id": 2,
      "prompt": "Is /Applications/Target.app vulnerable to dylib hijacking? Explain why or why not.",
      "expected_output": "Scans just that bundle, identifies any rpath-order or weak-dylib slot, reports the who-can-plant class and library-validation status, and explains the mechanism and mitigation.",
      "files": []
    },
    {
      "id": 3,
      "prompt": "Could an ordinary user on this machine plant a dylib that gets loaded by a root process for privilege escalation?",
      "expected_output": "Uses the elevation model: cross-references non-root-writable slots against root loaders (LaunchDaemons, setuid, helpers), applies library validation, and reports the count of true elevation findings with the writer class and loader reason for each, or states zero.",
      "files": []
    }
  ]
}
```

## references

```

```

## references/methodology.md

# Detection methodology

## Mach-O parsing (zero dependencies)

`scan.py` reads each file's magic bytes to gate on Mach-O or fat Mach-O, then memory-maps and walks the load commands with `struct`. Per image it extracts the `LC_RPATH` paths in declared order (order is what dyld obeys), the `LC_LOAD_DYLIB` and `LC_LOAD_WEAK_DYLIB` imports tagged with their weak flag, and the presence of `LC_CODE_SIGNATURE`.

Fat binaries: the arm64 slice is preferred; load commands are effectively identical across slices. Java `.class` files also start with `0xCAFEBABE` and are rejected by sanity-checking the fat arch count and re-validating each slice's magic. Only `MH_EXECUTE`, `MH_DYLIB`, and `MH_BUNDLE` are analyzed. For an executable, `@executable_path` resolves to its own directory; for a standalone dylib the hosting executable is unknown, so `@executable_path` rpaths are left unresolved.

## Prefilter

A full-disk walk is dominated by source and asset files in node_modules, caches, and .git trees. The scanner opens a file to read its magic only when the extension is not a known text, source, or asset type, so a Mach-O is never missed for carrying a novel or misleading binary extension. Cloud-synced trees (iCloud `Mobile Documents`, `CloudStorage`) are excluded because `lstat` on a dataless file blocks while macOS materializes it over the network, which stalls a whole-disk walk. Firmlink duplicates and pseudo filesystems are excluded to avoid double counting.

## Hijack candidate

For each imported dylib:

- `@rpath/<suffix>`: resolve `<suffix>` against every rpath in order. If none exist and the import is weak, the slot is the first plantable rpath directory (weak-dylib hijack). If some rpath satisfies it, an earlier plantable rpath directory that lacks the file is an rpath-order hijack. A non-weak import that resolves nowhere is a broken dependency, not a hijack.
- Absolute, `@loader_path`, or `@executable_path` weak imports: if the resolved path does not exist and its directory is plantable, it is a weak-dylib hijack. Paths under `/usr/lib` and `/System` are SIP-protected and dyld-cache-backed, never attacker-writable, and skipped.

## Who can plant (writer class)

`slot_writer()` reports the least-privileged principal that can create a file in the slot directory (or the nearest existing ancestor, for creatable slots), from the directory's permission bits and owner/group - never from the euid running the scan.

| bits / owner | class | meaning |
| --- | --- | --- |
| other-write | world | any user |
| group-write, gid 20 | anylocal | any local user (staff) |
| group-write, gid 80 | admin | administrators |
| group-write, other gid | user | a specific group |
| owner-write, uid != 0 | user | one owner |
| owner-write, uid 0, or none | root / none | only root; not attacker-exploitable, not reported |

ACLs: `acl_get_file` (ctypes, in-process, no fork) detects an extended ACL; only then is `ls -lde` parsed. An allow entry granting a write right (`add_file`, `write`, `append`, `delete`, ...) to a broader principal upgrades the class. Deny entries are not applied as a downgrade.

## Who loads (loader principal)

`build_root_context()` maps executables that run as root: LaunchDaemons in `/Library/LaunchDaemons` and `/System/Library/LaunchDaemons` with no or `root` `UserName` (LaunchAgents run as the user and are excluded), and files in `/Library/PrivilegedHelperTools`. `root_execution()` adds setuid/setgid-root binaries via a mode and owner check. A normal app runs as whoever launches it.

## Library validation

Invoked with native `codesign` only on hosts that have a candidate slot. `codesign --display --verbose=2` yields the `flags=...(runtime)` annotation (hardened runtime) and the Team ID; `codesign -d --entitlements -` reveals `disable-library-validation`. Library validation is enforced when the host is signed, has a real Team ID, has the hardened runtime or explicit `CS_REQUIRE_LV`, and lacks the disable entitlement.

## Severity

| condition | severity |
| --- | --- |
| plantable by ordinary user (world / anylocal), loader root, LV off | critical |
| plantable by admin or one user, loader root, LV off | high (elevation) |
| plantable, loader is the writer's own user, LV off | high (no elevation) |
| plantable, but library validation enforced | low |

## Blind spots (state these in every report)

- dyld shared cache: most `/usr/lib` and `/System` dylibs are not on-disk files; they are Apple-signed with library validation and out of scope.
- Permissions and SIP: files and directories unreadable without elevation are counted but not parsed. Re-run under `sudo` for root-only paths and say so.
- Cloud, firmlink, and network mounts are excluded to keep the walk from stalling or double counting; pass an explicit root to scan them deliberately.
- ACL deny entries are not modeled as a downgrade; a slot denied by an ACL but permitted by the bits may over-report.
- The attacker model is a local interactive user.

## scripts

```

```

## scripts/build_report.py

```python
#!/usr/bin/env python3
"""
Build an App -> loaded-dylib -> hijackable/not report from a scan.

Reads the findings JSON produced by scan.py to learn which binaries are
affected, then RE-PARSES each affected host to enumerate its full ordered import
list, classifying every loaded dylib:

  hijackable  -- attacker-plantable slot, library validation off   (HIGH)
  protected   -- plantable slot, but library validation blocks it   (LOW)
  ok          -- resolves safely (system path / first rpath / no writable slot)

Emits a structured report JSON (grouped by app bundle) and, with --html, a
self-contained report page.
"""

import argparse
import html
import json
import os
import sys


def filter_report(report, matches):
    """Keep only apps whose container path or any binary path contains one of the
    (case-insensitive) match substrings. Recompute scope-local counts/totals."""
    ms = [m.lower() for m in matches]

    def keep(app):
        if any(m in app["container"].lower() for m in ms):
            return True
        return any(any(m in b["path"].lower() for m in ms) for b in app["binaries"])

    apps = [a for a in report["apps"] if keep(a)]
    counts = {"info": 0, "low": 0, "medium": 0, "high": 0, "critical": 0}
    nb = 0
    for a in apps:
        nb += a["n_binaries"]
        for b in a["binaries"]:
            for r in b["imports"]:
                s = r.get("severity")
                if s in counts:
                    counts[s] += 1
    out = dict(report)
    out.update(apps=apps, counts=counts, n_apps=len(apps), n_binaries=nb,
               scope=", ".join(matches))
    return out


def app_container(path):
    if ".app/" in path:
        return path.split(".app/")[0] + ".app"

    parts = path.split("/")
    if "Cellar" in parts:
        i = parts.index("Cellar")
        return "/".join(parts[:i + 3])
    return os.path.dirname(path)


def build(scan_json):
    """Consume the full inventory emitted by scan.py (every analyzable host with
    its classified imports) and group it into a complete per-app report."""
    data = json.load(open(scan_json))
    inv = data.get("inventory")
    if inv is None:
        raise SystemExit("This scan JSON has no 'inventory'. Re-run scan.py "
                         "(it now always emits the full inventory).")

    apps = {}
    for h in inv:
        recs = h["imports"]
        nc = sum(1 for r in recs if r.get("severity") == "critical")
        nh = sum(1 for r in recs if r.get("severity") == "high")
        nl = sum(1 for r in recs if r.get("severity") == "low")
        ne = sum(1 for r in recs if r.get("elevation"))
        cont = app_container(h["host"])
        e = apps.setdefault(cont, {"container": cont, "binaries": [], "lv_known": False,
                                   "library_validation": None, "team_id": None,
                                   "signed": None, "hardened_runtime": None,
                                   "root_reason": None})


        if not e["lv_known"] and h.get("library_validation") is not None:
            e["lv_known"] = True
            e["library_validation"] = h["library_validation"]
            e["team_id"] = h["team_id"]
            e["signed"] = h["signed"]
            e["hardened_runtime"] = h["hardened_runtime"]
        if e["root_reason"] is None and h.get("runs_as_root"):
            e["root_reason"] = h.get("root_reason")
        e["binaries"].append({
            "path": h["host"],
            "rel": h["host"][len(cont):].lstrip("/") or os.path.basename(h["host"]),
            "imports": recs,
            "runs_as_root": h.get("runs_as_root", False),
            "root_reason": h.get("root_reason"),
            "n_critical": nc,
            "n_elevation": ne,
            "n_hijackable_high": nh,
            "n_hijackable_low": nl,
        })

    report = []
    for cont, e in apps.items():
        e["n_critical"] = sum(b["n_critical"] for b in e["binaries"])
        e["n_elevation"] = sum(b["n_elevation"] for b in e["binaries"])
        e["n_high"] = sum(b["n_hijackable_high"] for b in e["binaries"])
        e["n_low"] = sum(b["n_hijackable_low"] for b in e["binaries"])
        e["n_binaries"] = len(e["binaries"])
        e["verdict"] = ("critical" if e["n_critical"] else "hijackable" if e["n_high"]
                        else "protected" if e["n_low"] else "ok")
        e["binaries"].sort(key=lambda b: (-b["n_critical"], -b["n_elevation"],
                                          -b["n_hijackable_high"], -b["n_hijackable_low"], b["rel"]))
        report.append(e)
    report.sort(key=lambda e: (-e["n_critical"], -e["n_elevation"], -e["n_high"],
                               -e["n_low"], e["container"].lower()))
    n_root = sum(1 for h in inv if h.get("runs_as_root"))
    n_elev = sum(e["n_elevation"] for e in report)
    return {"stats": data.get("stats"), "counts": data.get("counts"),
            "elapsed_seconds": data.get("elapsed_seconds"),
            "n_apps": len(report), "n_binaries": len(inv), "n_root": n_root,
            "n_elev": n_elev, "apps": report}


VERDICT_BADGE = {
    "critical": ("PRIVESC - ROOT", "crit"),
    "hijackable": ("HIJACKABLE", "high"),
    "protected": ("PROTECTED", "low"),
    "ok": ("CLEAN", "ok"),
}

WRITER_LABEL = {
    "world": "any user",
    "anylocal": "any local user",
    "admin": "admins",
    "user": "one user",
    "root": "root only",
    "none": "-",
}


def esc(s):
    return html.escape(str(s), quote=True)


def imp_sort_key(r):
    """Critical first, then hijackable (high), then protected (low), then rest."""
    return {"critical": 0, "high": 1, "low": 2}.get(r["severity"], 3)


def row_class(r):
    if r["severity"] in ("critical", "high", "low"):
        return "crit" if r["severity"] == "critical" else r["severity"]
    if r["verdict"] == "missing":
        return "missing"
    return "ok"


def render_html(report):
    apps = report["apps"]
    st = report["stats"] or {}
    counts = report["counts"] or {}
    n_critical = sum(1 for a in apps if a["verdict"] == "critical")
    n_hijackable = sum(1 for a in apps if a["verdict"] == "hijackable")
    n_protected = sum(1 for a in apps if a["verdict"] == "protected")

    out = []
    out.append("<title>Dylib Hijack Report</title>")
    out.append('<link rel="preconnect" href="https://fonts.googleapis.com">')
    out.append('<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>')
    out.append('<link rel="stylesheet" href="https://fonts.googleapis.com/css2?'
               'family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap">')
    out.append(STYLE)
    out.append('<div class="wrap">')
    scope = report.get("scope")
    eyebrow = (f'macOS &middot; Mach-O audit &middot; scope: {esc(scope)}' if scope
               else "macOS &middot; Mach-O audit")
    out.append(f'<div class="mast"><span class="eyebrow">{eyebrow}</span>'
               '<h1>Dylib Hijack Report</h1></div>')
    if scope:
        out.append(f'<p class="sub">Scoped to <b>{esc(scope)}</b>: '
                   f'{format(report.get("n_binaries", 0), ",")} Mach-O binaries across '
                   f'{format(report.get("n_apps", 0), ",")} matching apps &amp; locations, '
                   f'with the dylibs each loads and whether they are hijackable.</p>')
    else:
        out.append(f'<p class="sub">Full inventory: every one of the '
                   f'{format(report.get("n_binaries", 0), ",")} loadable Mach-O binaries on the '
                   f'system, across {format(report.get("n_apps", 0), ",")} apps &amp; locations, '
                   f'with the dylibs each loads and whether they are hijackable.</p>')


    out.append('<div class="tiles">')
    out.append(tile(report.get("n_elev", 0), "ELEVATION slots (privesc)", "crit"))
    out.append(tile(counts.get("critical", 0), "of those: any-user -> root", "crit"))
    out.append(tile(counts.get("high", 0), "hijackable, user-context", "high"))
    out.append(tile(counts.get("low", 0), "protected (LV blocks)", "low"))
    out.append("</div>")

    out.append('<p class="legend"><b>How to read this:</b> the <b>who-can-plant</b> column '
               'names the least-privileged principal who can write each slot (from the '
               'directory&rsquo;s permission bits <i>and any ACL</i>, not from who ran the scan). '
               '<span class="chip crit">PRIVESC -> root</span> = that writer is less privileged '
               'than the process that loads it, and the loader is <b>root</b> &mdash; a true '
               'elevation. <span class="chip high">HIJACKABLE</span> = plantable + library '
               'validation off, but loaded in the writer&rsquo;s own context (no privilege gain). '
               '<span class="chip low">PROTECTED</span> = library validation blocks a foreign '
               'dylib. Tick <b>Elevation only</b> to show just the boundary-crossing cases. '
               '<span class="chip ok">clean</span> binaries are listed at each app&rsquo;s foot.</p>')

    out.append('<div class="toolbar">'
               '<input type="search" id="q" placeholder="Filter apps, binaries, dylibs..." '
               'autocomplete="off" spellcheck="false" aria-label="Filter report">'
               '<label class="elevtoggle"><input type="checkbox" id="elev"> '
               'Elevation (privesc) only</label>'
               '<button type="button" id="ex">Expand all</button>'
               '<button type="button" id="co">Collapse all</button>'
               '<span class="count" id="count"></span></div>')

    for a in apps:
        label, cls = VERDICT_BADGE[a["verdict"]]
        if a["lv_known"]:
            lv = "library validation ON" if a["library_validation"] else "library validation OFF"
            gap = ("unsigned" if not a["signed"] else
                   ("no hardened runtime" if not a["hardened_runtime"] else
                    "disable-library-validation entitlement"))
            gap_txt = "" if a["library_validation"] else f' &middot; gap: {esc(gap)}'
            lv_txt = f' &middot; {esc(lv)}{gap_txt}'
        else:
            lv_txt = ""


        hj, seen = [], set()
        for sev in ("critical", "high"):
            for b in a["binaries"]:
                for r in b["imports"]:
                    if r.get("severity") == sev:
                        bn = os.path.basename(r["import"])
                        if bn not in seen:
                            seen.add(bn); hj.append(bn)
        hj_line = ""
        if hj:
            shown = ", ".join(hj[:6]) + (f" +{len(hj) - 6} more" if len(hj) > 6 else "")
            hj_line = f'<span class="hjnames">{esc(shown)}</span>'

        root_line = ""
        if a["n_critical"] and a.get("root_reason"):
            root_line = f'<span class="rootnote">runs as root &middot; {esc(a["root_reason"])}</span>'

        crit_txt = f'{a["n_critical"]} privesc &middot; ' if a["n_critical"] else ""
        elev_attr = ' data-elev="1"' if a["n_elevation"] else ""
        out.append(f'<details class="app {cls}"{elev_attr}>')
        out.append(f'<summary class="apphead {cls}"><span class="caret"></span>'
                   f'<span class="badge {cls}">{label}</span>'
                   f'<span class="apppath">{esc(collapse_home(a["container"]))}</span>'
                   f'<span class="appmeta">{a["n_binaries"]} binaries &middot; '
                   f'{crit_txt}{a["n_high"]} hijackable &middot; {a["n_low"]} protected{lv_txt}</span>'
                   f'{root_line}{hj_line}</summary>')
        out.append('<div class="appbody">')

        flagged = [b for b in a["binaries"]
                   if b["n_critical"] or b["n_hijackable_high"] or b["n_hijackable_low"]]
        clean = [b for b in a["binaries"]
                 if not (b["n_critical"] or b["n_hijackable_high"] or b["n_hijackable_low"])]

        for b in flagged:
            nc, nh, nl = b["n_critical"], b["n_hijackable_high"], b["n_hijackable_low"]
            if nc:
                bchip = f'<span class="chip crit">{nc} privesc (root)</span>'
            elif nh:
                bchip = f'<span class="chip high">{nh} hijackable</span>'
            else:
                bchip = f'<span class="chip low">{nl} protected</span>'
            rootnote = (f' <span class="rootnote">root &middot; {esc(b["root_reason"])}</span>'
                        if b.get("runs_as_root") and b.get("root_reason") else "")
            belev = ' data-elev="1"' if b["n_elevation"] else ""
            out.append(f'<details class="bin"{belev}>')
            out.append(f'<summary class="binsum"><span class="caret"></span>{bchip}'
                       f'<span class="binname">{esc(b["rel"])}</span>{rootnote}</summary>')
            out.append('<div class="tscroll"><table><thead><tr><th>loaded dylib</th><th>link</th>'
                       '<th>who can plant</th><th>verdict</th><th>detail</th></tr></thead><tbody>')
            for r in sorted(b["imports"], key=imp_sort_key):
                rc = row_class(r)
                if r["verdict"] == "hijackable":
                    v = {"critical": ("PRIVESC -> root", "crit"),
                         "high": ("HIJACKABLE", "high")}.get(r["severity"], ("protected", "low"))
                elif r["verdict"] == "missing":
                    v = ("missing", "missing")
                else:
                    v = ("ok", "ok")
                weak = "weak" if r["weak"] else "strong"
                writer = WRITER_LABEL.get(r.get("writer"), "-") if r["verdict"] == "hijackable" else "-"
                elev_tag = ""
                if r.get("elevation"):
                    elev_tag = f' <span class="elevtag">{esc(r.get("elevation_kind", "privesc"))}</span>'
                relev = ' data-elev="1"' if r.get("elevation") else ""
                detail = r["slot"] if r["slot"] else (r["reason"] or "")
                out.append(f'<tr class="{rc}"{relev}><td class="mono">{esc(r["import"])}</td>'
                           f'<td>{weak}</td><td class="writer">{esc(writer)}</td>'
                           f'<td><span class="chip {v[1]}">{v[0]}</span>{elev_tag}</td>'
                           f'<td class="mono detail">{esc(collapse_home(detail))}</td></tr>')
            out.append("</tbody></table></div></details>")


        if clean:
            out.append('<details class="bin cleanwrap">')
            out.append(f'<summary class="binsum"><span class="caret"></span>'
                       f'<span class="chip ok">clean</span><span class="binname">'
                       f'{len(clean)} clean {"binary" if len(clean)==1 else "binaries"} '
                       f'&middot; no hijackable dylibs</span></summary>')
            out.append('<div class="cleanlist">')
            for b in clean:
                names = " &middot; ".join(esc(os.path.basename(r["import"]))
                                          for r in b["imports"]) or "&mdash;"
                out.append(f'<div class="cbin"><span class="cbinname">{esc(b["rel"])}</span>'
                           f'<span class="cdl">{names}</span></div>')
            out.append("</div></details>")
        out.append("</div></details>")

    out.append(SCRIPT)

    n_root = report.get("n_root", 0)
    root_verdict = ("none of them has a hijackable slot &mdash; no privilege-escalation "
                    "path found" if counts.get("critical", 0) == 0
                    else f'{counts.get("critical", 0)} carry a hijackable slot &mdash; PRIVESC')
    out.append(
        f'<p class="foot"><b>Root-execution context:</b> {format(n_root, ",")} binaries run '
        f'as root (LaunchDaemons, setuid, or privileged helpers); {root_verdict}.<br>'
        f'<b>Full inventory:</b> all '
        f'{format(report.get("n_binaries", 0), ",")} loadable Mach-O binaries (executables, '
        f'dylibs, bundles) found on the volume are listed above, grouped into '
        f'{format(report.get("n_apps", 0), ",")} apps &amp; locations. Clean binaries show their '
        f'loaded dylibs by basename; expand a flagged binary for full paths and slot detail.<br>'
        f'<b>Blind spots:</b> {format(st.get("unreadable_dirs", 0), ",")} directories and '
        f'{format(st.get("unreadable_files", 0), ",")} files were unreadable without elevation; '
        f'system dylibs in the dyld shared cache are not on-disk files (Apple-signed, '
        f'library-validated, out of scope). Scan wall-time {report.get("elapsed_seconds", 0):.0f}s.</p>')
    out.append("</div>")
    return "\n".join(out)


def collapse_home(p):
    home = os.path.expanduser("~")
    return p.replace(home, "~") if isinstance(p, str) else p


def tile(n, label, cls):
    return (f'<div class="tile {cls}"><div class="num">{format(n, ",")}</div>'
            f'<div class="lbl">{esc(label)}</div></div>')


STYLE = """<style>
/* light palette (also the un-stamped default via bare :root) --------------- */
:root{
  --bg:#eef1f4; --card:#ffffff; --panel:#f7f9fb;
  --ink:#12161c; --mut:#5b6672; --faint:#8b96a3; --line:#dde3ea;
  --accent:#2f6f8f;                         /* cool slate-teal, tooling accent */
  --crit:#8f1710; --critbg:#fbe4e1; --critedge:#dd9a92; --critsolid:#9a1a12;
  --high:#b3312a; --highbg:#fbeceb; --highedge:#e7b3ae;
  --low:#8a6410; --lowbg:#fbf3df; --lowedge:#e7d3a0;
  --ok:#2c7a52;  --okbg:#eaf4ee;  --okedge:#bcdcc9;
}
/* system-dark: only prefers-color-scheme, no explicit stamp ----------------- */
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
  --bg:#0d0f12; --card:#161a1f; --panel:#12151a;
  --ink:#e6eaef; --mut:#9aa5b1; --faint:#6b7885; --line:#262c34;
  --accent:#6fb3cf;
  --crit:#ff8a7a; --critbg:#3f1512; --critedge:#7a2c23; --critsolid:#c0392b;
  --high:#ff7a6d; --highbg:#341917; --highedge:#5a2620;
  --low:#e6b653; --lowbg:#2f2711; --lowedge:#4d3f19;
  --ok:#6bd39a; --okbg:#122619; --okedge:#20402d;
}}
/* explicit toggles win in both directions ---------------------------------- */
:root[data-theme="dark"]{
  --bg:#0d0f12; --card:#161a1f; --panel:#12151a;
  --ink:#e6eaef; --mut:#9aa5b1; --faint:#6b7885; --line:#262c34;
  --accent:#6fb3cf;
  --crit:#ff8a7a; --critbg:#3f1512; --critedge:#7a2c23; --critsolid:#c0392b;
  --high:#ff7a6d; --highbg:#341917; --highedge:#5a2620;
  --low:#e6b653; --lowbg:#2f2711; --lowedge:#4d3f19;
  --ok:#6bd39a; --okbg:#122619; --okedge:#20402d;
}
*{box-sizing:border-box}
body{background:var(--bg);color:var(--ink);margin:0;
  font-family:"IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
  font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased}
.wrap{max-width:1040px;margin:0 auto;padding:40px 22px 72px}
.mast{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;
  border-bottom:2px solid var(--ink);padding-bottom:14px;margin-bottom:6px}
h1{font-size:23px;font-weight:700;letter-spacing:-.01em;margin:0;text-wrap:balance}
.eyebrow{font-family:"IBM Plex Mono",monospace;font-size:11px;font-weight:500;
  text-transform:uppercase;letter-spacing:.14em;color:var(--accent)}
.sub{color:var(--mut);margin:10px 0 26px;font-size:14px}
.tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:22px}
.tile{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 16px 14px;position:relative}
.tile::before{content:"";position:absolute;left:0;top:12px;bottom:12px;width:3px;border-radius:2px;background:var(--faint)}
.tile.high::before{background:var(--high)} .tile.low::before{background:var(--low)}
.tile .num{font-family:"IBM Plex Mono",monospace;font-size:29px;font-weight:600;
  font-variant-numeric:tabular-nums;line-height:1;padding-left:8px}
.tile.crit::before{background:var(--critsolid)} .tile.crit .num{color:var(--crit)}
.tile.high .num{color:var(--high)} .tile.low .num{color:var(--low)}
.tile .lbl{color:var(--mut);font-size:12.5px;padding-left:8px;margin-top:6px}
.legend{background:var(--panel);border:1px solid var(--line);border-radius:8px;
  padding:12px 15px;font-size:13px;color:var(--mut);line-height:1.6}
.chip{display:inline-block;padding:1px 8px;border-radius:4px;font-family:"IBM Plex Mono",monospace;
  font-size:10.5px;font-weight:600;letter-spacing:.03em;vertical-align:middle;
  border:1px solid transparent}
.chip.crit{background:var(--critsolid);color:#fff;border-color:var(--critsolid)}
.chip.high{background:var(--highbg);color:var(--high);border-color:var(--highedge)}
.chip.low{background:var(--lowbg);color:var(--low);border-color:var(--lowedge)}
.chip.ok{background:var(--okbg);color:var(--ok);border-color:var(--okedge)}
.chip.missing{background:transparent;color:var(--faint);border-color:var(--line)}
.toolbar{display:flex;gap:8px;margin-bottom:4px;align-items:center;flex-wrap:wrap;
  position:sticky;top:0;z-index:5;background:var(--bg);padding:8px 0}
#q{flex:1;min-width:200px;font-family:"IBM Plex Sans",sans-serif;font-size:13px;color:var(--ink);
  background:var(--card);border:1px solid var(--line);border-radius:6px;padding:7px 11px}
#q::placeholder{color:var(--faint)}
#q:focus{outline:none;border-color:var(--accent)}
.toolbar button{font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--mut);
  background:var(--card);border:1px solid var(--line);border-radius:6px;padding:6px 11px;cursor:pointer}
.toolbar button:hover{color:var(--ink);border-color:var(--faint)}
.toolbar button:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.count{font-family:"IBM Plex Mono",monospace;font-size:11.5px;color:var(--mut)}
.elevtoggle{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--mut);
  font-family:"IBM Plex Mono",monospace;cursor:pointer;white-space:nowrap}
.elevtoggle input{accent-color:var(--critsolid)}
.writer{font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--mut);white-space:nowrap}
.elevtag{display:inline-block;font-family:"IBM Plex Mono",monospace;font-size:10px;font-weight:600;
  color:var(--crit);white-space:nowrap;margin-left:4px}
details.app{background:var(--card);border:1px solid var(--line);border-radius:10px;margin-top:12px;overflow:hidden}
summary{list-style:none;cursor:pointer}
summary::-webkit-details-marker{display:none}
summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.caret{display:inline-block;width:0;height:0;border-left:5px solid var(--faint);
  border-top:4px solid transparent;border-bottom:4px solid transparent;
  margin-right:2px;transition:transform .15s ease;flex:none}
details[open]>summary .caret{transform:rotate(90deg)}
.apphead{padding:14px 16px 14px 16px;border-left:4px solid var(--faint);
  display:flex;flex-wrap:wrap;align-items:center;gap:10px}
.apphead:hover{background:var(--panel)}
.apphead.crit{border-left-color:var(--critsolid)}
.apphead.high{border-left-color:var(--high)}
.apphead.low{border-left-color:var(--low)}
.apphead.ok{border-left-color:var(--ok)}
.hjnames{width:100%;font-family:"IBM Plex Mono",monospace;font-size:11.5px;color:var(--high);word-break:break-all}
.rootnote{font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--crit);font-weight:600}
.apphead .rootnote{width:100%}
.appbody{border-top:1px solid var(--line)}
details.bin{border-bottom:1px solid var(--line)}
details.bin:last-child{border-bottom:none}
.binsum{display:flex;align-items:center;gap:9px;padding:9px 16px;font-family:"IBM Plex Mono",monospace;font-size:12px}
.binsum:hover{background:var(--panel)}
.binsum .binname{color:var(--ink);font-weight:500;word-break:break-all}
.cleanwrap>summary .binname{color:var(--mut)}
.cleanlist{padding:2px 16px 12px 34px}
.cbin{padding:5px 0;border-top:1px solid var(--line);display:flex;flex-wrap:wrap;gap:2px 12px}
.cbin:first-child{border-top:none}
.cbinname{font-family:"IBM Plex Mono",monospace;font-size:11.5px;color:var(--ink);font-weight:500;word-break:break-all}
.cdl{font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--faint);word-break:break-all}
@media(prefers-reduced-motion:reduce){.caret{transition:none}}
.badge{font-family:"IBM Plex Mono",monospace;font-size:10.5px;font-weight:600;
  padding:3px 9px;border-radius:5px;letter-spacing:.04em;border:1px solid transparent}
.badge.crit{background:var(--critsolid);color:#fff;border-color:var(--critsolid)}
.badge.high{background:var(--highbg);color:var(--high);border-color:var(--highedge)}
.badge.low{background:var(--lowbg);color:var(--low);border-color:var(--lowedge)}
.badge.ok{background:var(--okbg);color:var(--ok);border-color:var(--okedge)}
.apppath{font-weight:600;font-size:14px;word-break:break-all}
.appmeta{color:var(--mut);font-size:12px;width:100%;font-family:"IBM Plex Mono",monospace}
.tscroll{overflow-x:auto;border-top:1px solid var(--line)}
table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:640px}
thead th{text-align:left;color:var(--faint);font-weight:600;padding:6px 16px;
  border-bottom:1px solid var(--line);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;
  font-family:"IBM Plex Mono",monospace}
tbody td{padding:7px 16px;border-bottom:1px solid var(--line);vertical-align:top}
tbody tr:last-child td{border-bottom:none}
tr.crit{background:var(--critbg)} tr.high{background:var(--highbg)} tr.low{background:var(--lowbg)}
.mono{font-family:"IBM Plex Mono",monospace;font-size:11.5px;word-break:break-all}
.detail{color:var(--mut);max-width:380px}
.foot{color:var(--faint);font-size:12px;margin-top:26px;border-top:1px solid var(--line);
  padding-top:14px;line-height:1.6}
@media(max-width:640px){.tiles{grid-template-columns:repeat(2,1fr)}.detail{max-width:none}}
</style>"""


SCRIPT = """<script>
(function(){
  var q=document.getElementById('q'), count=document.getElementById('count'),
      elev=document.getElementById('elev');
  var apps=[].slice.call(document.querySelectorAll('details.app'));
  for(var b of [['ex',true],['co',false]]){
    document.getElementById(b[0]).addEventListener('click',(function(v){return function(){
      for(var d of document.querySelectorAll('details'))d.open=v;};})(b[1]));
  }
  function norm(s){return s.toLowerCase();}
  function filter(){
    var term=norm(q.value.trim()), eo=elev.checked;
    var shownApps=0, shownRows=0;
    apps.forEach(function(app){
      var header=norm(app.querySelector('summary.apphead').textContent);
      var appVis=false;
      app.querySelectorAll('details.bin').forEach(function(bin){
        var bsum=norm(bin.querySelector('summary.binsum').textContent);
        var rows=bin.querySelectorAll('tbody tr');
        var binVis=false;
        rows.forEach(function(tr){
          var matchOK=!term||header.indexOf(term)>=0||bsum.indexOf(term)>=0||norm(tr.textContent).indexOf(term)>=0;
          var elevOK=!eo||tr.getAttribute('data-elev')==='1';
          var show=matchOK&&elevOK;
          tr.style.display=show?'':'none';
          if(show){binVis=true; shownRows++;}
        });
        var isClean=rows.length===0, bshow;
        if(eo){bshow=binVis;}
        else if(isClean){bshow=!term||bsum.indexOf(term)>=0||header.indexOf(term)>=0;}
        else{bshow=binVis||bsum.indexOf(term)>=0||header.indexOf(term)>=0;}
        bin.style.display=bshow?'':'none';
        bin.open=(term||eo)?bshow:false;
        if(bshow)appVis=true;
      });
      var ashow=eo?appVis:(appVis||header.indexOf(term)>=0);
      app.style.display=ashow?'':'none';
      app.open=(term||eo)?ashow:false;
      if(ashow)shownApps++;
    });
    count.textContent=(term||eo)?(shownApps+' apps, '+shownRows+' dylibs'):'';
  }
  q.addEventListener('input',filter);
  elev.addEventListener('change',filter);
  q.addEventListener('keydown',function(e){if(e.key==='Escape'){q.value='';filter();}});
})();
</script>"""


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("findings_json")
    ap.add_argument("--json", help="write structured report JSON here")
    ap.add_argument("--html", help="write HTML report here")
    ap.add_argument("--match", action="append", metavar="SUBSTR",
                    help="Only include apps/binaries whose path contains SUBSTR "
                         "(case-insensitive; repeatable). E.g. --match claude.")
    args = ap.parse_args()
    report = build(args.findings_json)
    if args.match:
        report = filter_report(report, args.match)
        print(f"scoped to {args.match}: {report['n_apps']} apps, "
              f"{report['n_binaries']} binaries")
    if args.json:
        json.dump(report, open(args.json, "w"), indent=2)
        print("report JSON:", args.json)
    if args.html:
        open(args.html, "w").write(render_html(report))
        print("report HTML:", args.html)
    if not args.json and not args.html:
        json.dump(report, sys.stdout, indent=2)


if __name__ == "__main__":
    main()
```

## scripts/scan.py

```python
#!/usr/bin/env python3
"""
dylib-hijack-scan: detect macOS applications susceptible to (or already victims of)
dylib hijacking.

Pure-stdlib Mach-O parser. The only external process invoked is the native
`codesign` tool, and only on candidate binaries (those with a real hijack slot),
so there is no fork-per-file cost across a full-system sweep.

Detection is based on Patrick Wardle's dylib-hijacking research:

  1. Weak-dylib hijack   -- an LC_LOAD_WEAK_DYLIB import whose file does not exist
                            on disk. The binary runs fine without it, so an attacker
                            can plant a dylib at that path and get it loaded.

  2. Rpath-order hijack  -- an @rpath-relative import that resolves via a LATER
                            LC_RPATH entry, while an EARLIER rpath directory is
                            attacker-writable (or creatable) and lacks the file.
                            dyld searches rpaths in order and loads the first match,
                            so the attacker's planted copy wins.

The vector is neutralised by **library validation** (hardened runtime without the
`com.apple.security.cs.disable-library-validation` entitlement): dyld refuses to
load a dylib not signed by the host's Team ID (or Apple). Findings are severity-
scored with that mitigation in mind.
"""

import argparse
import ctypes
import ctypes.util
import json
import mmap
import os
import plistlib
import re
import struct
import subprocess
import sys
import time


MH_MAGIC     = 0xFEEDFACE
MH_CIGAM     = 0xCEFAEDFE
MH_MAGIC_64  = 0xFEEDFACF
MH_CIGAM_64  = 0xCFFAEDFE
FAT_MAGIC    = 0xCAFEBABE
FAT_CIGAM    = 0xBEBAFECA
FAT_MAGIC_64 = 0xCAFEBABF
FAT_CIGAM_64 = 0xBFBAFECA

MACHO_MAGICS = {MH_MAGIC, MH_CIGAM, MH_MAGIC_64, MH_CIGAM_64}
FAT_MAGICS   = {FAT_MAGIC, FAT_CIGAM, FAT_MAGIC_64, FAT_CIGAM_64}

LC_REQ_DYLD          = 0x80000000
LC_LOAD_DYLIB        = 0x0C
LC_LOAD_WEAK_DYLIB   = 0x18 | LC_REQ_DYLD
LC_RPATH             = 0x1C | LC_REQ_DYLD
LC_REEXPORT_DYLIB    = 0x1F | LC_REQ_DYLD
LC_LOAD_UPWARD_DYLIB = 0x23 | LC_REQ_DYLD
LC_CODE_SIGNATURE    = 0x1D

MH_EXECUTE = 0x2
MH_DYLIB   = 0x6
MH_BUNDLE  = 0x8


DEFAULT_EXCLUDES = {
    "/dev", "/Volumes", "/System/Volumes/Data", "/System/Volumes/Preboot",
    "/System/Volumes/VM", "/System/Volumes/Update", "/private/var/vm",
    "/.Spotlight-V100", "/.fseventsd", "/.DocumentRevisions-V100",
    "/private/var/folders",


    os.path.expanduser("~/Library/Mobile Documents"),
    os.path.expanduser("~/Library/CloudStorage"),
    os.path.expanduser("~/.Trash"),
}


class MachOImage:
    __slots__ = ("filetype", "rpaths", "imports", "has_code_sig")

    def __init__(self):
        self.filetype = 0
        self.rpaths = []
        self.imports = []
        self.has_code_sig = False


def _cstr(buf, start, limit):
    end = buf.find(b"\x00", start, limit)
    if end == -1:
        end = limit
    return buf[start:end].decode("utf-8", "replace")


def _parse_slice(mm, base, size):
    """Parse one thin Mach-O at offset `base`. Returns MachOImage or None."""
    if base + 28 > len(mm):
        return None
    magic = struct.unpack_from("<I", mm, base)[0]
    if magic in (MH_MAGIC_64, MH_CIGAM_64):
        endian = "<" if magic == MH_MAGIC_64 else ">"
        is64 = True
    elif magic in (MH_MAGIC, MH_CIGAM):
        endian = "<" if magic == MH_MAGIC else ">"
        is64 = False
    else:
        return None

    hdr_fmt = endian + "IiiIII" + ("I" if is64 else "")

    fields = struct.unpack_from(hdr_fmt, mm, base)
    filetype = fields[3]
    ncmds = fields[4]
    hdr_size = 32 if is64 else 28

    img = MachOImage()
    img.filetype = filetype

    off = base + hdr_size
    limit = min(len(mm), base + size) if size else len(mm)
    for _ in range(ncmds):
        if off + 8 > limit:
            break
        cmd, cmdsize = struct.unpack_from(endian + "II", mm, off)
        if cmdsize < 8 or off + cmdsize > limit:
            break
        c = cmd & 0xFFFFFFFF
        if c in (LC_LOAD_DYLIB, LC_LOAD_WEAK_DYLIB, LC_REEXPORT_DYLIB, LC_LOAD_UPWARD_DYLIB):
            str_off = struct.unpack_from(endian + "I", mm, off + 8)[0]
            if 0 < str_off < cmdsize:
                path = _cstr(mm, off + str_off, off + cmdsize)
                img.imports.append((path, c == LC_LOAD_WEAK_DYLIB))
        elif c == LC_RPATH:
            str_off = struct.unpack_from(endian + "I", mm, off + 8)[0]
            if 0 < str_off < cmdsize:
                img.rpaths.append(_cstr(mm, off + str_off, off + cmdsize))
        elif c == LC_CODE_SIGNATURE:
            img.has_code_sig = True
        off += cmdsize
    return img


def parse_macho(path):
    """Return a merged MachOImage for the file, or None if not Mach-O.

    For fat binaries the arm64(e) slice is preferred; load commands are otherwise
    near-identical across slices, so one representative slice suffices.
    """
    try:
        with open(path, "rb") as f:
            fileno = f.fileno()
            fsize = os.fstat(fileno).st_size
            if fsize < 28:
                return None
            mm = mmap.mmap(fileno, 0, prot=mmap.PROT_READ)
    except (OSError, ValueError):
        return None

    try:
        magic_be = struct.unpack_from(">I", mm, 0)[0]
        if magic_be in FAT_MAGICS:
            is64 = magic_be in (FAT_MAGIC_64, FAT_CIGAM_64)
            nfat = struct.unpack_from(">I", mm, 4)[0]
            if nfat > 64:
                return None
            entry_sz = 32 if is64 else 20
            slices = []
            pos = 8
            for _ in range(nfat):
                if pos + entry_sz > len(mm):
                    break
                if is64:
                    cputype, _sub, offset, size, _al = struct.unpack_from(">iiQQI", mm, pos)
                else:
                    cputype, _sub, offset, size, _al = struct.unpack_from(">iiIII", mm, pos)
                slices.append((cputype, offset, size))
                pos += entry_sz
            if not slices:
                return None

            chosen = next((s for s in slices if s[0] == 0x0100000C), slices[0])
            return _parse_slice(mm, chosen[1], chosen[2])
        elif struct.unpack_from("<I", mm, 0)[0] in MACHO_MAGICS:
            return _parse_slice(mm, 0, len(mm))
        return None
    except (struct.error, ValueError):
        return None
    finally:
        mm.close()


def looks_macho(path):
    """Cheap gate: read the first 4 bytes and check for a Mach-O/fat magic."""
    try:
        with open(path, "rb") as f:
            head = f.read(4)
    except OSError:
        return False
    if len(head) < 4:
        return False
    be = struct.unpack(">I", head)[0]
    le = struct.unpack("<I", head)[0]
    return be in FAT_MAGICS or le in MACHO_MAGICS or be in MACHO_MAGICS


def resolve_special(p, exec_dir, loader_dir):
    if p.startswith("@executable_path"):
        return os.path.normpath(exec_dir + p[len("@executable_path"):])
    if p.startswith("@loader_path"):
        return os.path.normpath(loader_dir + p[len("@loader_path"):])
    return p


STAFF_GID = 20
ADMIN_GID = 80
NONROOT_WRITERS = ("world", "anylocal", "admin", "user")


def _nearest_existing(directory):
    d = directory
    while d and not os.path.exists(d):
        parent = os.path.dirname(d)
        if parent == d:
            return None
        d = parent
    return d if d and os.path.exists(d) else None


_ACL_TYPE_EXTENDED = 0x00000100
_ACL_GROUP_CLASS = {"everyone": "world", "staff": "anylocal",
                    "admin": "admin", "wheel": "root"}
_ACL_WRITE_RIGHTS = ("add_file", "write", "append", "delete", "write_data",
                     "add_subdirectory", "delete_child")
try:
    _libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
    _libc.acl_get_file.restype = ctypes.c_void_p
    _libc.acl_get_file.argtypes = [ctypes.c_char_p, ctypes.c_uint]
    _libc.acl_free.argtypes = [ctypes.c_void_p]
except Exception:
    _libc = None


def _has_acl(path):
    """True iff `path` carries an extended ACL. macOS returns NULL (no ACL) or a
    non-null acl_t; we only need presence, so we free it immediately."""
    if _libc is None:
        return False
    try:
        a = _libc.acl_get_file(os.fsencode(path), _ACL_TYPE_EXTENDED)
    except Exception:
        return False
    if a:
        _libc.acl_free(a)
        return True
    return False


def _acl_grant_class(directory):
    """Least-privileged writer class an ACL *allow* entry grants on `directory`
    (or 'none'). Only called for dirs that actually have an ACL, so the `ls`
    fork is rare. Deny-downgrade is intentionally not applied -- for a scanner,
    over-reporting a writable slot is safer than missing one."""
    try:
        out = subprocess.run(["/bin/ls", "-lde", directory],
                             capture_output=True, text=True, timeout=10).stdout
    except (OSError, subprocess.SubprocessError):
        return "none"
    decided = {}
    for line in out.splitlines():
        m = re.match(r"\s*\d+:\s+(user|group):([^\s]+(?:\s[^\s]+)*?)\s+(allow|deny)\s+(.*)",
                     line)
        if not m:
            continue
        typ, name, act, rights = m.group(1), m.group(2), m.group(3), m.group(4)
        if "only_inherit" in rights:
            continue
        if not any(r in rights for r in _ACL_WRITE_RIGHTS):
            continue
        key = (typ, name)
        decided.setdefault(key, act)
    granted = []
    for (typ, name), act in decided.items():
        if act != "allow":
            continue
        if typ == "group":
            granted.append(_ACL_GROUP_CLASS.get(name, "user"))
        else:
            granted.append("root" if name == "root" else "user")
    if not granted:
        return "none"
    return min(granted, key=lambda c: _WRITER_RANK.get(c, 9))


def slot_writer(slot_path):
    """Least-privileged principal that can create a planted dylib at slot_path.

    Determined from the slot directory's permission BITS and owner/group -- NOT
    from the euid running the scan -- so the verdict is the same whether the scan
    runs as a standard user, an admin, or root. Returns (klass, detail):

      world    -- any user (o+w)
      anylocal -- any local user (group staff, gid 20)
      admin    -- administrators only (group admin, gid 80)
      user     -- one specific non-root user (owner, or a service group)
      root     -- only root/wheel can plant  -> NOT attacker-exploitable
      none     -- not writable by anyone / no ancestor
    """
    d = _nearest_existing(os.path.dirname(slot_path))
    if not d:
        return ("none", "no existing ancestor")
    try:
        st = os.stat(d)
    except OSError:
        return ("none", "stat failed")
    m = st.st_mode
    where = d if d == os.path.dirname(slot_path) else f"creatable via ancestor {d}"
    if m & 0o0002:
        klass, detail = "world", f"world-writable ({where})"
    elif m & 0o0020:
        if st.st_gid == STAFF_GID:
            klass, detail = "anylocal", f"group staff-writable ({where})"
        elif st.st_gid == ADMIN_GID:
            klass, detail = "admin", f"group admin-writable ({where})"
        elif st.st_gid == 0:
            klass, detail = "root", f"group wheel-writable ({where})"
        else:
            klass, detail = "user", f"group {st.st_gid}-writable ({where})"
    elif m & 0o0200:
        if st.st_uid == 0:
            klass, detail = "root", f"root-owned, root-only ({where})"
        else:
            klass, detail = "user", f"owned by uid {st.st_uid} ({where})"
    else:
        klass, detail = "none", f"not writable ({where})"


    if _has_acl(d):
        acl_cls = _acl_grant_class(d)
        if acl_cls != "none" and _WRITER_RANK.get(acl_cls, 9) < _WRITER_RANK.get(klass, 9):
            klass, detail = acl_cls, f"ACL grants write to {acl_cls} ({d})"
    return (klass, detail)


_WRITER_RANK = {"world": 0, "anylocal": 1, "admin": 2, "user": 2, "root": 3, "none": 4}


def elevation_verdict(host, writer_class, lib_val, root_ctx):
    """Given a plantable slot, decide severity + whether it crosses a privilege
    boundary. Returns (severity, elevation: bool, kind, loader_reason)."""
    if lib_val:
        return ("low", False, "library validation blocks a foreign dylib", None)
    is_root, reason = root_execution(host, root_ctx)
    if is_root and _WRITER_RANK.get(writer_class, 9) < 3:
        if writer_class in ("world", "anylocal"):
            return ("critical", True, "ordinary local user -> root", reason)
        if writer_class == "admin":
            return ("high", True, "admin -> root", reason)
        return ("high", True, "another user -> root", reason)

    return ("high", False, "same user context (no privilege gain)", None)


_codesign_cache = {}

def library_validation(path):
    """Return dict describing the host's signing posture, via native `codesign`.

    Only called for candidate binaries, so the subprocess cost is negligible.
    """
    if path in _codesign_cache:
        return _codesign_cache[path]
    info = {"signed": False, "hardened_runtime": False,
            "disable_lv_entitlement": False, "library_validation": False,
            "team_id": None}
    require_lv = False
    try:
        p = subprocess.run(["/usr/bin/codesign", "--display", "--verbose=2", path],
                           capture_output=True, text=True, timeout=20)
        err = p.stderr
        if "code object is not signed" not in err and p.returncode == 0:
            info["signed"] = True


        m = re.search(r"flags=0x[0-9a-fA-F]+\s*\(([^)]*)\)", err)
        if m:
            annot = m.group(1)
            info["hardened_runtime"] = "runtime" in annot
            require_lv = "library-validation" in annot
        for line in err.splitlines():
            if line.startswith("TeamIdentifier="):
                tid = line.split("=", 1)[1].strip()
                info["team_id"] = None if tid in ("", "not set") else tid
    except (OSError, subprocess.SubprocessError):
        pass

    if info["signed"]:
        try:
            e = subprocess.run(
                ["/usr/bin/codesign", "-d", "--entitlements", "-", "--xml", path],
                capture_output=True, text=True, timeout=20)
            blob = (e.stdout or "") + (e.stderr or "")
            info["disable_lv_entitlement"] = "disable-library-validation" in blob
        except (OSError, subprocess.SubprocessError):
            pass


    info["library_validation"] = (
        info["signed"]
        and (info["hardened_runtime"] or require_lv)
        and not info["disable_lv_entitlement"]
        and info["team_id"] is not None
    )
    _codesign_cache[path] = info
    return info


DYLD_CACHE_ROOTS = ("/usr/lib/", "/System/Library/", "/System/iOSSupport/")


def classify_imports(path, img):
    """Return one record per imported dylib of a host, each with a verdict:
    'hijackable' (plantable slot), 'missing' (required, absent), or 'ok'.

    Severity on hijackable rows is filled in by the caller once it knows the
    host's library-validation posture."""
    host_dir = os.path.dirname(os.path.abspath(path))
    exec_dir = host_dir if img.filetype == MH_EXECUTE else None
    loader_dir = host_dir

    resolved_rpaths = []
    for rp in img.rpaths:
        if rp.startswith("@executable_path") and exec_dir is None:
            resolved_rpaths.append((rp, None))
        else:
            resolved_rpaths.append((rp, resolve_special(rp, exec_dir or host_dir, loader_dir)))

    def plant(cand):
        """(plantable_by_non_root, writer_class, detail) for a candidate slot."""
        wk, detail = slot_writer(cand)
        return (wk in NONROOT_WRITERS, wk, detail)

    records = []
    for imp_path, weak in img.imports:
        rec = {"import": imp_path, "weak": weak, "verdict": "ok", "severity": None,
               "slot": None, "reason": "", "resolved": None, "writer": None}

        if imp_path.startswith("@rpath/"):
            suffix = imp_path[len("@rpath/"):]
            existing_idx = None
            cands = []
            for raw, rdir in resolved_rpaths:
                if rdir is None:
                    cands.append((raw, None, False)); continue
                c = os.path.join(rdir, suffix)
                e = os.path.exists(c)
                cands.append((raw, c, e))
                if e and existing_idx is None:
                    existing_idx = len(cands) - 1
            if existing_idx is None:
                hit = None
                for raw, c, _ in cands:
                    if c is None:
                        continue
                    ok, wk, detail = plant(c)
                    if ok:
                        hit = (c, wk, detail); break
                if weak and hit:
                    rec.update(verdict="hijackable", slot=hit[0], writer=hit[1],
                               reason="weak import, dylib absent everywhere; " + hit[2])
                elif weak:
                    rec.update(reason="weak import, absent, but no non-root-writable rpath slot")
                else:
                    rec.update(verdict="missing", reason="required dylib not found in any rpath")
            else:
                rec["resolved"] = cands[existing_idx][1]
                hit = None
                for i in range(existing_idx):
                    raw, c, _ = cands[i]
                    if c is None:
                        continue
                    ok, wk, detail = plant(c)
                    if ok:
                        hit = (c, wk, detail); break
                if hit:
                    rec.update(verdict="hijackable", slot=hit[0], writer=hit[1],
                               reason="earlier rpath is plantable; " + hit[2])
                else:
                    rec.update(reason="resolves via rpath; no earlier non-root-writable slot")

        elif imp_path.startswith("@loader_path") or imp_path.startswith("@executable_path"):
            resolved = resolve_special(imp_path, exec_dir or host_dir, loader_dir)
            rec["resolved"] = resolved
            if not resolved.startswith("@") and not os.path.exists(resolved):
                ok, wk, detail = plant(resolved)
                if weak and ok:
                    rec.update(verdict="hijackable", slot=resolved, writer=wk,
                               reason="weak import, missing; " + detail)
                elif weak:
                    rec.update(reason="weak import, missing, slot not non-root-writable")
                else:
                    rec.update(verdict="missing", reason="required dylib missing")
            else:
                rec.update(reason="resolves relative to loader")
        else:
            rec["resolved"] = imp_path
            if imp_path.startswith(DYLD_CACHE_ROOTS):


                rec.update(reason="system / dyld shared cache (SIP-protected)")
            elif not os.path.exists(imp_path):
                ok, wk, detail = plant(imp_path)
                if weak and ok:
                    rec.update(verdict="hijackable", slot=imp_path, writer=wk,
                               reason="weak import, missing; " + detail)
                elif weak:
                    rec.update(reason="weak import, missing, slot not non-root-writable")
                else:
                    rec.update(verdict="missing", reason="required dylib missing")
            else:
                rec.update(reason="absolute system/bundled path")
        records.append(rec)
    return records


def build_root_context():
    """Map executable realpath -> reason, for binaries that execute as root.

    A hijackable slot only escalates privilege when the *host* runs as root: then
    an unprivileged user who can plant the dylib gets root code execution. Sources:
      - LaunchDaemons (root at boot unless UserName overrides) -- NOT LaunchAgents,
        which run as the logged-in user.
      - Privileged helper tools (SMJobBless, run as root).
    setuid/setgid-root executables are detected per-host during the walk.
    """
    ctx = {}
    for dd in ("/Library/LaunchDaemons", "/System/Library/LaunchDaemons"):
        try:
            names = os.listdir(dd)
        except OSError:
            continue
        for name in names:
            if not name.endswith(".plist"):
                continue
            try:
                with open(os.path.join(dd, name), "rb") as fh:
                    pl = plistlib.load(fh)
            except Exception:
                continue
            user = pl.get("UserName")
            if user and user != "root":
                continue
            prog = pl.get("Program")
            if not prog:
                args = pl.get("ProgramArguments")
                prog = args[0] if isinstance(args, list) and args else None
            if isinstance(prog, str) and prog:
                ctx.setdefault(os.path.realpath(prog), f"LaunchDaemon {name} (root)")
    try:
        pht = "/Library/PrivilegedHelperTools"
        for name in os.listdir(pht):
            fp = os.path.join(pht, name)
            if os.path.isfile(fp):
                ctx.setdefault(os.path.realpath(fp), "privileged helper tool (root)")
    except OSError:
        pass
    return ctx


def root_execution(path, root_ctx):
    """Return (runs_as_root, reason) for a host path."""
    rp = os.path.realpath(path)
    if rp in root_ctx:
        return True, root_ctx[rp]
    try:
        st = os.stat(path)
        if st.st_mode & 0o4000 and st.st_uid == 0:
            return True, "setuid root"
        if st.st_mode & 0o2000 and st.st_gid == 0:
            return True, "setgid wheel"
    except OSError:
        pass
    return False, None


SKIP_EXTS = frozenset({

    ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".map", ".json", ".json5",
    ".py", ".pyc", ".pyi", ".rb", ".go", ".rs", ".java", ".kt", ".swift",
    ".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".m", ".mm", ".cs", ".php",
    ".pl", ".lua", ".sh", ".bash", ".zsh", ".fish", ".sql", ".r",
    ".html", ".htm", ".xml", ".xhtml", ".css", ".scss", ".sass", ".less",
    ".md", ".markdown", ".rst", ".txt", ".text", ".rtf", ".tex", ".csv", ".tsv",
    ".yml", ".yaml", ".toml", ".ini", ".cfg", ".conf", ".properties",
    ".lock", ".log", ".gitignore", ".gitattributes", ".editorconfig", ".env",

    ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".ico",
    ".svg", ".icns", ".heic", ".psd", ".ai", ".sketch",
    ".mp3", ".wav", ".aiff", ".flac", ".m4a", ".ogg", ".mp4", ".mov", ".avi",
    ".mkv", ".webm", ".m4v", ".pdf",
    ".woff", ".woff2", ".ttf", ".otf", ".eot",

    ".zip", ".gz", ".tgz", ".bz2", ".xz", ".zst", ".7z", ".rar", ".tar",
    ".jar", ".war", ".class", ".wasm", ".db", ".sqlite", ".sqlite3", ".dat",
    ".plist", ".strings", ".nib", ".storyboardc", ".car", ".pak", ".bin",
    ".pack", ".idx", ".ncd", ".metallib", ".spv", ".glsl",
    ".a", ".lib", ".o", ".d", ".pdb", ".dSYM",
})

def _could_be_macho(name, dirpath, is_exec):
    """Cheap name/mode gate applied before opening a file to read its magic.

    Open (return True) unless the file carries a known non-Mach-O text/asset
    extension. Executables are always opened. This keeps the residual blind spot
    to Mach-O files that both lack the executable bit AND wear a deny-listed
    extension -- practically nonexistent.
    """
    if is_exec:
        return True
    dot = name.rfind(".")
    if dot <= 0:
        return True
    return name[dot:].lower() not in SKIP_EXTS


def iter_macho_files(roots, excludes, stats, no_prefilter=False):
    seen = set()
    hb = [0]
    for root in roots:
        for dirpath, dirnames, filenames in os.walk(root, followlinks=False,
                                                     onerror=lambda e: stats.__setitem__(
                                                         "unreadable_dirs", stats["unreadable_dirs"] + 1)):

            if stats["files_walked"] - hb[0] >= 400000:
                hb[0] = stats["files_walked"]
                print(f"  [walk] {stats['files_walked']:,} files, at {dirpath}",
                      file=sys.stderr, flush=True)

            dirnames[:] = [d for d in dirnames
                           if os.path.join(dirpath, d) not in excludes]
            for fn in filenames:
                fp = os.path.join(dirpath, fn)
                try:
                    st = os.lstat(fp)
                except OSError:
                    stats["unreadable_files"] += 1
                    continue
                if not (st.st_mode & 0o170000) == 0o100000:
                    continue
                if st.st_size < 28:
                    continue
                stats["files_walked"] += 1
                if not no_prefilter and not _could_be_macho(fn, dirpath, bool(st.st_mode & 0o111)):
                    stats["skipped_prefilter"] += 1
                    continue
                real = (st.st_dev, st.st_ino)
                if real in seen:
                    continue
                seen.add(real)
                stats["files_examined"] += 1
                if looks_macho(fp):
                    yield fp


def main():
    ap = argparse.ArgumentParser(description="Scan for dylib-hijackable Mach-O binaries.")
    ap.add_argument("roots", nargs="*", default=None,
                    help="Directories to scan (default: whole system, '/').")
    ap.add_argument("--json", metavar="FILE", help="Write full findings as JSON.")
    ap.add_argument("--min-severity", default="low",
                    choices=["info", "low", "medium", "high", "critical"],
                    help="Minimum severity to print (default: low).")
    ap.add_argument("--quiet", action="store_true", help="Only print the summary.")
    ap.add_argument("--no-prefilter", action="store_true",
                    help="Open EVERY regular file to read its magic (literal full "
                         "coverage; much slower on dev machines with node_modules).")
    args = ap.parse_args()

    roots = args.roots if args.roots else ["/"]
    excludes = set(DEFAULT_EXCLUDES)

    stats = {"files_walked": 0, "skipped_prefilter": 0, "files_examined": 0,
             "macho_parsed": 0, "parse_failures": 0, "unreadable_files": 0,
             "unreadable_dirs": 0, "hosts_with_findings": 0}
    sev_rank = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
    min_rank = sev_rank[args.min_severity]

    ftype_name = {MH_EXECUTE: "executable", MH_DYLIB: "dylib", MH_BUNDLE: "bundle"}
    root_ctx = build_root_context()
    all_findings = []
    inventory = []
    t0 = time.time()
    for fp in iter_macho_files(roots, excludes, stats, no_prefilter=args.no_prefilter):
        img = parse_macho(fp)
        if img is None:
            stats["parse_failures"] += 1
            continue
        stats["macho_parsed"] += 1
        if stats["macho_parsed"] % 3000 == 0:
            print(f"  ... {stats['macho_parsed']:,} Mach-O parsed, "
                  f"{stats['files_walked']:,} files walked, {time.time() - t0:.0f}s",
                  file=sys.stderr, flush=True)
        if img.filetype not in ftype_name or not img.imports:
            continue
        recs = classify_imports(fp, img)
        hij = [r for r in recs if r["verdict"] == "hijackable"]
        runs_as_root, root_reason = root_execution(fp, root_ctx)
        if hij:
            sig = library_validation(fp)
            lv = sig["library_validation"]
            for r in hij:


                sev, elev, kind, lreason = elevation_verdict(fp, r.get("writer"), lv, root_ctx)
                r["severity"] = sev
                r["elevation"] = elev
                r["elevation_kind"] = kind
                all_findings.append({
                    "host": fp, "filetype": ftype_name[img.filetype],
                    "kind": "weak-missing" if "weak import" in r["reason"] else "rpath-order",
                    "import": r["import"], "hijack_slot": r["slot"], "reason": r["reason"],
                    "writer": r.get("writer"), "elevation": elev, "elevation_kind": kind,
                    "signed": sig["signed"], "hardened_runtime": sig["hardened_runtime"],
                    "library_validation": lv, "team_id": sig["team_id"],
                    "runs_as_root": runs_as_root, "root_reason": root_reason,
                    "severity": sev})
            host_sig = {"signed": sig["signed"], "hardened_runtime": sig["hardened_runtime"],
                        "library_validation": lv, "team_id": sig["team_id"]}
            stats["hosts_with_findings"] += 1
        else:
            host_sig = {"signed": None, "hardened_runtime": None,
                        "library_validation": None, "team_id": None}
        inventory.append({"host": fp, "filetype": ftype_name[img.filetype],
                          "imports": recs, "runs_as_root": runs_as_root,
                          "root_reason": root_reason, **host_sig})
    elapsed = time.time() - t0

    all_findings.sort(key=lambda f: -sev_rank[f["severity"]])
    counts = {"info": 0, "low": 0, "medium": 0, "high": 0, "critical": 0}
    for f in all_findings:
        counts[f["severity"]] += 1

    if not args.quiet:
        for f in all_findings:
            if sev_rank[f["severity"]] < min_rank:
                continue
            lv = "LV-protected" if f["library_validation"] else "NO library validation"
            print(f"[{f['severity'].upper():6}] {f['kind']}  ({lv})")
            print(f"         host : {f['host']}")
            print(f"         import: {f['import']}")
            print(f"         slot  : {f['hijack_slot']}")
            print(f"         why   : {f['reason']}")
            print()

    print("=" * 70)
    print("COVERAGE")
    print(f"  files walked       : {stats['files_walked']:,}")
    print(f"  skipped (prefilter): {stats['skipped_prefilter']:,}  (can't be Mach-O by name/mode)")
    print(f"  magic-checked      : {stats['files_examined']:,}")
    print(f"  Mach-O parsed      : {stats['macho_parsed']:,}")
    print(f"  parse failures     : {stats['parse_failures']:,}")
    print(f"  unreadable files   : {stats['unreadable_files']:,}  (blind spot: permissions)")
    print(f"  unreadable dirs    : {stats['unreadable_dirs']:,}  (blind spot: permissions/SIP)")
    print(f"  elapsed            : {elapsed:.1f}s")
    print("  NOTE: system dylibs in the dyld shared cache are not on-disk files;")
    print("        they are Apple-signed with library validation and out of scope.")
    n_elev = sum(1 for f in all_findings if f.get("elevation"))
    print("FINDINGS")
    print(f"  hosts with findings: {stats['hosts_with_findings']:,}")
    print(f"  ELEVATION (privesc): {n_elev:,}   (writer less privileged than loader -> crosses a boundary)")
    print(f"    of which CRITICAL: {counts['critical']:,}   (ordinary local user -> root)")
    print(f"  high    : {counts['high']:,}   (plantable slot, NO library validation)")
    print(f"  low     : {counts['low']:,}   (slot exists but library validation blocks it)")
    print("=" * 70)

    if args.json:


        with open(args.json, "w") as fh:
            json.dump({"stats": stats, "elapsed_seconds": elapsed,
                       "counts": counts, "findings": all_findings,
                       "inventory": inventory}, fh, separators=(",", ":"))
        print(f"Findings + full inventory written to {args.json} "
              f"({len(inventory):,} hosts)")


if __name__ == "__main__":
    main()
```

