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
|
import stat
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from applepy.checks.classify import SEV_HARDENING, SEV_LIVE, classify_groupwrite
|
||||||
from applepy.context import RunContext
|
from applepy.context import RunContext
|
||||||
from applepy.findings import Finding, Severity
|
from applepy.findings import Finding, Severity
|
||||||
from applepy.registry import CheckRegistry
|
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]:
|
def check_launchdaemon_bin_writable(ctx: RunContext) -> list[Finding]:
|
||||||
"""Detect LaunchDaemon/LaunchAgent plists whose referenced binary is world- or group-writable."""
|
"""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:
|
for root in _LAUNCHD_ROOTS:
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
@@ -277,26 +279,37 @@ def check_launchdaemon_bin_writable(ctx: RunContext) -> list[Finding]:
|
|||||||
bin_path = Path(prog)
|
bin_path = Path(prog)
|
||||||
if not bin_path.is_file():
|
if not bin_path.is_file():
|
||||||
continue
|
continue
|
||||||
if _is_writable_by_non_root(bin_path):
|
if not _is_writable_by_non_root(bin_path):
|
||||||
try:
|
continue
|
||||||
mode = oct(bin_path.stat().st_mode)
|
verdict = classify_groupwrite(str(bin_path))
|
||||||
except OSError:
|
if verdict is None:
|
||||||
mode = "unknown"
|
continue
|
||||||
hits.append(f"{plist_path.name} → {prog} (mode {mode})")
|
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)"
|
findings: list[Finding] = []
|
||||||
if hits:
|
|
||||||
status, sev = "FAIL", Severity.CRITICAL
|
live_raw = "\n".join(live_hits) if live_hits else "(none found)"
|
||||||
found_desc = f"{len(hits)} LaunchDaemon/LaunchAgent binary/binaries are writable by non-root"
|
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:
|
else:
|
||||||
status, sev = "PASS", Severity.INFORMATIONAL
|
live_status, live_sev = "PASS", Severity.INFORMATIONAL
|
||||||
found_desc = "All LaunchDaemon/LaunchAgent binaries are root-owned and not world/group-writable"
|
live_found = "All LaunchDaemon/LaunchAgent binaries are root-owned and not world/group-writable"
|
||||||
|
|
||||||
return [Finding(
|
findings.append(Finding(
|
||||||
id="privesc-ld-binary-writable",
|
id="privesc-ld-binary-writable",
|
||||||
title="Privilege Escalation — Writable LaunchDaemon/LaunchAgent Binary",
|
title="Privilege Escalation — Writable LaunchDaemon/LaunchAgent Binary",
|
||||||
category=_CATEGORY,
|
category=_CATEGORY,
|
||||||
severity=sev,
|
severity=live_sev,
|
||||||
description=(
|
description=(
|
||||||
"LaunchDaemons run as root at boot. If the binary a LaunchDaemon references is "
|
"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 "
|
"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(
|
evidence=_ev_privesc(
|
||||||
"ld-binary-writable",
|
"ld-binary-writable",
|
||||||
status,
|
live_status,
|
||||||
"All daemon binaries are root-owned and non-writable by unprivileged users",
|
"All daemon binaries are root-owned and non-writable by unprivileged users",
|
||||||
found_desc,
|
live_found,
|
||||||
raw,
|
live_raw,
|
||||||
),
|
),
|
||||||
worksheet=_WORKSHEET,
|
worksheet=_WORKSHEET,
|
||||||
mitre_techniques=("T1543.004",),
|
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."
|
"Investigate the owning application — this may indicate a software packaging defect."
|
||||||
),
|
),
|
||||||
references=("https://www.redfoxsec.com/blog/macos-security-privilege-escalation",),
|
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