diff --git a/applepy/checks/classify.py b/applepy/checks/classify.py new file mode 100644 index 0000000..c046fe6 --- /dev/null +++ b/applepy/checks/classify.py @@ -0,0 +1,186 @@ +"""Group-writeable launch-item finding classifier. + +Replaces the naive "group-write bit set => HIGH" rule with an enrichment +pipeline that classifies findings into: + + live — exploitable on this host right now + hardening — bit is set but no realistic principal can use it + ignore — false positive (e.g. wheel-only with no ACLs, tight parent) + +Motivating case: root:wheel 0o100775 on Microsoft AutoUpdate plists +was being reported as HIGH, but ``wheel`` typically has no non-root members +on macOS, making the finding non-exploitable. Treat it as a hardening +regression worth reporting upstream, not a critical finding for the host. +""" + +from __future__ import annotations + +import grp +import os +import re +import stat +import subprocess +from collections.abc import Iterable +from dataclasses import dataclass + +SEV_LIVE = "live" # currently exploitable +SEV_HARDENING = "hardening" # mode is loose but no live attacker principal +SEV_IGNORE = "ignore" # bit set is benign in context + + +@dataclass(frozen=True) +class Verdict: + severity: str + reason: str + principals: tuple[str, ...] # accounts that could exploit, if any + + +# ── group membership ───────────────────────────────────────────────────────── + +def _non_root_members(gid: int) -> list[str]: + """Return members of *gid* excluding root. + + macOS resolves Directory-Services groups (admin, staff) through nsswitch, + so the stdlib ``grp`` module sees them without shelling out to ``dscl``. + """ + try: + g = grp.getgrgid(gid) + except KeyError: + return [] + return [m for m in g.gr_mem if m != "root"] + + +# ── ACL parsing ────────────────────────────────────────────────────────────── + +# `ls -le` ACL lines look like: +# 0: user:bob allow write,delete,append +# 1: group:admin deny read +_ACL_LINE = re.compile( + r"^\s*\d+:\s*(?Puser|group):(?P[^\s]+)\s+" + r"(?Pallow|deny)\s+(?P[\w,]+)\s*$" +) +_WRITE_PERMS = frozenset({ + "write", "append", "delete", "writeattr", "writeextattr", + "writesecurity", "chown", +}) + + +def _acl_writers(path: str) -> list[str]: + """Return principals (user:name / group:name) with allow-write ACLs. + + Empty list means no ACLs grant write — the mode bits are the whole story. + """ + try: + out = subprocess.check_output( + ["/bin/ls", "-led", path], + stderr=subprocess.DEVNULL, + text=True, + ) + except subprocess.CalledProcessError: + return [] + + writers: list[str] = [] + for line in out.splitlines(): + m = _ACL_LINE.match(line) + if not m or m["verb"] != "allow": + continue + perms = set(m["perms"].split(",")) + if perms & _WRITE_PERMS: + writers.append(f"{m['kind']}:{m['name']}") + return writers + + +# ── parent-dir reachability ────────────────────────────────────────────────── + +def _parent_allows_replace(path: str) -> bool: + """True if a non-root principal could move-aside or unlink *path* via its + parent directory, regardless of the file's own mode. + """ + parent = os.path.dirname(path) or "/" + try: + st = os.stat(parent) + except OSError: + return False + # Group or other write on the parent dir → move-aside / replace primitive. + if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH): + return True + # Non-root owner on parent → owner can rewrite contents trivially. + if st.st_uid != 0: + return True + return False + + +# ── main classifier ────────────────────────────────────────────────────────── + +def classify_groupwrite(path: str) -> Verdict | None: + """Classify a launch item / privileged binary that has group or other + write set. Returns None if the path has no group/other write bit. + """ + try: + st = os.stat(path) + except OSError as e: + return Verdict(SEV_IGNORE, f"stat failed: {e}", ()) + + mode = st.st_mode + group_w = bool(mode & stat.S_IWGRP) + other_w = bool(mode & stat.S_IWOTH) + if not (group_w or other_w): + return None + + principals: list[str] = [] + + # World-writeable is always live — every local user qualifies. + if other_w: + principals.append("other:*") + + # Group-writeable is live only if the group has non-root members. + if group_w: + members = _non_root_members(st.st_gid) + if members: + try: + gname = grp.getgrgid(st.st_gid).gr_name + except KeyError: + gname = str(st.st_gid) + principals.extend(f"group:{gname}:{m}" for m in members) + + # ACLs can grant write independently of mode bits. + principals.extend(_acl_writers(path)) + + parent_replace = _parent_allows_replace(path) + + if principals: + why = ( + f"writeable by {len(principals)} non-root principal(s); " + + ("parent dir also reachable" if parent_replace else "parent dir is tight") + ) + return Verdict(SEV_LIVE, why, tuple(principals)) + + if parent_replace: + return Verdict( + SEV_LIVE, + "file mode appears safe but parent directory permits " + "move-aside / replace by a non-root principal", + (), + ) + + # Bit is set, but nobody can realistically use it on this host. + # Still worth surfacing so the vendor can ship 0o755 next time. + return Verdict( + SEV_HARDENING, + "group-writeable bit set, but group has no non-root members, " + "no ACLs grant write, and parent dir is tight — defence-in-depth " + "regression only", + (), + ) + + +# ── batch helper ───────────────────────────────────────────────────────────── + +def reclassify(paths: Iterable[str]) -> dict[str, Verdict]: + """Run classify_groupwrite over a set of paths.""" + out: dict[str, Verdict] = {} + for p in paths: + v = classify_groupwrite(p) + if v is not None: + out[p] = v + return out diff --git a/applepy/checks/privesc.py b/applepy/checks/privesc.py index 039206b..c0ed5f1 100644 --- a/applepy/checks/privesc.py +++ b/applepy/checks/privesc.py @@ -17,6 +17,7 @@ import sqlite3 import stat from pathlib import Path +from applepy.checks.classify import SEV_HARDENING, SEV_LIVE, classify_groupwrite from applepy.context import RunContext from applepy.findings import Finding, Severity from applepy.registry import CheckRegistry @@ -265,7 +266,8 @@ def _parse_launchd_program(plist_path: Path) -> str | None: def check_launchdaemon_bin_writable(ctx: RunContext) -> list[Finding]: """Detect LaunchDaemon/LaunchAgent plists whose referenced binary is world- or group-writable.""" - hits: list[str] = [] + live_hits: list[str] = [] + hardening_hits: list[str] = [] for root in _LAUNCHD_ROOTS: if not root.is_dir(): @@ -277,26 +279,37 @@ def check_launchdaemon_bin_writable(ctx: RunContext) -> list[Finding]: bin_path = Path(prog) if not bin_path.is_file(): continue - if _is_writable_by_non_root(bin_path): - try: - mode = oct(bin_path.stat().st_mode) - except OSError: - mode = "unknown" - hits.append(f"{plist_path.name} → {prog} (mode {mode})") + if not _is_writable_by_non_root(bin_path): + continue + verdict = classify_groupwrite(str(bin_path)) + if verdict is None: + continue + try: + mode = oct(bin_path.stat().st_mode & 0o777) + except OSError: + mode = "unknown" + entry = f"{plist_path.name} → {prog} (mode {mode})" + if verdict.severity == SEV_LIVE: + live_hits.append(entry) + elif verdict.severity == SEV_HARDENING: + hardening_hits.append(f"{entry} [{verdict.reason}]") + # SEV_IGNORE → skip - raw = "\n".join(hits) if hits else "(none found)" - if hits: - status, sev = "FAIL", Severity.CRITICAL - found_desc = f"{len(hits)} LaunchDaemon/LaunchAgent binary/binaries are writable by non-root" + findings: list[Finding] = [] + + live_raw = "\n".join(live_hits) if live_hits else "(none found)" + if live_hits: + live_status, live_sev = "FAIL", Severity.CRITICAL + live_found = f"{len(live_hits)} LaunchDaemon/LaunchAgent binary/binaries are writable by non-root" else: - status, sev = "PASS", Severity.INFORMATIONAL - found_desc = "All LaunchDaemon/LaunchAgent binaries are root-owned and not world/group-writable" + live_status, live_sev = "PASS", Severity.INFORMATIONAL + live_found = "All LaunchDaemon/LaunchAgent binaries are root-owned and not world/group-writable" - return [Finding( + findings.append(Finding( id="privesc-ld-binary-writable", title="Privilege Escalation — Writable LaunchDaemon/LaunchAgent Binary", category=_CATEGORY, - severity=sev, + severity=live_sev, description=( "LaunchDaemons run as root at boot. If the binary a LaunchDaemon references is " "writable by an unprivileged user, that user can replace the binary and have their " @@ -304,10 +317,10 @@ def check_launchdaemon_bin_writable(ctx: RunContext) -> list[Finding]: ), evidence=_ev_privesc( "ld-binary-writable", - status, + live_status, "All daemon binaries are root-owned and non-writable by unprivileged users", - found_desc, - raw, + live_found, + live_raw, ), worksheet=_WORKSHEET, mitre_techniques=("T1543.004",), @@ -320,7 +333,41 @@ def check_launchdaemon_bin_writable(ctx: RunContext) -> list[Finding]: "Investigate the owning application — this may indicate a software packaging defect." ), references=("https://www.redfoxsec.com/blog/macos-security-privilege-escalation",), - )] + )) + + if hardening_hits: + hardening_raw = "\n".join(hardening_hits) + findings.append(Finding( + id="privesc-ld-binary-writable-hardening", + title="Hardening — Group-Writeable LaunchDaemon/LaunchAgent Binary (Unexploitable on This Host)", + category="Hardening", + severity=Severity.LOW, + description=( + "These LaunchDaemon/LaunchAgent binaries have the group-writeable bit set, but the " + "group has no non-root members on this host and no ACLs grant write access. " + "The bit poses no immediate exploitation risk but should be removed upstream " + "(vendor should ship mode 0o755)." + ), + evidence=_ev_privesc( + "ld-binary-writable-hardening", + "WARN", + "LaunchDaemon/LaunchAgent binaries use minimal permissions (0o755 or stricter)", + f"{len(hardening_hits)} binary/binaries have group-writeable bit with no exploitable principal", + hardening_raw, + ), + worksheet=_WORKSHEET, + mitre_techniques=("T1543.004",), + risk="Bit is currently unexploitable but represents a defence-in-depth regression if group membership changes.", + impact="Negligible on this host; report to the software vendor for remediation in a future release.", + remediation=( + "Report to the software vendor; no immediate host-level action required.\n" + "If patching locally:\n" + " sudo chmod 755 /path/to/binary" + ), + references=("https://www.redfoxsec.com/blog/macos-security-privilege-escalation",), + )) + + return findings # ---------------------------------------------------------------------------