privesc: replace naive group-write severity with per-principal classifier, adding checks/classify.py to distinguish exploitable findings from defence-in-depth regressions where the group has no non-root members.
This commit is contained in:
186
applepy/checks/classify.py
Normal file
186
applepy/checks/classify.py
Normal file
@@ -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*(?P<kind>user|group):(?P<name>[^\s]+)\s+"
|
||||
r"(?P<verb>allow|deny)\s+(?P<perms>[\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
|
||||
@@ -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):
|
||||
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)
|
||||
mode = oct(bin_path.stat().st_mode & 0o777)
|
||||
except OSError:
|
||||
mode = "unknown"
|
||||
hits.append(f"{plist_path.name} → {prog} (mode {mode})")
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user