diff --git a/cspell.json b/cspell.json index cd80c79a..ee2d3ae9 100644 --- a/cspell.json +++ b/cspell.json @@ -213,6 +213,7 @@ "fallbacked", "Fapi", "Fbar", + "Fcomp", "fde", "fea", "ffcecb", @@ -401,6 +402,7 @@ "passwordless", "pcap", "pcapng", + "pcaps", "pcr", "PDBs", "peekable", @@ -469,6 +471,7 @@ "rockylinux", "rolename", "rootdir", + "rprivate", "rpmbuild", "RPMS", "rstr", @@ -527,6 +530,7 @@ "stdint", "stdoutput", "stduser", + "subshell", "subsecond", "substatus", "Substatuses", diff --git a/linux-ebpf/ebpf_cgroup.c b/linux-ebpf/ebpf_cgroup.c index b49036cf..d40bb3ed 100644 --- a/linux-ebpf/ebpf_cgroup.c +++ b/linux-ebpf/ebpf_cgroup.c @@ -42,8 +42,8 @@ struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); __type(key, struct gpa_audit_key); // source port and protocol - __type(value, struct gpa_audit_event); // audit event (canonical struct) - __uint(max_entries, 200); // LRU evicts oldest on overflow + __type(value, struct gpa_linux_audit_event); // audit event + executable identity + __uint(max_entries, 200); // LRU evicts oldest on overflow } audit_map SEC(".maps"); struct @@ -56,7 +56,7 @@ struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); __type(key, __u64); // pid-tgid or socket cookie - __type(value, struct gpa_sock_addr_local_entry); + __type(value, struct gpa_linux_sock_addr_local_entry); __uint(max_entries, 200); } local_map SEC(".maps"); @@ -83,6 +83,41 @@ local_ip_bind_monitor_only_enabled(void) return entry != NULL && entry->enabled != 0; } +static __always_inline void +capture_executable_identity(struct gpa_linux_executable_identity *identity) +{ + struct task_struct *task = (struct task_struct *)bpf_get_current_task(); + struct mm_struct *mm = BPF_CORE_READ(task, mm); + if (mm == NULL) + { + return; + } + + struct file *exe_file = BPF_CORE_READ(mm, exe_file); + if (exe_file == NULL) + { + return; + } + + struct inode *inode = BPF_CORE_READ(exe_file, f_inode); + if (inode == NULL) + { + return; + } + + struct super_block *super_block = BPF_CORE_READ(inode, i_sb); + if (super_block == NULL) + { + return; + } + + __u64 inode_number = BPF_CORE_READ(inode, i_ino); + identity->device = BPF_CORE_READ(super_block, s_dev); + identity->inode_low = (__u32)inode_number; + identity->inode_high = (__u32)(inode_number >> 32); + identity->valid = 1; +} + /* update audit map entry if not skip redirecting. return 0 if the entry is updated, otherwise @@ -124,8 +159,9 @@ update_local_map_entry(struct bpf_sock_addr *ctx, __be32 destination_ipv4, __u32 return 0; } - struct gpa_sock_addr_local_entry entry = {0}; - entry.audit = audit; + struct gpa_linux_sock_addr_local_entry entry = {0}; + entry.audit.audit = audit; + capture_executable_identity(&entry.audit.executable); entry.protocol = ctx->protocol; __u64 ret = bpf_map_update_elem(&local_map, &pid_tip, &entry, 0); if (ret != 0) @@ -252,13 +288,13 @@ int connect6(struct bpf_sock_addr *ctx) } static __always_inline int -update_audit_map_entry_sk(__u32 local_port, struct gpa_sock_addr_local_entry *local_entry) +update_audit_map_entry_sk(__u32 local_port, struct gpa_linux_sock_addr_local_entry *local_entry) { struct gpa_audit_key key = {0}; key.protocol = local_entry->protocol; key.source_port = local_port; - struct gpa_audit_event entry = local_entry->audit; + struct gpa_linux_audit_event entry = local_entry->audit; __u64 ret = bpf_map_update_elem(&audit_map, &key, &entry, 0); if (ret != 0) @@ -294,7 +330,7 @@ trace_tcp_connect(struct sock *sk) } // Find the entry in the local map. - struct gpa_sock_addr_local_entry *local_entry = bpf_map_lookup_elem(&local_map, &pid_tgid); + struct gpa_linux_sock_addr_local_entry *local_entry = bpf_map_lookup_elem(&local_map, &pid_tgid); if (local_entry != NULL) { update_audit_map_entry_sk(skc_num, local_entry); diff --git a/linux-ebpf/socket.h b/linux-ebpf/socket.h index 527e450d..23ea8de4 100644 --- a/linux-ebpf/socket.h +++ b/linux-ebpf/socket.h @@ -25,6 +25,36 @@ typedef struct gpa_audit_key sock_addr_audit_key; typedef struct gpa_audit_event sock_addr_audit_entry; typedef struct gpa_sock_addr_local_entry sock_addr_local_entry; +// Linux-only executable identity captured from the connecting task's mm->exe_file. +// Kept out of the shared gpa_audit_event so the Windows eBPF ABI is unchanged. +// Size: 16 bytes - matches Rust LinuxExecutableIdentity -> [u32; 4] +struct gpa_linux_executable_identity +{ + __u32 device; // super_block s_dev (kernel dev_t encoding) + __u32 inode_low; + __u32 inode_high; + __u32 valid; // 1 when the identity was read successfully +}; + +// Linux audit_map value: the shared audit event followed by the executable identity. +// Size: 44 bytes - matches Rust LinuxAuditMapValue -> [u32; 11] +struct gpa_linux_audit_event +{ + struct gpa_audit_event audit; + struct gpa_linux_executable_identity executable; +}; + +// Linux local_map value. Size: 48 bytes (12 x u32) +struct gpa_linux_sock_addr_local_entry +{ + __u32 protocol; + struct gpa_linux_audit_event audit; +}; + +_Static_assert(sizeof(struct gpa_linux_executable_identity) == 16, "executable identity must be 16 bytes"); +_Static_assert(sizeof(struct gpa_linux_audit_event) == 44, "linux audit event must be 44 bytes ([u32; 11])"); +_Static_assert(sizeof(struct gpa_linux_sock_addr_local_entry) == 48, "linux local entry must be 48 bytes"); + // IPv4 socket tuple (used for connection tracking) typedef struct _bpf_sock_tuple_ipv4 { @@ -76,4 +106,40 @@ struct sock { struct sock_common __sk_common; }; +// Minimal kernel structs used to read the connecting task's executable identity: +// task_struct->mm->exe_file->f_inode->{i_sb->s_dev, i_ino} +// As with the structs above, only the named fields are declared; CO-RE relocates +// them against the running kernel's BTF, and the type names must match the kernel's. + +// Filesystem superblock; s_dev is the backing device (dev_t encoding) of the file's +// filesystem. Together with i_ino it identifies the file independent of any path or +// mount namespace. +struct super_block { + __u32 s_dev; +}; + +// On-disk file object; (s_dev, i_ino) is the executable identity compared by user mode. +struct inode { + unsigned long i_ino; + struct super_block *i_sb; +}; + +// Open file object; f_inode is the inode backing the file. +struct file { + struct inode *f_inode; +}; + +// Process address space; exe_file is the file execve() mapped as the main +// executable (the same file /proc//exe resolves to), but read here without a +// path lookup, so a bind mount or symlink cannot change what is observed. +// May be NULL for kernel threads. +struct mm_struct { + struct file *exe_file; +}; + +// Current task (from bpf_get_current_task()); mm is NULL for kernel threads. +struct task_struct { + struct mm_struct *mm; +}; + #pragma clang attribute pop \ No newline at end of file diff --git a/pentest/linux/DESIGN.md b/pentest/linux/DESIGN.md index 85d6dcb5..42b2b5e7 100644 --- a/pentest/linux/DESIGN.md +++ b/pentest/linux/DESIGN.md @@ -58,7 +58,7 @@ Target: Guest Proxy Agent (GPA) running on this Azure VM |----|------|----------|----------------------------| | C1 | Run a non-elevated process; attempt WireServer call (must be denied per `WireServer::authorize` requiring `runAsElevated`). | 403 Forbidden, audit entry written. | Baseline AuthZ check: WireServer is privileged-by-default at GPA. If a non-root caller could reach goal-state, *any* container or unprivileged guest user could read VM secrets/extension config. Also verifies the audit-log path actually fires on deny (operators rely on it for incident response). | | C2 | `setuid` / capability dropping mid-process to confuse the `userId/runAsElevated` decision. | GPA reads creds at connect time from socket peer → still correct uid. | Confirms identity is captured from the kernel **at connect-time** via `SO_PEERCRED`, not re-read from `/proc` later. A TOCTOU here would let a process raise privileges *after* GPA cached its identity, or drop them to confuse audit attribution. | -| C3 | Bind-mount `/proc/self/exe` over a path matching an allowed binary; check the `processFullPath` reported in the log. | Identity comes from kernel-side eBPF/audit, not `/proc//exe` readlink. Decision unchanged. | Ensures `exePath`-based identity rules can't be spoofed by a mount-namespace attacker pretending to be `/usr/bin/curl` (or any other allow-listed binary). The matcher must use a kernel source of truth, not a user-controllable filesystem view. | +| C3 | In phase 4b S9, enter a private user+mount namespace, bind-mount the attacker's `/proc//exe` over allow-listed `/usr/bin/curl`, execute it through that path, and send the request using Bash builtins. | Genuine curl is allowed; the spoof is denied. Authorization compares the executable device/inode captured by eBPF at connect time with `/usr/bin/curl` resolved in GPA's trusted mount namespace. `/proc//exe` remains diagnostic only. | Ensures `exePath`-based identity rules can't be spoofed by a mount-namespace attacker pretending to be `/usr/bin/curl` (or any other allow-listed binary), and avoids post-connect `execve` and PID-reuse attribution races. | | C4 | `ptrace`-attach to an allowed process and inject syscall to `connect()` to IMDS. | Still attributed to that PID's cgroup/uid — this is by-design "in-process" trust; **document expected behavior**. | Documents the trust boundary: GPA cannot distinguish a victim process from a parasitic injector inside the same PID. The point of this test is to confirm the *boundary is documented*, not to claim it as a vuln — it sets reviewer expectations and prevents the threat model from drifting. | | C5 | New cgroup that is **not** a descendant of `/sys/fs/cgroup` root (e.g. unshare / `cgroupv1` mount in container). Try to evade the cgroup-attached eBPF and reach `169.254.169.254:80` directly. Harness probes `unshare -Cr -- true` first; auto-prepends `sudo -n` when not root and passwordless sudo is available. If the kernel disallows `unshare -Cr` for the caller (no `CAP_SYS_ADMIN`), record **INFO** ("environment limitation") instead of a misleading FAIL. | Connection still redirected to GPA (HTTP 200 from `169.254.169.254` via the redirect). If a `000` is observed despite a successful `unshare`, that's a real eBPF/cgroup gap — investigate `bpftool cgroup tree` and the attach point. | The cgroup-root-attached eBPF is the **single chokepoint** that forces all 169.254/168.63 traffic through GPA. If a container or `unshare` workload can move outside that cgroup hierarchy, it can talk to IMDS/WireServer with no AuthN at all. This is one of the highest-impact bypasses in the entire suite. | | C6 | Inside an unprivileged user namespace + new net namespace, route 169.254/16 directly. | Should fail (no key/no signature). Verify. | Even if eBPF is bypassed in a netns, the request still has no signature and the fabric should refuse it. Combined with C5, this gives a *layered* assurance: either the redirect catches it, or the fabric does — never both failing simultaneously. | @@ -244,4 +244,3 @@ cargo install --locked httpx-cli # optional ``` Plus a small workspace under [pentest/](./) (PoCs, pcaps, harness scripts). - diff --git a/pentest/linux/README.md b/pentest/linux/README.md index c7457dc3..ca70cfa8 100644 --- a/pentest/linux/README.md +++ b/pentest/linux/README.md @@ -15,7 +15,7 @@ lives in [DESIGN.md](DESIGN.md). | [phase2_listener/run.sh](phase2_listener/run.sh) | A1, A1b, A2, A3, A4 (CONNECT → 405), A5 (open-proxy), G1 (burst). | | [phase3_authn_authz/run.sh](phase3_authn_authz/run.sh) | P3-cap (tcpdump), B1a, B1b (kernel-side `ss -tnp` proof), B3, B4, C1 (low-IL probe via `nobody`), C5 (`unshare -Cr`), C7 (alt-IP-form parity). | | [phase4_rules_fuzz/url_diff.py](phase4_rules_fuzz/url_diff.py) | URL / encoding differential (11 variants of the IMDS token URL). | -| [phase4b_local_rules/run.py](phase4b_local_rules/run.py) | Local-file authorization-rules matrix: IMDS S1–S10 + S11/S12/S17/S18/S19 and WS S1–S10 + S11/S12/S17/S18/S19 (30 scenarios). Mutates `/var/lib/azure-proxy-agent/rules/`. | +| [phase4b_local_rules/run.py](phase4b_local_rules/run.py) | Local-file authorization-rules matrix: IMDS S1–S10 + S11/S12/S17/S18/S19 and WS S1–S10 + S11/S12/S17/S18/S19 (30 scenarios). S9 also runs C3's bind-mount executable spoof. Mutates `/var/lib/azure-proxy-agent/rules/`. | | [phase5_state_fs/audit.sh](phase5_state_fs/audit.sh) | E1 (keys/log/binary/unit mode+owner), F1 (no secrets in observable JSON), F4 (Rules dir mode+owner — admin-only WRITE), bpftool snapshots. | | [generate_report.py](generate_report.py) | Renders `results/report.html` from `results/findings.tsv` (+ latest `url_diff.tsv`). | | [run_all.sh](run_all.sh) | Driver. Runs phases 2 / 3 / 4 / 4b / 5 + report by default. Flags: `--no-truncate`, `--skip-phase{2,3,4,4b,5}`, `--phase4b-target {both,imds,wireserver}`, `--no-report`. | diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index 6131c309..42b2b5e7 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -1,1225 +1,246 @@ -#!/usr/bin/env python3 -""" -Phase 4b — auto-run pen-tests for the GPA local-file authorization rules. - -PRE-REQ: - The WS / fabric must already deliver a ruleId with - useLocalFileRules=true (so the agent merges the files in - /var/lib/azure-proxy-agent/rules/ instead of ignoring them). - The script verifies this at startup by reading status.json and the latest - AuthorizationRules_*.json snapshot. - -This script: - 1. Backs up any existing IMDS_Rules.json / WireServer_Rules.json. - 2. For each scenario: - a. Writes a crafted IMDS_Rules.json. - b. Waits for the agent's poll cycle (pollKeyStatusIntervalInSeconds + slack). - c. Sends a matrix of HTTP requests through GPA (eBPF-redirected) and - records the status code returned. - d. Compares each (request -> status) against the expected outcome. - 3. Restores backups and removes any files we created. - -Run as root: - sudo python3 phase4b_local_rules/run.py -""" -from __future__ import annotations -import argparse, datetime, http.client, json, os, shutil, signal, subprocess, sys, time -from contextlib import contextmanager -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Optional - -# ---------- environment ---------- -RULES_DIR = Path("/var/lib/azure-proxy-agent/rules") -LOG_DIR = Path("/var/log/azure-proxy-agent") -STATUS_FILE = LOG_DIR / "status.json" -IMDS_RULES_FILE = RULES_DIR / "IMDS_Rules.json" -WS_RULES_FILE = RULES_DIR / "WireServer_Rules.json" - -CONFIG_FILE = Path("/usr/lib/azure-proxy-agent/proxy-agent.json") # may not exist -POLL_FALLBACK_S = 15 -SLACK_S = 5 - -IMDS_HOST = "169.254.169.254" -IMDS_PORT = 80 -WS_HOST = "168.63.129.16" -WS_PORT = 80 - - -@dataclass -class Target: - name: str # "imds" | "wireserver" - host: str - port: int - rules_file: Path # IMDS_Rules.json | WireServer_Rules.json - - -TARGETS: dict[str, Target] = {} # filled in main() once paths are known - -# ---------- output ---------- -HERE = Path(__file__).resolve().parent -RESULTS_DIR = HERE.parent / "results" -RESULTS_DIR.mkdir(parents=True, exist_ok=True) -FINDINGS = RESULTS_DIR / "findings.tsv" -SCENARIO_LOG= RESULTS_DIR / "phase4b_local_rules.log" - -GREEN, RED, YELLOW, RESET = "\033[32m", "\033[31m", "\033[33m", "\033[0m" - - -def ts() -> str: - return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def record(test_id: str, status: str, msg: str = "") -> None: - color = {"PASS": GREEN, "FAIL": RED, "INFO": YELLOW}.get(status, "") - print(f"[{color}{status}{RESET}] {test_id} {msg}") - with FINDINGS.open("a") as fh: - fh.write(f"{ts()}\t{test_id}\t{status}\t{msg}\n") - - -# ---------- helpers ---------- - -def require_root() -> None: - if os.geteuid() != 0: - sys.exit("Must run as root (rules dir is 0700 root).") - - -def get_poll_interval() -> int: - """Read pollKeyStatusIntervalInSeconds from the agent config; fallback to 15.""" - for candidate in ( - CONFIG_FILE, - Path("/etc/azure-proxy-agent/proxy-agent.json"), - Path(__file__).resolve().parents[2] - / "proxy_agent" / "config" / "GuestProxyAgent.linux.json", - ): - try: - with candidate.open() as fh: - cfg = json.load(fh) - return int(cfg.get("pollKeyStatusIntervalInSeconds", POLL_FALLBACK_S)) - except Exception: - continue - return POLL_FALLBACK_S - - -def latest_authorization_rules_snapshot() -> Optional[Path]: - snaps = sorted(LOG_DIR.glob("AuthorizationRules_*.json")) - return snaps[-1] if snaps else None - - -def assert_use_local_file_rules_active() -> None: - """Read status.json + latest AuthorizationRules snapshot to confirm flag is on.""" - if not STATUS_FILE.exists(): - sys.exit(f"{STATUS_FILE} not found; is azure-proxy-agent running?") - status = json.loads(STATUS_FILE.read_text()) - snap = latest_authorization_rules_snapshot() - snap_text = snap.read_text() if snap else "" - - flag_in_snap = "useLocalFileRules-true" in snap_text - if not flag_in_snap: - record("PRE", "FAIL", - f"useLocalFileRules-true NOT present in {snap}; " - "the fabric is delivering plain rule ids — local rules will be ignored. " - "Enable useLocalFileRules from the control plane and retry.") - sys.exit(2) - - # The agent's local-rules merge logic honors `mode` ONLY from the remote - # rule (see local_rules.rs). Any `mode: enforce` we set in a local file - # is silently overridden by whatever the fabric is currently sending. - # If the live remote mode is `Audit` (or `Disabled`), every "expected - # 403" deny scenario will receive a 200 and report a misleading FAIL. - # Fail-fast here so the user knows to flip the fabric mode rather than - # interpret 32 bogus failures. - effective_modes = {} - try: - snap_json = json.loads(snap_text) - for tgt in ("imds", "wireserver"): - m = (snap_json.get("computedRules", {}).get(tgt, {}).get("mode") - or snap_json.get("inputRules", {}).get(tgt, {}).get("mode")) - if m: - effective_modes[tgt] = m - except Exception: - pass - non_enforce = {t: m for t, m in effective_modes.items() if m.lower() != "enforce"} - if non_enforce: - record("PRE", "FAIL", - f"effective remote mode is not Enforce ({non_enforce}); " - "local-rules deny scenarios will be silently audited and report bogus FAILs. " - "Switch the fabric/HostGAPlugin rule mode to Enforce and retry.") - sys.exit(2) - - record("PRE", "PASS", - f"useLocalFileRules-true confirmed in {snap.name if snap else '?'}; " - f"effective remote mode={effective_modes}") - - -def get_current_user_identity() -> dict: - """Return identity dict suitable for the rules engine, matching the - process/user that will actually be sending requests (root in this run). - - NOTE: probes are sent via python http.client, so processName/exePath - would be 'python3' / '/usr/bin/python3'. The engine AND-matches every - Some(_) field on Identity (None == wildcard), so we deliberately leave - exePath/processName UNSET to avoid false-alarm 403s. The dedicated S9 - scenarios that target exePath matching build their own identity inline.""" - user = subprocess.check_output(["id", "-un"], text=True).strip() - grp = subprocess.check_output(["id", "-gn"], text=True).strip() - return { - "name": "selfRoot", - "userName": user, - "groupName": grp, - } - - -def _wire_encode_path(path: str) -> str: - """Percent-encode non-ASCII path bytes as UTF-8 before send. - - Python's http.client transmits the request line via request.encode('ascii') - and raises UnicodeEncodeError on any raw non-ASCII char (e.g. the U+0131 - dotless-i confusable in token_unicode_dotless). That exception is caught - below as a false -1 — the request never reaches GPA, so we'd never observe - the real authorization decision. Browsers and .NET's System.Uri percent- - encode non-ASCII path bytes as UTF-8 before sending; mirror that so the - confusable arrives on the wire as %C4%B1 and GPA's 403 is actually exercised. - - Only non-ASCII codepoints are rewritten; ASCII (including existing %XX, - '/', '?', ';', '&', '=') passes through verbatim so every other probe is - byte-for-byte unchanged on the wire.""" - return "".join( - ch if ord(ch) < 0x80 else "".join(f"%{b:02X}" for b in ch.encode("utf-8")) - for ch in path - ) - - -def send(target: Target, method: str, path: str, - headers: Optional[dict] = None, timeout: float = 8.0) -> int: - h: dict[str, str] = {} - if target.name == "imds": - h["Metadata"] = "true" - elif target.name == "wireserver": - h["x-ms-version"] = "2012-11-30" - if headers: - h.update(headers) - try: - conn = http.client.HTTPConnection(target.host, target.port, timeout=timeout) - conn.request(method, _wire_encode_path(path), headers=h) - resp = conn.getresponse() - resp.read(64) - conn.close() - return resp.status - except Exception: - return -1 - - -# ---------- file management ---------- - -@contextmanager -def backup_rules_dir(targets: list[Target]): - """Backup the rules files for the given targets and any unrelated - pre-existing IMDS/WireServer rules files. Restore on exit.""" - backups: dict[Path, Path] = {} - paths_to_manage = {t.rules_file for t in targets} - # also back up the *other* rules file if present so we don't accidentally leave it behind - paths_to_manage.update({IMDS_RULES_FILE, WS_RULES_FILE}) - for p in paths_to_manage: - if p.exists(): - bak = p.with_suffix(p.suffix + f".pentest-bak.{int(time.time())}") - shutil.copy2(p, bak) - backups[p] = bak - try: - yield - finally: - for p in paths_to_manage: - try: - p.unlink() - except FileNotFoundError: - pass - for orig, bak in backups.items(): - shutil.move(str(bak), str(orig)) - - -def write_rules(path: Path, doc: Any) -> None: - """Write rules JSON atomically.""" - path.parent.mkdir(mode=0o700, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - if isinstance(doc, str): - tmp.write_text(doc) - else: - tmp.write_text(json.dumps(doc, indent=2)) - os.chmod(tmp, 0o600) - os.replace(tmp, path) - - -def write_raw(path: Path, raw_text: str) -> None: - path.parent.mkdir(mode=0o700, exist_ok=True) - path.write_text(raw_text) - os.chmod(path, 0o600) - - -# ---------- scenarios ---------- - -@dataclass -class Probe: - name: str - path: str - expected: Any # int or tuple/list of acceptable HTTP statuses - method: str = "GET" - - -@dataclass -class Scenario: - sid: str - target: Target - description: str - rules: Any # dict (will be json.dumps'd) or raw str - probes: list[Probe] = field(default_factory=list) - raw: bool = False # write rules verbatim (for malformed-JSON test) - bom: bool = False # prepend a UTF-8 BOM (EF BB BF) to the file - - -def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: - scenarios: list[Scenario] = [] - pfx = "IMDS" - - # S1: disabled + allow-by-default — all calls succeed (control) - scenarios.append(Scenario( - sid=f"{pfx}-S1-disabled-allow", - target=target, - description="mode=disabled, defaultAccess=allow → no enforcement, baseline 200s", - rules={ - "defaultAccess": "allow", - "mode": "disabled", - "id": "pentest-s1", - "rules": {}, - }, - probes=[ - Probe("instance", "/metadata/instance?api-version=2021-02-01", 200), - Probe("token", - "/metadata/identity/oauth2/token?api-version=2018-02-01" - "&resource=https://management.azure.com/", 200), - ], - )) - - # S2: enforce + deny-by-default, NO allow rules → everything 403 - scenarios.append(Scenario( - sid=f"{pfx}-S2-enforce-deny-empty", - target=target, - description="mode=enforce, defaultAccess=deny, no rules → all 403", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-s2", - "rules": {}, - }, - probes=[ - Probe("instance", "/metadata/instance?api-version=2021-02-01", 403), - Probe("versions", "/metadata/versions", 403), - ], - )) - - # S3: regression guard — the agent MUST IGNORE `mode` written into the - # local rules file. The merge logic in proxy_agent/src/key_keeper/local_rules.rs - # takes `mode` from the remote rule only; `LocalAuthorizationRulesFile` - # has no `mode` field, so serde silently drops it. - # PRE has confirmed the effective remote mode is `enforce`, so writing - # mode=audit + defaultAccess=deny + empty rules must still 403. A 200 here - # would mean the agent started honoring the local `mode` (audit-mode - # pass-through) — a security regression worth a high-sev finding. - scenarios.append(Scenario( - sid=f"{pfx}-S3-audit-deny-empty", - target=target, - description="local mode=audit must be IGNORED; remote enforce + local deny → 403", - rules={ - "defaultAccess": "deny", - "mode": "audit", - "id": "pentest-s3", - "rules": {}, - }, - probes=[ - Probe("instance_denied", "/metadata/instance?api-version=2021-02-01", 403), - Probe("versions_denied", "/metadata/versions", 403), - ], - )) - - # S4: explicit allow for /metadata/instance, deny everything else - scenarios.append(Scenario( - sid=f"{pfx}-S4-allow-one-path", - target=target, - description="enforce + deny, allow only /metadata/instance for current identity", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-s4", - "rules": { - "privileges": [{"name": "p_instance", "path": "/metadata/instance"}], - "roles": [{"name": "r_instance", "privileges": ["p_instance"]}], - "identities": [identity], - "roleAssignments": [{"role": "r_instance", - "identities": [identity["name"]]}], - }, - }, - probes=[ - Probe("instance_allowed", "/metadata/instance?api-version=2021-02-01", 200), - Probe("token_denied", - "/metadata/identity/oauth2/token?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("versions_denied", "/metadata/versions", 403), - ], - )) - - # S5: same allow rule but bound to a non-matching user → 403 - bogus_identity = {**identity, "name": "nobodyId", "userName": "nosuchuser_xyz"} - scenarios.append(Scenario( - sid=f"{pfx}-S5-wrong-identity", - target=target, - description="enforce + deny, allow path /metadata/instance for a non-matching user", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-s5", - "rules": { - "privileges": [{"name": "p_instance", "path": "/metadata/instance"}], - "roles": [{"name": "r_instance", "privileges": ["p_instance"]}], - "identities": [bogus_identity], - "roleAssignments": [{"role": "r_instance", - "identities": [bogus_identity["name"]]}], - }, - }, - probes=[ - Probe("instance_denied", "/metadata/instance?api-version=2021-02-01", 403), - ], - )) - - # S6: encoding bypass — rule allows /metadata/instance only; - # try to reach /metadata/identity/oauth2/token via percent-encoded slash. - scenarios.append(Scenario( - sid=f"{pfx}-S6-encoding-bypass", - target=target, - description="enforce + deny; allow /metadata/instance; " - "test %2F-encoded slash variants of token path", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-s6", - "rules": { - "privileges": [{"name": "p_instance", "path": "/metadata/instance"}], - "roles": [{"name": "r_instance", "privileges": ["p_instance"]}], - "identities": [identity], - "roleAssignments": [{"role": "r_instance", - "identities": [identity["name"]]}], - }, - }, - probes=[ - Probe("token_uppercase_pct", - "/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_lowercase_pct", - "/metadata/identity/oauth2%2ftoken?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_double_encoded", - "/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_question_encoded", - "/metadata/identity/oauth2/token%3Fapi-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - # Both 403 (denied by GPA) and 404 (forwarded but rejected by IMDS) - # demonstrate the bypass attempt FAILED — neither reaches a permitted - # endpoint. Accept either as a successful denial. - Probe("token_dot_segments", - "/metadata/./identity/../identity/oauth2/token" - "?api-version=2018-02-01" - "&resource=https://management.azure.com/", (403, 404)), - ], - )) - - # S7: query-parameter rule — only allow when api-version matches - scenarios.append(Scenario( - sid=f"{pfx}-S7-query-param-required", - target=target, - description="enforce + deny; allow /metadata/instance ONLY with " - "api-version=2021-02-01", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-s7", - "rules": { - "privileges": [{ - "name": "p_instance_q", - "path": "/metadata/instance", - "queryParameters": {"api-version": "2021-02-01"}, - }], - "roles": [{"name": "r_q", - "privileges": ["p_instance_q"]}], - "identities": [identity], - "roleAssignments": [{"role": "r_q", - "identities": [identity["name"]]}], - }, - }, - probes=[ - Probe("matching_version", - "/metadata/instance?api-version=2021-02-01", 200), - Probe("missing_version", - "/metadata/instance", 403), - Probe("wrong_version", - "/metadata/instance?api-version=2017-04-02", 403), - ], - )) - - # S8: identity match by groupName only - scenarios.append(Scenario( - sid=f"{pfx}-S8-group-only-identity", - target=target, - description="enforce + deny; identity matches by groupName only", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-s8", - "rules": { - "privileges": [{"name": "p_any", "path": "/metadata/"}], - "roles": [{"name": "r_any", "privileges": ["p_any"]}], - "identities": [{ - "name": "rootGroup", - "groupName": identity["groupName"], - }], - "roleAssignments": [{"role": "r_any", - "identities": ["rootGroup"]}], - }, - }, - probes=[ - Probe("instance_allowed", - "/metadata/instance?api-version=2021-02-01", 200), - ], - )) - - # S9: identity match by exePath — allow only when caller is /usr/bin/curl - scenarios.append(Scenario( - sid=f"{pfx}-S9-exepath-identity", - target=target, - description="enforce + deny; identity restricts to exePath=/usr/bin/curl", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-s9", - "rules": { - "privileges": [{"name": "p_any", "path": "/metadata/"}], - "roles": [{"name": "r_any", "privileges": ["p_any"]}], - "identities": [{ - "name": "curlOnly", - "exePath": "/usr/bin/curl", - }], - "roleAssignments": [{"role": "r_any", - "identities": ["curlOnly"]}], - }, - }, - probes=[ - # Probe is sent via http.client (python). It SHOULD be denied because - # exePath != /usr/bin/curl. We separately invoke curl below to get a 200. - Probe("python_caller_denied", - "/metadata/instance?api-version=2021-02-01", 403), - ], - )) - - # S10: malformed JSON → fail-closed - scenarios.append(Scenario( - sid=f"{pfx}-S10-malformed-json", - target=target, - description="malformed local rules JSON → agent must fail-closed (all 403)", - rules="{ this is not valid json", - raw=True, - probes=[ - Probe("instance_denied", - "/metadata/instance?api-version=2021-02-01", 403), - Probe("versions_denied", "/metadata/versions", 403), - ], - )) - - # ------------------------------------------------------------------ - # S11..S19 — local rules file as adversarial admin input - # ------------------------------------------------------------------ - - # S11: file written with an explicit UTF-8 BOM (EF BB BF). Regression - # guard for the misc_helpers::json_read_from_file BOM fix. - scenarios.append(Scenario( - sid=f"{pfx}-S11-utf8-bom", - target=target, - description="rules file prefixed with a UTF-8 BOM must still parse (deny → 403)", - rules={"defaultAccess": "deny", "mode": "enforce", - "id": "pentest-s11", "rules": {}}, - bom=True, - probes=[ - Probe("instance_denied", - "/metadata/instance?api-version=2021-02-01", 403), - Probe("versions_denied", "/metadata/versions", 403), - ], - )) - - # S12: duplicate "defaultAccess" keys (allow then deny); serde_json - # last-wins per RFC 8259 — the agent must deny. - scenarios.append(Scenario( - sid=f"{pfx}-S12-duplicate-keys", - target=target, - description="duplicate defaultAccess key (allow,deny) must resolve last-wins → 403", - rules='{"defaultAccess":"allow","defaultAccess":"deny",' - '"id":"pentest-s12","rules":{}}', - raw=True, - probes=[ - Probe("instance_denied", - "/metadata/instance?api-version=2021-02-01", 403), - Probe("versions_denied", "/metadata/versions", 403), - ], - )) - - # S17: local rule names that ALREADY contain the LocalFileRules_ prefix. - # Validates that prefix_local_rule_names is idempotent / consistent. - s17_identity = {**identity, "name": "LocalFileRules_idCurrentUser"} - scenarios.append(Scenario( - sid=f"{pfx}-S17-name-collision-shielded", - target=target, - description="local rule names already containing LocalFileRules_ prefix still bind correctly", - rules={ - "defaultAccess": "deny", "mode": "enforce", - "id": "pentest-s17", - "rules": { - "privileges": [{"name": "LocalFileRules_p_instance", - "path": "/metadata/instance"}], - "roles": [{"name": "LocalFileRules_r_instance", - "privileges": ["LocalFileRules_p_instance"]}], - "identities": [s17_identity], - "roleAssignments": [{"role": "LocalFileRules_r_instance", - "identities": [s17_identity["name"]]}], - }, - }, - probes=[ - Probe("instance_allowed", - "/metadata/instance?api-version=2021-02-01", 200), - Probe("versions_denied", "/metadata/versions", 403), - ], - )) - - # S18: dangling role -> privilege reference → validator rejects → fail-closed. - scenarios.append(Scenario( - sid=f"{pfx}-S18-dangling-references", - target=target, - description="role references non-existent privilege → validator rejects → 403", - rules={ - "defaultAccess": "allow", "mode": "enforce", - "id": "pentest-s18", - "rules": { - "privileges": [{"name": "p_real", "path": "/metadata/"}], - "roles": [{"name": "r_broken", - "privileges": ["p_ghost"]}], - "identities": [identity], - "roleAssignments": [{"role": "r_broken", - "identities": [identity["name"]]}], - }, - }, - probes=[ - Probe("instance_denied", - "/metadata/instance?api-version=2021-02-01", 403), - Probe("versions_denied", "/metadata/versions", 403), - ], - )) - - # S19: identity userName uses Cyrillic homoglyphs (о = U+043E) in place - # of ASCII 'o'. Matcher must use byte-exact compare — current user 'root' - # must NOT match 'rооt'. - homo_user = (identity["userName"][0] - + ("\u043e" * (len(identity["userName"]) - 2)) - + identity["userName"][-1]) \ - if len(identity["userName"]) >= 3 else "r\u043e\u043et" - homo_identity = {**identity, "name": "homoUser", "userName": homo_user} - scenarios.append(Scenario( - sid=f"{pfx}-S19-identity-homoglyph", - target=target, - description="Cyrillic homoglyph in userName must NOT match ASCII current user → 403", - rules={ - "defaultAccess": "deny", "mode": "enforce", - "id": "pentest-s19", - "rules": { - "privileges": [{"name": "p_instance", - "path": "/metadata/instance"}], - "roles": [{"name": "r_instance", - "privileges": ["p_instance"]}], - "identities": [homo_identity], - "roleAssignments": [{"role": "r_instance", - "identities": [homo_identity["name"]]}], - }, - }, - probes=[ - Probe("instance_denied", - "/metadata/instance?api-version=2021-02-01", 403), - Probe("versions_denied", "/metadata/versions", 403), - ], - )) - - # S20: explicit fail-closed of the IMDS managed-identity token endpoint. - # defaultAccess=deny + mode=enforce + empty privileges means EVERY path — - # including every URL-equivalence variant of /metadata/identity/oauth2/ - # token (mixed case, %2F-encoded slash, double-encoded, trailing dot, - # extra slashes, fragment, matrix param, Unicode confusable) — must fall - # through to the deny default. With shadow-mode (M3) enabled this is the - # rule set under which the canonical authorizer can surface - # CANON_DIVERGENCE log lines: any variant where the legacy substring - # matcher and the canonical normalized-path matcher disagree shows up as - # `legacy=allow canon=deny` (or vice versa) in the GPA service log even - # though the on-wire response stays 403. Any 200 response here is a true - # bypass. - # `token_extra_slashes` may also surface as 404 once the pre-canonical - # traversal guard (path_has_traversal) catches `//` before the rule - # engine runs — both 403 and 404 mean the bypass attempt failed. - scenarios.append(Scenario( - sid=f"{pfx}-S20-deny-token-path", - target=target, - description="enforce + deny + no privileges; every URL-equivalence " - "variant of the IMDS token path must 403 (or 404 when " - "GPA traversal-guard fires)", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-s20", - "rules": {}, - }, - probes=[ - Probe("token_canonical", - "/metadata/identity/oauth2/token?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_uppercase_pct", - "/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_lowercase_pct", - "/metadata/identity/oauth2%2ftoken?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_double_encoded", - "/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_trailing_dot_path", - "/metadata/identity/oauth2/token./?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_param_injection", - "/metadata/identity/oauth2/token;x=1?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_case_path", - "/Metadata/Identity/OAuth2/Token?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_extra_slashes", - "//metadata///identity//oauth2//token?api-version=2018-02-01" - "&resource=https://management.azure.com/", (403, 404)), - Probe("token_fragment", - "/metadata/identity/oauth2/token?api-version=2018-02-01" - "&resource=https://management.azure.com/#x", 403), - Probe("token_unicode_dotless", - "/metadata/\u0131dentity/oauth2/token?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - ], - )) - - return scenarios - - -def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: - """WireServer-targeted local-file rule scenarios. - - WireServer is privileged-by-default (only root/admin allowed at the proxy - layer), so all probes assume the harness is running as root. - """ - scenarios: list[Scenario] = [] - pfx = "WS" - - GOALSTATE = "/machine/?comp=goalstate" - SHARED = "/machine/?comp=sharedConfig" - HOSTING = "/machine/?comp=hostingenvironmentconfig" - CERTS = "/machine/?comp=certificates" - EXTCONFIG = "/machine/?comp=extensionsConfig" - VERSIONS = "/?comp=versions" - - # WS-S1: control — disabled + allow - scenarios.append(Scenario( - sid=f"{pfx}-S1-disabled-allow", - target=target, - description="mode=disabled, defaultAccess=allow → baseline 200s", - rules={ - "defaultAccess": "allow", - "mode": "disabled", - "id": "pentest-ws-s1", - "rules": {}, - }, - probes=[ - Probe("goalstate", GOALSTATE, 200), - Probe("versions", VERSIONS, 200), - ], - )) - - # WS-S2: enforce + deny + no rules → all 403 - scenarios.append(Scenario( - sid=f"{pfx}-S2-enforce-deny-empty", - target=target, - description="mode=enforce, defaultAccess=deny, no rules → all 403", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-ws-s2", - "rules": {}, - }, - probes=[ - Probe("goalstate_denied", GOALSTATE, 403), - Probe("versions_denied", VERSIONS, 403), - Probe("shared_denied", SHARED, 403), - ], - )) - - # WS-S3: regression guard — the agent MUST IGNORE `mode` from the local - # rules file. Same contract as IMDS-S3. - scenarios.append(Scenario( - sid=f"{pfx}-S3-audit-deny-empty", - target=target, - description="local mode=audit must be IGNORED; remote enforce + local deny → 403", - rules={ - "defaultAccess": "deny", - "mode": "audit", - "id": "pentest-ws-s3", - "rules": {}, - }, - probes=[ - Probe("goalstate_denied", GOALSTATE, 403), - Probe("versions_denied", VERSIONS, 403), - ], - )) - - # WS-S4: only allow goalstate; everything else 403 - scenarios.append(Scenario( - sid=f"{pfx}-S4-allow-goalstate-only", - target=target, - description="enforce + deny; allow only /machine/ with comp=goalstate", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-ws-s4", - "rules": { - "privileges": [{ - "name": "p_goalstate", - "path": "/machine/", - "queryParameters": {"comp": "goalstate"}, - }], - "roles": [{"name": "r_goalstate", - "privileges": ["p_goalstate"]}], - "identities": [identity], - "roleAssignments": [{"role": "r_goalstate", - "identities": [identity["name"]]}], - }, - }, - probes=[ - Probe("goalstate_allowed", GOALSTATE, 200), - Probe("shared_denied", SHARED, 403), - Probe("hosting_denied", HOSTING, 403), - Probe("certs_denied", CERTS, 403), - Probe("extconfig_denied", EXTCONFIG, 403), - Probe("versions_denied", VERSIONS, 403), - ], - )) - - # WS-S5: identity mismatch → even goalstate 403 - bogus_identity = {**identity, "name": "nobodyId", "userName": "nosuchuser_xyz"} - scenarios.append(Scenario( - sid=f"{pfx}-S5-wrong-identity", - target=target, - description="enforce + deny; allow goalstate but bound to non-matching user", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-ws-s5", - "rules": { - "privileges": [{"name": "p_machine", "path": "/machine/"}], - "roles": [{"name": "r_machine", - "privileges": ["p_machine"]}], - "identities": [bogus_identity], - "roleAssignments": [{"role": "r_machine", - "identities": [bogus_identity["name"]]}], - }, - }, - probes=[ - Probe("goalstate_denied", GOALSTATE, 403), - ], - )) - - # WS-S6: encoding/path-traversal bypass attempts on a deny path. - # Allow only goalstate; try to reach certificates via encoded variants. - scenarios.append(Scenario( - sid=f"{pfx}-S6-encoding-bypass", - target=target, - description="enforce + deny; allow only goalstate; " - "test encoded/path-tricks for /machine/?comp=certificates", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-ws-s6", - "rules": { - "privileges": [{ - "name": "p_goalstate", - "path": "/machine/", - "queryParameters": {"comp": "goalstate"}, - }], - "roles": [{"name": "r_goalstate", - "privileges": ["p_goalstate"]}], - "identities": [identity], - "roleAssignments": [{"role": "r_goalstate", - "identities": [identity["name"]]}], - }, - }, - probes=[ - Probe("certs_uppercase_pct", - "/machine%2F?comp=certificates", 403), - Probe("certs_question_encoded", - "/machine/%3Fcomp=certificates", 403), - # 403 (denied at GPA) or 404 (forwarded but rejected upstream) both - # mean the bypass attempt failed. Accept either as success. - Probe("certs_dot_segments", - "/machine/./../machine/?comp=certificates", (403, 404)), - Probe("certs_extra_slashes", - "//machine///?comp=certificates", 403), - Probe("certs_value_case", - "/machine/?comp=Certificates", 403), - Probe("goalstate_value_case_should_match", - "/machine/?comp=GOALSTATE", 200), - ], - )) - - # WS-S7: comp= query parameter must match exactly - scenarios.append(Scenario( - sid=f"{pfx}-S7-query-param-required", - target=target, - description="enforce + deny; allow /machine/ ONLY with comp=goalstate", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-ws-s7", - "rules": { - "privileges": [{ - "name": "p_goalstate_q", - "path": "/machine/", - "queryParameters": {"comp": "goalstate"}, - }], - "roles": [{"name": "r_q", - "privileges": ["p_goalstate_q"]}], - "identities": [identity], - "roleAssignments": [{"role": "r_q", - "identities": [identity["name"]]}], - }, - }, - probes=[ - Probe("matching_comp", GOALSTATE, 200), - Probe("missing_comp", "/machine/", 403), - Probe("wrong_comp", "/machine/?comp=hostingenvironmentconfig", 403), - # GPA must FORWARD this (extra params don't break the privilege match); - # WireServer itself may then 400 on the unknown param. Both 200 and 400 - # mean GPA forwarded — only a 403 would indicate a GPA-side false deny. - Probe("extra_param_ok", - "/machine/?comp=goalstate&incarnation=1", (200, 400)), - ], - )) - - # WS-S8: identity match by groupName only - scenarios.append(Scenario( - sid=f"{pfx}-S8-group-only-identity", - target=target, - description="enforce + deny; identity matches by groupName only", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-ws-s8", - "rules": { - "privileges": [{"name": "p_machine", "path": "/machine/"}], - "roles": [{"name": "r_machine", - "privileges": ["p_machine"]}], - "identities": [{ - "name": "rootGroup", - "groupName": identity["groupName"], - }], - "roleAssignments": [{"role": "r_machine", - "identities": ["rootGroup"]}], - }, - }, - probes=[ - Probe("goalstate_allowed", GOALSTATE, 200), - ], - )) - - # WS-S9: per-process AuthZ via exePath — only /usr/bin/curl allowed - scenarios.append(Scenario( - sid=f"{pfx}-S9-exepath-identity", - target=target, - description="enforce + deny; identity restricts to exePath=/usr/bin/curl", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-ws-s9", - "rules": { - "privileges": [{"name": "p_machine", "path": "/machine/"}], - "roles": [{"name": "r_machine", - "privileges": ["p_machine"]}], - "identities": [{ - "name": "curlOnly", - "exePath": "/usr/bin/curl", - }], - "roleAssignments": [{"role": "r_machine", - "identities": ["curlOnly"]}], - }, - }, - probes=[ - Probe("python_caller_denied", GOALSTATE, 403), - ], - )) - - # WS-S10: malformed JSON → fail-closed - scenarios.append(Scenario( - sid=f"{pfx}-S10-malformed-json", - target=target, - description="malformed local rules JSON → agent must fail-closed (all 403)", - rules="{ this is not valid json", - raw=True, - probes=[ - Probe("goalstate_denied", GOALSTATE, 403), - Probe("versions_denied", VERSIONS, 403), - ], - )) - - # ------------------------------------------------------------------ - # S11..S19 — mirror of IMDS adversarial-admin-input tests - # ------------------------------------------------------------------ - - scenarios.append(Scenario( - sid=f"{pfx}-S11-utf8-bom", - target=target, - description="rules file prefixed with a UTF-8 BOM must still parse (deny → 403)", - rules={"defaultAccess": "deny", "mode": "enforce", - "id": "pentest-ws-s11", "rules": {}}, - bom=True, - probes=[ - Probe("goalstate_denied", GOALSTATE, 403), - Probe("versions_denied", VERSIONS, 403), - ], - )) - - scenarios.append(Scenario( - sid=f"{pfx}-S12-duplicate-keys", - target=target, - description="duplicate defaultAccess key (allow,deny) must resolve last-wins → 403", - rules='{"defaultAccess":"allow","defaultAccess":"deny",' - '"id":"pentest-ws-s12","rules":{}}', - raw=True, - probes=[ - Probe("goalstate_denied", GOALSTATE, 403), - Probe("versions_denied", VERSIONS, 403), - ], - )) - - ws17_identity = {**identity, "name": "LocalFileRules_idCurrentUser"} - scenarios.append(Scenario( - sid=f"{pfx}-S17-name-collision-shielded", - target=target, - description="local rule names already containing LocalFileRules_ prefix still bind correctly", - rules={ - "defaultAccess": "deny", "mode": "enforce", - "id": "pentest-ws-s17", - "rules": { - "privileges": [{"name": "LocalFileRules_p_machine", - "path": "/machine/"}], - "roles": [{"name": "LocalFileRules_r_machine", - "privileges": ["LocalFileRules_p_machine"]}], - "identities": [ws17_identity], - "roleAssignments": [{"role": "LocalFileRules_r_machine", - "identities": [ws17_identity["name"]]}], - }, - }, - probes=[Probe("goalstate_allowed", GOALSTATE, 200)], - )) - - scenarios.append(Scenario( - sid=f"{pfx}-S18-dangling-references", - target=target, - description="role references non-existent privilege → validator rejects → 403", - rules={ - "defaultAccess": "allow", "mode": "enforce", - "id": "pentest-ws-s18", - "rules": { - "privileges": [{"name": "p_real", "path": "/machine/"}], - "roles": [{"name": "r_broken", - "privileges": ["p_ghost"]}], - "identities": [identity], - "roleAssignments": [{"role": "r_broken", - "identities": [identity["name"]]}], - }, - }, - probes=[ - Probe("goalstate_denied", GOALSTATE, 403), - Probe("versions_denied", VERSIONS, 403), - ], - )) - - ws_homo_user = (identity["userName"][0] - + ("\u043e" * (len(identity["userName"]) - 2)) - + identity["userName"][-1]) \ - if len(identity["userName"]) >= 3 else "r\u043e\u043et" - ws_homo_identity = {**identity, "name": "homoUser", "userName": ws_homo_user} - scenarios.append(Scenario( - sid=f"{pfx}-S19-identity-homoglyph", - target=target, - description="Cyrillic homoglyph in userName must NOT match ASCII current user → 403", - rules={ - "defaultAccess": "deny", "mode": "enforce", - "id": "pentest-ws-s19", - "rules": { - "privileges": [{"name": "p_machine", "path": "/machine/"}], - "roles": [{"name": "r_machine", - "privileges": ["p_machine"]}], - "identities": [ws_homo_identity], - "roleAssignments": [{"role": "r_machine", - "identities": [ws_homo_identity["name"]]}], - }, - }, - probes=[ - Probe("goalstate_denied", GOALSTATE, 403), - Probe("versions_denied", VERSIONS, 403), - ], - )) - - return scenarios - - -def build_scenarios(targets: list[Target], identity: dict) -> list[Scenario]: - out: list[Scenario] = [] - for t in targets: - if t.name == "imds": - out.extend(_imds_scenarios(t, identity)) - elif t.name == "wireserver": - out.extend(_wireserver_scenarios(t, identity)) - else: - raise ValueError(f"unknown target {t.name}") - return out - - -# ---------- runner ---------- - -def wait_for_refresh(poll_s: int) -> None: - delay = poll_s + SLACK_S - print(f" ... waiting {delay}s for rule refresh ...") - time.sleep(delay) - - -def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: - print(f"\n=== {sc.sid}: {sc.description}") - if sc.raw: - text = sc.rules if isinstance(sc.rules, str) else json.dumps(sc.rules) - else: - text = json.dumps(sc.rules, indent=2) - if sc.bom: - # Deterministic explicit BOM, regardless of platform default encoding. - sc.target.rules_file.parent.mkdir(mode=0o700, exist_ok=True) - tmp = sc.target.rules_file.with_suffix(sc.target.rules_file.suffix + ".tmp") - tmp.write_bytes(b"\xef\xbb\xbf" + text.encode("utf-8")) - os.chmod(tmp, 0o600) - os.replace(tmp, sc.target.rules_file) - elif sc.raw: - write_raw(sc.target.rules_file, text) - else: - write_rules(sc.target.rules_file, sc.rules) - wait_for_refresh(poll_s) - - passes = fails = 0 - for p in sc.probes: - actual = send(sc.target, p.method, p.path) - if isinstance(p.expected, (tuple, list, set)): - ok = actual in p.expected - else: - ok = (actual == p.expected) - record(f"{sc.sid}/{p.name}", - "PASS" if ok else "FAIL", - f"expected={p.expected} actual={actual} path={p.path[:80]}") - passes += int(ok); fails += int(not ok) - - # Extra real-curl probe for S9 to demonstrate the positive case - if sc.sid.endswith("S9-exepath-identity"): - if sc.target.name == "imds": - url = (f"http://{sc.target.host}/metadata/instance" - "?api-version=2021-02-01") - extra_hdr = ["-H", "Metadata: true"] - else: # wireserver - url = f"http://{sc.target.host}/machine/?comp=goalstate" - extra_hdr = ["-H", "x-ms-version: 2012-11-30"] - try: - out = subprocess.run( - ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", - "--max-time", "8", *extra_hdr, url], - capture_output=True, text=True, timeout=15) - actual = int(out.stdout or "0") - except Exception: - actual = -1 - ok = (actual == 200) - record(f"{sc.sid}/curl_caller_allowed", - "PASS" if ok else "FAIL", - f"expected=200 actual={actual} (curl invocation)") - passes += int(ok); fails += int(not ok) - - return passes, fails - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--target", - choices=["imds", "wireserver", "both"], default="both", - help="which authorization target's local rules to exercise") - ap.add_argument("--scenarios", help="comma-separated scenario sids to run " - "(default: all). Match is exact.") - ap.add_argument("--poll", type=int, default=None, - help="override poll interval seconds") - args = ap.parse_args() - - require_root() - assert_use_local_file_rules_active() - - poll_s = args.poll or get_poll_interval() - record("CFG", "INFO", f"poll interval = {poll_s}s") - - identity = get_current_user_identity() - record("CFG", "INFO", - f"current identity: user={identity['userName']} " - f"group={identity['groupName']}") - - targets: list[Target] = [] - if args.target in ("imds", "both"): - targets.append(Target("imds", IMDS_HOST, IMDS_PORT, IMDS_RULES_FILE)) - if args.target in ("wireserver", "both"): - targets.append(Target("wireserver", WS_HOST, WS_PORT, WS_RULES_FILE)) - record("CFG", "INFO", - f"targets: {', '.join(t.name for t in targets)}") - - all_scenarios = build_scenarios(targets, identity) - if args.scenarios: - wanted = set(args.scenarios.split(",")) - all_scenarios = [s for s in all_scenarios if s.sid in wanted] - - total_p = total_f = 0 - with backup_rules_dir(targets): - for sc in all_scenarios: - try: - p, f = run_scenario(sc, poll_s) - except KeyboardInterrupt: - print("\nInterrupted; restoring backups.") - raise - except Exception as exc: - record(sc.sid, "FAIL", f"scenario crashed: {exc}") - p, f = 0, 1 - total_p += p; total_f += f - - print("\n========================================") - print(f"local-rules pen-test: {total_p} PASS, {total_f} FAIL") - print(f"Findings appended to {FINDINGS}") - return 0 if total_f == 0 else 1 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except SystemExit: - raise - except Exception as exc: - print(f"FATAL: {exc}", file=sys.stderr) - sys.exit(3) +# GPA Pen-Test Design + +Target: Guest Proxy Agent (GPA) running on this Azure VM +(kernel 6.11, listener `127.0.0.1:3080`, eBPF redirecting IMDS +`169.254.169.254:80`, WireServer `168.63.129.16:80`, HostGAPlugin +`168.63.129.16:32526`). + +--- + +## 1. Scope & Trust Model + +### In scope (the GPA enforcement boundary) +- TCP listener `127.0.0.1:3080` (the proxy itself). +- eBPF redirect program attached at the cgroup root. +- The HMAC signing path (`x-ms-azure-signature`) injected by GPA on authorized requests. +- AuthZ engine in [proxy_authorizer.rs](../../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../../proxy_agent/src/proxy/authorization_rules.rs). +- Key-keeper / latch state in `/var/lib/azure-proxy-agent/keys` and config in [GuestProxyAgent.linux.json](../../proxy_agent/config/GuestProxyAgent.linux.json). +- Provisioning, status, and log surfaces under `/var/log/azure-proxy-agent`. +- The handler/service binaries from the extension ([proxy_agent_extension/](../../proxy_agent_extension/), [proxy_agent_setup/](../../proxy_agent_setup/)). + +### Out of scope +- WireServer/IMDS server-side responses. +- The host fabric. +- Kernel CVEs unrelated to GPA. +- Azure control plane. + +### Attacker models to test against +1. **Unprivileged guest user** (no sudo) — primary threat model: confused-deputy / SSRF. +2. **Privileged guest user** (root in a container, root on host) — GPA still must not be a privilege oracle for WireServer if not allowed. +3. **Adjacent container / cgroup-namespaced workload** — can it bypass cgroup-attached eBPF? +4. **Network-adjacent attacker** (other VMs on same vNet / DHCP) — must not authenticate. +5. **Local malicious binary with bind-mount / path tricks** — can it forge `processFullPath`? + +--- + +## 2. Pen-Test Scenarios (mapped to GPA invariants) + +### A. Network exposure / listener hardening +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| A1 | Port-scan localhost and all NICs (`nmap -sT -p- 127.0.0.1` and ``). | Only `127.0.0.1:3080` open; nothing on external IFs. | The proxy must be reachable **only** from in-guest processes. Any binding to an external interface would let a network-adjacent attacker (vNet peer, lateral mover) speak to GPA directly and turn it into a fabric oracle. This is the most basic perimeter test — if it fails, every other AuthN/AuthZ control is moot. | +| A2 | Connect to `3080` from another VM in the vNet. | TCP RST / unreachable. | Cross-checks A1 from the *attacker's* viewpoint: even if a misconfig binds to `0.0.0.0`, NSG/host firewall must still drop it. Verifies defense-in-depth between the listener config and the cloud network filter. | +| A3 | Send malformed HTTP, oversized headers, slow-loris, chunked-encoding desync to `:3080`. | Graceful close, no crash, no memory growth. | Detects parser bugs (panic, OOM, integer overflow) in the hyper-based listener. A crash is an availability finding (fabric calls then fail-open or fail-closed depending on H4-style behavior); a desync would let one client smuggle requests under another client's identity — the worst-case AuthZ bypass. | +| A4 | TLS / HTTP/2 upgrade attempts, `CONNECT` method, Host-header smuggling. Automated probe is `curl -X CONNECT --request-target 'example.com:443' http://127.0.0.1:3080` (replaces an earlier `printf | nc` pipeline that raced the server's `Connection: close` FIN). | **HTTP/1.1 405 Method Not Allowed** with `Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH`; no proxying to arbitrary hosts. Implemented in [proxy_server.rs](../proxy_agent/src/proxy/proxy_server.rs) via an explicit `is_method_allowed` allow-list returned **before** any forwarding, plus `.with_upgrades()` on `serve_connection` so hyper actually delivers `CONNECT` to our handler instead of intercepting it as an upgrade. Unit test: `unsupported_methods_return_405`. | The proxy is HTTP/1.1-only and must not be a generic forward proxy. `CONNECT` or a smuggled `Host:` header could turn GPA into an SSRF gadget that talks to any internal IP from the *signed*, *authorized* GPA process — a privilege escalation against the host fabric. | +| A5 | Open-proxy test: `curl -x 127.0.0.1:3080 http://example.com`. | Refused — only IMDS/WireServer/HostGA destinations reachable. | Confirms the destination allow-list is enforced regardless of how the request is framed. Prevents using GPA as an exfiltration / pivot proxy and ensures the `x-ms-azure-signature` is never minted for a non-fabric URL. | + +### B. AuthN bypass — forging the signature +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| B1 | Direct `curl http://168.63.129.16/...` and `curl http://169.254.169.254/...` from guest, observe whether GPA inserts a valid `x-ms-azure-signature`. Then try the **same request bypassing 3080** (raw-socket / scapy with crafted SYN to the real IP, dropping the cgroup hook). | Without GPA injection, WireServer rejects; the signature must not be forgeable offline. | Validates the **fundamental authentication invariant**: only requests that traverse GPA receive a valid signature, and the fabric refuses any request that arrives without one. If a raw-socket call ever succeeds, an attacker can talk to WireServer/HostGAPlugin without going through any of GPA's authorization rules — effectively bypassing the entire product. | +| B2 | Replay attack: capture an authorized request from `/var/log/azure-proxy-agent/ProxyAgent.Connection.log` correlations + tcpdump on lo, replay it from another process. | Replay rejected (timestamp / nonce / per-connection binding). | A signature that is valid forever / for any caller would let any local process steal a previously-observed authorization. Confirms the signing scheme binds to time + connection context, not just URL+key. | +| B3 | Steal the key file: as non-root, attempt to read `/var/lib/azure-proxy-agent/keys/*`. As root, exfiltrate and try to sign from another VM. | Non-root: EACCES. Root-exfil: signature still rejected by WireServer for a different VM identity (vTPM/HostGAPlugin binding). | Two-layer test: (1) least-privilege access to key material on disk; (2) even with the key, the fabric should bind it to *this* VM's identity (provisioned via HostGAPlugin), so a stolen key is useless on another VM. Failure of (2) would mean a single host compromise yields cross-VM fabric access. | +| B1b | Open a TCP socket to the IMDS IP from the test shell and snapshot `ss -tnp` for the FD. The harness keeps the FD open in the *current* shell (an earlier subshell-scoped `( exec 3<> ... )` closed the FD before `ss` ran and produced empty results). | Kernel reports the connection's remote endpoint as `127.0.0.1:3080` (or the local GPA listener), proving the cgroup-attached eBPF redirect is in effect for this cgroup. PASS on `:3080`/`127.0.0.1`, FAIL if the kernel shows the raw IMDS IP. | Automated proof that the eBPF redirect is doing its job for the calling process — without this, B1's bypass attempt is meaningless because we can't tell whether the request was forced through GPA or simply happened to be answered by IMDS. | +| B4 | Submit a request with attacker-supplied `x-ms-azure-signature`/`x-ms-azure-time-tick` headers through the proxy. | GPA strips/overwrites client-supplied auth headers (verify in code path). | Prevents header smuggling: a malicious in-guest caller must not be able to *inject* a forged signature that GPA then forwards verbatim. GPA must always overwrite/normalize these headers before forwarding so the fabric only ever sees GPA's own signature. | + +### C. AuthZ bypass — cgroup / eBPF audit spoofing +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| C1 | Run a non-elevated process; attempt WireServer call (must be denied per `WireServer::authorize` requiring `runAsElevated`). | 403 Forbidden, audit entry written. | Baseline AuthZ check: WireServer is privileged-by-default at GPA. If a non-root caller could reach goal-state, *any* container or unprivileged guest user could read VM secrets/extension config. Also verifies the audit-log path actually fires on deny (operators rely on it for incident response). | +| C2 | `setuid` / capability dropping mid-process to confuse the `userId/runAsElevated` decision. | GPA reads creds at connect time from socket peer → still correct uid. | Confirms identity is captured from the kernel **at connect-time** via `SO_PEERCRED`, not re-read from `/proc` later. A TOCTOU here would let a process raise privileges *after* GPA cached its identity, or drop them to confuse audit attribution. | +| C3 | In phase 4b S9, enter a private user+mount namespace, bind-mount the attacker's `/proc//exe` over allow-listed `/usr/bin/curl`, execute it through that path, and send the request using Bash builtins. | Genuine curl is allowed; the spoof is denied. Authorization compares the executable device/inode captured by eBPF at connect time with `/usr/bin/curl` resolved in GPA's trusted mount namespace. `/proc//exe` remains diagnostic only. | Ensures `exePath`-based identity rules can't be spoofed by a mount-namespace attacker pretending to be `/usr/bin/curl` (or any other allow-listed binary), and avoids post-connect `execve` and PID-reuse attribution races. | +| C4 | `ptrace`-attach to an allowed process and inject syscall to `connect()` to IMDS. | Still attributed to that PID's cgroup/uid — this is by-design "in-process" trust; **document expected behavior**. | Documents the trust boundary: GPA cannot distinguish a victim process from a parasitic injector inside the same PID. The point of this test is to confirm the *boundary is documented*, not to claim it as a vuln — it sets reviewer expectations and prevents the threat model from drifting. | +| C5 | New cgroup that is **not** a descendant of `/sys/fs/cgroup` root (e.g. unshare / `cgroupv1` mount in container). Try to evade the cgroup-attached eBPF and reach `169.254.169.254:80` directly. Harness probes `unshare -Cr -- true` first; auto-prepends `sudo -n` when not root and passwordless sudo is available. If the kernel disallows `unshare -Cr` for the caller (no `CAP_SYS_ADMIN`), record **INFO** ("environment limitation") instead of a misleading FAIL. | Connection still redirected to GPA (HTTP 200 from `169.254.169.254` via the redirect). If a `000` is observed despite a successful `unshare`, that's a real eBPF/cgroup gap — investigate `bpftool cgroup tree` and the attach point. | The cgroup-root-attached eBPF is the **single chokepoint** that forces all 169.254/168.63 traffic through GPA. If a container or `unshare` workload can move outside that cgroup hierarchy, it can talk to IMDS/WireServer with no AuthN at all. This is one of the highest-impact bypasses in the entire suite. | +| C6 | Inside an unprivileged user namespace + new net namespace, route 169.254/16 directly. | Should fail (no key/no signature). Verify. | Even if eBPF is bypassed in a netns, the request still has no signature and the fabric should refuse it. Combined with C5, this gives a *layered* assurance: either the redirect catches it, or the fabric does — never both failing simultaneously. | +| C7 | IPv6 / link-local / alternate forms (`http://[::ffff:169.254.169.254]/...`, `http://0xa9fea9fe/...`, `http://2852039166/...`). Harness now captures the canonical response (status + sha256 of body) at B1a and **automatically diffs** each alt form against it. | **PASS** when alt status code AND body sha256 match the canonical (proves eBPF treated them identically). **PASS** also when alt is refused with a 4xx / `000` (no bypass). **INFO** when status matches but body sha differs (per-request token noise — manual diff). **FAIL** when alt returns a different non-4xx status than the canonical — possible eBPF/SSRF-filter bypass. | Ensures the eBPF redirect matches the **destination IP**, not a string form, so address-encoding tricks (hex, decimal, IPv4-mapped IPv6) can't reach the fabric while skipping GPA. Classic SSRF-filter bypass technique applied at the network layer. | + +### D. AuthZ rule engine fuzzing +Target: [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs) + +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| D1 | URL parsing differentials: `%2F` vs `/`, `%2f`, double-encoding, `;param`, `..`, trailing dot in host, mixed case scheme. | Rule match parity with server-side; no bypass of deny rule via encoding. | The classic AuthZ-bypass family: rule engine and upstream server disagree on what a URL means, so a deny rule for `/foo/bar` is evaded by `/foo%2Fbar`. Phase 4b's S6 scenarios are the automated form of this; D1 is the broader fuzz that exercises every encoding edge case the matcher must canonicalize. | +| D2 | Path-traversal in `processFullPath` matcher (symlink to allowed binary). | Deny based on resolved path / inode. | Companion to C3 from the rule-engine side: ensures `exePath` matching is on a canonical kernel-resolved path, not a string the caller can manufacture via symlinks/bind-mounts. | +| D3 | Identity case-sensitivity, Unicode normalization in user names / process names. | Stable match. | A rule for identity `"root"` must not be bypassed by `"Root"` or a Unicode-homoglyph variant, and conversely an allow for `"alice"` must not accidentally match `"ALICE"`. Ensures the matcher's normalization (lowercasing, NFC) is applied consistently and documented. | +| D4 | Property-based fuzz of the rule loader with malformed `AuthorizationRules_*.json` — ensure invalid JSON / huge file = reject, default-closed. | Service stays up; falls back to safe defaults. | The loader is a privileged code path that runs every poll cycle. A panic here either (a) crashes the agent (DoS), or (b) leaves stale rules in memory (potentially fail-open). Phase 4b's S10 covers the local-rules variant; D4 covers the fabric-delivered rules path. | +| D5 | TOCTOU between rule reload and an in-flight request. | Authorization re-checked per request. | A request that began under permissive rules must not slip through after a deny rule has been published. Confirms the engine snapshots rules per-request and doesn't latch a pre-reload decision — important for incident response when an operator pushes an emergency-deny rule. | + +### E. Key-keeper / provisioning +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| E1 | Permissions/ownership audit on `/var/lib/azure-proxy-agent/keys`, `/var/log/azure-proxy-agent`, the binary, the unit file, drop-ins. | Owned by root, mode 0700/0600 on secrets, no world-write. | Foundational hardening: world-readable key material lets *any* local user steal the signing key (which then needs to be defeated only by B3-style identity binding). World-writable binaries/units allow trivial root escalation on next service restart. Cheap, high-signal check that catches packaging regressions. | +| E2 | Symlink attack: pre-create the keys/log path as a symlink to `/etc/shadow` before service start. | Service refuses to follow / aborts. | Detects unsafe `open()` patterns (no `O_NOFOLLOW`, no `mkdir`-then-`open` race) that would let a non-root attacker who briefly controls `/var/lib` redirect GPA's writes to overwrite arbitrary root-owned files at service start. | +| E3 | Disk-full / read-only `/var` — fault injection. | Service degrades safely; does **not** drop to "open" mode. | Verifies the **fail-closed invariant** under storage failure. If GPA can't persist keys/rules and silently falls back to allowing all traffic, an attacker who can fill `/var` (or trigger a remount-ro) gains unauthenticated fabric access. | +| E4 | Kill `azure-proxy-agent` and observe whether eBPF redirect remains and whether traffic now reaches IMDS unauthenticated. | Either redirect persists denying traffic, or systemd restarts; no "fail-open" window allowing forged calls. | Tests the crash/restart safety window. There must never be a moment where the eBPF redirect is gone *and* the userspace authenticator is absent — that interval would let in-guest attackers reach raw IMDS/WireServer. Also catches eBPF prog-leak bugs (G4 is the count-side check). | +| E5 | Downgrade the latched key (replay an old `keys/*` file). | Rejected by HostGAPlugin / refresh logic. | Prevents key-rollback attacks: a root attacker who keeps a snapshot of an older key must not be able to swap it in to replay/forge requests after the fabric has rotated. Confirms key versioning/monotonicity. | + +### F. Local IPC / status surface +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| F1 | Read `/var/log/azure-proxy-agent/status.json`, `AuthorizationRules_*.json`, `ProxyAgent.Connection.log` as non-root. | If readable, ensure no secrets (key material, signatures) are leaked. | The status/log surface is intentionally observable for diagnostics. The risk is data leakage — if the signing key, raw signatures, or token bodies appear in those files, any local user becomes equivalent to root for fabric AuthN. Confirms the redaction policy is consistently applied. | +| F2 | Log injection — use a process with newline/control chars in cmdline (`prctl(PR_SET_NAME)` or `argv[0]` containing `\n{...fake JSON...}`). | Logs sanitize / escape; cannot forge audit entries. | If an attacker can inject newlines into the audit log, they can fabricate "PASS" entries that hide their real activity, or break SIEM parsers. Tests that all caller-controlled strings are escaped before being written. | +| F3 | Symlink/race on the log directory rotation. | No arbitrary file overwrite as root. | Same threat family as E2 but specific to log rotation. A non-root attacker who can swap the log file with a symlink to `/etc/passwd` between rotation and write would get an arbitrary-file-overwrite as root. | +| F4 | Mode/owner/group audit on `/var/lib/azure-proxy-agent/rules` (the dir and every file under it). | Owned by `root:root`; not world-writable; not group-writable by any non-root group. | Local rules are admin-controlled **authZ INPUT**, not secrets. The risk is integrity, not confidentiality: a non-root attacker who can WRITE a rules file can ship a doc with `useLocalFileRules` content (or, if the BOM/duplicate-key/dangling-ref guards in S11/S12/S18 ever regress, an effectively allow-all doc) and short-circuit remote enforcement contracts at the next refresh. Read perms are intentionally not flagged because the agent persists the merged ruleset to `/var/log/azure-proxy-agent/AuthorizationRules_*.json` anyway. | + +### G. Resource / DoS +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| G1 | Connection flood to `127.0.0.1:3080` from many PIDs. | Throttled; CPU/Memory cap from systemd drop-ins (`50-MemoryMax.conf`) kicks in; service survives. | A local DoS that takes GPA offline is effectively a fail-open enabler if combined with E4 weaknesses (window where redirect is up but authenticator is dead). Also a noisy-neighbor risk on shared VMs. | +| G2 | Large-body POST / very long URL. | Bounded; rejected with 4xx. | Memory-amplification / parser DoS guard. Unbounded body buffering is a common cause of OOM in proxy code; bounding it also mitigates one class of A3-style desyncs. | +| G3 | Many distinct cgroups generating audit entries — exercise `redirector::lookup_audit` map size. | No unbounded growth; LRU/eviction. | The eBPF audit map is finite. Without LRU/eviction, an attacker spawning many short-lived cgroups can fill the map and evict legitimate entries — effectively erasing identity for real callers and forcing a fail-open or fail-closed mode change. | +| G4 | Crash recovery: SIGKILL → systemd restart loop. | Restart succeeds; no orphan eBPF programs each restart (check `bpftool prog show`). | eBPF programs not cleanly detached on shutdown accumulate across restarts — each one keeps a kernel allocation pinned, eventually exhausting `kmemleak`/JIT memory and degrading the host. Also catches double-attach bugs that make the redirect ambiguous. | + +### H. Update / extension handler +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| H1 | `proxy_agent_setup` backup/restore tampering; supply a malicious previous-version archive. | Signature/manifest verification rejects. | The setup tool runs as root and replaces the production binary. If it accepts an unverified rollback archive, a root attacker (or a compromised extension publisher) can persist a backdoored GPA build that still looks legitimate to systemd. Verifies the supply-chain boundary at the agent's own update path. | +| H2 | Extension handler ([handler_main.rs](../proxy_agent_extension/src/handler_main.rs)) command-injection in settings / env. | Sanitized. | Settings come from the Azure control plane and are passed through Linux shell-ish surfaces (env vars, command lines). An unescaped `$(...)` or `;` would let a control-plane-side attacker (or a misconfigured ARM template) execute arbitrary commands as root on the guest — a classic confused-deputy escalation. | + +--- + +## 3. Execution Plan + +### Phase 0 — Prep (read-only, ~30 min) +- Snapshot the VM (or at least `/var/lib/azure-proxy-agent` and `/var/log/azure-proxy-agent`). +- Record baseline: `bpftool prog show`, `bpftool cgroup tree`, `ss -tnlp`, `systemctl cat azure-proxy-agent`, file modes under `/var/lib/azure-proxy-agent`, `/usr/sbin/azure-proxy-agent` hashes. +- Save current `AuthorizationRules_*.json` and `status.json`. + +### Phase 1 — Passive recon (no traffic to fabric) +- Run scenarios A1, E1, F1, F2 prep. +- Static review of [proxy_authorizer.rs](../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs) for the URL/identity matchers you'll exercise in D. + +### Phase 2 — Localhost / listener tests +- A1–A5, G1–G2 against `127.0.0.1:3080`. +- Tools: `nmap`, `curl`, `httpx`, `wrk`, custom Python (`socket`, `h2`, `scapy`). + +### Phase 3 — AuthN/AuthZ functional tests (with fabric) +- B1–B4, C1–C7 from the guest. Use a **non-root** test account, then a **root** account, then a **container** (`docker run --rm -it --network host alpine`) and a **user-namespace** (`unshare -Urn`) variant. +- Capture wire traffic with `tcpdump -i lo -i eth0 -w pen.pcap host 169.254.169.254 or host 168.63.129.16 or port 3080`. +- Verify by checking server response codes (200 vs 401/403) and the GPA `ProxyAgent.Connection.log` decision. + +### Phase 4 — Rule-engine fuzz +- D1–D5. Build a small Rust harness (cargo test inside the repo) plus a black-box URL fuzzer (`radamsa` or `boofuzz`) hitting `:3080` once an allow-rule is in place, then once with a deny-rule, and diff. + +### Phase 4b — Local-file authorization rules (`useLocalFileRules`) + +Automated by [pentest/linux/phase4b_local_rules/run.py](phase4b_local_rules/run.py). + +**Pre-conditions.** Two checks run at harness startup, both fail-fast as `PRE FAIL` so a misconfigured environment doesn't produce a wall of misleading per-scenario failures: + +1. **`useLocalFileRules-true` marker present** — the agent only consumes `/var/lib/azure-proxy-agent/rules/{IMDS,WireServer}_Rules.json` when the rule ID delivered by the fabric (HostGAPlugin) is a base64-encoded JSON whose `useLocalFileRules` field is `true`. The harness verifies this by grepping the latest `AuthorizationRules_*.json` snapshot under `/var/log/azure-proxy-agent` for the literal `useLocalFileRules-true`. +2. **Effective remote `mode` is `Enforce`** — `merge_authorization_item()` in [local_rules.rs](../proxy_agent/src/key_keeper/local_rules.rs) honors `mode` *only* from the remote rule. If the fabric is currently sending `Audit` or `Disabled`, every "expected 403" deny scenario will silently pass through and report a bogus FAIL. The harness parses `computedRules.{imds,wireserver}.mode` from the latest snapshot and aborts with `PRE FAIL` if either is non-Enforce. + +If either pre-condition fails, flip the flag / mode from your control plane (or mock fabric) and rerun. + +**Operating model.** Each scenario: +1. atomically writes a crafted `IMDS_Rules.json` or `WireServer_Rules.json` (mode 0600 root) into the rules dir, +2. waits `pollKeyStatusIntervalInSeconds + 5s` for the agent's next refresh, +3. fires a probe matrix and asserts the GPA-returned status code, +4. moves on to the next scenario; backups of any pre-existing rule files are restored on exit (including on `KeyboardInterrupt` / crash). + +#### IMDS scenarios (target `imds`) + +| ID | Rule shape | Probes & expected status | Why this scenario | +|----|------------|--------------------------|-------------------| +| `IMDS-S1-disabled-allow` | `mode=disabled`, `defaultAccess=allow`, no rules | `/metadata/instance` → 200, `/metadata/identity/oauth2/token` → 200 (control) | **Control / smoke test.** Establishes that the harness, the listener, the eBPF redirect and IMDS itself are all healthy *before* we start denying. If S1 fails, every later "deny worked" result is suspect because we haven't proven we can ever get a 200. | +| `IMDS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | `/metadata/instance` → 403, `/metadata/versions` → 403 | **Default-deny invariant.** With enforce mode and no allow rules, *everything* must be blocked. A FAIL here means the engine fails-open on missing rules — the highest-severity AuthZ regression possible. | +| `IMDS-S3-audit-deny-empty` | `audit` + `deny`, no rules (mode set in LOCAL file) | `/metadata/instance` → **403**, `/metadata/versions` → **403** | **Regression guard for local-`mode` isolation.** `LocalAuthorizationRulesFile` deliberately has no `mode` field, and `merge_authorization_item()` keeps the remote rule's mode. Since PRE has confirmed the live remote mode is `enforce`, writing `mode=audit` locally must be silently dropped and all probes must 403. A 200 here means somebody wired local `mode` into the merge logic — audit-mode pass-through becomes attacker-controllable from any process that can write to the Rules dir. | +| `IMDS-S4-allow-one-path` | `enforce` + `deny`; allow `/metadata/instance` for current identity | instance → 200, token → 403, versions → 403 | **Path-scoping precision.** Allowing one path must not leak access to siblings. Specifically guards against the `oauth2/token` endpoint (which mints managed-identity tokens) being reachable when only `/metadata/instance` was authorized. | +| `IMDS-S5-wrong-identity` | Same allow but bound to non-existent user | instance → 403 | **Identity is required.** A path-only allow must not match if the identity binding fails. Catches engines that short-circuit on path match and forget to AND with identity — a privilege-escalation if any in-guest user can reach an allow-listed path. | +| `IMDS-S6-encoding-bypass` | Allow `/metadata/instance` only | `/metadata/identity/oauth2%2Ftoken`, `%2f`, `%252F`, `%3F`, `./../` → all **403** | **Canonicalization of the request URL.** The classic AuthZ-bypass family from the SSRF/WAF world applied to GPA: percent-encoded slashes, double-encoding, and dot-segments must not let a request "look like" `/metadata/instance` to the matcher while actually reaching `/metadata/identity/oauth2/token`. (Dot-segment probes accept 403 OR 404 — both prove the bypass attempt did not reach a permitted endpoint.) | +| `IMDS-S7-query-param-required` | Privilege requires `api-version=2021-02-01` | matching → 200, missing → 403, wrong → 403 | **Query-parameter matching.** Some allow rules are pinned to a specific API version (e.g. to lock down a known-safe response shape). Verifies the matcher actually inspects the query string and doesn't treat path-only as sufficient. | +| `IMDS-S8-group-only-identity` | Identity match by `groupName` only | instance → 200 | **None == wildcard semantics.** Identity fields that are unset must act as wildcards (match-anything), and at least one set field must AND-match. Guards against accidental over-restriction when operators write a minimal identity (group-only is a common operational pattern). | +| `IMDS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` invocation → 200 | **Per-process identity provider works.** Verifies the kernel-side audit map actually populates `claims.processFullPath` so `exePath` rules are enforceable. If both halves return the same code, identity attribution is broken on this kernel — a regression that would let any process impersonate any other. | +| `IMDS-S10-malformed-json` | `IMDS_Rules.json` is invalid JSON | All probes → 403 (fail-closed) | **Fail-closed on rule-parse error.** A corrupted local rules file must NOT result in "no rules → default-allow" or in the agent crashing. Confirms the loader builds a deny-all sentinel ruleset on parse failure rather than silently reverting to an unrestricted state. | +| `IMDS-S11-utf8-bom` | Valid deny doc written with an explicit UTF-8 BOM (EF BB BF) prefix | All probes → 403 | **BOM-trim regression guard.** `misc_helpers::json_read_from_file` strips a leading BOM before handing bytes to `serde_json::from_str`. A 200 here would mean the BOM-fix regressed, parse failed, and the agent silently fell back to no-rules / allow. | +| `IMDS-S12-duplicate-keys` | Raw JSON with `"defaultAccess":"allow"` then `"defaultAccess":"deny"` in the same object | All probes → 403 | **Duplicate-key last-wins.** Per RFC 8259 / serde_json semantics the second value wins. A 200 would mean either first-wins (silent escalation if a future schema rewrite adds a stricter override) or that the parser raised an error and the agent treated "unparseable" as "no rules → allow". | +| `IMDS-S17-name-collision-shielded` | Local rules whose privilege/role/identity names ALREADY start with `LocalFileRules_` | instance → 200, versions → 403 | **Prefixer idempotency.** `prefix_local_rule_names` must rewrite both definitions AND cross-references consistently even when the admin pre-includes the prefix. 403 on instance would mean the prefixer broke the role assignment (e.g. renamed the definition but not the reference), turning a documented operator pattern into an outage. | +| `IMDS-S18-dangling-references` | Role whose `privileges` array names a privilege that is not defined | All probes → 403 | **Validator coverage.** `validate_local_rules_file` must reject; agent fail-closes. A 200 would mean the validator skipped the dangling-ref check and the role applied with an empty / undefined privilege list — a hint that the matcher's empty-rule semantics could be coerced into allow. | +| `IMDS-S19-identity-homoglyph` | Identity `userName` uses Cyrillic А (U+0410) in place of ASCII A; probe runs as the real ASCII user | All probes → 403 | **Byte-exact identity comparison.** The matcher must NOT apply Unicode normalization to identity strings. A 200 would mean NFC/NFKC normalization is happening somewhere in identity comparison and an attacker who can write the rules file could allow-list a homoglyph that the OS still resolves to the real user — silent privilege escalation. | + +#### WireServer scenarios (target `wireserver`) + +WireServer is privileged-by-default at the proxy layer (`runAsElevated` required), so the harness must run as root. Probes use `x-ms-version: 2012-11-30` and the standard goal-state endpoints: + +``` +/machine/?comp=goalstate +/machine/?comp=hostingenvironmentconfig +/machine/?comp=sharedConfig +/machine/?comp=certificates +/machine/?comp=extensionsConfig +/?comp=versions +``` + +| ID | Rule shape | Probes & expected status | Why this scenario | +|----|------------|--------------------------|-------------------| +| `WS-S1-disabled-allow` | `disabled`, `allow`, no rules | goalstate → 200, versions → 200 (control) | **Control / smoke test for the privileged path.** WireServer requires elevated identity by default, so this also confirms the test process is correctly recognized as root — prerequisite for every later WS scenario. | +| `WS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | goalstate → 403, versions → 403, sharedConfig → 403 | **Default-deny on the high-value target.** WireServer hands out goal-state, certificates and extension config; an empty-rules fail-open here directly leaks VM secrets. Highest-severity gate in the WS suite. | +| `WS-S3-audit-deny-empty` | `audit` + `deny`, no rules (mode set in LOCAL file) | goalstate → **403**, versions → **403** | **Regression guard for local-`mode` isolation on WS** (same purpose as IMDS-S3). The WireServer path is the more sensitive of the two — honoring a local `audit` would silently pass goalstate / certificates / extensionsConfig with deny-but-allow semantics. | +| `WS-S4-allow-goalstate-only` | Allow `/machine/` with `comp=goalstate` only | goalstate → 200; sharedConfig / hostingenvironmentconfig / certificates / extensionsConfig / versions → 403 | **Comp-scoped path matching.** All WS endpoints share the same path (`/machine/`) and differ only by query `comp=`. Confirms the engine's combined path+query matching is precise enough to allow `goalstate` while blocking the much more sensitive `certificates`/`extensionsConfig`. | +| `WS-S5-wrong-identity` | Allow goalstate but bound to non-matching user | goalstate → 403 | **Identity-required on WS.** Same purpose as IMDS-S5 but on the privileged target: an identity mismatch must turn an otherwise-matching path+query rule into a deny. | +| `WS-S6-encoding-bypass` | Allow only `goalstate` | `/machine%2F?comp=certificates`, `/machine/%3Fcomp=certificates`, `./../`, `//machine///`, `comp=Certificates` (case mismatch) → all 403; `comp=GOALSTATE` (case-insensitive value) → 200 (verifies the lowercase normalization is applied symmetrically). | **Canonicalization on WS.** Mirrors IMDS-S6 plus two WS-specific concerns: (1) extra slashes (`//machine///`) must not bypass the `/machine/` prefix check, (2) comp-value comparison must be case-insensitive **on both sides** — if normalization is one-sided, an attacker writes `comp=Certificates` to evade an allow that was authored as `comp=certificates`, or vice versa. | +| `WS-S7-query-param-required` | Require `comp=goalstate` | matching → 200, no `comp` → 403, `comp=hostingenvironmentconfig` → 403, `comp=goalstate&incarnation=1` → 200 | **Required vs extra params.** Required `comp=` must be present, but extra unrelated params (`incarnation`) must NOT cause a false deny — the WALinuxAgent legitimately appends extras. Catches over-strict matchers that compare the whole query string. (The 200 probe accepts 200 OR 400 since WireServer itself may reject the extra param; both prove GPA forwarded.) | +| `WS-S8-group-only-identity` | Identity match by `groupName` only | goalstate → 200 | **Wildcard semantics on WS.** Same purpose as IMDS-S8: confirms the operator-friendly minimal-identity pattern works on the privileged target. | +| `WS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` → 200 | **Per-process identity on WS.** Verifies `exePath` differentiation works for the privileged target — important because operators commonly want to limit WS access to the WALinuxAgent binary specifically, not "any root process". | +| `WS-S10-malformed-json` | `WireServer_Rules.json` is invalid JSON | goalstate → 403, versions → 403 (fail-closed) | **Fail-closed for WS rules.** Same purpose as IMDS-S10; failure here is even more severe because a parse-error fail-open on WS exposes certificates and extension config. | +| `WS-S11-utf8-bom` | Valid deny doc written with an explicit UTF-8 BOM (EF BB BF) prefix | goalstate → 403, versions → 403 | **BOM-trim regression guard on WS.** Same contract as IMDS-S11; consequence of regression is more severe (cert/extension exposure). | +| `WS-S12-duplicate-keys` | Raw JSON with duplicate `defaultAccess` keys (allow then deny) | goalstate → 403, versions → 403 | **Last-wins on WS.** Same contract as IMDS-S12. | +| `WS-S17-name-collision-shielded` | Local rules whose names already start with `LocalFileRules_`, allow-listing `/machine/` | goalstate → 200 | **Prefixer idempotency on WS.** Same contract as IMDS-S17 against the more sensitive target. | +| `WS-S18-dangling-references` | Role with a dangling privilege reference | goalstate → 403, versions → 403 | **Validator coverage on WS.** Same contract as IMDS-S18. | +| `WS-S19-identity-homoglyph` | Identity `userName` substitutes Cyrillic о (U+043E) for ASCII 'o'; probe runs as the real ASCII user | goalstate → 403, versions → 403 | **Byte-exact identity comparison on WS.** Same contract as IMDS-S19. Privilege-escalation impact is direct because WS endpoints expose certificates and extension config. | + +#### Invocation + +```bash +sudo phase4b_local_rules/run.py # both targets, all scenarios +sudo phase4b_local_rules/run.py --target imds # only IMDS matrix +sudo phase4b_local_rules/run.py --target wireserver # only WireServer matrix +sudo phase4b_local_rules/run.py --scenarios IMDS-S6-encoding-bypass,WS-S6-encoding-bypass +sudo phase4b_local_rules/run.py --poll 10 # override refresh wait +``` + +Findings are appended to `pentest/linux/results/findings.tsv`; a per-run console transcript lands in `pentest/linux/results/phase4b_local_rules.log`. + +**Triage rules.** +- Any `*S2`, `*S6`, `*S10` `FAIL` is a high-severity AuthZ-bypass / fail-open finding. +- `*S4`, `*S5`, `*S7` failures indicate identity/path/query matcher regressions. +- `*S9` failures (where `python_caller_denied` returns 200 or `curl_caller_allowed` returns 403) indicate the per-process identity provider is broken on this kernel. + +### Phase 5 — State / FS / persistence +- E1, F1, F4 (read-only audit) → [phase5_state_fs/audit.sh](phase5_state_fs/audit.sh). E1 covers keys/log/binary/unit modes+ownership; F1 grep-scans observable JSON for secret-shaped strings; F4 audits the local-rules dir (admin-only WRITE) since it is privileged authZ INPUT. +- E2–E5, F2–F3, H1–H2 are destructive (symlink races, disk-full, kill-and-watch, key downgrade, log injection) and are intentionally NOT in `audit.sh` — run them manually, and only on a snapshotted VM. + +### Phase 6 — Resilience / DoS +- G1–G4. Watch `systemd-cgtop`, `journalctl -fu azure-proxy-agent`, and `bpftool prog show` for prog leaks across restarts. + +### Phase 7 — Triage & report +- For each finding: PoC, severity (CVSS), affected file/line, suggested fix, whether already mitigated upstream. +- All harness records are appended (TSV) to `pentest/linux/results/findings.tsv`. The HTML report is built by [pentest/linux/generate_report.py](generate_report.py) and written to `pentest/linux/results/report.html`. The report is organized into: + 1. **Run configuration & harness setup** — diagnostic rows (`CFG`, `PRE`) emitted by harnesses (poll interval, current identity, enabled targets, pre-flight checks). Excluded from test counts. + 2. **Failures requiring triage** — only real test FAILs. + 3. **Informational findings (no action required)** — real INFO rows: environment limitations, audit-mode probes, by-design observations, manual-review items. + 4. **Per-phase tables** — full PASS/FAIL/INFO breakdown per phase. + 5. **Phase 4 URL/encoding differential** — last `url_diff.tsv`. + 6. **Cheat sheet & artifacts** — raw paths to pcaps, bpftool snapshots, etc. +- Driver: [pentest/linux/run_all.sh](run_all.sh) runs phases 2 / 3 / 4 / 4b / 5 in order and then renders the HTML report. Phase 4b mutates `/var/lib/azure-proxy-agent/rules/` and can be scoped with `--phase4b-target {both,imds,wireserver}` or skipped with `--skip-phase4b`; the standard cycle is `sudo bash pentest/linux/run_all.sh`. + +--- + +## 4. Tooling staged on this box + +```bash +sudo apt install -y nmap tcpdump bpftool radamsa python3-scapy wrk jq strace +cargo install --locked httpx-cli # optional +``` + +Plus a small workspace under [pentest/](./) (PoCs, pcaps, harness scripts). diff --git a/pentest/linux/test_catalog.py b/pentest/linux/test_catalog.py index cf127e29..525c7ba8 100644 --- a/pentest/linux/test_catalog.py +++ b/pentest/linux/test_catalog.py @@ -131,6 +131,14 @@ class TestInfo(TypedDict, total=False): "repro_manual": "sudo -u nobody curl -sS -o /dev/null -w '%{http_code}\\n' -H 'x-ms-version: 2012-11-30' 'http://168.63.129.16/machine/?comp=goalstate'", "fix": "If FAIL with 200, check the WireServer authorizer (`proxy_agent/src/proxy/proxy_authorizer.rs`) — `runAsElevated` gate is broken.", }, + "C3": { + "title": "Bind-mount executable identity spoof", + "design": "An executable reached through an allow-listed pathname must match the device/inode captured by eBPF at connect time; a mount-namespace pathname alone is not authorization identity.", + "automation": "The phase-4b S9 scenario permits `/usr/bin/curl`, then enters a user+mount namespace, bind-mounts the attacker's `/proc//exe` over `/usr/bin/curl`, executes it through that path, and sends the HTTP request with Bash builtins. Genuine curl must receive 200; the spoof must receive 403.", + "repro_script": "sudo python3 pentest/linux/phase4b_local_rules/run.py --scenarios IMDS-S9-exepath-identity", + "repro_manual": "sudo unshare --user --map-root-user --mount -- bash -c 'mount --make-rprivate /; mount --bind /proc/$$/exe /usr/bin/curl; /usr/bin/curl -c \"exec 3<>/dev/tcp/169.254.169.254/80; printf \\\\\"GET /metadata/instance?api-version=2021-02-01 HTTP/1.1\\\\\\\\r\\\\\\\\nHost: 169.254.169.254\\\\\\\\r\\\\\\\\nMetadata: true\\\\\\\\r\\\\\\\\nConnection: close\\\\\\\\r\\\\\\\\n\\\\\\\\r\\\\\\\\n\\\\\" >&3; cat <&3\"'", + "fix": "Capture executable file identity atomically in the cgroup connect eBPF hook and compare it with the configured path resolved in GPA's trusted mount namespace. Never authorize from `/proc//exe` text alone; fail closed when kernel identity is unavailable.", + }, "C5": { "title": "cgroup-namespace evasion attempt", "design": "Inside `unshare -Cr`, a process should still hit the cgroup-attached eBPF program (the cgroup IS still under the root cgroup hierarchy). Connection must remain redirected.", diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 index 464aaa61..c33505fb 100644 --- a/pentest/windows/Phase4b-LocalRules.ps1 +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -84,46 +84,72 @@ function Get-LatestAuthRulesSnapshot { } function Assert-UseLocalFileRules { + param([string[]] $TargetNames) + $snap = Get-LatestAuthRulesSnapshot if (-not $snap) { Write-Finding PRE FAIL "no AuthorizationRules_*.json under $GpaLogDir; is GuestProxyAgent running?" exit 2 } $text = Get-Content -Raw $snap.FullName - if ($text -notmatch 'useLocalFileRules-true') { - Write-Finding PRE FAIL ("useLocalFileRules-true NOT present in $($snap.Name); " + - 'fabric is delivering plain rule ids — local rules will be ignored. ' + - 'Enable useLocalFileRules from the control plane and retry.') + try { + $rulesSnapshot = $text | ConvertFrom-Json + } + catch { + Write-Finding PRE FAIL "cannot parse authorization rules snapshot $($snap.Name): $_" exit 2 } + + $localRulesDisabled = @() + foreach ($targetName in $TargetNames) { + $computedRule = $rulesSnapshot.computedRules.$targetName + if (-not $computedRule -or $computedRule.id -notmatch 'useLocalFileRules-true') { + $localRulesDisabled += $targetName + } + } + if ($localRulesDisabled.Count -gt 0) { + Write-Finding PRE INFO ("useLocalFileRules=true is not active for targets " + + "$($localRulesDisabled -join ', ') in $($snap.Name); skipping local-rule " + + 'tests for those targets.') + } + $activeTargetNames = @($TargetNames | Where-Object { $_ -notin $localRulesDisabled }) + # Effective remote mode must be Enforce, otherwise deny scenarios silently 200. $modes = @{} - try { - $j = $text | ConvertFrom-Json - foreach ($t in @('imds', 'wireserver')) { - $m = $null - if ($j.computedRules -and $j.computedRules.$t) { $m = $j.computedRules.$t.mode } - if (-not $m -and $j.inputRules -and $j.inputRules.$t) { $m = $j.inputRules.$t.mode } - if ($m) { $modes[$t] = $m } + foreach ($targetName in $activeTargetNames) { + $mode = $null + if ($rulesSnapshot.computedRules -and $rulesSnapshot.computedRules.$targetName) { + $mode = $rulesSnapshot.computedRules.$targetName.mode } + if (-not $mode -and $rulesSnapshot.inputRules -and $rulesSnapshot.inputRules.$targetName) { + $mode = $rulesSnapshot.inputRules.$targetName.mode + } + if ($mode) { $modes[$targetName] = $mode } } - catch { } $nonEnforce = @{} - foreach ($k in $modes.Keys) { - if ($modes[$k].ToLower() -ne 'enforce') { $nonEnforce[$k] = $modes[$k] } + foreach ($targetName in $activeTargetNames) { + if (-not $modes.ContainsKey($targetName) -or $modes[$targetName].ToLower() -ne 'enforce') { + $nonEnforce[$targetName] = if ($modes.ContainsKey($targetName)) { $modes[$targetName] } else { 'missing' } + } } if ($nonEnforce.Count -gt 0) { $detail = ($nonEnforce.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ' - Write-Finding PRE FAIL ("effective remote mode is not Enforce ($detail); " + - 'deny scenarios will be silently audited and report bogus FAILs. ' + - 'Switch the fabric/HostGAPlugin rule mode to Enforce and retry.') + Write-Finding PRE INFO ("effective remote mode is not Enforce ($detail); " + + 'skipping local-rule tests for those targets.') + $activeTargetNames = @($activeTargetNames | Where-Object { -not $nonEnforce.ContainsKey($_) }) + } + if ($activeTargetNames.Count -eq 0) { + Write-Finding PRE FAIL 'no requested targets support enforce-mode local-file rules' exit 2 } - $modeStr = ($modes.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ' - Write-Finding PRE PASS "useLocalFileRules-true confirmed in $($snap.Name); effective remote mode={$modeStr}" + $modeStr = ($activeTargetNames | ForEach-Object { "$_=$($modes[$_])" }) -join ', ' + Write-Finding PRE PASS ("useLocalFileRules-true confirmed for " + + "$($activeTargetNames -join ', ') in $($snap.Name); effective remote mode={$modeStr}") + return $activeTargetNames } -Assert-UseLocalFileRules +$TargetNames = if ($Target -eq 'both') { @('imds', 'wireserver') } else { @($Target) } +$TargetNames = @(Assert-UseLocalFileRules -TargetNames $TargetNames) # --- identity captured at probe-time ---------------------------------------- # Probes are sent from this PowerShell process. The matcher AND-matches every @@ -138,7 +164,7 @@ $Identity = @{ groupName = 'Administrators' } Write-Finding CFG INFO "current identity: user=$($Identity.userName) group=$($Identity.groupName)" -Write-Finding CFG INFO "targets: $Target" +Write-Finding CFG INFO "targets: $($TargetNames -join ', ')" # Latin → Cyrillic visual-homoglyph table used by the S19 scenarios. Pairs # only characters that render (in common fonts) as visually indistinguishable @@ -866,8 +892,8 @@ function Invoke-Scenario { function Build-AllScenarios { $all = @() - if ($Target -in @('imds', 'both')) { $all += Build-ImdsScenarios -Identity $Identity } - if ($Target -in @('wireserver', 'both')) { $all += Build-WireServerScenarios -Identity $Identity } + if ($TargetNames -contains 'imds') { $all += Build-ImdsScenarios -Identity $Identity } + if ($TargetNames -contains 'wireserver') { $all += Build-WireServerScenarios -Identity $Identity } if ($Scenarios) { $all = $all | Where-Object { $Scenarios -contains $_.Sid } } return $all } diff --git a/proxy_agent/src/key_keeper/key.rs b/proxy_agent/src/key_keeper/key.rs index fa129de6..65cb15b0 100644 --- a/proxy_agent/src/key_keeper/key.rs +++ b/proxy_agent/src/key_keeper/key.rs @@ -37,8 +37,10 @@ use hyper::Uri; use proxy_agent_shared::hyper_client; use proxy_agent_shared::logger::LoggerLevel; use serde_derive::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fmt::{Display, Formatter}; -use std::{collections::HashMap, path::PathBuf}; +#[cfg(windows)] +use std::path::PathBuf; use std::{ffi::OsString, time::Duration}; const AUDIT_MODE: &str = "audit"; @@ -338,6 +340,16 @@ impl Clone for Identity { } } +/// Converts a `stat` `st_dev` (userspace encoding: glibc `makedev`) to the kernel-internal +/// `dev_t` encoding (`MKDEV`: major << 20 | minor) that `super_block->s_dev` holds and that +/// the eBPF program reports in `Claims::processExecutableDevice`. +#[cfg(not(windows))] +pub(crate) fn kernel_dev_from_st_dev(st_dev: u64) -> u64 { + let major = u64::from(libc::major(st_dev)); + let minor = u64::from(libc::minor(st_dev)); + (major << 20) | minor +} + impl Identity { pub fn is_match(&self, logger: &mut ConnectionLogger, claims: &Claims) -> bool { logger.write( @@ -386,24 +398,82 @@ impl Identity { } } if let Some(ref exe_path) = self.exePath { - let process_path_buf: PathBuf = exe_path.into(); - if process_path_buf == claims.processFullPath { - logger.write( - LoggerLevel::Trace, - format!( - "Matched process full path '{}' from identity '{}'", - exe_path, self.name - ), - ); - } else { + #[cfg(not(windows))] + { + use std::os::unix::fs::MetadataExt; + + let metadata = match std::fs::metadata(exe_path) { + Ok(metadata) => metadata, + Err(err) => { + logger.write( + LoggerLevel::Warn, + format!( + "Could not resolve executable identity '{}' from identity '{}': {}", + exe_path, self.name, err + ), + ); + return false; + } + }; + if !claims.processExecutableIdentityValid { + logger.write( + LoggerLevel::Warn, + format!( + "Kernel executable identity unavailable for identity '{}'", + self.name + ), + ); + return false; + } + // Authorize by file identity, not by path: the eBPF hook captured the + // (device, inode) of the caller's mapped executable, which a bind mount + // or symlink over `exePath` cannot change. Compare it with the file that + // `exePath` resolves to in GPA's own (trusted) mount namespace. A mismatch + // means the caller is a different file than the one the rule allows, even + // if its `/proc//exe` path text looks identical. + // stat's st_dev uses the userspace encoding, but the eBPF value is the + // kernel's internal s_dev, so re-encode before comparing. + if kernel_dev_from_st_dev(metadata.dev()) != claims.processExecutableDevice + || metadata.ino() != claims.processExecutableInode + { + logger.write( + LoggerLevel::Trace, + format!( + "Not matched executable identity '{}' from identity '{}'", + exe_path, self.name + ), + ); + return false; + } logger.write( LoggerLevel::Trace, format!( - "Not matched process full path '{}' from identity '{}'", + "Matched executable identity '{}' from identity '{}'", exe_path, self.name ), ); - return false; + } + #[cfg(windows)] + { + let process_path_buf: PathBuf = exe_path.into(); + if process_path_buf == claims.processFullPath { + logger.write( + LoggerLevel::Trace, + format!( + "Matched process full path '{}' from identity '{}'", + exe_path, self.name + ), + ); + } else { + logger.write( + LoggerLevel::Trace, + format!( + "Not matched process full path '{}' from identity '{}'", + exe_path, self.name + ), + ); + return false; + } } } if let Some(ref group_name) = self.groupName { @@ -973,7 +1043,6 @@ mod tests { use std::os::unix::ffi::OsStringExt; #[cfg(windows)] use std::os::windows::ffi::OsStringExt; - use std::path::PathBuf; use super::Key; use super::KeyStatus; @@ -1701,9 +1770,29 @@ mod tests { ); } + #[cfg(not(windows))] + #[test] + fn kernel_dev_from_st_dev_matches_kernel_encoding() { + // /dev/sda1 (8,1): stat st_dev is 0x801, kernel s_dev is 0x800001. + assert_eq!(super::kernel_dev_from_st_dev(0x801), 0x80_0001); + // Minor numbers above 255 are split across the userspace encoding. + let st_dev = libc::makedev(259, 0x1234); + assert_eq!( + super::kernel_dev_from_st_dev(st_dev), + (259u64 << 20) | 0x1234 + ); + } + #[tokio::test] async fn test_identity_is_match() { + #[cfg(not(windows))] + use std::os::unix::fs::MetadataExt; + let mut logger = ConnectionLogger::new(1, 1); + let current_exe = std::env::current_exe().unwrap(); + let current_exe_text = current_exe.to_string_lossy().to_string(); + #[cfg(not(windows))] + let current_exe_metadata = std::fs::metadata(¤t_exe).unwrap(); let mut claims = super::Claims { userName: "test".to_string(), @@ -1715,17 +1804,26 @@ mod tests { clientIp: "00.000.000".to_string(), clientPort: 0, // doesn't matter for this test runAsElevated: true, - processFullPath: PathBuf::from("test"), + processFullPath: current_exe, + #[cfg(not(windows))] + processExecutableDevice: super::kernel_dev_from_st_dev(current_exe_metadata.dev()), + #[cfg(windows)] + processExecutableDevice: 0, + #[cfg(not(windows))] + processExecutableInode: current_exe_metadata.ino(), + #[cfg(windows)] + processExecutableInode: 0, + processExecutableIdentityValid: true, }; - let identity = r#"{ + let identity: Identity = serde_json::from_value(serde_json::json!({ "name": "test", "userName": "test", "groupName": "test", - "exePath": "test", + "exePath": current_exe_text, "processName": "test" - }"#; - let identity: Identity = serde_json::from_str(identity).unwrap(); + })) + .unwrap(); assert!( identity.is_match(&mut logger, &claims), "identity should be matched" @@ -1813,15 +1911,30 @@ mod tests { !identity4.is_match(&mut logger, &claims), "identity should not be matched" ); - let identity4 = r#"{ + let identity4: Identity = serde_json::from_value(serde_json::json!({ "name": "test", - "exePath": "test" - }"#; - let identity4: Identity = serde_json::from_str(identity4).unwrap(); + "exePath": current_exe_text + })) + .unwrap(); assert!( identity4.is_match(&mut logger, &claims), "identity should be matched" ); + #[cfg(not(windows))] + { + claims.processExecutableInode ^= 1; + assert!( + !identity4.is_match(&mut logger, &claims), + "a spoofed path with a different kernel executable identity must not match" + ); + claims.processExecutableInode ^= 1; + claims.processExecutableIdentityValid = false; + assert!( + !identity4.is_match(&mut logger, &claims), + "exePath identity must fail closed when kernel identity is unavailable" + ); + claims.processExecutableIdentityValid = true; + } // test groupName let identity5 = r#"{ diff --git a/proxy_agent/src/proxy.rs b/proxy_agent/src/proxy.rs index 65a28309..999ddfb9 100644 --- a/proxy_agent/src/proxy.rs +++ b/proxy_agent/src/proxy.rs @@ -57,6 +57,12 @@ pub struct Claims { #[serde(with = "os_string_as_string")] pub processName: OsString, pub processFullPath: PathBuf, + #[serde(default)] + pub processExecutableDevice: u64, + #[serde(default)] + pub processExecutableInode: u64, + #[serde(default)] + pub processExecutableIdentityValid: bool, pub processCmdLine: String, pub runAsElevated: bool, pub clientIp: String, @@ -163,6 +169,9 @@ impl Claims { processId: 0, processName: OsString::from(EMPTY), processFullPath: PathBuf::from(EMPTY), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: EMPTY.to_string(), runAsElevated: false, clientIp: EMPTY.to_string(), @@ -185,6 +194,9 @@ impl Claims { processId: p.pid, processName: p.name, processFullPath: p.exe_full_name, + processExecutableDevice: entry.executable_device, + processExecutableInode: entry.executable_inode, + processExecutableIdentityValid: entry.executable_identity_valid, processCmdLine: p.command_line.clone(), runAsElevated: entry.is_admin == 1, clientIp: client_ip.to_string(), diff --git a/proxy_agent/src/proxy/authorization_rules.rs b/proxy_agent/src/proxy/authorization_rules.rs index 7eb261d4..b93ad31b 100644 --- a/proxy_agent/src/proxy/authorization_rules.rs +++ b/proxy_agent/src/proxy/authorization_rules.rs @@ -644,6 +644,18 @@ mod tests { let mut temp_test_path = std::env::temp_dir(); temp_test_path.push(logger_key); let mut test_logger = ConnectionLogger::new(0, 0); + let current_exe = std::env::current_exe().unwrap(); + #[cfg(not(windows))] + let (current_exe_dev, current_exe_ino) = { + use std::os::unix::fs::MetadataExt; + let metadata = std::fs::metadata(¤t_exe).unwrap(); + ( + crate::key_keeper::key::kernel_dev_from_st_dev(metadata.dev()), + metadata.ino(), + ) + }; + #[cfg(windows)] + let (current_exe_dev, current_exe_ino) = (0u64, 0u64); // Test Enforce Mode let access_control_rules = AccessControlRules { @@ -658,7 +670,7 @@ mod tests { }]), identities: Some(vec![Identity { name: "test".to_string(), - exePath: Some("test".to_string()), + exePath: Some(current_exe.to_string_lossy().to_string()), groupName: Some("test".to_string()), processName: Some("test".to_string()), userName: Some("test".to_string()), @@ -687,7 +699,10 @@ mod tests { userName: "test".to_string(), userGroups: vec!["test".to_string()], processId: 0, - processFullPath: PathBuf::from("test"), + processFullPath: current_exe.clone(), + processExecutableDevice: current_exe_dev, + processExecutableInode: current_exe_ino, + processExecutableIdentityValid: true, clientIp: "0".to_string(), clientPort: 0, // doesn't matter for this test processName: OsString::from("test"), @@ -941,6 +956,9 @@ mod tests { userGroups: vec!["users".to_string()], processId: 1234, processFullPath: PathBuf::from("/usr/bin/curl"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, clientIp: "127.0.0.1".to_string(), clientPort: 12345, processName: OsString::from("curl"), @@ -976,6 +994,9 @@ mod tests { userGroups: vec!["users".to_string()], processId: 5678, processFullPath: PathBuf::from("/usr/bin/curl"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, clientIp: "127.0.0.1".to_string(), clientPort: 12345, processName: OsString::from("curl"), @@ -1072,6 +1093,9 @@ mod tests { userGroups: vec![], processId: 0, processFullPath: PathBuf::from("p"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, clientIp: "0".to_string(), clientPort: 0, processName: OsString::from("p"), diff --git a/proxy_agent/src/proxy/proxy_authorizer.rs b/proxy_agent/src/proxy/proxy_authorizer.rs index 0c59c82a..5b434dfb 100644 --- a/proxy_agent/src/proxy/proxy_authorizer.rs +++ b/proxy_agent/src/proxy/proxy_authorizer.rs @@ -308,6 +308,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), @@ -382,6 +385,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), @@ -508,6 +514,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), @@ -617,6 +626,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), @@ -743,6 +755,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), diff --git a/proxy_agent/src/redirector.rs b/proxy_agent/src/redirector.rs index 892bdf88..f3bb73a7 100644 --- a/proxy_agent/src/redirector.rs +++ b/proxy_agent/src/redirector.rs @@ -166,6 +166,12 @@ pub struct AuditEntry { pub destination_port: u16, // in network byte order #[serde(default)] pub address_family: AddressFamily, + #[serde(default)] + pub executable_device: u64, + #[serde(default)] + pub executable_inode: u64, + #[serde(default)] + pub executable_identity_valid: bool, } impl AuditEntry { @@ -177,6 +183,9 @@ impl AuditEntry { destination_ipv4: 0, destination_port: 0, address_family: AddressFamily::IPv4, + executable_device: 0, + executable_inode: 0, + executable_identity_valid: false, } } diff --git a/proxy_agent/src/redirector/linux.rs b/proxy_agent/src/redirector/linux.rs index f0cf8745..97868ff6 100644 --- a/proxy_agent/src/redirector/linux.rs +++ b/proxy_agent/src/redirector/linux.rs @@ -8,7 +8,7 @@ use crate::common::{ }; use crate::redirector::shared_ebpf::linux_types::{ alert_only_event, destination_entry, sock_addr_audit_entry, sock_addr_audit_key, - sock_addr_skip_process_entry, AuditMapKey, AuditMapValue, ALERT_ONLY_MAP_NAME, AUDIT_MAP_NAME, + sock_addr_skip_process_entry, AuditMapKey, ALERT_ONLY_MAP_NAME, AUDIT_MAP_NAME, CONFIG_MAP_NAME, GPA_CONFIG_LOCAL_IP_BIND_MONITOR_ONLY, POLICY_MAP_NAME, SKIP_PROCESS_MAP_NAME, }; use crate::redirector::{ip_to_string, AlertOnlyEntry, AuditEntry}; @@ -29,6 +29,21 @@ pub struct BpfObject { event_runtime: super::EventTaskRuntime, } +/// Linux audit_map value: the shared 28-byte audit entry followed by the executable +/// identity captured in-kernel (device, inode low/high, valid flag). +/// Mirrors `struct gpa_linux_audit_event` in linux-ebpf/socket.h. +pub type LinuxAuditMapValue = [u32; 11]; + +fn linux_audit_entry_from_array(value: LinuxAuditMapValue) -> AuditEntry { + let mut base = [0u32; 7]; + base.copy_from_slice(&value[..7]); + let mut entry = sock_addr_audit_entry::from_array(base).to_audit_entry(); + entry.executable_device = u64::from(value[7]); + entry.executable_inode = u64::from(value[8]) | (u64::from(value[9]) << 32); + entry.executable_identity_valid = value[10] != 0; + entry +} + // BpfObject is a wrapper around Bpf object to interact with Linux eBPF programs and maps impl BpfObject { pub fn new(ebpf: Ebpf) -> Self { @@ -305,25 +320,24 @@ impl BpfObject { pub fn lookup_audit(&self, source_port: u16) -> Result { let audit_map_name = AUDIT_MAP_NAME; match self.ebpf()?.map(audit_map_name) { - Some(map) => match HashMap::<&MapData, AuditMapKey, AuditMapValue>::try_from(map) { - Ok(audit_map) => { - let key = sock_addr_audit_key::from_source_port(source_port); - match audit_map.get(&key.as_array(), 0) { - Ok(value) => { - let audit_value = sock_addr_audit_entry::from_array(value); - Ok(audit_value.to_audit_entry()) + Some(map) => { + match HashMap::<&MapData, AuditMapKey, LinuxAuditMapValue>::try_from(map) { + Ok(audit_map) => { + let key = sock_addr_audit_key::from_source_port(source_port); + match audit_map.get(&key.as_array(), 0) { + Ok(value) => Ok(linux_audit_entry_from_array(value)), + Err(err) => Err(Error::Bpf(BpfErrorType::MapLookupElem( + source_port.to_string(), + err.to_string(), + ))), } - Err(err) => Err(Error::Bpf(BpfErrorType::MapLookupElem( - source_port.to_string(), - err.to_string(), - ))), } + Err(err) => Err(Error::Bpf(BpfErrorType::LoadBpfMapHashMap( + audit_map_name.to_string(), + err.to_string(), + ))), } - Err(err) => Err(Error::Bpf(BpfErrorType::LoadBpfMapHashMap( - audit_map_name.to_string(), - err.to_string(), - ))), - }, + } None => Err(Error::Bpf(BpfErrorType::GetBpfMap( audit_map_name.to_string(), "Map does not exist".to_string(), @@ -418,23 +432,25 @@ impl BpfObject { pub fn remove_audit_map_entry(&mut self, source_port: u16) -> Result<()> { let audit_map_name = AUDIT_MAP_NAME; match self.ebpf_mut()?.map_mut(audit_map_name) { - Some(map) => match HashMap::<&mut MapData, AuditMapKey, AuditMapValue>::try_from(map) { - Ok(mut audit_map) => { - let key = sock_addr_audit_key::from_source_port(source_port); - audit_map.remove(&key.as_array()).map_err(|err| { - Error::Bpf(BpfErrorType::MapDeleteElem( - source_port.to_string(), - format!("Error: {err}"), - )) - })?; - } - Err(err) => { - return Err(Error::Bpf(BpfErrorType::LoadBpfMapHashMap( - audit_map_name.to_string(), - err.to_string(), - ))); + Some(map) => { + match HashMap::<&mut MapData, AuditMapKey, LinuxAuditMapValue>::try_from(map) { + Ok(mut audit_map) => { + let key = sock_addr_audit_key::from_source_port(source_port); + audit_map.remove(&key.as_array()).map_err(|err| { + Error::Bpf(BpfErrorType::MapDeleteElem( + source_port.to_string(), + format!("Error: {err}"), + )) + })?; + } + Err(err) => { + return Err(Error::Bpf(BpfErrorType::LoadBpfMapHashMap( + audit_map_name.to_string(), + err.to_string(), + ))); + } } - }, + } None => { return Err(Error::Bpf(BpfErrorType::GetBpfMap( audit_map_name.to_string(), @@ -628,10 +644,11 @@ pub async fn update_hostga_redirect_policy( #[cfg(test)] #[cfg(feature = "test-with-root")] mod tests { + use super::LinuxAuditMapValue; use crate::common::config; use crate::common::constants; use crate::redirector::shared_ebpf::linux_types::{ - sock_addr_audit_entry, sock_addr_audit_key, AuditMapKey, AuditMapValue, + sock_addr_audit_entry, sock_addr_audit_key, AuditMapKey, }; use aya::maps::HashMap; use proxy_agent_shared::misc_helpers; @@ -721,16 +738,20 @@ mod tests { address_family: crate::redirector::shared_ebpf::GPA_ADDRESS_FAMILY_IPV6, reserved: 0, }; + let mut linux_value: LinuxAuditMapValue = [0; 11]; + linux_value[..7].copy_from_slice(&value.as_array()); + linux_value[7] = 8; + linux_value[8] = 7; + linux_value[9] = 6; + linux_value[10] = 1; { // drop map_mut("audit_map") within this scope - let mut audit_map: HashMap<&mut aya::maps::MapData, AuditMapKey, AuditMapValue> = - HashMap::<&mut aya::maps::MapData, AuditMapKey, AuditMapValue>::try_from( + let mut audit_map: HashMap<&mut aya::maps::MapData, AuditMapKey, LinuxAuditMapValue> = + HashMap::<&mut aya::maps::MapData, AuditMapKey, LinuxAuditMapValue>::try_from( bpf.ebpf_mut().unwrap().map_mut("audit_map").unwrap(), ) .unwrap(); - audit_map - .insert(key.as_array(), value.as_array(), 0) - .unwrap(); + audit_map.insert(key.as_array(), linux_value, 0).unwrap(); } let audit = bpf.lookup_audit(source_port); match audit { @@ -753,6 +774,9 @@ mod tests { "destination_port is not equal" ); assert_eq!(entry.address_family, crate::redirector::AddressFamily::IPv6); + assert_eq!(entry.executable_device, 8); + assert_eq!(entry.executable_inode, (6u64 << 32) | 7); + assert!(entry.executable_identity_valid); } Err(err) => { println!("lookup_audit_internal error: {}", err); @@ -761,3 +785,41 @@ mod tests { } } } + +#[cfg(test)] +mod audit_decode_tests { + use super::{linux_audit_entry_from_array, LinuxAuditMapValue}; + + #[test] + fn linux_audit_value_decodes_executable_identity() { + let value: LinuxAuditMapValue = [ + 1000, + 42, + 0, + 0x0102_0304, + 80, + 4, + 0, + 8, + 0x89AB_CDEF, + 0x0123_4567, + 1, + ]; + let entry = linux_audit_entry_from_array(value); + + assert_eq!(entry.logon_id, 1000); + assert_eq!(entry.process_id, 42); + assert_eq!(entry.executable_device, 8); + assert_eq!(entry.executable_inode, 0x0123_4567_89AB_CDEF); + assert!(entry.executable_identity_valid); + + let mut invalid = value; + invalid[10] = 0; + assert!(!linux_audit_entry_from_array(invalid).executable_identity_valid); + } + + #[test] + fn linux_audit_value_matches_ebpf_layout() { + assert_eq!(std::mem::size_of::(), 44); + } +} diff --git a/proxy_agent/src/redirector/shared_ebpf.rs b/proxy_agent/src/redirector/shared_ebpf.rs index 7b0b8c2c..7c23cd91 100644 --- a/proxy_agent/src/redirector/shared_ebpf.rs +++ b/proxy_agent/src/redirector/shared_ebpf.rs @@ -225,6 +225,9 @@ impl sock_addr_audit_entry { } else { crate::redirector::AddressFamily::IPv4 }, + executable_device: 0, + executable_inode: 0, + executable_identity_valid: false, } } } @@ -257,6 +260,9 @@ impl sock_addr_audit_entry_legacy { destination_ipv4: self.destination_ipv4, destination_port: self.destination_port, address_family: crate::redirector::AddressFamily::IPv4, + executable_device: 0, + executable_inode: 0, + executable_identity_valid: false, } } } @@ -415,8 +421,8 @@ impl AuditValueEntry { pub mod linux_types { pub use super::{ alert_only_event, destination_entry, sock_addr_audit_entry, sock_addr_audit_key, - sock_addr_skip_process_entry, AuditMapKey, AuditMapValue, ALERT_ONLY_MAP_NAME, - AUDIT_MAP_NAME, CONFIG_MAP_NAME, GPA_CONFIG_LOCAL_IP_BIND_MONITOR_ONLY, POLICY_MAP_NAME, + sock_addr_skip_process_entry, AuditMapKey, ALERT_ONLY_MAP_NAME, AUDIT_MAP_NAME, + CONFIG_MAP_NAME, GPA_CONFIG_LOCAL_IP_BIND_MONITOR_ONLY, POLICY_MAP_NAME, SKIP_PROCESS_MAP_NAME, }; }