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:
Warezpeddler
2026-05-05 21:36:25 +01:00
parent a67459e7e0
commit 575d119542
2 changed files with 252 additions and 19 deletions

186
applepy/checks/classify.py Normal file
View 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

View File

@@ -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
# ---------------------------------------------------------------------------